feat: add meeting notes backend

This commit is contained in:
richjohnrambo
2026-09-05 17:55:27 +08:00
parent 9e290b76b3
commit baf15848d3
12 changed files with 1633 additions and 52 deletions
+51
View File
@@ -460,6 +460,7 @@ CREATE TABLE IF NOT EXISTS tinode.im_meetings (
id BIGINT NOT NULL AUTO_INCREMENT, id BIGINT NOT NULL AUTO_INCREMENT,
meeting_id VARCHAR(64) NOT NULL, meeting_id VARCHAR(64) NOT NULL,
organizer_id BIGINT NOT NULL, organizer_id BIGINT NOT NULL,
recorder_user_id BIGINT NOT NULL,
title VARCHAR(200) NOT NULL, title VARCHAR(200) NOT NULL,
start_at DATETIME(3) NOT NULL, start_at DATETIME(3) NOT NULL,
end_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_meeting (meeting_id),
UNIQUE INDEX uk_im_meetings_idempotency (organizer_id, idempotency_key), UNIQUE INDEX uk_im_meetings_idempotency (organizer_id, idempotency_key),
INDEX idx_im_meetings_organizer (organizer_id, start_at), 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) INDEX idx_im_meetings_state_time (state, start_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; ) 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 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; ) 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 ( CREATE TABLE IF NOT EXISTS tinode.im_daily_reports (
id BIGINT NOT NULL AUTO_INCREMENT, id BIGINT NOT NULL AUTO_INCREMENT,
report_id VARCHAR(64) NOT NULL, report_id VARCHAR(64) NOT NULL,
@@ -946,6 +996,7 @@ VALUES
('organization', '组织架构', '/organization', 10, 'organization', 1, 'enabled'), ('organization', '组织架构', '/organization', 10, 'organization', 1, 'enabled'),
('attendance', '打卡', '/attendance', 20, 'attendance', 1, 'enabled'), ('attendance', '打卡', '/attendance', 20, 'attendance', 1, 'enabled'),
('meetings', '会议通知', '/meetings', 30, 'meetings', 1, 'enabled'), ('meetings', '会议通知', '/meetings', 30, 'meetings', 1, 'enabled'),
('meeting_notes', '会议笔记', '/meeting-notes', 35, '', 1, 'enabled'),
('daily_reports', '日报', '/reports/daily', 40, 'daily_reports', 1, 'enabled'), ('daily_reports', '日报', '/reports/daily', 40, 'daily_reports', 1, 'enabled'),
('monthly_reports', '月报', '/reports/monthly', 50, 'monthly_reports', 1, 'enabled'), ('monthly_reports', '月报', '/reports/monthly', 50, 'monthly_reports', 1, 'enabled'),
('executive_mailbox', '总经理信箱', '/executive-mailbox', 60, 'executive_mailbox', 1, 'enabled') ('executive_mailbox', '总经理信箱', '/executive-mailbox', 60, 'executive_mailbox', 1, 'enabled')
+5
View File
@@ -412,6 +412,11 @@ type TenantBusinessAdapter interface {
TenantMeetingCancel(t.TenantID, t.Uid, string, string) (*t.Meeting, error) TenantMeetingCancel(t.TenantID, t.Uid, string, string) (*t.Meeting, error)
TenantMeetingRemind(t.TenantID, t.Uid, string, string) ([]t.MeetingParticipant, error) TenantMeetingRemind(t.TenantID, t.Uid, string, string) ([]t.MeetingParticipant, error)
TenantMeetingRespond(t.TenantID, t.Uid, string, string, int) (*t.Meeting, 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) 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) TenantDailyReportCreateDraft(t.TenantID, t.Uid, string, time.Time) (*t.DailyReport, error)
TenantDailyReportGet(t.TenantID, t.Uid, string) (*t.DailyReport, error) TenantDailyReportGet(t.TenantID, t.Uid, string) (*t.DailyReport, error)
+120 -1
View File
@@ -281,6 +281,9 @@ func (a *adapter) Open(jsonconfig json.RawMessage) error {
if err = ensureIMAttendanceTables(a.db); err != nil { if err = ensureIMAttendanceTables(a.db); err != nil {
return errors.New("mysql adapter failed to ensure attendance schema: " + err.Error()) 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 { if err = ensureSnowflakePrimaryKeys(a.db); err != nil {
return errors.New("mysql adapter failed to ensure snowflake primary keys: " + err.Error()) 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() db.Close()
return nil, nil, err return nil, nil, err
} }
if err = ensureIMMeetingNotesTables(db); err != nil {
db.Close()
return nil, nil, err
}
if err = ensureSnowflakePrimaryKeys(db); err != nil { if err = ensureSnowflakePrimaryKeys(db); err != nil {
db.Close() db.Close()
return nil, nil, err return nil, nil, err
@@ -1212,6 +1219,7 @@ func createIMWorkbenchTables(tx *sql.Tx) error {
('organization','组织架构','/organization',10,'organization',1,'enabled'), ('organization','组织架构','/organization',10,'organization',1,'enabled'),
('attendance','打卡','/attendance',20,'attendance',1,'enabled'), ('attendance','打卡','/attendance',20,'attendance',1,'enabled'),
('meetings','会议通知','/meetings',30,'meetings',1,'enabled'), ('meetings','会议通知','/meetings',30,'meetings',1,'enabled'),
('meeting_notes','会议笔记','/meeting-notes',35,'',1,'enabled'),
('daily_reports','日报','/reports/daily',40,'daily_reports',1,'enabled'), ('daily_reports','日报','/reports/daily',40,'daily_reports',1,'enabled'),
('monthly_reports','月报','/reports/monthly',50,'monthly_reports',1,'enabled'), ('monthly_reports','月报','/reports/monthly',50,'monthly_reports',1,'enabled'),
('executive_mailbox','总经理信箱','/executive-mailbox',60,'executive_mailbox',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, id BIGINT NOT NULL AUTO_INCREMENT,
meeting_id VARCHAR(64) NOT NULL, meeting_id VARCHAR(64) NOT NULL,
organizer_id BIGINT NOT NULL, organizer_id BIGINT NOT NULL,
recorder_user_id BIGINT NOT NULL,
title VARCHAR(200) NOT NULL, title VARCHAR(200) NOT NULL,
start_at DATETIME(3) NOT NULL, start_at DATETIME(3) NOT NULL,
end_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_meeting(meeting_id),
UNIQUE INDEX uk_im_meetings_idempotency(organizer_id, idempotency_key), UNIQUE INDEX uk_im_meetings_idempotency(organizer_id, idempotency_key),
INDEX idx_im_meetings_organizer(organizer_id, start_at), 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) INDEX idx_im_meetings_state_time(state, start_at)
)`, )`,
`CREATE TABLE im_meeting_participants( `CREATE TABLE im_meeting_participants(
@@ -1484,6 +1494,33 @@ func createIMMeetingTables(tx *sql.Tx) error {
INDEX idx_im_meeting_participants_response(meeting_id, response), 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 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 { for _, stmt := range statements {
if _, err := tx.Exec(stmt); err != nil { if _, err := tx.Exec(stmt); err != nil {
@@ -1493,6 +1530,63 @@ func createIMMeetingTables(tx *sql.Tx) error {
return nil 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 { func createIMReportTables(tx *sql.Tx) error {
if _, err := tx.Exec( if _, err := tx.Exec(
`CREATE TABLE im_daily_reports( `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 { if err = rows.Scan(&id, &loc); err != nil {
break 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(&noteRefs); err != nil {
break
}
if noteRefs > 0 {
continue
}
if loc != "" { if loc != "" {
locations = append(locations, 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) 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`, ) 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 { if err != nil {
return false, err return false, err
} }
+430 -7
View File
@@ -3346,8 +3346,12 @@ func (a *adapter) TenantMeetingCreate(id t.TenantID, uid t.Uid, meeting *t.Meeti
if meeting.IdempotencyKey != "" { if meeting.IdempotencyKey != "" {
idempotencyKey = 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) recorderID := meeting.RecorderID
VALUES(?,?,?,?,?,?,?,?,?,1)`, meeting.MeetingID, decodeUID(uid), strings.TrimSpace(meeting.Title), meeting.StartAt.UTC(), meeting.EndAt.UTC(), strings.TrimSpace(meeting.Location), 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) t.MeetingStateScheduled, string(reminderJSON), idempotencyKey)
if err != nil { if err != nil {
if isDupe(err) && meeting.IdempotencyKey != "" { 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 return nil, err
} }
participants := uniqueMeetingParticipants(uid, attendeeIDs) participants := uniqueMeetingParticipants(uid, append(attendeeIDs, recorderID))
now := time.Now().UTC().Round(time.Millisecond) now := time.Now().UTC().Round(time.Millisecond)
for _, participant := range participants { for _, participant := range participants {
response := t.MeetingResponsePending 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 { if limit <= 0 || limit > 100 {
limit = 20 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=? 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) != "" { if strings.TrimSpace(cursor) != "" {
parsed, err := strconv.ParseInt(strings.TrimSpace(cursor), 10, 64) parsed, err := strconv.ParseInt(strings.TrimSpace(cursor), 10, 64)
if err != nil || parsed <= 0 { if err != nil || parsed <= 0 {
@@ -3431,6 +3435,7 @@ func (a *adapter) TenantMeetingList(id t.TenantID, uid t.Uid, cursor string, lim
ID int64 ID int64
MeetingID string `db:"meeting_id"` MeetingID string `db:"meeting_id"`
OrganizerID int64 `db:"organizer_id"` OrganizerID int64 `db:"organizer_id"`
RecorderID int64 `db:"recorder_user_id"`
Title string Title string
StartAt time.Time `db:"start_at"` StartAt time.Time `db:"start_at"`
EndAt time.Time `db:"end_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, ID: row.ID,
MeetingID: row.MeetingID, MeetingID: row.MeetingID,
OrganizerID: store.EncodeUid(row.OrganizerID), OrganizerID: store.EncodeUid(row.OrganizerID),
RecorderID: store.EncodeUid(row.RecorderID),
Title: row.Title, Title: row.Title,
StartAt: row.StartAt, StartAt: row.StartAt,
EndAt: row.EndAt, EndAt: row.EndAt,
@@ -3463,6 +3469,9 @@ func (a *adapter) TenantMeetingList(id t.TenantID, uid t.Uid, cursor string, lim
if row.IdempotencyKey.Valid { if row.IdempotencyKey.Valid {
meeting.IdempotencyKey = row.IdempotencyKey.String meeting.IdempotencyKey = row.IdempotencyKey.String
} }
if meeting.RecorderID.IsZero() {
meeting.RecorderID = meeting.OrganizerID
}
if len(row.ReminderMinutes) > 0 { if len(row.ReminderMinutes) > 0 {
_ = json.Unmarshal(row.ReminderMinutes, &meeting.ReminderMinutes) _ = 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=?") sets = append(sets, "location=?")
args = append(args, strings.TrimSpace(*patch.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 { if patch.ReminderMinutes != nil {
encoded, err := json.Marshal(patch.ReminderMinutes) encoded, err := json.Marshal(patch.ReminderMinutes)
if err != nil { if err != nil {
@@ -3600,6 +3616,12 @@ func (a *adapter) TenantMeetingUpdate(id t.TenantID, uid t.Uid, meetingID string
if affected == 0 { if affected == 0 {
return nil, t.ErrInvalidResponse 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, "") participantIDs, err := meetingParticipantIDsTx(ctx, tx, meeting.MeetingID, "")
if err != nil { if err != nil {
return nil, err return nil, err
@@ -3881,6 +3903,7 @@ func (a *adapter) meetingByID(ctx context.Context, meetingID string, viewer t.Ui
ID int64 ID int64
MeetingID string `db:"meeting_id"` MeetingID string `db:"meeting_id"`
OrganizerID int64 `db:"organizer_id"` OrganizerID int64 `db:"organizer_id"`
RecorderID int64 `db:"recorder_user_id"`
Title string Title string
StartAt time.Time `db:"start_at"` StartAt time.Time `db:"start_at"`
EndAt time.Time `db:"end_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"` UpdatedAt time.Time `db:"updated_at"`
}{} }{}
if err := sqlx.GetContext(ctx, a.db, &row, `SELECT m.* FROM im_meetings AS m 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=? 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 { if err == sql.ErrNoRows {
return nil, t.ErrNotFound return nil, t.ErrNotFound
} }
@@ -3906,6 +3929,7 @@ func (a *adapter) meetingByID(ctx context.Context, meetingID string, viewer t.Ui
ID: row.ID, ID: row.ID,
MeetingID: row.MeetingID, MeetingID: row.MeetingID,
OrganizerID: store.EncodeUid(row.OrganizerID), OrganizerID: store.EncodeUid(row.OrganizerID),
RecorderID: store.EncodeUid(row.RecorderID),
Title: row.Title, Title: row.Title,
StartAt: row.StartAt, StartAt: row.StartAt,
EndAt: row.EndAt, EndAt: row.EndAt,
@@ -3919,6 +3943,9 @@ func (a *adapter) meetingByID(ctx context.Context, meetingID string, viewer t.Ui
if row.IdempotencyKey.Valid { if row.IdempotencyKey.Valid {
meeting.IdempotencyKey = row.IdempotencyKey.String meeting.IdempotencyKey = row.IdempotencyKey.String
} }
if meeting.RecorderID.IsZero() {
meeting.RecorderID = meeting.OrganizerID
}
if len(row.ReminderMinutes) > 0 { if len(row.ReminderMinutes) > 0 {
_ = json.Unmarshal(row.ReminderMinutes, &meeting.ReminderMinutes) _ = json.Unmarshal(row.ReminderMinutes, &meeting.ReminderMinutes)
} }
@@ -3969,6 +3996,402 @@ func (a *adapter) meetingParticipantsByQuery(ctx context.Context, query string,
return participants, nil 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<?")
args = append(args, parsed)
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
args = append(args, limit+1)
rows, err := a.db.QueryxContext(ctx, `SELECT m.* FROM im_meetings AS m
WHERE `+strings.Join(where, " AND ")+`
ORDER BY m.end_at DESC,m.id DESC LIMIT ?`, args...)
if err != nil {
return nil, "", err
}
defer rows.Close()
meetings := make([]t.Meeting, 0, limit)
nextCursor := ""
for rows.Next() {
meeting, rowID, err := scanMeetingRow(rows)
if err != nil {
return nil, "", err
}
if len(meetings) == limit {
nextCursor = strconv.FormatInt(rowID, 10)
continue
}
participants, err := a.meetingParticipants(ctx, meeting.MeetingID)
if err != nil {
return nil, "", err
}
meeting.Participants = participants
meeting.State = t.MeetingStateEnded
meetings = append(meetings, *meeting)
}
if err = rows.Err(); err != nil {
return nil, "", err
}
return meetings, nextCursor, nil
}
func (a *adapter) TenantMeetingNoteFiles(id t.TenantID, uid t.Uid, meetingID string) ([]t.MeetingNoteFile, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantMeetingNoteFiles(id, uid, meetingID)
}
}
if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(meetingID) == "" {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
if _, err := a.meetingNoteVisible(ctx, uid, strings.TrimSpace(meetingID)); err != nil {
return nil, err
}
return a.meetingNoteFiles(ctx, strings.TrimSpace(meetingID))
}
func (a *adapter) TenantMeetingNoteFileCreate(id t.TenantID, uid t.Uid, meetingID, noteFileID, fileID, idempotencyKey string) (*t.MeetingNoteFile, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantMeetingNoteFileCreate(id, uid, meetingID, noteFileID, fileID, idempotencyKey)
}
}
if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(meetingID) == "" || strings.TrimSpace(noteFileID) == "" || strings.TrimSpace(fileID) == "" || strings.TrimSpace(idempotencyKey) == "" {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
meeting, err := a.meetingNoteWritable(ctx, uid, strings.TrimSpace(meetingID))
if err != nil {
return nil, err
}
file, err := a.TenantFileGet(id, strings.TrimSpace(fileID))
if err != nil {
return nil, err
}
if file == nil || file.Status != t.UploadCompleted {
return nil, t.ErrNotFound
}
_, err = a.db.ExecContext(ctx, `INSERT INTO im_meeting_note_files(note_file_id,meeting_id,file_id,uploader_id,file_name,mime_type,file_size,file_etag,status,preview_status,preview_mode,idempotency_key,version)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,1)`, strings.TrimSpace(noteFileID), meeting.MeetingID, strings.TrimSpace(fileID), decodeUID(uid), strings.TrimSpace(fileID),
file.MimeType, file.Size, file.ETag, t.MeetingNoteFileStatusActive, "ready", "native", strings.TrimSpace(idempotencyKey))
if err != nil {
if isDupe(err) {
return a.meetingNoteFileByIdempotency(ctx, meeting.MeetingID, uid, strings.TrimSpace(idempotencyKey))
}
return nil, err
}
return a.meetingNoteFileByID(ctx, meeting.MeetingID, strings.TrimSpace(noteFileID))
}
func (a *adapter) TenantMeetingNoteFileReplace(id t.TenantID, uid t.Uid, meetingID, noteFileID, fileID, idempotencyKey string, version int) (*t.MeetingNoteFile, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantMeetingNoteFileReplace(id, uid, meetingID, noteFileID, fileID, idempotencyKey, version)
}
}
if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(meetingID) == "" || strings.TrimSpace(noteFileID) == "" || strings.TrimSpace(fileID) == "" || strings.TrimSpace(idempotencyKey) == "" || version <= 0 {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
meeting, err := a.meetingNoteWritable(ctx, uid, strings.TrimSpace(meetingID))
if err != nil {
return nil, err
}
file, err := a.TenantFileGet(id, strings.TrimSpace(fileID))
if err != nil {
return nil, err
}
if file == nil || file.Status != t.UploadCompleted {
return nil, t.ErrNotFound
}
res, err := a.db.ExecContext(ctx, `UPDATE im_meeting_note_files
SET file_id=?,file_name=?,mime_type=?,file_size=?,file_etag=?,idempotency_key=?,version=version+1,updated_at=CURRENT_TIMESTAMP(3)
WHERE meeting_id=? AND note_file_id=? AND uploader_id=? AND status=? AND version=?`,
strings.TrimSpace(fileID), strings.TrimSpace(fileID), file.MimeType, file.Size, file.ETag, strings.TrimSpace(idempotencyKey),
meeting.MeetingID, strings.TrimSpace(noteFileID), decodeUID(uid), t.MeetingNoteFileStatusActive, version)
if err != nil {
return nil, err
}
affected, err := res.RowsAffected()
if err != nil {
return nil, err
}
if affected == 0 {
return nil, t.ErrInvalidResponse
}
return a.meetingNoteFileByID(ctx, meeting.MeetingID, strings.TrimSpace(noteFileID))
}
func (a *adapter) TenantMeetingNoteFileDelete(id t.TenantID, uid t.Uid, meetingID, noteFileID, _ string, version int) (*t.MeetingNoteFile, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantMeetingNoteFileDelete(id, uid, meetingID, noteFileID, "", version)
}
}
if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(meetingID) == "" || strings.TrimSpace(noteFileID) == "" || version <= 0 {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
meeting, err := a.meetingNoteWritable(ctx, uid, strings.TrimSpace(meetingID))
if err != nil {
return nil, err
}
item, err := a.meetingNoteFileByID(ctx, meeting.MeetingID, strings.TrimSpace(noteFileID))
if err != nil {
return nil, err
}
res, err := a.db.ExecContext(ctx, `UPDATE im_meeting_note_files
SET status=?,deleted_by=?,deleted_at=?,version=version+1,updated_at=CURRENT_TIMESTAMP(3)
WHERE meeting_id=? AND note_file_id=? AND uploader_id=? AND status=? AND version=?`,
t.MeetingNoteFileStatusDeleted, decodeUID(uid), time.Now().UTC().Round(time.Millisecond),
meeting.MeetingID, strings.TrimSpace(noteFileID), decodeUID(uid), t.MeetingNoteFileStatusActive, version)
if err != nil {
return nil, err
}
affected, err := res.RowsAffected()
if err != nil {
return nil, err
}
if affected == 0 {
return nil, t.ErrInvalidResponse
}
deletedAt := time.Now().UTC().Round(time.Millisecond)
item.Status = t.MeetingNoteFileStatusDeleted
item.DeletedBy = uid
item.DeletedAt = &deletedAt
item.Version++
return item, nil
}
func scanMeetingRow(scanner interface{ StructScan(any) error }) (*t.Meeting, int64, error) {
row := struct {
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"`
Location string
State string
ReminderMinutes []byte `db:"reminder_minutes"`
IdempotencyKey sql.NullString `db:"idempotency_key"`
Version int
CanceledAt *time.Time `db:"canceled_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}{}
if err := scanner.StructScan(&row); err != nil {
return nil, 0, err
}
meeting := &t.Meeting{
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,
Location: row.Location,
State: row.State,
Version: row.Version,
CanceledAt: row.CanceledAt,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
if meeting.RecorderID.IsZero() {
meeting.RecorderID = meeting.OrganizerID
}
if row.IdempotencyKey.Valid {
meeting.IdempotencyKey = row.IdempotencyKey.String
}
if len(row.ReminderMinutes) > 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 { func uniqueMeetingParticipants(organizer t.Uid, attendeeIDs []t.Uid) []t.Uid {
seen := map[t.Uid]bool{organizer: true} seen := map[t.Uid]bool{organizer: true}
participants := []t.Uid{organizer} participants := []t.Uid{organizer}
+1 -1
View File
@@ -380,7 +380,7 @@ func (a *adapter) TenantGroupInvitationCreate(id t.TenantID, inv *t.GroupInvitat
inv.IdempotencyKey = inv.InvitationID inv.IdempotencyKey = inv.InvitationID
} }
if inv.Status == "" { if inv.Status == "" {
inv.Status = t.GroupInvitationStatusJoined inv.Status = t.GroupInvitationStatusPending
} }
if inv.ExpiresAt.IsZero() { if inv.ExpiresAt.IsZero() {
inv.ExpiresAt = now.Add(7 * 24 * time.Hour) inv.ExpiresAt = now.Add(7 * 24 * time.Hour)
+483 -34
View File
@@ -1356,6 +1356,10 @@ func serveIMAbuseReasons(wrt http.ResponseWriter, req *http.Request) {
return return
} }
targetType := strings.ToLower(strings.TrimSpace(req.URL.Query().Get("target_type"))) 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) reasons, err := store.Safety.AbuseReasons(principal.TenantID, targetType)
if err != nil { if err != nil {
logs.Warn.Println("im abuse reasons: using defaults after store error", principal.TenantID, targetType, err) 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 { var body struct {
Title string `json:"title"` Title string `json:"title"`
RecorderUserID string `json:"recorder_user_id"`
StartAt string `json:"start_at"` StartAt string `json:"start_at"`
EndAt string `json:"end_at"` EndAt string `json:"end_at"`
Location string `json:"location"` Location string `json:"location"`
@@ -2144,9 +2149,25 @@ func serveIMMeetings(wrt http.ResponseWriter, req *http.Request) {
if !ok { if !ok {
return 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{ meeting, err := store.Meetings.Create(principal.TenantID, principal.Uid, &types.Meeting{
MeetingID: "mtg_" + meetingID, MeetingID: "mtg_" + meetingID,
OrganizerID: principal.Uid, OrganizerID: principal.Uid,
RecorderID: recorderID,
Title: strings.TrimSpace(body.Title), Title: strings.TrimSpace(body.Title),
StartAt: startAt, StartAt: startAt,
EndAt: endAt, 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, 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}) 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) { func serveIMMeetingList(wrt http.ResponseWriter, req *http.Request) {
@@ -2217,6 +2238,148 @@ func serveIMMeetingResource(wrt http.ResponseWriter, req *http.Request) {
case "my-response": case "my-response":
serveIMMeetingMyResponse(wrt, req, meetingID) serveIMMeetingMyResponse(wrt, req, meetingID)
return 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) 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) writeIMRelationError(wrt, req, err)
return 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) { 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 { var body struct {
Title *string `json:"title"` Title *string `json:"title"`
RecorderUserID *string `json:"recorder_user_id"`
StartAt *string `json:"start_at"` StartAt *string `json:"start_at"`
EndAt *string `json:"end_at"` EndAt *string `json:"end_at"`
Location *string `json:"location"` Location *string `json:"location"`
@@ -2255,6 +2419,18 @@ func serveIMMeetingUpdate(wrt http.ResponseWriter, req *http.Request, meetingID
return return
} }
patch := types.MeetingPatch{Title: body.Title, Location: body.Location, ReminderMinutes: body.ReminderMinutes, Version: body.Version} 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 { if body.StartAt != nil {
startAt, err := parseIMTime(*body.StartAt) startAt, err := parseIMTime(*body.StartAt)
if err != nil { 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, 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}) 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) { 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, 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}) 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) { 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, 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}) 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) { 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)) payload := make([]map[string]any, 0, len(items))
for _, item := range 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}) 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 created, existing := 0, 0
failed := make([]map[string]any, 0) failed := make([]map[string]any, 0)
for _, src := range body.Messages { 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}) failed = append(failed, map[string]any{"topic": src.Topic, "seq": src.Seq, "reason": imReasonMalformedRequest})
continue 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 { 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 continue
} }
fav := &types.FavoriteItem{ fav := &types.FavoriteItem{
@@ -3387,11 +3571,11 @@ func serveIMFavoriteBatchCreate(wrt http.ResponseWriter, req *http.Request) {
FavoriteID: "fav_" + store.Store.GetUidString(), FavoriteID: "fav_" + store.Store.GetUidString(),
UserID: principal.Uid, UserID: principal.Uid,
ItemType: imMessageType(msg), ItemType: imMessageType(msg),
SourceTopic: src.Topic, SourceTopic: sourceTopic,
SourceSeq: src.Seq, SourceSeq: src.Seq,
Snapshot: favoriteSnapshot(msg), Snapshot: favoriteSnapshot(msg),
AttachmentRefs: favoriteAttachmentRefs(msg), AttachmentRefs: favoriteAttachmentRefs(msg),
Availability: "available", Availability: messageAvailability(*msg),
IdempotencyKey: strings.TrimSpace(req.Header.Get("Idempotency-Key")), IdempotencyKey: strings.TrimSpace(req.Header.Get("Idempotency-Key")),
CollectedAt: now, CollectedAt: now,
CreatedAt: now, CreatedAt: now,
@@ -3443,7 +3627,12 @@ func serveIMFavoriteDetail(wrt http.ResponseWriter, req *http.Request, favoriteI
writeIMRelationError(wrt, req, err) writeIMRelationError(wrt, req, err)
return 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: case http.MethodDelete:
if err := store.Favorites.Delete(principal.TenantID, principal.Uid, favoriteID); err != nil { if err := store.Favorites.Delete(principal.TenantID, principal.Uid, favoriteID); err != nil {
writeIMRelationError(wrt, req, err) writeIMRelationError(wrt, req, err)
@@ -4316,6 +4505,9 @@ func serveIMGroupList(wrt http.ResponseWriter, req *http.Request) {
continue continue
} }
item := groupListItemPayload(sub) 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 { if category != "" && groupCategory(groupMemberRole(sub.ModeGiven&sub.ModeWant)) != category {
continue continue
} }
@@ -4357,7 +4549,7 @@ func serveIMGroupCreate(wrt http.ResponseWriter, req *http.Request) {
} }
topicName := genTopicName() topicName := genTopicName()
now := time.Now().UTC().Round(time.Millisecond) 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 != "" { if avatar := strings.TrimSpace(body.AvatarFileID); avatar != "" {
public["avatar_file_id"] = avatar public["avatar_file_id"] = avatar
} }
@@ -4667,6 +4859,7 @@ func serveIMGroupDetail(wrt http.ResponseWriter, req *http.Request, topic string
return return
} }
public["name"] = name public["name"] = name
public["fn"] = name
} }
if body.AvatarFileID != nil { if body.AvatarFileID != nil {
public["avatar_file_id"] = strings.TrimSpace(*body.AvatarFileID) public["avatar_file_id"] = strings.TrimSpace(*body.AvatarFileID)
@@ -4923,6 +5116,7 @@ func serveIMGroupInvitations(wrt http.ResponseWriter, req *http.Request, topic s
return return
} }
results := make([]map[string]any, 0, len(body.UserIDs)) results := make([]map[string]any, 0, len(body.UserIDs))
now := time.Now().UTC().Round(time.Millisecond)
for _, rawID := range body.UserIDs { for _, rawID := range body.UserIDs {
uid := types.ParseUserId(strings.TrimSpace(rawID)) uid := types.ParseUserId(strings.TrimSpace(rawID))
item := map[string]any{"user_id": 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 continue
} }
item["user_id"] = uid.UserId() item["user_id"] = uid.UserId()
now := time.Now().UTC().Round(time.Millisecond) targetSub, err := store.Subs.Get(principal.TenantID, topic, uid, false)
joined, err := store.GroupBusiness.InviteMember(principal.TenantID, topic, principal.Uid, uid, if err != nil && !errors.Is(err, types.ErrNotFound) {
strings.TrimSpace(body.Message), strings.TrimSpace(req.Header.Get("Idempotency-Key")), item["status"] = "failed"
groupMemberPrivate("", false, false, 1, now), now) 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 { if err != nil {
item["status"] = "failed" item["status"] = "failed"
item["reason"] = relationErrorReason(err) item["reason"] = relationErrorReason(err)
results = append(results, item) results = append(results, item)
continue continue
} }
if !joined { item["invitation_id"] = inv.InvitationID
item["status"] = "already_member" item["status"] = inv.Status
results = append(results, item) item["expires_at"] = inv.ExpiresAt.Format(time.RFC3339Nano)
continue 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) results = append(results, item)
} }
dispatchIMGroupEvents(principal.TenantID) dispatchIMGroupEvents(principal.TenantID)
@@ -6667,6 +6893,26 @@ func parseIMUserIDList(wrt http.ResponseWriter, req *http.Request, values []stri
return uids, true 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) { func parseIMNonNegativeCursor(raw string) (int, error) {
raw = strings.TrimSpace(raw) raw = strings.TrimSpace(raw)
if 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 { func groupDetailPayload(group *types.Topic, selfSub *types.Subscription) map[string]any {
public := map[string]any{} public := map[string]any{}
topic := "" 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 { func messageSearchPayload(msg types.Message, query string) map[string]any {
text := messageText(msg) text := messageText(msg)
snippet := textSnippet(text, query)
return map[string]any{ return map[string]any{
"seq": msg.SeqId, "seq": msg.SeqId,
"from": msg.From, "from": msg.From,
"ts": msg.CreatedAt.UTC().Format(time.RFC3339Nano), "ts": msg.CreatedAt.UTC().Format(time.RFC3339Nano),
"type": imMessageType(&msg), "type": imMessageType(&msg),
"snippet": textSnippet(text, query), "version": msg.Version,
"matches": textMatches(text, query), "snippet": snippet,
"matches": textMatches(snippet, query),
"snapshot": favoriteSnapshot(&msg), "snapshot": favoriteSnapshot(&msg),
"availability": messageAvailability(msg), "availability": messageAvailability(msg),
} }
@@ -7479,6 +7770,7 @@ func messageContextPayload(msg types.Message) map[string]any {
"from": msg.From, "from": msg.From,
"ts": msg.CreatedAt.UTC().Format(time.RFC3339Nano), "ts": msg.CreatedAt.UTC().Format(time.RFC3339Nano),
"type": imMessageType(&msg), "type": imMessageType(&msg),
"version": msg.Version,
"head": msg.Head, "head": msg.Head,
"content": msg.Content, "content": msg.Content,
"availability": messageAvailability(msg), "availability": messageAvailability(msg),
@@ -7848,9 +8140,24 @@ func meetingPayload(meeting *types.Meeting, viewer types.Uid) map[string]any {
return meetingPayloadWithUserSummaries(meeting, viewer, nil) 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 { 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) participants := meetingParticipantPayloadsWithSummaries(meeting.Participants, userSummaries)
organizer := meetingUserSummary(meeting.OrganizerID, userSummaries) organizer := meetingUserSummary(meeting.OrganizerID, userSummaries)
recorderID := meeting.RecorderID
if recorderID.IsZero() {
recorderID = meeting.OrganizerID
}
recorder := meetingUserSummary(recorderID, userSummaries)
startAt := meeting.StartAt.UTC() startAt := meeting.StartAt.UTC()
endAt := meeting.EndAt.UTC() endAt := meeting.EndAt.UTC()
data := map[string]any{ data := map[string]any{
@@ -7858,6 +8165,8 @@ func meetingPayloadWithUserSummaries(meeting *types.Meeting, viewer types.Uid, u
"title": meeting.Title, "title": meeting.Title,
"organizer_id": meeting.OrganizerID.UserId(), "organizer_id": meeting.OrganizerID.UserId(),
"organizer": organizer, "organizer": organizer,
"recorder_user_id": recorderID.UserId(),
"recorder": recorder,
"start_at": startAt.Format(time.RFC3339Nano), "start_at": startAt.Format(time.RFC3339Nano),
"end_at": endAt.Format(time.RFC3339Nano), "end_at": endAt.Format(time.RFC3339Nano),
"meeting_time": meetingTimeRange(startAt, endAt), "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 { if displayName, ok := organizer["display_name"]; ok {
data["organizer_display_name"] = displayName data["organizer_display_name"] = displayName
} }
if displayName, ok := recorder["display_name"]; ok {
data["recorder_display_name"] = displayName
}
if meeting.CanceledAt != nil { if meeting.CanceledAt != nil {
data["canceled_at"] = meeting.CanceledAt.UTC().Format(time.RFC3339Nano) 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 seen[meeting.OrganizerID] = true
uids = append(uids, meeting.OrganizerID) 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 { for _, participant := range meeting.Participants {
if !participant.UserID.IsZero() && !seen[participant.UserID] { if !participant.UserID.IsZero() && !seen[participant.UserID] {
seen[participant.UserID] = true seen[participant.UserID] = true
@@ -7977,6 +8293,89 @@ func meetingParticipantNames(participants []map[string]any) []string {
return names 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 { func meetingTimeRange(startAt, endAt time.Time) string {
if startAt.IsZero() || endAt.IsZero() { if startAt.IsZero() || endAt.IsZero() {
return "" return ""
@@ -7991,7 +8390,14 @@ func meetingParticipantUIDs(meeting *types.Meeting) []types.Uid {
if meeting == nil { if meeting == nil {
return 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 { 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 { func abuseReportPayload(report *types.AbuseReport) map[string]any {
return map[string]any{ return map[string]any{
"report_id": report.ReportID, "report_id": report.ReportID,
@@ -8448,7 +8863,7 @@ func tenantEntryFeedbackPayload(feedback *types.TenantEntryFeedback) map[string]
func isAllowedIMWorkbenchRoute(route string) bool { func isAllowedIMWorkbenchRoute(route string) bool {
switch route { 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 return true
default: default:
return false return false
@@ -8857,6 +9272,7 @@ func favoriteSnapshot(msg *types.Message) map[string]any {
"from": msg.From, "from": msg.From,
"ts": msg.CreatedAt.UTC().Format(time.RFC3339Nano), "ts": msg.CreatedAt.UTC().Format(time.RFC3339Nano),
"type": imMessageType(msg), "type": imMessageType(msg),
"version": msg.Version,
"head": msg.Head, "head": msg.Head,
"content": msg.Content, "content": msg.Content,
"text": messageText(*msg), "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) { 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) topic = strings.TrimSpace(topic)
if topic == "" { if topic == "" {
return "", false return "", false
@@ -9175,7 +9599,7 @@ func messageText(msg types.Message) string {
case string: case string:
return content return content
case map[string]any: 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 != "" { if value := stringFromAny(content[key]); value != "" {
return value return value
} }
@@ -9194,11 +9618,11 @@ func messageAvailability(msg types.Message) string {
func textSnippet(text, query string) string { func textSnippet(text, query string) string {
text = strings.TrimSpace(text) text = strings.TrimSpace(text)
if len([]rune(text)) <= 120 || strings.TrimSpace(query) == "" { runes := []rune(text)
if len(runes) <= 120 || strings.TrimSpace(query) == "" {
return text return text
} }
lower := strings.ToLower(text) idx := runeIndexFold(text, query)
idx := strings.Index(lower, strings.ToLower(strings.TrimSpace(query)))
if idx < 0 { if idx < 0 {
idx = 0 idx = 0
} }
@@ -9207,7 +9631,6 @@ func textSnippet(text, query string) string {
start = 0 start = 0
} }
end := start + 120 end := start + 120
runes := []rune(text)
if end > len(runes) { if end > len(runes) {
end = len(runes) end = len(runes)
} }
@@ -9219,11 +9642,36 @@ func textMatches(text, query string) []map[string]any {
if query == "" { if query == "" {
return []map[string]any{} return []map[string]any{}
} }
idx := strings.Index(strings.ToLower(text), strings.ToLower(query)) idx := runeIndexFold(text, query)
if idx < 0 { if idx < 0 {
return []map[string]any{} 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 { func validIMRealName(name, idType, idNumber string) bool {
@@ -9555,6 +10003,7 @@ func cleanStringList(values []string, limit int) []string {
func isAllowedPublicUploadPurpose(purpose string) bool { func isAllowedPublicUploadPurpose(purpose string) bool {
switch strings.TrimSpace(purpose) { switch strings.TrimSpace(purpose) {
case types.PublicUploadPurposeEnterpriseLicense, types.PublicUploadPurposeTenantFeedback, case types.PublicUploadPurposeEnterpriseLicense, types.PublicUploadPurposeTenantFeedback,
types.PublicUploadPurposeUserAvatar,
types.PublicUploadPurposeGroupAvatar: types.PublicUploadPurposeGroupAvatar:
return true return true
default: default:
@@ -9574,7 +10023,7 @@ func isAllowedPublicUploadMime(purpose, mimeType string) bool {
return strings.HasPrefix(mimeType, "image/jpeg") || return strings.HasPrefix(mimeType, "image/jpeg") ||
strings.HasPrefix(mimeType, "image/png") || strings.HasPrefix(mimeType, "image/png") ||
strings.HasPrefix(mimeType, "image/webp") strings.HasPrefix(mimeType, "image/webp")
case types.PublicUploadPurposeGroupAvatar: case types.PublicUploadPurposeUserAvatar, types.PublicUploadPurposeGroupAvatar:
return strings.HasPrefix(mimeType, "image/jpeg") || return strings.HasPrefix(mimeType, "image/jpeg") ||
strings.HasPrefix(mimeType, "image/png") || strings.HasPrefix(mimeType, "image/png") ||
strings.HasPrefix(mimeType, "image/webp") strings.HasPrefix(mimeType, "image/webp")
+436 -7
View File
@@ -269,6 +269,14 @@ type testMeetingsStore struct {
responseVersion int responseVersion int
} }
type testMeetingNotesStore struct {
meetings []types.Meeting
files []types.MeetingNoteFile
query string
cursor string
limit int
}
type testDailyReportsStore struct { type testDailyReportsStore struct {
report *types.DailyReport report *types.DailyReport
saved bool saved bool
@@ -658,6 +666,43 @@ func (s *testMeetingsStore) Respond(_ types.TenantID, uid types.Uid, _ string, r
return s.meeting, nil 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) { func (s *testDailyReportsStore) List(_ types.TenantID, _ types.Uid, _, _, _, _, _ string, _ int) ([]types.DailyReport, string, error) {
if s.report == nil { if s.report == nil {
return nil, "", 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) { func TestServeIMVerificationCodesCreatesChallenge(t *testing.T) {
oldTenants := store.Tenants oldTenants := store.Tenants
oldVerifications := store.Verifications 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) { func TestServeIMMeetingMyResponse(t *testing.T) {
ctrl := gomock.NewController(t) ctrl := gomock.NewController(t)
defer ctrl.Finish() 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) { func TestServeIMDailyReportsCreateAndList(t *testing.T) {
ctrl := gomock.NewController(t) ctrl := gomock.NewController(t)
defer ctrl.Finish() defer ctrl.Finish()
@@ -3697,9 +3936,23 @@ func TestServeIMGroupList(t *testing.T) {
defer ctrl.Finish() defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42) expectIMBearer(t, ctrl, "token-1", 7, 42)
oldUsers := store.Users oldUsers := store.Users
oldGroupBusiness := store.GroupBusiness
users := mock_store.NewMockUsersPersistenceInterface(ctrl) 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 store.Users = users
defer func() { store.Users = oldUsers }() store.GroupBusiness = groupBusiness
defer func() {
store.Users = oldUsers
store.GroupBusiness = oldGroupBusiness
}()
sub := types.Subscription{ sub := types.Subscription{
TenantID: 7, TenantID: 7,
User: types.Uid(42).String(), User: types.Uid(42).String(),
@@ -3708,8 +3961,8 @@ func TestServeIMGroupList(t *testing.T) {
ModeGiven: types.ModeCFull, ModeGiven: types.ModeCFull,
ObjHeader: types.ObjHeader{UpdatedAt: time.Date(2026, 8, 12, 9, 0, 0, 0, time.UTC)}, 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.SetPublic(map[string]any{"name": "grpAlpha"})
sub.SetSubCnt(3) sub.SetSubCnt(0)
users.EXPECT().GetSubs(types.TenantID(7), types.Uid(42)).Return([]types.Subscription{sub}, nil) 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) 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) data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
items := data["items"].([]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) t.Fatalf("unexpected group list response: %#v", data)
} }
} }
@@ -4086,7 +4339,8 @@ func TestServeIMGroupLeave(t *testing.T) {
func TestServeIMGroupInvitationAddsNonMember(t *testing.T) { func TestServeIMGroupInvitationAddsNonMember(t *testing.T) {
ctrl := gomock.NewController(t) ctrl := gomock.NewController(t)
defer ctrl.Finish() 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 oldSubs := store.Subs
oldGroupBusiness := store.GroupBusiness oldGroupBusiness := store.GroupBusiness
subs := mock_store.NewMockSubsPersistenceInterface(ctrl) 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{ 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, TenantID: 7, User: owner.String(), Topic: "grpAlpha", ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull,
}, nil) }, nil)
subs.EXPECT().Get(types.TenantID(7), "grpAlpha", target, false).Return(nil, types.ErrNotFound)
req := httptest.NewRequest(http.MethodPost, "/v1/im/groups/grpAlpha/invitations", req := httptest.NewRequest(http.MethodPost, "/v1/im/groups/grpAlpha/invitations",
bytes.NewBufferString(`{"user_ids":["`+target.UserId()+`"],"message":"welcome"}`)) 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) data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
items := data["items"].([]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) 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) 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) Return(&auth.Rec{TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth}, nil, nil).Times(3)
store.Store = mockStore store.Store = mockStore
oldFavorites := store.Favorites oldFavorites := store.Favorites
oldSubs := store.Subs
oldMessages := store.Messages
now := time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC) now := time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC)
favorites := &testFavoritesStore{items: []types.FavoriteItem{{ favorites := &testFavoritesStore{items: []types.FavoriteItem{{
TenantID: 7, TenantID: 7,
@@ -4474,11 +4731,36 @@ func TestServeIMFavoritesListDetailBatchDelete(t *testing.T) {
CollectedAt: now, CollectedAt: now,
}}} }}}
store.Favorites = favorites store.Favorites = favorites
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
messages := mock_store.NewMockMessagesPersistenceInterface(ctrl)
store.Subs = subs
store.Messages = messages
defer func() { defer func() {
store.Store = oldStore store.Store = oldStore
store.Favorites = oldFavorites 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 := httptest.NewRequest(http.MethodGet, "/v1/im/favorites?type=text", nil)
req.Header.Set("Authorization", "Bearer token-1") req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
@@ -4516,6 +4798,7 @@ func TestServeIMFavoriteBatchCreateCountsExisting(t *testing.T) {
mockStore.EXPECT().GetUidString().Return("new-fav") mockStore.EXPECT().GetUidString().Return("new-fav")
oldFavorites := store.Favorites oldFavorites := store.Favorites
oldSubs := store.Subs
oldMessages := store.Messages oldMessages := store.Messages
now := time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC) now := time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC)
store.Favorites = &testFavoritesStore{items: []types.FavoriteItem{{ store.Favorites = &testFavoritesStore{items: []types.FavoriteItem{{
@@ -4529,13 +4812,23 @@ func TestServeIMFavoriteBatchCreateCountsExisting(t *testing.T) {
Availability: "available", Availability: "available",
CollectedAt: now, CollectedAt: now,
}}} }}}
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
messages := mock_store.NewMockMessagesPersistenceInterface(ctrl) messages := mock_store.NewMockMessagesPersistenceInterface(ctrl)
store.Subs = subs
store.Messages = messages store.Messages = messages
t.Cleanup(func() { t.Cleanup(func() {
store.Favorites = oldFavorites store.Favorites = oldFavorites
store.Subs = oldSubs
store.Messages = oldMessages 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{ messages.EXPECT().GetOne(types.TenantID(7), "grpAlpha", int64(3)).Return(&types.Message{
TenantID: 7, TenantID: 7,
Topic: "grpAlpha", 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) { func TestServeIMFavoriteForwardSavesMessages(t *testing.T) {
ctrl := gomock.NewController(t) ctrl := gomock.NewController(t)
defer ctrl.Finish() defer ctrl.Finish()
@@ -5149,6 +5540,14 @@ func TestServeIMAbuseReportsAndReasons(t *testing.T) {
t.Fatalf("abuse reason scope mismatch: %#v", safety) 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", 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"]}`)) bytes.NewBufferString(`{"target":{"type":"user","user_id":"usrKwAAAAAAAAA"},"reason_code":"fraud","description":"疑似诈骗","evidence_file_ids":["file1"]}`))
req.Header.Set("Authorization", "Bearer token-1") 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) { func TestServeIMTopicCalls(t *testing.T) {
ctrl := gomock.NewController(t) ctrl := gomock.NewController(t)
defer ctrl.Finish() defer ctrl.Finish()
+1
View File
@@ -828,6 +828,7 @@ func main() {
mux.HandleFunc(config.ApiPath+"v1/im/attendance/records", serveIMAttendanceRecords) 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/checkins", serveIMAttendanceCheckins)
mux.HandleFunc(config.ApiPath+"v1/im/attendance/appeals", serveIMAttendanceAppeals) 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", serveIMMeetings)
mux.HandleFunc(config.ApiPath+"v1/im/meetings/", serveIMMeetingResource) mux.HandleFunc(config.ApiPath+"v1/im/meetings/", serveIMMeetingResource)
mux.HandleFunc(config.ApiPath+"v1/im/reports/abuse/reasons", serveIMAbuseReasons) mux.HandleFunc(config.ApiPath+"v1/im/reports/abuse/reasons", serveIMAbuseReasons)
+70 -1
View File
@@ -2231,9 +2231,19 @@ type MeetingsPersistenceInterface interface {
Respond(tenantID types.TenantID, uid types.Uid, meetingID, response string, version int) (*types.Meeting, error) 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 meetingsMapper struct{}
type meetingNotesMapper struct{}
var Meetings MeetingsPersistenceInterface var Meetings MeetingsPersistenceInterface
var MeetingNotes MeetingNotesPersistenceInterface
func (meetingsMapper) Create(tenantID types.TenantID, uid types.Uid, meeting *types.Meeting, attendeeIDs []types.Uid) (*types.Meeting, error) { 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) { 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) 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 { func validMeetingForCreate(meeting *types.Meeting) bool {
return strings.TrimSpace(meeting.MeetingID) != "" && return strings.TrimSpace(meeting.MeetingID) != "" &&
strings.TrimSpace(meeting.Title) != "" && strings.TrimSpace(meeting.Title) != "" &&
@@ -2324,7 +2392,7 @@ func validMeetingForCreate(meeting *types.Meeting) bool {
} }
func validMeetingPatch(patch types.MeetingPatch) 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 return false
} }
if patch.Title != nil && strings.TrimSpace(*patch.Title) == "" { if patch.Title != nil && strings.TrimSpace(*patch.Title) == "" {
@@ -4645,6 +4713,7 @@ func init() {
Organization = organizationMapper{} Organization = organizationMapper{}
Attendance = attendanceMapper{} Attendance = attendanceMapper{}
Meetings = meetingsMapper{} Meetings = meetingsMapper{}
MeetingNotes = meetingNotesMapper{}
DailyReports = dailyReportsMapper{} DailyReports = dailyReportsMapper{}
MonthlyReports = monthlyReportsMapper{} MonthlyReports = monthlyReportsMapper{}
Mailbox = mailboxMapper{} Mailbox = mailboxMapper{}
+2 -1
View File
@@ -12,7 +12,8 @@ const (
GroupMemberStatusActive = "active" GroupMemberStatusActive = "active"
GroupMemberStatusLeft = "left" GroupMemberStatusLeft = "left"
GroupInvitationStatusJoined = "joined" GroupInvitationStatusPending = "pending"
GroupInvitationStatusJoined = "joined"
GroupJoinRequestStatusPending = "pending" GroupJoinRequestStatusPending = "pending"
GroupJoinRequestStatusAccepted = "accepted" GroupJoinRequestStatusAccepted = "accepted"
+33
View File
@@ -4,6 +4,7 @@ import "time"
const ( const (
MeetingStateScheduled = "scheduled" MeetingStateScheduled = "scheduled"
MeetingStateEnded = "ended"
MeetingStateCanceled = "canceled" MeetingStateCanceled = "canceled"
MeetingResponsePending = "pending" MeetingResponsePending = "pending"
@@ -16,6 +17,7 @@ type Meeting struct {
ID int64 ID int64
MeetingID string MeetingID string
OrganizerID Uid OrganizerID Uid
RecorderID Uid
Title string Title string
StartAt time.Time StartAt time.Time
EndAt time.Time EndAt time.Time
@@ -42,9 +44,40 @@ type MeetingParticipant struct {
type MeetingPatch struct { type MeetingPatch struct {
Title *string Title *string
RecorderID *Uid
StartAt *time.Time StartAt *time.Time
EndAt *time.Time EndAt *time.Time
Location *string Location *string
ReminderMinutes []int ReminderMinutes []int
Version 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
}
+1
View File
@@ -5,6 +5,7 @@ import "time"
const ( const (
PublicUploadPurposeEnterpriseLicense = "enterprise_license" PublicUploadPurposeEnterpriseLicense = "enterprise_license"
PublicUploadPurposeTenantFeedback = "tenant_entry_feedback" PublicUploadPurposeTenantFeedback = "tenant_entry_feedback"
PublicUploadPurposeUserAvatar = "user_avatar"
PublicUploadPurposeGroupAvatar = "group_avatar" PublicUploadPurposeGroupAvatar = "group_avatar"
TenantApplicationStatusReceived = "received" TenantApplicationStatusReceived = "received"