feat: add meeting notes backend

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