feat: add meeting notes backend
This commit is contained in:
@@ -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')
|
||||||
|
|||||||
@@ -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
@@ -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(¬eRefs); 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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{}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user