diff --git a/server/db/adapter.go b/server/db/adapter.go index 1079435..1b49616 100644 --- a/server/db/adapter.go +++ b/server/db/adapter.go @@ -405,6 +405,7 @@ type TenantBusinessAdapter interface { 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) 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) TenantMeetingUpdate(t.TenantID, t.Uid, string, t.MeetingPatch, string) (*t.Meeting, error) TenantMeetingCancel(t.TenantID, t.Uid, string, string) (*t.Meeting, error) diff --git a/server/db/mysql/adapter.go b/server/db/mysql/adapter.go index b07bf69..ca3fe0d 100644 --- a/server/db/mysql/adapter.go +++ b/server/db/mysql/adapter.go @@ -113,9 +113,23 @@ func (a *adapter) getContextForTx() (context.Context, context.CancelFunc) { 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) { if strings.TrimSpace(dsn) != "" { - cfg, err := ms.ParseDSN(strings.TrimPrefix(dsn, "mysql://")) + cfg, err := ms.ParseDSN(normalizeMySQLDSN(dsn)) if err != nil { return nil, "", err } @@ -180,7 +194,7 @@ func (a *adapter) Open(jsonconfig json.RawMessage) error { // Note: this method is deprecated. if config.DSN != "" { // Remove optional schema. - a.dsn = strings.TrimPrefix(config.DSN, "mysql://") + a.dsn = normalizeMySQLDSN(config.DSN) } else { a.dsn = defaultDSN } diff --git a/server/db/mysql/adapter_dsn_test.go b/server/db/mysql/adapter_dsn_test.go new file mode 100644 index 0000000..820b23f --- /dev/null +++ b/server/db/mysql/adapter_dsn_test.go @@ -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) + } + }) + } +} diff --git a/server/db/mysql/tenant_adapter.go b/server/db/mysql/tenant_adapter.go index e2515a8..7516af4 100644 --- a/server/db/mysql/tenant_adapter.go +++ b/server/db/mysql/tenant_adapter.go @@ -2308,7 +2308,7 @@ func (a *adapter) TenantMessageSearch(id t.TenantID, topic string, uid t.Uid, qu args = append(args, "%"+queryText+"%") } 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) } 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) } +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 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) { if !a.tenantScopedDB { tenantAdp, err := a.routedTenantAdapter(id) diff --git a/server/im_http.go b/server/im_http.go index d17c927..82351a2 100644 --- a/server/im_http.go +++ b/server/im_http.go @@ -1838,6 +1838,10 @@ func serveIMOrganizationSearch(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 { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return @@ -1905,6 +1909,28 @@ func serveIMMeetings(wrt http.ResponseWriter, req *http.Request) { 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) { meetingID, tail, ok := meetingResourcePath(req.URL.Path) if !ok { diff --git a/server/im_http_test.go b/server/im_http_test.go index 6be43e7..e6d0966 100644 --- a/server/im_http_test.go +++ b/server/im_http_test.go @@ -263,6 +263,9 @@ type testOrganizationStore struct { type testMeetingsStore struct { meeting *types.Meeting + list []types.Meeting + listCursor string + listLimit int attendeeIDs []types.Uid created *types.Meeting response string @@ -604,6 +607,12 @@ func (s *testMeetingsStore) Create(_ types.TenantID, uid types.Uid, meeting *typ 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) { 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) { ctrl := gomock.NewController(t) defer ctrl.Finish() diff --git a/server/store/store.go b/server/store/store.go index d3e302d..33c8636 100644 --- a/server/store/store.go +++ b/server/store/store.go @@ -2127,6 +2127,7 @@ func (organizationMapper) Search(tenantID types.TenantID, uid types.Uid, queryTe type MeetingsPersistenceInterface interface { 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) 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) @@ -2149,6 +2150,20 @@ func (meetingsMapper) Create(tenantID types.TenantID, uid types.Uid, meeting *ty 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) { if uid.IsZero() || strings.TrimSpace(meetingID) == "" { return nil, types.ErrMalformed