Fix meeting list and media search issues
This commit is contained in:
@@ -405,6 +405,7 @@ type TenantBusinessAdapter interface {
|
|||||||
TenantOrganizationMembersAll(t.TenantID, t.Uid, string, string, string, int) ([]t.OrganizationMember, string, error)
|
TenantOrganizationMembersAll(t.TenantID, t.Uid, string, string, string, int) ([]t.OrganizationMember, string, error)
|
||||||
TenantOrganizationSearch(t.TenantID, t.Uid, string, string, string, int) ([]t.OrganizationSearchItem, string, error)
|
TenantOrganizationSearch(t.TenantID, t.Uid, string, string, string, int) ([]t.OrganizationSearchItem, string, error)
|
||||||
TenantMeetingCreate(t.TenantID, t.Uid, *t.Meeting, []t.Uid) (*t.Meeting, error)
|
TenantMeetingCreate(t.TenantID, t.Uid, *t.Meeting, []t.Uid) (*t.Meeting, error)
|
||||||
|
TenantMeetingList(t.TenantID, t.Uid, string, int) ([]t.Meeting, string, error)
|
||||||
TenantMeetingGet(t.TenantID, t.Uid, string) (*t.Meeting, error)
|
TenantMeetingGet(t.TenantID, t.Uid, string) (*t.Meeting, error)
|
||||||
TenantMeetingUpdate(t.TenantID, t.Uid, string, t.MeetingPatch, string) (*t.Meeting, error)
|
TenantMeetingUpdate(t.TenantID, t.Uid, string, t.MeetingPatch, string) (*t.Meeting, error)
|
||||||
TenantMeetingCancel(t.TenantID, t.Uid, string, string) (*t.Meeting, error)
|
TenantMeetingCancel(t.TenantID, t.Uid, string, string) (*t.Meeting, error)
|
||||||
|
|||||||
@@ -113,9 +113,23 @@ func (a *adapter) getContextForTx() (context.Context, context.CancelFunc) {
|
|||||||
return context.Background(), nil
|
return context.Background(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeMySQLDSN(dsn string) string {
|
||||||
|
dsn = strings.TrimSpace(dsn)
|
||||||
|
if strings.HasPrefix(dsn, "mysql://") {
|
||||||
|
return strings.TrimPrefix(dsn, "mysql://")
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(dsn, "mysql:") {
|
||||||
|
credentials, _, _ := strings.Cut(strings.TrimPrefix(dsn, "mysql:"), "@")
|
||||||
|
if strings.Count(credentials, ":") >= 1 {
|
||||||
|
return strings.TrimPrefix(dsn, "mysql:")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dsn
|
||||||
|
}
|
||||||
|
|
||||||
func openMySQLDB(config configType, dsn, dbName string) (*sqlx.DB, string, error) {
|
func openMySQLDB(config configType, dsn, dbName string) (*sqlx.DB, string, error) {
|
||||||
if strings.TrimSpace(dsn) != "" {
|
if strings.TrimSpace(dsn) != "" {
|
||||||
cfg, err := ms.ParseDSN(strings.TrimPrefix(dsn, "mysql://"))
|
cfg, err := ms.ParseDSN(normalizeMySQLDSN(dsn))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
@@ -180,7 +194,7 @@ func (a *adapter) Open(jsonconfig json.RawMessage) error {
|
|||||||
// Note: this method is deprecated.
|
// Note: this method is deprecated.
|
||||||
if config.DSN != "" {
|
if config.DSN != "" {
|
||||||
// Remove optional schema.
|
// Remove optional schema.
|
||||||
a.dsn = strings.TrimPrefix(config.DSN, "mysql://")
|
a.dsn = normalizeMySQLDSN(config.DSN)
|
||||||
} else {
|
} else {
|
||||||
a.dsn = defaultDSN
|
a.dsn = defaultDSN
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
//go:build mysql
|
||||||
|
// +build mysql
|
||||||
|
|
||||||
|
package mysql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
ms "github.com/go-sql-driver/mysql"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNormalizeMySQLDSN(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
dsn string
|
||||||
|
user string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "go driver dsn",
|
||||||
|
dsn: "root:secret@tcp(127.0.0.1:3306)/tinode?parseTime=true",
|
||||||
|
user: "root",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "url scheme dsn",
|
||||||
|
dsn: "mysql://root:secret@tcp(127.0.0.1:3306)/tinode?parseTime=true",
|
||||||
|
user: "root",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "legacy scheme dsn",
|
||||||
|
dsn: "mysql:root:secret@tcp(127.0.0.1:3306)/tinode?parseTime=true",
|
||||||
|
user: "root",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
cfg, err := ms.ParseDSN(normalizeMySQLDSN(tc.dsn))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseDSN failed: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.User != tc.user {
|
||||||
|
t.Fatalf("user=%q, want %q", cfg.User, tc.user)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2308,7 +2308,7 @@ func (a *adapter) TenantMessageSearch(id t.TenantID, topic string, uid t.Uid, qu
|
|||||||
args = append(args, "%"+queryText+"%")
|
args = append(args, "%"+queryText+"%")
|
||||||
}
|
}
|
||||||
if messageType != "" && messageType != "all" {
|
if messageType != "" && messageType != "all" {
|
||||||
where = append(where, "(JSON_UNQUOTE(JSON_EXTRACT(m.head,'$.type'))=? OR JSON_UNQUOTE(JSON_EXTRACT(m.head,'$.x-im-type'))=?)")
|
where = append(where, "(JSON_UNQUOTE(JSON_EXTRACT(m.head,'$.type'))=? OR JSON_UNQUOTE(JSON_EXTRACT(m.head,'$.\"x-im-type\"'))=?)")
|
||||||
args = append(args, messageType, messageType)
|
args = append(args, messageType, messageType)
|
||||||
}
|
}
|
||||||
ctx, cancel := a.getContext()
|
ctx, cancel := a.getContext()
|
||||||
@@ -3110,6 +3110,105 @@ func (a *adapter) TenantMeetingCreate(id t.TenantID, uid t.Uid, meeting *t.Meeti
|
|||||||
return a.TenantMeetingGet(id, uid, meeting.MeetingID)
|
return a.TenantMeetingGet(id, uid, meeting.MeetingID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *adapter) TenantMeetingList(id t.TenantID, uid t.Uid, cursor string, limit int) ([]t.Meeting, string, error) {
|
||||||
|
if !a.tenantScopedDB {
|
||||||
|
tenantAdp, err := a.routedTenantAdapter(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
if tenantAdp != a {
|
||||||
|
return tenantAdp.TenantMeetingList(id, uid, cursor, limit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := validTenant(id); err != nil || uid.IsZero() {
|
||||||
|
return nil, "", t.ErrMalformed
|
||||||
|
}
|
||||||
|
if limit <= 0 || limit > 100 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
where := []string{`(m.organizer_id=? OR EXISTS (
|
||||||
|
SELECT 1 FROM im_meeting_participants AS p WHERE p.meeting_id=m.meeting_id AND p.user_id=?
|
||||||
|
))`}
|
||||||
|
args := []any{decodeUID(uid), decodeUID(uid)}
|
||||||
|
if strings.TrimSpace(cursor) != "" {
|
||||||
|
parsed, err := strconv.ParseInt(strings.TrimSpace(cursor), 10, 64)
|
||||||
|
if err != nil || parsed <= 0 {
|
||||||
|
return nil, "", t.ErrMalformed
|
||||||
|
}
|
||||||
|
where = append(where, "m.id<?")
|
||||||
|
args = append(args, parsed)
|
||||||
|
}
|
||||||
|
ctx, cancel := a.getContext()
|
||||||
|
if cancel != nil {
|
||||||
|
defer cancel()
|
||||||
|
}
|
||||||
|
args = append(args, limit+1)
|
||||||
|
rows, err := a.db.QueryxContext(ctx, `SELECT m.* FROM im_meetings AS m
|
||||||
|
WHERE `+strings.Join(where, " AND ")+`
|
||||||
|
ORDER BY m.start_at DESC,m.id DESC LIMIT ?`, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
meetings := make([]t.Meeting, 0, limit)
|
||||||
|
nextCursor := ""
|
||||||
|
for rows.Next() {
|
||||||
|
row := struct {
|
||||||
|
ID int64
|
||||||
|
MeetingID string `db:"meeting_id"`
|
||||||
|
OrganizerID int64 `db:"organizer_id"`
|
||||||
|
Title string
|
||||||
|
StartAt time.Time `db:"start_at"`
|
||||||
|
EndAt time.Time `db:"end_at"`
|
||||||
|
Location string
|
||||||
|
State string
|
||||||
|
ReminderMinutes []byte `db:"reminder_minutes"`
|
||||||
|
IdempotencyKey sql.NullString `db:"idempotency_key"`
|
||||||
|
Version int
|
||||||
|
CanceledAt *time.Time `db:"canceled_at"`
|
||||||
|
CreatedAt time.Time `db:"created_at"`
|
||||||
|
UpdatedAt time.Time `db:"updated_at"`
|
||||||
|
}{}
|
||||||
|
if err = rows.StructScan(&row); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
meeting := t.Meeting{
|
||||||
|
ID: row.ID,
|
||||||
|
MeetingID: row.MeetingID,
|
||||||
|
OrganizerID: store.EncodeUid(row.OrganizerID),
|
||||||
|
Title: row.Title,
|
||||||
|
StartAt: row.StartAt,
|
||||||
|
EndAt: row.EndAt,
|
||||||
|
Location: row.Location,
|
||||||
|
State: row.State,
|
||||||
|
Version: row.Version,
|
||||||
|
CanceledAt: row.CanceledAt,
|
||||||
|
CreatedAt: row.CreatedAt,
|
||||||
|
UpdatedAt: row.UpdatedAt,
|
||||||
|
}
|
||||||
|
if row.IdempotencyKey.Valid {
|
||||||
|
meeting.IdempotencyKey = row.IdempotencyKey.String
|
||||||
|
}
|
||||||
|
if len(row.ReminderMinutes) > 0 {
|
||||||
|
_ = json.Unmarshal(row.ReminderMinutes, &meeting.ReminderMinutes)
|
||||||
|
}
|
||||||
|
if len(meetings) == limit {
|
||||||
|
nextCursor = strconv.FormatInt(row.ID, 10)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
participants, err := a.meetingParticipants(ctx, row.MeetingID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
meeting.Participants = participants
|
||||||
|
meetings = append(meetings, meeting)
|
||||||
|
}
|
||||||
|
if err = rows.Err(); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
return meetings, nextCursor, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (a *adapter) TenantMeetingGet(id t.TenantID, uid t.Uid, meetingID string) (*t.Meeting, error) {
|
func (a *adapter) TenantMeetingGet(id t.TenantID, uid t.Uid, meetingID string) (*t.Meeting, error) {
|
||||||
if !a.tenantScopedDB {
|
if !a.tenantScopedDB {
|
||||||
tenantAdp, err := a.routedTenantAdapter(id)
|
tenantAdp, err := a.routedTenantAdapter(id)
|
||||||
|
|||||||
@@ -1838,6 +1838,10 @@ func serveIMOrganizationSearch(wrt http.ResponseWriter, req *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func serveIMMeetings(wrt http.ResponseWriter, req *http.Request) {
|
func serveIMMeetings(wrt http.ResponseWriter, req *http.Request) {
|
||||||
|
if req.Method == http.MethodGet {
|
||||||
|
serveIMMeetingList(wrt, req)
|
||||||
|
return
|
||||||
|
}
|
||||||
if req.Method != http.MethodPost {
|
if req.Method != http.MethodPost {
|
||||||
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
||||||
return
|
return
|
||||||
@@ -1905,6 +1909,28 @@ func serveIMMeetings(wrt http.ResponseWriter, req *http.Request) {
|
|||||||
writeIMSuccess(wrt, req, meetingPayload(meeting, principal.Uid))
|
writeIMSuccess(wrt, req, meetingPayload(meeting, principal.Uid))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func serveIMMeetingList(wrt http.ResponseWriter, req *http.Request) {
|
||||||
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limit := parseIMLimit(req.URL.Query().Get("limit"), 20, 100)
|
||||||
|
meetings, nextCursor, err := store.Meetings.List(principal.TenantID, principal.Uid, strings.TrimSpace(req.URL.Query().Get("cursor")), limit)
|
||||||
|
if err != nil {
|
||||||
|
writeIMRelationError(wrt, req, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items := make([]map[string]any, 0, len(meetings))
|
||||||
|
for i := range meetings {
|
||||||
|
items = append(items, meetingPayload(&meetings[i], principal.Uid))
|
||||||
|
}
|
||||||
|
writeIMSuccess(wrt, req, map[string]any{
|
||||||
|
"items": items,
|
||||||
|
"next_cursor": nextCursor,
|
||||||
|
"has_more": nextCursor != "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func serveIMMeetingResource(wrt http.ResponseWriter, req *http.Request) {
|
func serveIMMeetingResource(wrt http.ResponseWriter, req *http.Request) {
|
||||||
meetingID, tail, ok := meetingResourcePath(req.URL.Path)
|
meetingID, tail, ok := meetingResourcePath(req.URL.Path)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -263,6 +263,9 @@ type testOrganizationStore struct {
|
|||||||
|
|
||||||
type testMeetingsStore struct {
|
type testMeetingsStore struct {
|
||||||
meeting *types.Meeting
|
meeting *types.Meeting
|
||||||
|
list []types.Meeting
|
||||||
|
listCursor string
|
||||||
|
listLimit int
|
||||||
attendeeIDs []types.Uid
|
attendeeIDs []types.Uid
|
||||||
created *types.Meeting
|
created *types.Meeting
|
||||||
response string
|
response string
|
||||||
@@ -604,6 +607,12 @@ func (s *testMeetingsStore) Create(_ types.TenantID, uid types.Uid, meeting *typ
|
|||||||
return meeting, nil
|
return meeting, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *testMeetingsStore) List(_ types.TenantID, _ types.Uid, cursor string, limit int) ([]types.Meeting, string, error) {
|
||||||
|
s.listCursor = cursor
|
||||||
|
s.listLimit = limit
|
||||||
|
return s.list, "next-1", nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *testMeetingsStore) Get(_ types.TenantID, _ types.Uid, _ string) (*types.Meeting, error) {
|
func (s *testMeetingsStore) Get(_ types.TenantID, _ types.Uid, _ string) (*types.Meeting, error) {
|
||||||
return s.meeting, nil
|
return s.meeting, nil
|
||||||
}
|
}
|
||||||
@@ -2355,6 +2364,49 @@ func TestServeIMMeetingsCreate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServeIMMeetingsList(t *testing.T) {
|
||||||
|
ctrl := gomock.NewController(t)
|
||||||
|
defer ctrl.Finish()
|
||||||
|
viewer := types.Uid(42)
|
||||||
|
expectIMBearer(t, ctrl, "token-1", 7, viewer)
|
||||||
|
oldMeetings := store.Meetings
|
||||||
|
now := time.Now().UTC().Round(time.Millisecond)
|
||||||
|
meetings := &testMeetingsStore{list: []types.Meeting{{
|
||||||
|
MeetingID: "mtg_test",
|
||||||
|
OrganizerID: viewer,
|
||||||
|
Title: "周会",
|
||||||
|
StartAt: now.Add(time.Hour),
|
||||||
|
EndAt: now.Add(2 * time.Hour),
|
||||||
|
Location: "A101",
|
||||||
|
State: types.MeetingStateScheduled,
|
||||||
|
Version: 1,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
Participants: []types.MeetingParticipant{
|
||||||
|
{MeetingID: "mtg_test", UserID: viewer, Response: types.MeetingResponseAccepted},
|
||||||
|
},
|
||||||
|
}}}
|
||||||
|
store.Meetings = meetings
|
||||||
|
defer func() { store.Meetings = oldMeetings }()
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/v1/im/meetings?limit=20&cursor=c1", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer token-1")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
serveIMMeetings(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||||
|
}
|
||||||
|
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
||||||
|
items := data["items"].([]any)
|
||||||
|
if len(items) != 1 || meetings.listCursor != "c1" || meetings.listLimit != 20 {
|
||||||
|
t.Fatalf("unexpected list response: data=%#v cursor=%q limit=%d", data, meetings.listCursor, meetings.listLimit)
|
||||||
|
}
|
||||||
|
if items[0].(map[string]any)["meeting_id"] != "mtg_test" || data["has_more"] != true {
|
||||||
|
t.Fatalf("unexpected meeting payload: %#v", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestServeIMMeetingMyResponse(t *testing.T) {
|
func TestServeIMMeetingMyResponse(t *testing.T) {
|
||||||
ctrl := gomock.NewController(t)
|
ctrl := gomock.NewController(t)
|
||||||
defer ctrl.Finish()
|
defer ctrl.Finish()
|
||||||
|
|||||||
@@ -2127,6 +2127,7 @@ func (organizationMapper) Search(tenantID types.TenantID, uid types.Uid, queryTe
|
|||||||
|
|
||||||
type MeetingsPersistenceInterface interface {
|
type MeetingsPersistenceInterface interface {
|
||||||
Create(tenantID types.TenantID, uid types.Uid, meeting *types.Meeting, attendeeIDs []types.Uid) (*types.Meeting, error)
|
Create(tenantID types.TenantID, uid types.Uid, meeting *types.Meeting, attendeeIDs []types.Uid) (*types.Meeting, error)
|
||||||
|
List(tenantID types.TenantID, uid types.Uid, cursor string, limit int) ([]types.Meeting, string, error)
|
||||||
Get(tenantID types.TenantID, uid types.Uid, meetingID string) (*types.Meeting, error)
|
Get(tenantID types.TenantID, uid types.Uid, meetingID string) (*types.Meeting, error)
|
||||||
Update(tenantID types.TenantID, uid types.Uid, meetingID string, patch types.MeetingPatch, idempotencyKey string) (*types.Meeting, error)
|
Update(tenantID types.TenantID, uid types.Uid, meetingID string, patch types.MeetingPatch, idempotencyKey string) (*types.Meeting, error)
|
||||||
Cancel(tenantID types.TenantID, uid types.Uid, meetingID, idempotencyKey string) (*types.Meeting, error)
|
Cancel(tenantID types.TenantID, uid types.Uid, meetingID, idempotencyKey string) (*types.Meeting, error)
|
||||||
@@ -2149,6 +2150,20 @@ func (meetingsMapper) Create(tenantID types.TenantID, uid types.Uid, meeting *ty
|
|||||||
return tenantAdp.TenantMeetingCreate(tenantID, uid, meeting, attendeeIDs)
|
return tenantAdp.TenantMeetingCreate(tenantID, uid, meeting, attendeeIDs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (meetingsMapper) List(tenantID types.TenantID, uid types.Uid, cursor string, limit int) ([]types.Meeting, string, error) {
|
||||||
|
if uid.IsZero() {
|
||||||
|
return nil, "", types.ErrMalformed
|
||||||
|
}
|
||||||
|
if limit <= 0 || limit > 100 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
tenantAdp, err := tenantBusinessAdapter(tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
return tenantAdp.TenantMeetingList(tenantID, uid, strings.TrimSpace(cursor), limit)
|
||||||
|
}
|
||||||
|
|
||||||
func (meetingsMapper) Get(tenantID types.TenantID, uid types.Uid, meetingID string) (*types.Meeting, error) {
|
func (meetingsMapper) Get(tenantID types.TenantID, uid types.Uid, meetingID string) (*types.Meeting, error) {
|
||||||
if uid.IsZero() || strings.TrimSpace(meetingID) == "" {
|
if uid.IsZero() || strings.TrimSpace(meetingID) == "" {
|
||||||
return nil, types.ErrMalformed
|
return nil, types.ErrMalformed
|
||||||
|
|||||||
Reference in New Issue
Block a user