diff --git a/docs/IM-数据库变更汇总.sql b/docs/IM-数据库变更汇总.sql index 9fd84e7..f4afacf 100644 --- a/docs/IM-数据库变更汇总.sql +++ b/docs/IM-数据库变更汇总.sql @@ -460,6 +460,7 @@ CREATE TABLE IF NOT EXISTS tinode.im_meetings ( id BIGINT NOT NULL AUTO_INCREMENT, meeting_id VARCHAR(64) NOT NULL, organizer_id BIGINT NOT NULL, + recorder_user_id BIGINT NOT NULL, title VARCHAR(200) NOT NULL, start_at DATETIME(3) NOT NULL, end_at DATETIME(3) NOT NULL, @@ -475,6 +476,7 @@ CREATE TABLE IF NOT EXISTS tinode.im_meetings ( UNIQUE INDEX uk_im_meetings_meeting (meeting_id), UNIQUE INDEX uk_im_meetings_idempotency (organizer_id, idempotency_key), INDEX idx_im_meetings_organizer (organizer_id, start_at), + INDEX idx_im_meetings_recorder (recorder_user_id, end_at), INDEX idx_im_meetings_state_time (state, start_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; @@ -493,6 +495,54 @@ CREATE TABLE IF NOT EXISTS tinode.im_meeting_participants ( CONSTRAINT fk_im_meeting_participants_meeting FOREIGN KEY (meeting_id) REFERENCES tinode.im_meetings(meeting_id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +SET @im_sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = 'tinode' AND TABLE_NAME = 'im_meetings' AND COLUMN_NAME = 'recorder_user_id') = 0, + 'ALTER TABLE tinode.im_meetings ADD COLUMN recorder_user_id BIGINT NOT NULL DEFAULT 0 AFTER organizer_id', + 'SELECT 1' +); +PREPARE im_stmt FROM @im_sql; +EXECUTE im_stmt; +DEALLOCATE PREPARE im_stmt; + +UPDATE tinode.im_meetings SET recorder_user_id = organizer_id WHERE recorder_user_id = 0; + +SET @im_sql = IF( + (SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = 'tinode' AND TABLE_NAME = 'im_meetings' AND INDEX_NAME = 'idx_im_meetings_recorder') = 0, + 'CREATE INDEX idx_im_meetings_recorder ON tinode.im_meetings(recorder_user_id, end_at)', + 'SELECT 1' +); +PREPARE im_stmt FROM @im_sql; +EXECUTE im_stmt; +DEALLOCATE PREPARE im_stmt; + +CREATE TABLE IF NOT EXISTS tinode.im_meeting_note_files ( + id BIGINT NOT NULL AUTO_INCREMENT, + note_file_id VARCHAR(64) NOT NULL, + meeting_id VARCHAR(64) NOT NULL, + file_id VARCHAR(64) NOT NULL, + uploader_id BIGINT NOT NULL, + file_name VARCHAR(255) NOT NULL DEFAULT '', + mime_type VARCHAR(128) NOT NULL DEFAULT '', + file_size BIGINT NOT NULL DEFAULT 0, + file_etag VARCHAR(128) NOT NULL DEFAULT '', + status VARCHAR(16) NOT NULL DEFAULT 'active', + preview_status VARCHAR(16) NOT NULL DEFAULT 'ready', + preview_mode VARCHAR(16) NOT NULL DEFAULT 'native', + preview_file_id VARCHAR(64) NOT NULL DEFAULT '', + idempotency_key VARCHAR(128) NOT NULL DEFAULT '', + version INT NOT NULL DEFAULT 1, + deleted_by BIGINT, + deleted_at DATETIME(3), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE INDEX uk_im_meeting_note_files_id (note_file_id), + INDEX idx_im_meeting_note_files_file (meeting_id, file_id, status), + UNIQUE INDEX uk_im_meeting_note_files_idempotency (meeting_id, uploader_id, idempotency_key), + INDEX idx_im_meeting_note_files_meeting (meeting_id, status, id DESC), + INDEX idx_im_meeting_note_files_uploader (uploader_id, id DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + CREATE TABLE IF NOT EXISTS tinode.im_daily_reports ( id BIGINT NOT NULL AUTO_INCREMENT, report_id VARCHAR(64) NOT NULL, @@ -946,6 +996,7 @@ VALUES ('organization', '组织架构', '/organization', 10, 'organization', 1, 'enabled'), ('attendance', '打卡', '/attendance', 20, 'attendance', 1, 'enabled'), ('meetings', '会议通知', '/meetings', 30, 'meetings', 1, 'enabled'), + ('meeting_notes', '会议笔记', '/meeting-notes', 35, '', 1, 'enabled'), ('daily_reports', '日报', '/reports/daily', 40, 'daily_reports', 1, 'enabled'), ('monthly_reports', '月报', '/reports/monthly', 50, 'monthly_reports', 1, 'enabled'), ('executive_mailbox', '总经理信箱', '/executive-mailbox', 60, 'executive_mailbox', 1, 'enabled') diff --git a/server/db/adapter.go b/server/db/adapter.go index 2886328..164f055 100644 --- a/server/db/adapter.go +++ b/server/db/adapter.go @@ -412,6 +412,11 @@ type TenantBusinessAdapter interface { TenantMeetingCancel(t.TenantID, t.Uid, string, string) (*t.Meeting, error) TenantMeetingRemind(t.TenantID, t.Uid, string, string) ([]t.MeetingParticipant, error) TenantMeetingRespond(t.TenantID, t.Uid, string, string, int) (*t.Meeting, error) + TenantMeetingNoteList(t.TenantID, t.Uid, string, string, int) ([]t.Meeting, string, error) + TenantMeetingNoteFiles(t.TenantID, t.Uid, string) ([]t.MeetingNoteFile, error) + TenantMeetingNoteFileCreate(t.TenantID, t.Uid, string, string, string, string) (*t.MeetingNoteFile, error) + TenantMeetingNoteFileReplace(t.TenantID, t.Uid, string, string, string, string, int) (*t.MeetingNoteFile, error) + TenantMeetingNoteFileDelete(t.TenantID, t.Uid, string, string, string, int) (*t.MeetingNoteFile, error) TenantDailyReportList(t.TenantID, t.Uid, string, string, string, string, string, int) ([]t.DailyReport, string, error) TenantDailyReportCreateDraft(t.TenantID, t.Uid, string, time.Time) (*t.DailyReport, error) TenantDailyReportGet(t.TenantID, t.Uid, string) (*t.DailyReport, error) diff --git a/server/db/mysql/adapter.go b/server/db/mysql/adapter.go index b9fea07..3520094 100644 --- a/server/db/mysql/adapter.go +++ b/server/db/mysql/adapter.go @@ -281,6 +281,9 @@ func (a *adapter) Open(jsonconfig json.RawMessage) error { if err = ensureIMAttendanceTables(a.db); err != nil { return errors.New("mysql adapter failed to ensure attendance schema: " + err.Error()) } + if err = ensureIMMeetingNotesTables(a.db); err != nil { + return errors.New("mysql adapter failed to ensure meeting notes schema: " + err.Error()) + } if err = ensureSnowflakePrimaryKeys(a.db); err != nil { return errors.New("mysql adapter failed to ensure snowflake primary keys: " + err.Error()) } @@ -521,6 +524,10 @@ func (a *adapter) TenantAdapterFor(tenantID t.TenantID) (dbadapter.TenantBusines db.Close() return nil, nil, err } + if err = ensureIMMeetingNotesTables(db); err != nil { + db.Close() + return nil, nil, err + } if err = ensureSnowflakePrimaryKeys(db); err != nil { db.Close() return nil, nil, err @@ -1212,6 +1219,7 @@ func createIMWorkbenchTables(tx *sql.Tx) error { ('organization','组织架构','/organization',10,'organization',1,'enabled'), ('attendance','打卡','/attendance',20,'attendance',1,'enabled'), ('meetings','会议通知','/meetings',30,'meetings',1,'enabled'), + ('meeting_notes','会议笔记','/meeting-notes',35,'',1,'enabled'), ('daily_reports','日报','/reports/daily',40,'daily_reports',1,'enabled'), ('monthly_reports','月报','/reports/monthly',50,'monthly_reports',1,'enabled'), ('executive_mailbox','总经理信箱','/executive-mailbox',60,'executive_mailbox',1,'enabled') @@ -1453,6 +1461,7 @@ func createIMMeetingTables(tx *sql.Tx) error { id BIGINT NOT NULL AUTO_INCREMENT, meeting_id VARCHAR(64) NOT NULL, organizer_id BIGINT NOT NULL, + recorder_user_id BIGINT NOT NULL, title VARCHAR(200) NOT NULL, start_at DATETIME(3) NOT NULL, end_at DATETIME(3) NOT NULL, @@ -1468,6 +1477,7 @@ func createIMMeetingTables(tx *sql.Tx) error { UNIQUE INDEX uk_im_meetings_meeting(meeting_id), UNIQUE INDEX uk_im_meetings_idempotency(organizer_id, idempotency_key), INDEX idx_im_meetings_organizer(organizer_id, start_at), + INDEX idx_im_meetings_recorder(recorder_user_id, end_at), INDEX idx_im_meetings_state_time(state, start_at) )`, `CREATE TABLE im_meeting_participants( @@ -1484,6 +1494,33 @@ func createIMMeetingTables(tx *sql.Tx) error { INDEX idx_im_meeting_participants_response(meeting_id, response), CONSTRAINT fk_im_meeting_participants_meeting FOREIGN KEY(meeting_id) REFERENCES im_meetings(meeting_id) ON DELETE CASCADE )`, + `CREATE TABLE im_meeting_note_files( + id BIGINT NOT NULL AUTO_INCREMENT, + note_file_id VARCHAR(64) NOT NULL, + meeting_id VARCHAR(64) NOT NULL, + file_id VARCHAR(64) NOT NULL, + uploader_id BIGINT NOT NULL, + file_name VARCHAR(255) NOT NULL DEFAULT '', + mime_type VARCHAR(128) NOT NULL DEFAULT '', + file_size BIGINT NOT NULL DEFAULT 0, + file_etag VARCHAR(128) NOT NULL DEFAULT '', + status VARCHAR(16) NOT NULL DEFAULT 'active', + preview_status VARCHAR(16) NOT NULL DEFAULT 'ready', + preview_mode VARCHAR(16) NOT NULL DEFAULT 'native', + preview_file_id VARCHAR(64) NOT NULL DEFAULT '', + idempotency_key VARCHAR(128) NOT NULL DEFAULT '', + version INT NOT NULL DEFAULT 1, + deleted_by BIGINT, + deleted_at DATETIME(3), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), + UNIQUE INDEX uk_im_meeting_note_files_id(note_file_id), + INDEX idx_im_meeting_note_files_file(meeting_id,file_id,status), + UNIQUE INDEX uk_im_meeting_note_files_idempotency(meeting_id,uploader_id,idempotency_key), + INDEX idx_im_meeting_note_files_meeting(meeting_id,status,id DESC), + INDEX idx_im_meeting_note_files_uploader(uploader_id,id DESC) + )`, } for _, stmt := range statements { if _, err := tx.Exec(stmt); err != nil { @@ -1493,6 +1530,63 @@ func createIMMeetingTables(tx *sql.Tx) error { return nil } +func ensureIMMeetingNotesTables(db *sqlx.DB) error { + if db == nil { + return nil + } + var recorderExists bool + if err := db.QueryRow(`SELECT EXISTS( + SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='im_meetings' AND column_name='recorder_user_id' + )`).Scan(&recorderExists); err != nil { + return err + } + if !recorderExists { + if _, err := db.Exec(`ALTER TABLE im_meetings ADD COLUMN recorder_user_id BIGINT NOT NULL DEFAULT 0 AFTER organizer_id`); err != nil { + return err + } + if _, err := db.Exec(`UPDATE im_meetings SET recorder_user_id=organizer_id WHERE recorder_user_id=0`); err != nil { + return err + } + if _, err := db.Exec(`CREATE INDEX idx_im_meetings_recorder ON im_meetings(recorder_user_id,end_at)`); err != nil && !isDupe(err) { + return err + } + } + if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS im_meeting_note_files( + id BIGINT NOT NULL AUTO_INCREMENT, + note_file_id VARCHAR(64) NOT NULL, + meeting_id VARCHAR(64) NOT NULL, + file_id VARCHAR(64) NOT NULL, + uploader_id BIGINT NOT NULL, + file_name VARCHAR(255) NOT NULL DEFAULT '', + mime_type VARCHAR(128) NOT NULL DEFAULT '', + file_size BIGINT NOT NULL DEFAULT 0, + file_etag VARCHAR(128) NOT NULL DEFAULT '', + status VARCHAR(16) NOT NULL DEFAULT 'active', + preview_status VARCHAR(16) NOT NULL DEFAULT 'ready', + preview_mode VARCHAR(16) NOT NULL DEFAULT 'native', + preview_file_id VARCHAR(64) NOT NULL DEFAULT '', + idempotency_key VARCHAR(128) NOT NULL DEFAULT '', + version INT NOT NULL DEFAULT 1, + deleted_by BIGINT, + deleted_at DATETIME(3), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), + UNIQUE INDEX uk_im_meeting_note_files_id(note_file_id), + INDEX idx_im_meeting_note_files_file(meeting_id,file_id,status), + UNIQUE INDEX uk_im_meeting_note_files_idempotency(meeting_id,uploader_id,idempotency_key), + INDEX idx_im_meeting_note_files_meeting(meeting_id,status,id DESC), + INDEX idx_im_meeting_note_files_uploader(uploader_id,id DESC) + )`); err != nil { + return err + } + _, err := db.Exec(`INSERT INTO im_workbench_apps(code,name,route,sort_order,unread_key,enabled,permission) + VALUES('meeting_notes','会议笔记','/meeting-notes',35,'',1,'enabled') + ON DUPLICATE KEY UPDATE name=VALUES(name),route=VALUES(route),sort_order=VALUES(sort_order),unread_key=VALUES(unread_key),updated_at=CURRENT_TIMESTAMP(3)`) + return err +} + func createIMReportTables(tx *sql.Tx) error { if _, err := tx.Exec( `CREATE TABLE im_daily_reports( @@ -4936,6 +5030,14 @@ func (a *adapter) FileDeleteUnused(olderThan time.Time, limit int) ([]string, er if err = rows.Scan(&id, &loc); err != nil { break } + var noteRefs int + fileID := store.EncodeUid(int64(id)).String() + if err = tx.QueryRow(`SELECT COUNT(*) FROM im_meeting_note_files WHERE file_id=? AND status=? LIMIT 1`, fileID, t.MeetingNoteFileStatusActive).Scan(¬eRefs); err != nil { + break + } + if noteRefs > 0 { + continue + } if loc != "" { locations = append(locations, loc) } @@ -5075,8 +5177,25 @@ func (a *adapter) FileUserHasAccess(fid string, uid t.Uid) (bool, error) { AND d.low<=m.seqid AND (d.hi=0 OR d.hi>m.seqid) ) ) + OR EXISTS ( + SELECT 1 FROM im_meeting_note_files AS mnf + INNER JOIN im_meetings AS mtg + ON mtg.meeting_id=mnf.meeting_id + WHERE mnf.file_id=? AND mnf.status='active' + AND mtg.state<>? + AND mtg.end_at<=CURRENT_TIMESTAMP(3) + AND ( + mtg.organizer_id=? + OR mtg.recorder_user_id=? + OR EXISTS ( + SELECT 1 FROM im_meeting_participants AS mp + WHERE mp.meeting_id=mtg.meeting_id AND mp.user_id=? + ) + ) + ) ) LIMIT 1`, - store.DecodeUid(fileID), t.UploadCompleted, store.DecodeUid(uid), store.DecodeUid(uid), store.DecodeUid(uid), store.DecodeUid(uid), store.DecodeUid(uid)) + store.DecodeUid(fileID), t.UploadCompleted, store.DecodeUid(uid), store.DecodeUid(uid), store.DecodeUid(uid), store.DecodeUid(uid), store.DecodeUid(uid), + fid, t.MeetingStateCanceled, store.DecodeUid(uid), store.DecodeUid(uid), store.DecodeUid(uid)) if err != nil { return false, err } diff --git a/server/db/mysql/tenant_adapter.go b/server/db/mysql/tenant_adapter.go index 682ae6e..b8a83a9 100644 --- a/server/db/mysql/tenant_adapter.go +++ b/server/db/mysql/tenant_adapter.go @@ -3346,8 +3346,12 @@ func (a *adapter) TenantMeetingCreate(id t.TenantID, uid t.Uid, meeting *t.Meeti if meeting.IdempotencyKey != "" { idempotencyKey = meeting.IdempotencyKey } - _, err = tx.ExecContext(ctx, `INSERT INTO im_meetings(meeting_id,organizer_id,title,start_at,end_at,location,state,reminder_minutes,idempotency_key,version) - VALUES(?,?,?,?,?,?,?,?,?,1)`, meeting.MeetingID, decodeUID(uid), strings.TrimSpace(meeting.Title), meeting.StartAt.UTC(), meeting.EndAt.UTC(), strings.TrimSpace(meeting.Location), + recorderID := meeting.RecorderID + if recorderID.IsZero() { + recorderID = uid + } + _, err = tx.ExecContext(ctx, `INSERT INTO im_meetings(meeting_id,organizer_id,recorder_user_id,title,start_at,end_at,location,state,reminder_minutes,idempotency_key,version) + VALUES(?,?,?,?,?,?,?,?,?,?,1)`, meeting.MeetingID, decodeUID(uid), decodeUID(recorderID), strings.TrimSpace(meeting.Title), meeting.StartAt.UTC(), meeting.EndAt.UTC(), strings.TrimSpace(meeting.Location), t.MeetingStateScheduled, string(reminderJSON), idempotencyKey) if err != nil { if isDupe(err) && meeting.IdempotencyKey != "" { @@ -3357,7 +3361,7 @@ func (a *adapter) TenantMeetingCreate(id t.TenantID, uid t.Uid, meeting *t.Meeti } return nil, err } - participants := uniqueMeetingParticipants(uid, attendeeIDs) + participants := uniqueMeetingParticipants(uid, append(attendeeIDs, recorderID)) now := time.Now().UTC().Round(time.Millisecond) for _, participant := range participants { response := t.MeetingResponsePending @@ -3400,10 +3404,10 @@ func (a *adapter) TenantMeetingList(id t.TenantID, uid t.Uid, cursor string, lim if limit <= 0 || limit > 100 { limit = 20 } - where := []string{`(m.organizer_id=? OR EXISTS ( + where := []string{`(m.organizer_id=? OR m.recorder_user_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)} + args := []any{decodeUID(uid), decodeUID(uid), decodeUID(uid)} if strings.TrimSpace(cursor) != "" { parsed, err := strconv.ParseInt(strings.TrimSpace(cursor), 10, 64) if err != nil || parsed <= 0 { @@ -3431,6 +3435,7 @@ func (a *adapter) TenantMeetingList(id t.TenantID, uid t.Uid, cursor string, lim ID int64 MeetingID string `db:"meeting_id"` OrganizerID int64 `db:"organizer_id"` + RecorderID int64 `db:"recorder_user_id"` Title string StartAt time.Time `db:"start_at"` EndAt time.Time `db:"end_at"` @@ -3450,6 +3455,7 @@ func (a *adapter) TenantMeetingList(id t.TenantID, uid t.Uid, cursor string, lim ID: row.ID, MeetingID: row.MeetingID, OrganizerID: store.EncodeUid(row.OrganizerID), + RecorderID: store.EncodeUid(row.RecorderID), Title: row.Title, StartAt: row.StartAt, EndAt: row.EndAt, @@ -3463,6 +3469,9 @@ func (a *adapter) TenantMeetingList(id t.TenantID, uid t.Uid, cursor string, lim if row.IdempotencyKey.Valid { meeting.IdempotencyKey = row.IdempotencyKey.String } + if meeting.RecorderID.IsZero() { + meeting.RecorderID = meeting.OrganizerID + } if len(row.ReminderMinutes) > 0 { _ = json.Unmarshal(row.ReminderMinutes, &meeting.ReminderMinutes) } @@ -3571,6 +3580,13 @@ func (a *adapter) TenantMeetingUpdate(id t.TenantID, uid t.Uid, meetingID string sets = append(sets, "location=?") args = append(args, strings.TrimSpace(*patch.Location)) } + if patch.RecorderID != nil { + if patch.RecorderID.IsZero() { + return nil, t.ErrMalformed + } + sets = append(sets, "recorder_user_id=?") + args = append(args, decodeUID(*patch.RecorderID)) + } if patch.ReminderMinutes != nil { encoded, err := json.Marshal(patch.ReminderMinutes) if err != nil { @@ -3600,6 +3616,12 @@ func (a *adapter) TenantMeetingUpdate(id t.TenantID, uid t.Uid, meetingID string if affected == 0 { return nil, t.ErrInvalidResponse } + if patch.RecorderID != nil { + if _, err = tx.ExecContext(ctx, `INSERT IGNORE INTO im_meeting_participants(meeting_id,user_id,response,response_at) + VALUES(?,?,?,?)`, meeting.MeetingID, decodeUID(*patch.RecorderID), t.MeetingResponsePending, nil); err != nil { + return nil, err + } + } participantIDs, err := meetingParticipantIDsTx(ctx, tx, meeting.MeetingID, "") if err != nil { return nil, err @@ -3881,6 +3903,7 @@ func (a *adapter) meetingByID(ctx context.Context, meetingID string, viewer t.Ui ID int64 MeetingID string `db:"meeting_id"` OrganizerID int64 `db:"organizer_id"` + RecorderID int64 `db:"recorder_user_id"` Title string StartAt time.Time `db:"start_at"` EndAt time.Time `db:"end_at"` @@ -3894,9 +3917,9 @@ func (a *adapter) meetingByID(ctx context.Context, meetingID string, viewer t.Ui UpdatedAt time.Time `db:"updated_at"` }{} if err := sqlx.GetContext(ctx, a.db, &row, `SELECT m.* FROM im_meetings AS m - WHERE m.meeting_id=? AND (m.organizer_id=? OR EXISTS ( + WHERE m.meeting_id=? AND (m.organizer_id=? OR m.recorder_user_id=? OR EXISTS ( SELECT 1 FROM im_meeting_participants AS p WHERE p.meeting_id=m.meeting_id AND p.user_id=? - ))`, meetingID, decodeUID(viewer), decodeUID(viewer)); err != nil { + ))`, meetingID, decodeUID(viewer), decodeUID(viewer), decodeUID(viewer)); err != nil { if err == sql.ErrNoRows { return nil, t.ErrNotFound } @@ -3906,6 +3929,7 @@ func (a *adapter) meetingByID(ctx context.Context, meetingID string, viewer t.Ui ID: row.ID, MeetingID: row.MeetingID, OrganizerID: store.EncodeUid(row.OrganizerID), + RecorderID: store.EncodeUid(row.RecorderID), Title: row.Title, StartAt: row.StartAt, EndAt: row.EndAt, @@ -3919,6 +3943,9 @@ func (a *adapter) meetingByID(ctx context.Context, meetingID string, viewer t.Ui if row.IdempotencyKey.Valid { meeting.IdempotencyKey = row.IdempotencyKey.String } + if meeting.RecorderID.IsZero() { + meeting.RecorderID = meeting.OrganizerID + } if len(row.ReminderMinutes) > 0 { _ = json.Unmarshal(row.ReminderMinutes, &meeting.ReminderMinutes) } @@ -3969,6 +3996,402 @@ func (a *adapter) meetingParticipantsByQuery(ctx context.Context, query string, return participants, nil } +func (a *adapter) TenantMeetingNoteList(id t.TenantID, uid t.Uid, query, 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.TenantMeetingNoteList(id, uid, query, cursor, limit) + } + } + if err := validTenant(id); err != nil || uid.IsZero() { + return nil, "", t.ErrMalformed + } + if limit <= 0 || limit > 100 { + limit = 20 + } + where := []string{`m.state<>?`, `m.end_at<=?`, `(m.organizer_id=? OR m.recorder_user_id=? OR EXISTS ( + SELECT 1 FROM im_meeting_participants AS p WHERE p.meeting_id=m.meeting_id AND p.user_id=? + ))`} + args := []any{t.MeetingStateCanceled, time.Now().UTC(), decodeUID(uid), decodeUID(uid), decodeUID(uid)} + if q := strings.TrimSpace(query); q != "" { + where = append(where, "(m.title LIKE ? OR m.location LIKE ?)") + like := "%" + q + "%" + args = append(args, like, like) + } + 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) + } + return meeting, row.ID, nil +} + +func (a *adapter) meetingNoteVisible(ctx context.Context, uid t.Uid, meetingID string) (*t.Meeting, error) { + meeting, err := a.meetingByID(ctx, meetingID, uid) + if err != nil { + return nil, err + } + if meeting.State == t.MeetingStateCanceled || time.Now().UTC().Before(meeting.EndAt) { + return nil, t.ErrPermissionDenied + } + participants, err := a.meetingParticipants(ctx, meeting.MeetingID) + if err != nil { + return nil, err + } + meeting.Participants = participants + meeting.State = t.MeetingStateEnded + return meeting, nil +} + +func (a *adapter) meetingNoteWritable(ctx context.Context, uid t.Uid, meetingID string) (*t.Meeting, error) { + meeting, err := a.meetingNoteVisible(ctx, uid, meetingID) + if err != nil { + return nil, err + } + if meeting.RecorderID != uid { + return nil, t.ErrPermissionDenied + } + return meeting, nil +} + +func (a *adapter) meetingNoteFiles(ctx context.Context, meetingID string) ([]t.MeetingNoteFile, error) { + rows, err := a.db.QueryxContext(ctx, `SELECT * FROM im_meeting_note_files + WHERE meeting_id=? AND status=? ORDER BY id DESC`, meetingID, t.MeetingNoteFileStatusActive) + if err != nil { + return nil, err + } + defer rows.Close() + items := make([]t.MeetingNoteFile, 0, 4) + for rows.Next() { + item, err := scanMeetingNoteFileRow(rows) + if err != nil { + return nil, err + } + items = append(items, item) + } + return items, rows.Err() +} + +func (a *adapter) meetingNoteFileByID(ctx context.Context, meetingID, noteFileID string) (*t.MeetingNoteFile, error) { + row := a.db.QueryRowxContext(ctx, `SELECT * FROM im_meeting_note_files WHERE meeting_id=? AND note_file_id=? LIMIT 1`, meetingID, noteFileID) + item, err := scanMeetingNoteFileRow(row) + if err == sql.ErrNoRows { + return nil, t.ErrNotFound + } + if err != nil { + return nil, err + } + return &item, nil +} + +func (a *adapter) meetingNoteFileByIdempotency(ctx context.Context, meetingID string, uid t.Uid, idempotencyKey string) (*t.MeetingNoteFile, error) { + row := a.db.QueryRowxContext(ctx, `SELECT * FROM im_meeting_note_files WHERE meeting_id=? AND uploader_id=? AND idempotency_key=? LIMIT 1`, meetingID, decodeUID(uid), idempotencyKey) + item, err := scanMeetingNoteFileRow(row) + if err == sql.ErrNoRows { + return nil, t.ErrNotFound + } + if err != nil { + return nil, err + } + return &item, nil +} + +func scanMeetingNoteFileRow(scanner interface{ StructScan(any) error }) (t.MeetingNoteFile, error) { + row := struct { + ID int64 + NoteFileID string `db:"note_file_id"` + MeetingID string `db:"meeting_id"` + FileID string `db:"file_id"` + UploaderID int64 `db:"uploader_id"` + FileName string `db:"file_name"` + MIMEType string `db:"mime_type"` + FileSize int64 `db:"file_size"` + FileETag string `db:"file_etag"` + Status string + PreviewStatus string `db:"preview_status"` + PreviewMode string `db:"preview_mode"` + PreviewFileID string `db:"preview_file_id"` + IdempotencyKey string `db:"idempotency_key"` + Version int + DeletedBy sql.NullInt64 `db:"deleted_by"` + DeletedAt *time.Time `db:"deleted_at"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + }{} + if err := scanner.StructScan(&row); err != nil { + return t.MeetingNoteFile{}, err + } + item := t.MeetingNoteFile{ + ID: row.ID, + NoteFileID: row.NoteFileID, + MeetingID: row.MeetingID, + FileID: row.FileID, + UploaderID: store.EncodeUid(row.UploaderID), + FileName: row.FileName, + MIMEType: row.MIMEType, + FileSize: row.FileSize, + FileETag: row.FileETag, + Status: row.Status, + PreviewStatus: row.PreviewStatus, + PreviewMode: row.PreviewMode, + PreviewFileID: row.PreviewFileID, + IdempotencyKey: row.IdempotencyKey, + Version: row.Version, + DeletedAt: row.DeletedAt, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + } + if row.DeletedBy.Valid { + item.DeletedBy = store.EncodeUid(row.DeletedBy.Int64) + } + return item, nil +} + func uniqueMeetingParticipants(organizer t.Uid, attendeeIDs []t.Uid) []t.Uid { seen := map[t.Uid]bool{organizer: true} participants := []t.Uid{organizer} diff --git a/server/db/mysql/tenant_groups.go b/server/db/mysql/tenant_groups.go index eaa79b0..00cb322 100644 --- a/server/db/mysql/tenant_groups.go +++ b/server/db/mysql/tenant_groups.go @@ -380,7 +380,7 @@ func (a *adapter) TenantGroupInvitationCreate(id t.TenantID, inv *t.GroupInvitat inv.IdempotencyKey = inv.InvitationID } if inv.Status == "" { - inv.Status = t.GroupInvitationStatusJoined + inv.Status = t.GroupInvitationStatusPending } if inv.ExpiresAt.IsZero() { inv.ExpiresAt = now.Add(7 * 24 * time.Hour) diff --git a/server/im_http.go b/server/im_http.go index caab605..44b7d1a 100644 --- a/server/im_http.go +++ b/server/im_http.go @@ -1356,6 +1356,10 @@ func serveIMAbuseReasons(wrt http.ResponseWriter, req *http.Request) { return } targetType := strings.ToLower(strings.TrimSpace(req.URL.Query().Get("target_type"))) + if !isAllowedIMAbuseTargetType(targetType) { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "target_type"}) + return + } reasons, err := store.Safety.AbuseReasons(principal.TenantID, targetType) if err != nil { logs.Warn.Println("im abuse reasons: using defaults after store error", principal.TenantID, targetType, err) @@ -2116,6 +2120,7 @@ func serveIMMeetings(wrt http.ResponseWriter, req *http.Request) { } var body struct { Title string `json:"title"` + RecorderUserID string `json:"recorder_user_id"` StartAt string `json:"start_at"` EndAt string `json:"end_at"` Location string `json:"location"` @@ -2144,9 +2149,25 @@ func serveIMMeetings(wrt http.ResponseWriter, req *http.Request) { if !ok { return } + recorderID := principal.Uid + if value := strings.TrimSpace(body.RecorderUserID); value != "" { + recorderID = types.ParseUserId(value) + if recorderID.IsZero() { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "recorder_user_id"}) + return + } + } + if !ensureIMMeetingUserExists(principal.TenantID, recorderID) { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "recorder_user_id"}) + return + } + if !uidListContains(attendeeIDs, recorderID) && recorderID != principal.Uid { + attendeeIDs = append(attendeeIDs, recorderID) + } meeting, err := store.Meetings.Create(principal.TenantID, principal.Uid, &types.Meeting{ MeetingID: "mtg_" + meetingID, OrganizerID: principal.Uid, + RecorderID: recorderID, Title: strings.TrimSpace(body.Title), StartAt: startAt, EndAt: endAt, @@ -2166,7 +2187,7 @@ func serveIMMeetings(wrt http.ResponseWriter, req *http.Request) { } publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "meeting", "meeting.created", meeting.MeetingID, meetingParticipantUIDs(meeting), "会议通知", meeting.Title, map[string]any{"meeting_id": meeting.MeetingID, "version": meeting.Version, "state": meeting.State}) - writeIMSuccess(wrt, req, meetingPayload(meeting, principal.Uid)) + writeIMSuccess(wrt, req, meetingPayloadForTenant(principal.TenantID, meeting, principal.Uid)) } func serveIMMeetingList(wrt http.ResponseWriter, req *http.Request) { @@ -2217,6 +2238,148 @@ func serveIMMeetingResource(wrt http.ResponseWriter, req *http.Request) { case "my-response": serveIMMeetingMyResponse(wrt, req, meetingID) return + case "notes": + serveIMMeetingNotesDetail(wrt, req, meetingID) + return + } + if strings.HasPrefix(tail, "notes/files") { + serveIMMeetingNoteFiles(wrt, req, meetingID, tail) + return + } + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) +} + +func serveIMMeetingNotes(wrt http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + limit := parseIMLimit(req.URL.Query().Get("limit"), 20, 100) + meetings, nextCursor, err := store.MeetingNotes.List(principal.TenantID, principal.Uid, strings.TrimSpace(req.URL.Query().Get("query")), strings.TrimSpace(req.URL.Query().Get("cursor")), limit) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + userSummaries := meetingUserSummaries(principal.TenantID, meetings) + items := make([]map[string]any, 0, len(meetings)) + for i := range meetings { + files, err := store.MeetingNotes.ListFiles(principal.TenantID, principal.Uid, meetings[i].MeetingID) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + items = append(items, meetingNoteListItemPayload(&meetings[i], files, principal.Uid, userSummaries)) + } + writeIMSuccess(wrt, req, map[string]any{ + "items": items, + "next_cursor": nextCursor, + "has_more": nextCursor != "", + }) +} + +func serveIMMeetingNotesDetail(wrt http.ResponseWriter, req *http.Request, meetingID string) { + if req.Method != http.MethodGet { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + meeting, err := store.Meetings.Get(principal.TenantID, principal.Uid, meetingID) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + if !meetingNotesReadable(meeting, principal.Uid) { + writeIMRelationError(wrt, req, types.ErrPermissionDenied) + return + } + files, err := store.MeetingNotes.ListFiles(principal.TenantID, principal.Uid, meetingID) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, meetingNotesDetailPayload(principal.TenantID, meeting, files, principal.Uid)) +} + +func serveIMMeetingNoteFiles(wrt http.ResponseWriter, req *http.Request, meetingID, tail string) { + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + switch { + case tail == "notes/files" && req.Method == http.MethodPost: + idempotencyKey, ok := requireIMIdempotencyKey(wrt, req) + if !ok { + return + } + var body struct { + FileID string `json:"file_id"` + } + if !decodeIMJSONBody(wrt, req, &body) { + return + } + if strings.TrimSpace(body.FileID) == "" { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "file_id"}) + return + } + noteFileID := "mnote_" + store.Store.GetUidString() + file, err := store.MeetingNotes.CreateFile(principal.TenantID, principal.Uid, meetingID, noteFileID, body.FileID, idempotencyKey) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, meetingNoteFilePayload(file, principal.Uid, nil)) + return + case strings.HasPrefix(tail, "notes/files/"): + noteFileID := strings.TrimSpace(strings.TrimPrefix(tail, "notes/files/")) + if noteFileID == "" || strings.Contains(noteFileID, "/") { + writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "meeting note file route not found", nil) + return + } + if req.Method == http.MethodPut { + idempotencyKey, ok := requireIMIdempotencyKey(wrt, req) + if !ok { + return + } + var body struct { + FileID string `json:"file_id"` + Version int `json:"version"` + } + if !decodeIMJSONBody(wrt, req, &body) { + return + } + if strings.TrimSpace(body.FileID) == "" { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "file_id"}) + return + } + file, err := store.MeetingNotes.ReplaceFile(principal.TenantID, principal.Uid, meetingID, noteFileID, body.FileID, idempotencyKey, body.Version) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, meetingNoteFilePayload(file, principal.Uid, nil)) + return + } + if req.Method == http.MethodDelete { + version, err := strconv.Atoi(strings.TrimSpace(req.URL.Query().Get("version"))) + if err != nil || version <= 0 { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "version"}) + return + } + file, err := store.MeetingNotes.DeleteFile(principal.TenantID, principal.Uid, meetingID, noteFileID, strings.TrimSpace(req.Header.Get("Idempotency-Key")), version) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, meetingNoteFilePayload(file, principal.Uid, nil)) + return + } } writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) } @@ -2231,7 +2394,7 @@ func serveIMMeetingGet(wrt http.ResponseWriter, req *http.Request, meetingID str writeIMRelationError(wrt, req, err) return } - writeIMSuccess(wrt, req, meetingPayload(meeting, principal.Uid)) + writeIMSuccess(wrt, req, meetingPayloadForTenant(principal.TenantID, meeting, principal.Uid)) } func serveIMMeetingUpdate(wrt http.ResponseWriter, req *http.Request, meetingID string) { @@ -2245,6 +2408,7 @@ func serveIMMeetingUpdate(wrt http.ResponseWriter, req *http.Request, meetingID } var body struct { Title *string `json:"title"` + RecorderUserID *string `json:"recorder_user_id"` StartAt *string `json:"start_at"` EndAt *string `json:"end_at"` Location *string `json:"location"` @@ -2255,6 +2419,18 @@ func serveIMMeetingUpdate(wrt http.ResponseWriter, req *http.Request, meetingID return } patch := types.MeetingPatch{Title: body.Title, Location: body.Location, ReminderMinutes: body.ReminderMinutes, Version: body.Version} + if body.RecorderUserID != nil { + recorderID := types.ParseUserId(strings.TrimSpace(*body.RecorderUserID)) + if recorderID.IsZero() { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "recorder_user_id"}) + return + } + if !ensureIMMeetingUserExists(principal.TenantID, recorderID) { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "recorder_user_id"}) + return + } + patch.RecorderID = &recorderID + } if body.StartAt != nil { startAt, err := parseIMTime(*body.StartAt) if err != nil { @@ -2284,7 +2460,7 @@ func serveIMMeetingUpdate(wrt http.ResponseWriter, req *http.Request, meetingID } publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "meeting", "meeting.updated", meeting.MeetingID, meetingParticipantUIDs(meeting), "会议通知", meeting.Title, map[string]any{"meeting_id": meeting.MeetingID, "version": meeting.Version, "state": meeting.State}) - writeIMSuccess(wrt, req, meetingPayload(meeting, principal.Uid)) + writeIMSuccess(wrt, req, meetingPayloadForTenant(principal.TenantID, meeting, principal.Uid)) } func serveIMMeetingCancel(wrt http.ResponseWriter, req *http.Request, meetingID string) { @@ -2313,7 +2489,7 @@ func serveIMMeetingCancel(wrt http.ResponseWriter, req *http.Request, meetingID } publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "meeting", "meeting.canceled", meeting.MeetingID, meetingParticipantUIDs(meeting), "会议通知", meeting.Title, map[string]any{"meeting_id": meeting.MeetingID, "version": meeting.Version, "state": meeting.State}) - writeIMSuccess(wrt, req, meetingPayload(meeting, principal.Uid)) + writeIMSuccess(wrt, req, meetingPayloadForTenant(principal.TenantID, meeting, principal.Uid)) } func serveIMMeetingReminders(wrt http.ResponseWriter, req *http.Request, meetingID string) { @@ -2383,7 +2559,7 @@ func serveIMMeetingMyResponse(wrt http.ResponseWriter, req *http.Request, meetin } publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "meeting", "meeting.response.updated", meeting.MeetingID, meetingParticipantUIDs(meeting), "会议通知", meeting.Title, map[string]any{"meeting_id": meeting.MeetingID, "version": meeting.Version, "state": meeting.State}) - writeIMSuccess(wrt, req, meetingPayload(meeting, principal.Uid)) + writeIMSuccess(wrt, req, meetingPayloadForTenant(principal.TenantID, meeting, principal.Uid)) } func serveIMDailyReports(wrt http.ResponseWriter, req *http.Request) { @@ -3351,7 +3527,10 @@ func serveIMFavoriteList(wrt http.ResponseWriter, req *http.Request) { } payload := make([]map[string]any, 0, len(items)) for _, item := range items { - payload = append(payload, favoritePayload(item)) + itemPayload, ok := favoritePayloadForPrincipal(principal, item) + if ok { + payload = append(payload, itemPayload) + } } writeIMSuccess(wrt, req, map[string]any{"items": payload, "next_cursor": nextCursor}) } @@ -3373,13 +3552,18 @@ func serveIMFavoriteBatchCreate(wrt http.ResponseWriter, req *http.Request) { created, existing := 0, 0 failed := make([]map[string]any, 0) for _, src := range body.Messages { - if src.Topic == "" || src.Seq <= 0 { + sourceTopic, sourceOK := normalizeIMFavoriteSourceTopic(principal.Uid, src.Topic) + if !sourceOK || src.Seq <= 0 { failed = append(failed, map[string]any{"topic": src.Topic, "seq": src.Seq, "reason": imReasonMalformedRequest}) continue } - msg, err := store.Messages.GetOne(principal.TenantID, src.Topic, src.Seq) + if err := requireTopicReader(principal, sourceTopic); err != nil { + failed = append(failed, map[string]any{"topic": sourceTopic, "seq": src.Seq, "reason": relationErrorReason(err)}) + continue + } + msg, err := store.Messages.GetOne(principal.TenantID, sourceTopic, src.Seq) if err != nil { - failed = append(failed, map[string]any{"topic": src.Topic, "seq": src.Seq, "reason": relationErrorReason(err)}) + failed = append(failed, map[string]any{"topic": sourceTopic, "seq": src.Seq, "reason": relationErrorReason(err)}) continue } fav := &types.FavoriteItem{ @@ -3387,11 +3571,11 @@ func serveIMFavoriteBatchCreate(wrt http.ResponseWriter, req *http.Request) { FavoriteID: "fav_" + store.Store.GetUidString(), UserID: principal.Uid, ItemType: imMessageType(msg), - SourceTopic: src.Topic, + SourceTopic: sourceTopic, SourceSeq: src.Seq, Snapshot: favoriteSnapshot(msg), AttachmentRefs: favoriteAttachmentRefs(msg), - Availability: "available", + Availability: messageAvailability(*msg), IdempotencyKey: strings.TrimSpace(req.Header.Get("Idempotency-Key")), CollectedAt: now, CreatedAt: now, @@ -3443,7 +3627,12 @@ func serveIMFavoriteDetail(wrt http.ResponseWriter, req *http.Request, favoriteI writeIMRelationError(wrt, req, err) return } - writeIMSuccess(wrt, req, favoritePayload(*item)) + payload, ok := favoritePayloadForPrincipal(principal, *item) + if !ok { + writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "topic read permission required", nil) + return + } + writeIMSuccess(wrt, req, payload) case http.MethodDelete: if err := store.Favorites.Delete(principal.TenantID, principal.Uid, favoriteID); err != nil { writeIMRelationError(wrt, req, err) @@ -4316,6 +4505,9 @@ func serveIMGroupList(wrt http.ResponseWriter, req *http.Request) { continue } item := groupListItemPayload(sub) + if settings, err := store.GroupBusiness.GetSettings(principal.TenantID, sub.Topic); err == nil { + item = mergeGroupListSettingsPayload(item, settings) + } if category != "" && groupCategory(groupMemberRole(sub.ModeGiven&sub.ModeWant)) != category { continue } @@ -4357,7 +4549,7 @@ func serveIMGroupCreate(wrt http.ResponseWriter, req *http.Request) { } topicName := genTopicName() now := time.Now().UTC().Round(time.Millisecond) - public := map[string]any{"name": name, "version": 1} + public := map[string]any{"name": name, "fn": name, "version": 1} if avatar := strings.TrimSpace(body.AvatarFileID); avatar != "" { public["avatar_file_id"] = avatar } @@ -4667,6 +4859,7 @@ func serveIMGroupDetail(wrt http.ResponseWriter, req *http.Request, topic string return } public["name"] = name + public["fn"] = name } if body.AvatarFileID != nil { public["avatar_file_id"] = strings.TrimSpace(*body.AvatarFileID) @@ -4923,6 +5116,7 @@ func serveIMGroupInvitations(wrt http.ResponseWriter, req *http.Request, topic s return } results := make([]map[string]any, 0, len(body.UserIDs)) + now := time.Now().UTC().Round(time.Millisecond) for _, rawID := range body.UserIDs { uid := types.ParseUserId(strings.TrimSpace(rawID)) item := map[string]any{"user_id": strings.TrimSpace(rawID)} @@ -4933,22 +5127,54 @@ func serveIMGroupInvitations(wrt http.ResponseWriter, req *http.Request, topic s continue } item["user_id"] = uid.UserId() - now := time.Now().UTC().Round(time.Millisecond) - joined, err := store.GroupBusiness.InviteMember(principal.TenantID, topic, principal.Uid, uid, - strings.TrimSpace(body.Message), strings.TrimSpace(req.Header.Get("Idempotency-Key")), - groupMemberPrivate("", false, false, 1, now), now) + targetSub, err := store.Subs.Get(principal.TenantID, topic, uid, false) + if err != nil && !errors.Is(err, types.ErrNotFound) { + item["status"] = "failed" + item["reason"] = relationErrorReason(err) + results = append(results, item) + continue + } + if targetSub != nil && targetSub.DeletedAt == nil { + item["status"] = "already_member" + results = append(results, item) + continue + } + invitationID := "ginv_" + store.Store.GetUidString() + idempotencyKey := strings.TrimSpace(req.Header.Get("Idempotency-Key")) + if idempotencyKey != "" { + idempotencyKey += ":" + uid.UserId() + } else { + idempotencyKey = invitationID + } + inv, err := store.GroupBusiness.CreateInvitation(principal.TenantID, &types.GroupInvitation{ + InvitationID: invitationID, + Topic: topic, + InviterUserID: principal.Uid, + TargetUserID: uid, + Message: strings.TrimSpace(body.Message), + Status: types.GroupInvitationStatusPending, + Result: "", + IdempotencyKey: idempotencyKey, + ExpiresAt: now.Add(7 * 24 * time.Hour), + CreatedAt: now, + UpdatedAt: now, + }) if err != nil { item["status"] = "failed" item["reason"] = relationErrorReason(err) results = append(results, item) continue } - if !joined { - item["status"] = "already_member" - results = append(results, item) - continue + item["invitation_id"] = inv.InvitationID + item["status"] = inv.Status + item["expires_at"] = inv.ExpiresAt.Format(time.RFC3339Nano) + if imReliableHTTPEventsEnabled(req) { + publishIMConversationSystemNotice(principal.TenantID, principal.Uid.P2PName(uid), + "system_action", "group_invitation_created", "邀请你加入群聊。", + []imSystemMessageActor{{UserID: principal.Uid.UserId(), Click: map[string]any{"type": "user_profile", "target_id": principal.Uid.UserId()}}}, + []imSystemMessageAction{{Type: "group_invitation_accept", Label: "立即加入", TargetID: inv.InvitationID}}, + map[string]any{"invitation_id": inv.InvitationID, "topic": topic, "message": inv.Message}) } - item["status"] = "joined" results = append(results, item) } dispatchIMGroupEvents(principal.TenantID) @@ -6667,6 +6893,26 @@ func parseIMUserIDList(wrt http.ResponseWriter, req *http.Request, values []stri return uids, true } +func uidListContains(uids []types.Uid, uid types.Uid) bool { + for _, item := range uids { + if item == uid { + return true + } + } + return false +} + +func ensureIMMeetingUserExists(tenantID types.TenantID, uid types.Uid) bool { + if uid.IsZero() || store.Users == nil { + return !uid.IsZero() + } + user, err := store.Users.Get(tenantID, uid) + if err != nil { + return errors.Is(err, types.ErrUnsupported) + } + return user != nil +} + func parseIMNonNegativeCursor(raw string) (int, error) { raw = strings.TrimSpace(raw) if raw == "" { @@ -6829,6 +7075,21 @@ func groupListItemPayload(sub types.Subscription) map[string]any { } } +func mergeGroupListSettingsPayload(payload map[string]any, settings *types.GroupSettings) map[string]any { + if payload == nil { + payload = map[string]any{} + } + if settings == nil { + return payload + } + payload["topic"] = settings.Topic + payload["name"] = settings.Name + payload["avatar_file_id"] = settings.AvatarFileID + payload["member_count"] = settings.MemberCount + payload["updated_at"] = settings.UpdatedAt.UTC().Format(time.RFC3339Nano) + return payload +} + func groupDetailPayload(group *types.Topic, selfSub *types.Subscription) map[string]any { public := map[string]any{} topic := "" @@ -7459,15 +7720,45 @@ func favoritePayload(item types.FavoriteItem) map[string]any { } } +func favoritePayloadForPrincipal(principal *imPrincipal, item types.FavoriteItem) (map[string]any, bool) { + if principal == nil { + return nil, false + } + sourceTopic, ok := normalizeIMFavoriteSourceTopic(principal.Uid, item.SourceTopic) + if !ok { + return nil, false + } + if err := requireTopicReader(principal, sourceTopic); err != nil { + return nil, false + } + item.SourceTopic = sourceTopic + if store.Messages != nil { + msg, err := store.Messages.GetOne(principal.TenantID, sourceTopic, item.SourceSeq) + if err == nil && msg != nil { + item.Availability = messageAvailability(*msg) + if item.Availability == "available" { + item.ItemType = imMessageType(msg) + item.Snapshot = favoriteSnapshot(msg) + item.AttachmentRefs = favoriteAttachmentRefs(msg) + } + } else if errors.Is(err, types.ErrNotFound) { + item.Availability = "removed" + } + } + return favoritePayload(item), true +} + func messageSearchPayload(msg types.Message, query string) map[string]any { text := messageText(msg) + snippet := textSnippet(text, query) return map[string]any{ "seq": msg.SeqId, "from": msg.From, "ts": msg.CreatedAt.UTC().Format(time.RFC3339Nano), "type": imMessageType(&msg), - "snippet": textSnippet(text, query), - "matches": textMatches(text, query), + "version": msg.Version, + "snippet": snippet, + "matches": textMatches(snippet, query), "snapshot": favoriteSnapshot(&msg), "availability": messageAvailability(msg), } @@ -7479,6 +7770,7 @@ func messageContextPayload(msg types.Message) map[string]any { "from": msg.From, "ts": msg.CreatedAt.UTC().Format(time.RFC3339Nano), "type": imMessageType(&msg), + "version": msg.Version, "head": msg.Head, "content": msg.Content, "availability": messageAvailability(msg), @@ -7848,9 +8140,24 @@ func meetingPayload(meeting *types.Meeting, viewer types.Uid) map[string]any { return meetingPayloadWithUserSummaries(meeting, viewer, nil) } +func meetingPayloadForTenant(tenantID types.TenantID, meeting *types.Meeting, viewer types.Uid) map[string]any { + if meeting == nil { + return map[string]any{} + } + return meetingPayloadWithUserSummaries(meeting, viewer, meetingUserSummaries(tenantID, []types.Meeting{*meeting})) +} + func meetingPayloadWithUserSummaries(meeting *types.Meeting, viewer types.Uid, userSummaries map[types.Uid]map[string]any) map[string]any { + if meeting == nil { + return map[string]any{} + } participants := meetingParticipantPayloadsWithSummaries(meeting.Participants, userSummaries) organizer := meetingUserSummary(meeting.OrganizerID, userSummaries) + recorderID := meeting.RecorderID + if recorderID.IsZero() { + recorderID = meeting.OrganizerID + } + recorder := meetingUserSummary(recorderID, userSummaries) startAt := meeting.StartAt.UTC() endAt := meeting.EndAt.UTC() data := map[string]any{ @@ -7858,6 +8165,8 @@ func meetingPayloadWithUserSummaries(meeting *types.Meeting, viewer types.Uid, u "title": meeting.Title, "organizer_id": meeting.OrganizerID.UserId(), "organizer": organizer, + "recorder_user_id": recorderID.UserId(), + "recorder": recorder, "start_at": startAt.Format(time.RFC3339Nano), "end_at": endAt.Format(time.RFC3339Nano), "meeting_time": meetingTimeRange(startAt, endAt), @@ -7876,6 +8185,9 @@ func meetingPayloadWithUserSummaries(meeting *types.Meeting, viewer types.Uid, u if displayName, ok := organizer["display_name"]; ok { data["organizer_display_name"] = displayName } + if displayName, ok := recorder["display_name"]; ok { + data["recorder_display_name"] = displayName + } if meeting.CanceledAt != nil { data["canceled_at"] = meeting.CanceledAt.UTC().Format(time.RFC3339Nano) } @@ -7926,6 +8238,10 @@ func meetingUserSummaries(tenantID types.TenantID, meetings []types.Meeting) map seen[meeting.OrganizerID] = true uids = append(uids, meeting.OrganizerID) } + if !meeting.RecorderID.IsZero() && !seen[meeting.RecorderID] { + seen[meeting.RecorderID] = true + uids = append(uids, meeting.RecorderID) + } for _, participant := range meeting.Participants { if !participant.UserID.IsZero() && !seen[participant.UserID] { seen[participant.UserID] = true @@ -7977,6 +8293,89 @@ func meetingParticipantNames(participants []map[string]any) []string { return names } +func meetingNoteListItemPayload(meeting *types.Meeting, files []types.MeetingNoteFile, viewer types.Uid, userSummaries map[types.Uid]map[string]any) map[string]any { + data := meetingPayloadWithUserSummaries(meeting, viewer, userSummaries) + noteStatus := types.MeetingNoteStatusEmpty + if len(files) > 0 { + noteStatus = types.MeetingNoteStatusAvailable + } + data["note_status"] = noteStatus + data["file_count"] = len(files) + data["permissions"] = meetingNotePermissionsPayload(meeting, viewer) + return data +} + +func meetingNotesDetailPayload(tenantID types.TenantID, meeting *types.Meeting, files []types.MeetingNoteFile, viewer types.Uid) map[string]any { + userSummaries := meetingUserSummaries(tenantID, []types.Meeting{*meeting}) + data := meetingNoteListItemPayload(meeting, files, viewer, userSummaries) + fileItems := make([]map[string]any, 0, len(files)) + for i := range files { + fileItems = append(fileItems, meetingNoteFilePayload(&files[i], viewer, userSummaries)) + } + data["files"] = fileItems + return data +} + +func meetingNoteFilePayload(file *types.MeetingNoteFile, viewer types.Uid, userSummaries map[types.Uid]map[string]any) map[string]any { + if file == nil { + return map[string]any{} + } + return map[string]any{ + "note_file_id": file.NoteFileID, + "meeting_id": file.MeetingID, + "file_id": file.FileID, + "file_name": file.FileName, + "mime_type": file.MIMEType, + "file_size": file.FileSize, + "file_etag": file.FileETag, + "status": file.Status, + "preview_status": file.PreviewStatus, + "preview_mode": file.PreviewMode, + "preview_file_id": file.PreviewFileID, + "uploader_user_id": file.UploaderID.UserId(), + "uploader": meetingUserSummary(file.UploaderID, userSummaries), + "version": file.Version, + "permissions": map[string]any{ + "can_preview": file.Status == types.MeetingNoteFileStatusActive, + "can_download": file.Status == types.MeetingNoteFileStatusActive, + "can_replace": file.Status == types.MeetingNoteFileStatusActive && file.UploaderID == viewer, + "can_delete": file.Status == types.MeetingNoteFileStatusActive && file.UploaderID == viewer, + }, + "created_at": file.CreatedAt.UTC().Format(time.RFC3339Nano), + "updated_at": file.UpdatedAt.UTC().Format(time.RFC3339Nano), + } +} + +func meetingNotePermissionsPayload(meeting *types.Meeting, viewer types.Uid) map[string]any { + recorderID := meeting.RecorderID + if recorderID.IsZero() { + recorderID = meeting.OrganizerID + } + readable := meetingNotesReadable(meeting, viewer) + return map[string]any{ + "can_view": readable, + "can_upload": readable && recorderID == viewer, + "can_replace": readable && recorderID == viewer, + "can_delete": readable && recorderID == viewer, + "can_download": readable, + } +} + +func meetingNotesReadable(meeting *types.Meeting, viewer types.Uid) bool { + if meeting == nil || viewer.IsZero() || meeting.State == types.MeetingStateCanceled || time.Now().UTC().Before(meeting.EndAt) { + return false + } + if meeting.OrganizerID == viewer || meeting.RecorderID == viewer { + return true + } + for _, participant := range meeting.Participants { + if participant.UserID == viewer { + return true + } + } + return false +} + func meetingTimeRange(startAt, endAt time.Time) string { if startAt.IsZero() || endAt.IsZero() { return "" @@ -7991,7 +8390,14 @@ func meetingParticipantUIDs(meeting *types.Meeting) []types.Uid { if meeting == nil { return nil } - return meetingParticipantUserIDs(meeting.Participants) + items := meetingParticipantUserIDs(meeting.Participants) + if !meeting.OrganizerID.IsZero() && !uidListContains(items, meeting.OrganizerID) { + items = append(items, meeting.OrganizerID) + } + if !meeting.RecorderID.IsZero() && !uidListContains(items, meeting.RecorderID) { + items = append(items, meeting.RecorderID) + } + return items } func meetingParticipantUserIDs(participants []types.MeetingParticipant) []types.Uid { @@ -8392,6 +8798,15 @@ func defaultIMAbuseReasons(targetType string) []types.AbuseReason { } } +func isAllowedIMAbuseTargetType(targetType string) bool { + switch strings.ToLower(strings.TrimSpace(targetType)) { + case "user", "group", "message": + return true + default: + return false + } +} + func abuseReportPayload(report *types.AbuseReport) map[string]any { return map[string]any{ "report_id": report.ReportID, @@ -8448,7 +8863,7 @@ func tenantEntryFeedbackPayload(feedback *types.TenantEntryFeedback) map[string] func isAllowedIMWorkbenchRoute(route string) bool { switch route { - case "/organization", "/attendance", "/meetings", "/reports/daily", "/reports/monthly", "/executive-mailbox": + case "/organization", "/attendance", "/meetings", "/meeting-notes", "/reports/daily", "/reports/monthly", "/executive-mailbox": return true default: return false @@ -8857,6 +9272,7 @@ func favoriteSnapshot(msg *types.Message) map[string]any { "from": msg.From, "ts": msg.CreatedAt.UTC().Format(time.RFC3339Nano), "type": imMessageType(msg), + "version": msg.Version, "head": msg.Head, "content": msg.Content, "text": messageText(*msg), @@ -8906,6 +9322,14 @@ func forwardFailurePayload(topic string, seq int64, target, reason string) map[s } func normalizeIMForwardTopic(viewer types.Uid, topic string) (string, bool) { + return normalizeIMUserVisibleTopic(viewer, topic) +} + +func normalizeIMFavoriteSourceTopic(viewer types.Uid, topic string) (string, bool) { + return normalizeIMUserVisibleTopic(viewer, topic) +} + +func normalizeIMUserVisibleTopic(viewer types.Uid, topic string) (string, bool) { topic = strings.TrimSpace(topic) if topic == "" { return "", false @@ -9175,7 +9599,7 @@ func messageText(msg types.Message) string { case string: return content case map[string]any: - for _, key := range []string{"text", "content", "title", "summary", "name", "url"} { + for _, key := range []string{"text", "txt", "content", "title", "summary", "name", "url"} { if value := stringFromAny(content[key]); value != "" { return value } @@ -9194,11 +9618,11 @@ func messageAvailability(msg types.Message) string { func textSnippet(text, query string) string { text = strings.TrimSpace(text) - if len([]rune(text)) <= 120 || strings.TrimSpace(query) == "" { + runes := []rune(text) + if len(runes) <= 120 || strings.TrimSpace(query) == "" { return text } - lower := strings.ToLower(text) - idx := strings.Index(lower, strings.ToLower(strings.TrimSpace(query))) + idx := runeIndexFold(text, query) if idx < 0 { idx = 0 } @@ -9207,7 +9631,6 @@ func textSnippet(text, query string) string { start = 0 } end := start + 120 - runes := []rune(text) if end > len(runes) { end = len(runes) } @@ -9219,11 +9642,36 @@ func textMatches(text, query string) []map[string]any { if query == "" { return []map[string]any{} } - idx := strings.Index(strings.ToLower(text), strings.ToLower(query)) + idx := runeIndexFold(text, query) if idx < 0 { return []map[string]any{} } - return []map[string]any{{"start": idx, "end": idx + len(query)}} + return []map[string]any{{"start": idx, "end": idx + len([]rune(query))}} +} + +func runeIndexFold(text, query string) int { + query = strings.TrimSpace(query) + if text == "" || query == "" { + return -1 + } + textRunes := []rune(strings.ToLower(text)) + queryRunes := []rune(strings.ToLower(query)) + if len(queryRunes) == 0 || len(queryRunes) > len(textRunes) { + return -1 + } + for i := 0; i <= len(textRunes)-len(queryRunes); i++ { + matched := true + for j := range queryRunes { + if textRunes[i+j] != queryRunes[j] { + matched = false + break + } + } + if matched { + return i + } + } + return -1 } func validIMRealName(name, idType, idNumber string) bool { @@ -9555,6 +10003,7 @@ func cleanStringList(values []string, limit int) []string { func isAllowedPublicUploadPurpose(purpose string) bool { switch strings.TrimSpace(purpose) { case types.PublicUploadPurposeEnterpriseLicense, types.PublicUploadPurposeTenantFeedback, + types.PublicUploadPurposeUserAvatar, types.PublicUploadPurposeGroupAvatar: return true default: @@ -9574,7 +10023,7 @@ func isAllowedPublicUploadMime(purpose, mimeType string) bool { return strings.HasPrefix(mimeType, "image/jpeg") || strings.HasPrefix(mimeType, "image/png") || strings.HasPrefix(mimeType, "image/webp") - case types.PublicUploadPurposeGroupAvatar: + case types.PublicUploadPurposeUserAvatar, types.PublicUploadPurposeGroupAvatar: return strings.HasPrefix(mimeType, "image/jpeg") || strings.HasPrefix(mimeType, "image/png") || strings.HasPrefix(mimeType, "image/webp") diff --git a/server/im_http_test.go b/server/im_http_test.go index 1a0d0df..0b8a283 100644 --- a/server/im_http_test.go +++ b/server/im_http_test.go @@ -269,6 +269,14 @@ type testMeetingsStore struct { responseVersion int } +type testMeetingNotesStore struct { + meetings []types.Meeting + files []types.MeetingNoteFile + query string + cursor string + limit int +} + type testDailyReportsStore struct { report *types.DailyReport saved bool @@ -658,6 +666,43 @@ func (s *testMeetingsStore) Respond(_ types.TenantID, uid types.Uid, _ string, r return s.meeting, nil } +func (s *testMeetingNotesStore) List(_ types.TenantID, _ types.Uid, query, cursor string, limit int) ([]types.Meeting, string, error) { + s.query = query + s.cursor = cursor + s.limit = limit + return s.meetings, "note-next", nil +} + +func (s *testMeetingNotesStore) ListFiles(_ types.TenantID, _ types.Uid, _ string) ([]types.MeetingNoteFile, error) { + return s.files, nil +} + +func (s *testMeetingNotesStore) CreateFile(_ types.TenantID, uid types.Uid, meetingID, noteFileID, fileID, idempotencyKey string) (*types.MeetingNoteFile, error) { + now := time.Now().UTC().Round(time.Millisecond) + return &types.MeetingNoteFile{ + NoteFileID: noteFileID, MeetingID: meetingID, FileID: fileID, UploaderID: uid, + FileName: fileID, Status: types.MeetingNoteFileStatusActive, PreviewStatus: "ready", PreviewMode: "native", + IdempotencyKey: idempotencyKey, Version: 1, CreatedAt: now, UpdatedAt: now, + }, nil +} + +func (s *testMeetingNotesStore) ReplaceFile(_ types.TenantID, uid types.Uid, meetingID, noteFileID, fileID, idempotencyKey string, version int) (*types.MeetingNoteFile, error) { + now := time.Now().UTC().Round(time.Millisecond) + return &types.MeetingNoteFile{ + NoteFileID: noteFileID, MeetingID: meetingID, FileID: fileID, UploaderID: uid, + FileName: fileID, Status: types.MeetingNoteFileStatusActive, PreviewStatus: "ready", PreviewMode: "native", + IdempotencyKey: idempotencyKey, Version: version + 1, CreatedAt: now, UpdatedAt: now, + }, nil +} + +func (s *testMeetingNotesStore) DeleteFile(_ types.TenantID, uid types.Uid, meetingID, noteFileID, _ string, version int) (*types.MeetingNoteFile, error) { + now := time.Now().UTC().Round(time.Millisecond) + return &types.MeetingNoteFile{ + NoteFileID: noteFileID, MeetingID: meetingID, UploaderID: uid, + Status: types.MeetingNoteFileStatusDeleted, Version: version + 1, DeletedBy: uid, DeletedAt: &now, CreatedAt: now, UpdatedAt: now, + }, nil +} + func (s *testDailyReportsStore) List(_ types.TenantID, _ types.Uid, _, _, _, _, _ string, _ int) ([]types.DailyReport, string, error) { if s.report == nil { return nil, "", nil @@ -1772,6 +1817,51 @@ func TestServeIMPublicUploadsAllowsGroupAvatar(t *testing.T) { } } +func TestServeIMPublicUploadsAllowsUserAvatar(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + oldStore := store.Store + oldTenantEntry := store.TenantEntry + entry := &testTenantEntryStore{} + handler := &testPublicMediaHandler{} + mockStore := mock_store.NewMockPersistentStorageInterface(ctrl) + mockStore.EXPECT().GetMediaHandler().Return(handler).AnyTimes() + store.Store = mockStore + store.TenantEntry = entry + defer func() { + store.Store = oldStore + store.TenantEntry = oldTenantEntry + }() + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + if err := writer.WriteField("purpose", types.PublicUploadPurposeUserAvatar); err != nil { + t.Fatal(err) + } + part, err := writer.CreateFormFile("file", "avatar.png") + if err != nil { + t.Fatal(err) + } + if _, err := part.Write([]byte("\x89PNG\r\n\x1a\n" + strings.Repeat("\x00", 64))); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodPost, "/v1/im/public/uploads", &body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + rec := httptest.NewRecorder() + + serveIMPublicUploads(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status: got %d, body=%s", rec.Code, rec.Body.String()) + } + if entry.upload == nil || entry.upload.Purpose != types.PublicUploadPurposeUserAvatar { + t.Fatalf("unexpected upload: %#v", entry.upload) + } +} + func TestServeIMVerificationCodesCreatesChallenge(t *testing.T) { oldTenants := store.Tenants oldVerifications := store.Verifications @@ -2733,6 +2823,61 @@ func TestServeIMMeetingsList(t *testing.T) { } } +func TestServeIMMeetingGetResolvesDisplayNames(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + viewer := types.Uid(42) + attendee := types.Uid(43) + expectIMBearer(t, ctrl, "token-1", 7, viewer) + oldMeetings := store.Meetings + oldUsers := store.Users + now := time.Now().UTC().Round(time.Millisecond) + meetings := &testMeetingsStore{meeting: &types.Meeting{ + MeetingID: "mtg_test", + OrganizerID: viewer, + Title: "周会", + StartAt: now.Add(time.Hour), + EndAt: now.Add(2 * time.Hour), + State: types.MeetingStateScheduled, + Version: 1, + CreatedAt: now, + UpdatedAt: now, + Participants: []types.MeetingParticipant{ + {MeetingID: "mtg_test", UserID: viewer, Response: types.MeetingResponseAccepted}, + {MeetingID: "mtg_test", UserID: attendee, Response: types.MeetingResponsePending}, + }, + }} + store.Meetings = meetings + users := mock_store.NewMockUsersPersistenceInterface(ctrl) + organizer := types.User{Public: map[string]any{"fn": "张三"}} + organizer.SetUid(viewer) + participant := types.User{Public: map[string]any{"fn": "李四"}} + participant.SetUid(attendee) + users.EXPECT().GetAll(types.TenantID(7), viewer, attendee).Return([]types.User{organizer, participant}, nil) + store.Users = users + defer func() { + store.Meetings = oldMeetings + store.Users = oldUsers + }() + + req := httptest.NewRequest(http.MethodGet, "/v1/im/meetings/mtg_test", nil) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + serveIMMeetingResource(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status: got %d body=%s", rec.Code, rec.Body.String()) + } + data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) + if data["organizer_display_name"] != "张三" { + t.Fatalf("unexpected organizer display name: %#v", data) + } + participants := data["participants"].([]any) + if participants[0].(map[string]any)["display_name"] != "张三" || participants[1].(map[string]any)["display_name"] != "李四" { + t.Fatalf("unexpected participant names: %#v", participants) + } +} + func TestServeIMMeetingMyResponse(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() @@ -2772,6 +2917,100 @@ func TestServeIMMeetingMyResponse(t *testing.T) { } } +func TestServeIMMeetingNotesListAndDetail(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + viewer := types.Uid(42) + expectIMBearer(t, ctrl, "token-1", 7, viewer) + expectIMBearer(t, ctrl, "token-1", 7, viewer) + oldMeetings := store.Meetings + oldMeetingNotes := store.MeetingNotes + oldUsers := store.Users + now := time.Now().UTC().Round(time.Millisecond) + meeting := types.Meeting{ + MeetingID: "mtg_test", + OrganizerID: viewer, + RecorderID: viewer, + Title: "复盘会", + StartAt: now.Add(-2 * time.Hour), + EndAt: now.Add(-time.Hour), + State: types.MeetingStateEnded, + Version: 2, + CreatedAt: now.Add(-3 * time.Hour), + UpdatedAt: now, + Participants: []types.MeetingParticipant{ + {MeetingID: "mtg_test", UserID: viewer, Response: types.MeetingResponseAccepted}, + }, + } + file := types.MeetingNoteFile{ + NoteFileID: "mnote_test", MeetingID: "mtg_test", FileID: "file_test", UploaderID: viewer, + FileName: "file_test", MIMEType: "application/pdf", FileSize: 123, Status: types.MeetingNoteFileStatusActive, + PreviewStatus: "ready", PreviewMode: "native", Version: 1, CreatedAt: now, UpdatedAt: now, + } + store.Meetings = &testMeetingsStore{meeting: &meeting} + notes := &testMeetingNotesStore{meetings: []types.Meeting{meeting}, files: []types.MeetingNoteFile{file}} + store.MeetingNotes = notes + store.Users = nil + defer func() { + store.Meetings = oldMeetings + store.MeetingNotes = oldMeetingNotes + store.Users = oldUsers + }() + + req := httptest.NewRequest(http.MethodGet, "/v1/im/meeting-notes?query=%E5%A4%8D%E7%9B%98&limit=20", nil) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + serveIMMeetingNotes(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("list status=%d body=%s", rec.Code, rec.Body.String()) + } + data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) + items := data["items"].([]any) + item := items[0].(map[string]any) + if notes.query != "复盘" || item["note_status"] != types.MeetingNoteStatusAvailable || item["file_count"].(float64) != 1 || data["has_more"] != true { + t.Fatalf("unexpected meeting notes list: data=%#v store=%#v", data, notes) + } + + req = httptest.NewRequest(http.MethodGet, "/v1/im/meetings/mtg_test/notes", nil) + req.Header.Set("Authorization", "Bearer token-1") + rec = httptest.NewRecorder() + serveIMMeetingResource(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("detail status=%d body=%s", rec.Code, rec.Body.String()) + } + data = decodeIMHTTPResponse(t, rec).Data.(map[string]any) + files := data["files"].([]any) + if data["recorder_user_id"] != viewer.UserId() || len(files) != 1 || files[0].(map[string]any)["file_id"] != "file_test" { + t.Fatalf("unexpected meeting notes detail: %#v", data) + } +} + +func TestServeIMMeetingNoteFilesCreate(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + viewer := types.Uid(42) + mockStore := expectIMBearer(t, ctrl, "token-1", 7, viewer) + mockStore.EXPECT().GetUidString().Return("note-1") + oldMeetingNotes := store.MeetingNotes + store.MeetingNotes = &testMeetingNotesStore{} + defer func() { + store.MeetingNotes = oldMeetingNotes + }() + + req := httptest.NewRequest(http.MethodPost, "/v1/im/meetings/mtg_test/notes/files", strings.NewReader(`{"file_id":"file_test"}`)) + req.Header.Set("Authorization", "Bearer token-1") + req.Header.Set("Idempotency-Key", "note-file-1") + rec := httptest.NewRecorder() + serveIMMeetingResource(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) + if data["note_file_id"] != "mnote_note-1" || data["file_id"] != "file_test" || data["version"].(float64) != 1 { + t.Fatalf("unexpected note file payload: %#v", data) + } +} + func TestServeIMDailyReportsCreateAndList(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() @@ -3697,9 +3936,23 @@ func TestServeIMGroupList(t *testing.T) { defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldUsers := store.Users + oldGroupBusiness := store.GroupBusiness users := mock_store.NewMockUsersPersistenceInterface(ctrl) + groupBusiness := &testGroupBusinessStore{settings: &types.GroupSettings{ + Topic: "grpAlpha", + OwnerUserID: types.Uid(42), + Name: "Alpha", + AvatarFileID: "file-1", + MemberCount: 3, + Version: 1, + UpdatedAt: time.Date(2026, 8, 12, 10, 0, 0, 0, time.UTC), + }} store.Users = users - defer func() { store.Users = oldUsers }() + store.GroupBusiness = groupBusiness + defer func() { + store.Users = oldUsers + store.GroupBusiness = oldGroupBusiness + }() sub := types.Subscription{ TenantID: 7, User: types.Uid(42).String(), @@ -3708,8 +3961,8 @@ func TestServeIMGroupList(t *testing.T) { ModeGiven: types.ModeCFull, ObjHeader: types.ObjHeader{UpdatedAt: time.Date(2026, 8, 12, 9, 0, 0, 0, time.UTC)}, } - sub.SetPublic(map[string]any{"name": "Alpha", "avatar_file_id": "file-1"}) - sub.SetSubCnt(3) + sub.SetPublic(map[string]any{"name": "grpAlpha"}) + sub.SetSubCnt(0) users.EXPECT().GetSubs(types.TenantID(7), types.Uid(42)).Return([]types.Subscription{sub}, nil) req := httptest.NewRequest(http.MethodGet, "/v1/im/groups?category=owned&query=alp", nil) @@ -3723,7 +3976,7 @@ func TestServeIMGroupList(t *testing.T) { } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) items := data["items"].([]any) - if len(items) != 1 || items[0].(map[string]any)["name"] != "Alpha" || items[0].(map[string]any)["my_role"] != "owner" { + if len(items) != 1 || items[0].(map[string]any)["name"] != "Alpha" || items[0].(map[string]any)["member_count"].(float64) != 3 || items[0].(map[string]any)["my_role"] != "owner" { t.Fatalf("unexpected group list response: %#v", data) } } @@ -4086,7 +4339,8 @@ func TestServeIMGroupLeave(t *testing.T) { func TestServeIMGroupInvitationAddsNonMember(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - expectIMBearer(t, ctrl, "token-1", 7, 42) + mockStore := expectIMBearer(t, ctrl, "token-1", 7, 42) + mockStore.EXPECT().GetUidString().Return("test") oldSubs := store.Subs oldGroupBusiness := store.GroupBusiness subs := mock_store.NewMockSubsPersistenceInterface(ctrl) @@ -4102,6 +4356,7 @@ func TestServeIMGroupInvitationAddsNonMember(t *testing.T) { subs.EXPECT().Get(types.TenantID(7), "grpAlpha", owner, false).Return(&types.Subscription{ TenantID: 7, User: owner.String(), Topic: "grpAlpha", ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull, }, nil) + subs.EXPECT().Get(types.TenantID(7), "grpAlpha", target, false).Return(nil, types.ErrNotFound) req := httptest.NewRequest(http.MethodPost, "/v1/im/groups/grpAlpha/invitations", bytes.NewBufferString(`{"user_ids":["`+target.UserId()+`"],"message":"welcome"}`)) @@ -4115,10 +4370,10 @@ func TestServeIMGroupInvitationAddsNonMember(t *testing.T) { } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) items := data["items"].([]any) - if len(items) != 1 || items[0].(map[string]any)["status"] != "joined" { + if len(items) != 1 || items[0].(map[string]any)["status"] != types.GroupInvitationStatusPending { t.Fatalf("unexpected invitation response: %#v", data) } - if len(groupStore.invitations) != 1 || groupStore.invitations[0].TargetUserID != target { + if len(groupStore.invitations) != 1 || groupStore.invitations[0].TargetUserID != target || groupStore.invitations[0].InvitationID != "ginv_test" { t.Fatalf("invitation was not persisted: %#v", groupStore.invitations) } } @@ -4461,6 +4716,8 @@ func TestServeIMFavoritesListDetailBatchDelete(t *testing.T) { Return(&auth.Rec{TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth}, nil, nil).Times(3) store.Store = mockStore oldFavorites := store.Favorites + oldSubs := store.Subs + oldMessages := store.Messages now := time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC) favorites := &testFavoritesStore{items: []types.FavoriteItem{{ TenantID: 7, @@ -4474,11 +4731,36 @@ func TestServeIMFavoritesListDetailBatchDelete(t *testing.T) { CollectedAt: now, }}} store.Favorites = favorites + subs := mock_store.NewMockSubsPersistenceInterface(ctrl) + messages := mock_store.NewMockMessagesPersistenceInterface(ctrl) + store.Subs = subs + store.Messages = messages defer func() { store.Store = oldStore store.Favorites = oldFavorites + store.Subs = oldSubs + store.Messages = oldMessages }() + subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(&types.Subscription{ + TenantID: 7, + User: types.Uid(42).String(), + Topic: "grpAlpha", + ModeWant: types.ModeCPublic, + ModeGiven: types.ModeCPublic, + }, nil).Times(2) + messages.EXPECT().GetOne(types.TenantID(7), "grpAlpha", int64(3)).Return(&types.Message{ + TenantID: 7, + Topic: "grpAlpha", + SeqId: 3, + From: types.Uid(42).String(), + Head: types.KVMap{"type": "text"}, + Content: map[string]any{"text": "hello"}, + ObjHeader: types.ObjHeader{ + CreatedAt: now, + }, + }, nil).Times(2) + req := httptest.NewRequest(http.MethodGet, "/v1/im/favorites?type=text", nil) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() @@ -4516,6 +4798,7 @@ func TestServeIMFavoriteBatchCreateCountsExisting(t *testing.T) { mockStore.EXPECT().GetUidString().Return("new-fav") oldFavorites := store.Favorites + oldSubs := store.Subs oldMessages := store.Messages now := time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC) store.Favorites = &testFavoritesStore{items: []types.FavoriteItem{{ @@ -4529,13 +4812,23 @@ func TestServeIMFavoriteBatchCreateCountsExisting(t *testing.T) { Availability: "available", CollectedAt: now, }}} + subs := mock_store.NewMockSubsPersistenceInterface(ctrl) messages := mock_store.NewMockMessagesPersistenceInterface(ctrl) + store.Subs = subs store.Messages = messages t.Cleanup(func() { store.Favorites = oldFavorites + store.Subs = oldSubs store.Messages = oldMessages }) + subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(&types.Subscription{ + TenantID: 7, + User: types.Uid(42).String(), + Topic: "grpAlpha", + ModeWant: types.ModeCPublic, + ModeGiven: types.ModeCPublic, + }, nil) messages.EXPECT().GetOne(types.TenantID(7), "grpAlpha", int64(3)).Return(&types.Message{ TenantID: 7, Topic: "grpAlpha", @@ -4561,6 +4854,104 @@ func TestServeIMFavoriteBatchCreateCountsExisting(t *testing.T) { } } +func TestServeIMFavoriteBatchCreateRejectsUnreadableSource(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + expectIMBearer(t, ctrl, "token-1", 7, 42) + + oldFavorites := store.Favorites + oldSubs := store.Subs + oldMessages := store.Messages + store.Favorites = &testFavoritesStore{} + subs := mock_store.NewMockSubsPersistenceInterface(ctrl) + messages := mock_store.NewMockMessagesPersistenceInterface(ctrl) + store.Subs = subs + store.Messages = messages + t.Cleanup(func() { + store.Favorites = oldFavorites + store.Subs = oldSubs + store.Messages = oldMessages + }) + + subs.EXPECT().Get(types.TenantID(7), "grpSecret", types.Uid(42), false).Return(nil, types.ErrNotFound) + + req := httptest.NewRequest(http.MethodPost, "/v1/im/favorites", bytes.NewBufferString(`{"messages":[{"topic":"grpSecret","seq":"1"}]}`)) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + serveIMFavorites(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("create status: got %d body=%s", rec.Code, rec.Body.String()) + } + data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) + if data["created_count"].(float64) != 0 || data["existing_count"].(float64) != 0 { + t.Fatalf("unexpected favorite counts: %#v", data) + } + failed := data["failed"].([]any) + if len(failed) != 1 || failed[0].(map[string]any)["reason"] != "NOT_FOUND" { + t.Fatalf("unexpected failed response: %#v", data) + } +} + +func TestServeIMFavoriteBatchCreateNormalizesP2PUserTopic(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockStore := expectIMBearer(t, ctrl, "token-1", 7, 42) + mockStore.EXPECT().GetUidString().Return("new-fav") + + actor := types.Uid(42) + peer := types.Uid(43) + normalizedTopic := actor.P2PName(peer) + oldFavorites := store.Favorites + oldSubs := store.Subs + oldMessages := store.Messages + favorites := &testFavoritesStore{} + subs := mock_store.NewMockSubsPersistenceInterface(ctrl) + messages := mock_store.NewMockMessagesPersistenceInterface(ctrl) + store.Favorites = favorites + store.Subs = subs + store.Messages = messages + t.Cleanup(func() { + store.Favorites = oldFavorites + store.Subs = oldSubs + store.Messages = oldMessages + }) + + now := time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC) + subs.EXPECT().Get(types.TenantID(7), normalizedTopic, actor, false).Return(&types.Subscription{ + TenantID: 7, + User: actor.String(), + Topic: normalizedTopic, + ModeWant: types.ModeCPublic, + ModeGiven: types.ModeCPublic, + }, nil) + messages.EXPECT().GetOne(types.TenantID(7), normalizedTopic, int64(3)).Return(&types.Message{ + TenantID: 7, + Topic: normalizedTopic, + SeqId: 3, + From: peer.String(), + Head: types.KVMap{"type": "text"}, + Content: map[string]any{"text": "hello"}, + ObjHeader: types.ObjHeader{ + CreatedAt: now, + }, + }, nil) + + req := httptest.NewRequest(http.MethodPost, "/v1/im/favorites", bytes.NewBufferString(`{"messages":[{"topic":"`+peer.UserId()+`","seq":"3"}]}`)) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + serveIMFavorites(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("create status: got %d body=%s", rec.Code, rec.Body.String()) + } + data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) + if data["created_count"].(float64) != 1 || data["existing_count"].(float64) != 0 { + t.Fatalf("unexpected favorite counts: %#v", data) + } + if favorites.created == nil || favorites.created.SourceTopic != normalizedTopic { + t.Fatalf("favorite source topic was not normalized: %#v", favorites.created) + } +} + func TestServeIMFavoriteForwardSavesMessages(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() @@ -5149,6 +5540,14 @@ func TestServeIMAbuseReportsAndReasons(t *testing.T) { t.Fatalf("abuse reason scope mismatch: %#v", safety) } + req = httptest.NewRequest(http.MethodGet, "/v1/im/reports/abuse/reasons?target_type=group_member", nil) + req.Header.Set("Authorization", "Bearer token-1") + rec = httptest.NewRecorder() + serveIMAbuseReasons(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("unsupported reasons status: got %d body=%s", rec.Code, rec.Body.String()) + } + req = httptest.NewRequest(http.MethodPost, "/v1/im/reports/abuse", bytes.NewBufferString(`{"target":{"type":"user","user_id":"usrKwAAAAAAAAA"},"reason_code":"fraud","description":"疑似诈骗","evidence_file_ids":["file1"]}`)) req.Header.Set("Authorization", "Bearer token-1") @@ -5306,6 +5705,36 @@ func TestServeIMTopicReadActionsNormalizeP2PUserTopic(t *testing.T) { } } +func TestMessageSearchPayloadMatchesUseSnippetRuneOffsets(t *testing.T) { + msg := types.Message{ + TenantID: 7, + Topic: "grpAlpha", + SeqId: 1, + Version: 2, + From: types.Uid(42).String(), + Head: types.KVMap{"type": "text"}, + Content: map[string]any{"txt": "你们已成为好友,现在可以开始聊天了。", "fmt": []any{}}, + ObjHeader: types.ObjHeader{ + CreatedAt: time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC), + }, + } + payload := messageSearchPayload(msg, "好友") + if payload["snippet"] != "你们已成为好友,现在可以开始聊天了。" { + t.Fatalf("unexpected snippet: %#v", payload["snippet"]) + } + matches := payload["matches"].([]map[string]any) + if len(matches) != 1 || matches[0]["start"] != 5 || matches[0]["end"] != 7 { + t.Fatalf("unexpected matches: %#v", matches) + } + if payload["version"] != 2 { + t.Fatalf("unexpected version: %#v", payload["version"]) + } + snapshot := payload["snapshot"].(map[string]any) + if snapshot["text"] != "你们已成为好友,现在可以开始聊天了。" || snapshot["version"] != 2 { + t.Fatalf("unexpected snapshot: %#v", snapshot) + } +} + func TestServeIMTopicCalls(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() diff --git a/server/main.go b/server/main.go index a28fb18..d2fa7b3 100644 --- a/server/main.go +++ b/server/main.go @@ -828,6 +828,7 @@ func main() { mux.HandleFunc(config.ApiPath+"v1/im/attendance/records", serveIMAttendanceRecords) mux.HandleFunc(config.ApiPath+"v1/im/attendance/checkins", serveIMAttendanceCheckins) mux.HandleFunc(config.ApiPath+"v1/im/attendance/appeals", serveIMAttendanceAppeals) + mux.HandleFunc(config.ApiPath+"v1/im/meeting-notes", serveIMMeetingNotes) mux.HandleFunc(config.ApiPath+"v1/im/meetings", serveIMMeetings) mux.HandleFunc(config.ApiPath+"v1/im/meetings/", serveIMMeetingResource) mux.HandleFunc(config.ApiPath+"v1/im/reports/abuse/reasons", serveIMAbuseReasons) diff --git a/server/store/store.go b/server/store/store.go index 90d7cb4..72a9771 100644 --- a/server/store/store.go +++ b/server/store/store.go @@ -2231,9 +2231,19 @@ type MeetingsPersistenceInterface interface { Respond(tenantID types.TenantID, uid types.Uid, meetingID, response string, version int) (*types.Meeting, error) } +type MeetingNotesPersistenceInterface interface { + List(tenantID types.TenantID, uid types.Uid, query, cursor string, limit int) ([]types.Meeting, string, error) + ListFiles(tenantID types.TenantID, uid types.Uid, meetingID string) ([]types.MeetingNoteFile, error) + CreateFile(tenantID types.TenantID, uid types.Uid, meetingID, noteFileID, fileID, idempotencyKey string) (*types.MeetingNoteFile, error) + ReplaceFile(tenantID types.TenantID, uid types.Uid, meetingID, noteFileID, fileID, idempotencyKey string, version int) (*types.MeetingNoteFile, error) + DeleteFile(tenantID types.TenantID, uid types.Uid, meetingID, noteFileID, idempotencyKey string, version int) (*types.MeetingNoteFile, error) +} + type meetingsMapper struct{} +type meetingNotesMapper struct{} var Meetings MeetingsPersistenceInterface +var MeetingNotes MeetingNotesPersistenceInterface func (meetingsMapper) Create(tenantID types.TenantID, uid types.Uid, meeting *types.Meeting, attendeeIDs []types.Uid) (*types.Meeting, error) { if uid.IsZero() || meeting == nil || !validMeetingForCreate(meeting) { @@ -2315,6 +2325,64 @@ func (meetingsMapper) Respond(tenantID types.TenantID, uid types.Uid, meetingID, return tenantAdp.TenantMeetingRespond(tenantID, uid, strings.TrimSpace(meetingID), strings.TrimSpace(response), version) } +func (meetingNotesMapper) List(tenantID types.TenantID, uid types.Uid, query, 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.TenantMeetingNoteList(tenantID, uid, strings.TrimSpace(query), strings.TrimSpace(cursor), limit) +} + +func (meetingNotesMapper) ListFiles(tenantID types.TenantID, uid types.Uid, meetingID string) ([]types.MeetingNoteFile, error) { + if uid.IsZero() || strings.TrimSpace(meetingID) == "" { + return nil, types.ErrMalformed + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, err + } + return tenantAdp.TenantMeetingNoteFiles(tenantID, uid, strings.TrimSpace(meetingID)) +} + +func (meetingNotesMapper) CreateFile(tenantID types.TenantID, uid types.Uid, meetingID, noteFileID, fileID, idempotencyKey string) (*types.MeetingNoteFile, error) { + if uid.IsZero() || strings.TrimSpace(meetingID) == "" || strings.TrimSpace(noteFileID) == "" || strings.TrimSpace(fileID) == "" { + return nil, types.ErrMalformed + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, err + } + return tenantAdp.TenantMeetingNoteFileCreate(tenantID, uid, strings.TrimSpace(meetingID), strings.TrimSpace(noteFileID), strings.TrimSpace(fileID), strings.TrimSpace(idempotencyKey)) +} + +func (meetingNotesMapper) ReplaceFile(tenantID types.TenantID, uid types.Uid, meetingID, noteFileID, fileID, idempotencyKey string, version int) (*types.MeetingNoteFile, error) { + if uid.IsZero() || strings.TrimSpace(meetingID) == "" || strings.TrimSpace(noteFileID) == "" || strings.TrimSpace(fileID) == "" || version <= 0 { + return nil, types.ErrMalformed + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, err + } + return tenantAdp.TenantMeetingNoteFileReplace(tenantID, uid, strings.TrimSpace(meetingID), strings.TrimSpace(noteFileID), strings.TrimSpace(fileID), strings.TrimSpace(idempotencyKey), version) +} + +func (meetingNotesMapper) DeleteFile(tenantID types.TenantID, uid types.Uid, meetingID, noteFileID, idempotencyKey string, version int) (*types.MeetingNoteFile, error) { + if uid.IsZero() || strings.TrimSpace(meetingID) == "" || strings.TrimSpace(noteFileID) == "" || version <= 0 { + return nil, types.ErrMalformed + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, err + } + return tenantAdp.TenantMeetingNoteFileDelete(tenantID, uid, strings.TrimSpace(meetingID), strings.TrimSpace(noteFileID), strings.TrimSpace(idempotencyKey), version) +} + func validMeetingForCreate(meeting *types.Meeting) bool { return strings.TrimSpace(meeting.MeetingID) != "" && strings.TrimSpace(meeting.Title) != "" && @@ -2324,7 +2392,7 @@ func validMeetingForCreate(meeting *types.Meeting) bool { } func validMeetingPatch(patch types.MeetingPatch) bool { - if patch.Title == nil && patch.StartAt == nil && patch.EndAt == nil && patch.Location == nil && patch.ReminderMinutes == nil { + if patch.Title == nil && patch.RecorderID == nil && patch.StartAt == nil && patch.EndAt == nil && patch.Location == nil && patch.ReminderMinutes == nil { return false } if patch.Title != nil && strings.TrimSpace(*patch.Title) == "" { @@ -4645,6 +4713,7 @@ func init() { Organization = organizationMapper{} Attendance = attendanceMapper{} Meetings = meetingsMapper{} + MeetingNotes = meetingNotesMapper{} DailyReports = dailyReportsMapper{} MonthlyReports = monthlyReportsMapper{} Mailbox = mailboxMapper{} diff --git a/server/store/types/groups.go b/server/store/types/groups.go index e992198..3bf137a 100644 --- a/server/store/types/groups.go +++ b/server/store/types/groups.go @@ -12,7 +12,8 @@ const ( GroupMemberStatusActive = "active" GroupMemberStatusLeft = "left" - GroupInvitationStatusJoined = "joined" + GroupInvitationStatusPending = "pending" + GroupInvitationStatusJoined = "joined" GroupJoinRequestStatusPending = "pending" GroupJoinRequestStatusAccepted = "accepted" diff --git a/server/store/types/meetings.go b/server/store/types/meetings.go index cbd28ff..b2b93c3 100644 --- a/server/store/types/meetings.go +++ b/server/store/types/meetings.go @@ -4,6 +4,7 @@ import "time" const ( MeetingStateScheduled = "scheduled" + MeetingStateEnded = "ended" MeetingStateCanceled = "canceled" MeetingResponsePending = "pending" @@ -16,6 +17,7 @@ type Meeting struct { ID int64 MeetingID string OrganizerID Uid + RecorderID Uid Title string StartAt time.Time EndAt time.Time @@ -42,9 +44,40 @@ type MeetingParticipant struct { type MeetingPatch struct { Title *string + RecorderID *Uid StartAt *time.Time EndAt *time.Time Location *string ReminderMinutes []int Version int } + +const ( + MeetingNoteStatusEmpty = "empty" + MeetingNoteStatusAvailable = "available" + + MeetingNoteFileStatusActive = "active" + MeetingNoteFileStatusDeleted = "deleted" +) + +type MeetingNoteFile struct { + ID int64 + NoteFileID string + MeetingID string + FileID string + UploaderID Uid + FileName string + MIMEType string + FileSize int64 + FileETag string + Status string + PreviewStatus string + PreviewMode string + PreviewFileID string + IdempotencyKey string + Version int + DeletedBy Uid + DeletedAt *time.Time + CreatedAt time.Time + UpdatedAt time.Time +} diff --git a/server/store/types/tenant_entry.go b/server/store/types/tenant_entry.go index 687c213..596d86b 100644 --- a/server/store/types/tenant_entry.go +++ b/server/store/types/tenant_entry.go @@ -5,6 +5,7 @@ import "time" const ( PublicUploadPurposeEnterpriseLicense = "enterprise_license" PublicUploadPurposeTenantFeedback = "tenant_entry_feedback" + PublicUploadPurposeUserAvatar = "user_avatar" PublicUploadPurposeGroupAvatar = "group_avatar" TenantApplicationStatusReceived = "received"