feat: add meeting notes backend
This commit is contained in:
@@ -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
@@ -281,6 +281,9 @@ func (a *adapter) Open(jsonconfig json.RawMessage) error {
|
||||
if err = ensureIMAttendanceTables(a.db); err != nil {
|
||||
return errors.New("mysql adapter failed to ensure attendance schema: " + err.Error())
|
||||
}
|
||||
if err = ensureIMMeetingNotesTables(a.db); err != nil {
|
||||
return errors.New("mysql adapter failed to ensure meeting notes schema: " + err.Error())
|
||||
}
|
||||
if err = ensureSnowflakePrimaryKeys(a.db); err != nil {
|
||||
return errors.New("mysql adapter failed to ensure snowflake primary keys: " + err.Error())
|
||||
}
|
||||
@@ -521,6 +524,10 @@ func (a *adapter) TenantAdapterFor(tenantID t.TenantID) (dbadapter.TenantBusines
|
||||
db.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
if err = ensureIMMeetingNotesTables(db); err != nil {
|
||||
db.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
if err = ensureSnowflakePrimaryKeys(db); err != nil {
|
||||
db.Close()
|
||||
return nil, nil, err
|
||||
@@ -1212,6 +1219,7 @@ func createIMWorkbenchTables(tx *sql.Tx) error {
|
||||
('organization','组织架构','/organization',10,'organization',1,'enabled'),
|
||||
('attendance','打卡','/attendance',20,'attendance',1,'enabled'),
|
||||
('meetings','会议通知','/meetings',30,'meetings',1,'enabled'),
|
||||
('meeting_notes','会议笔记','/meeting-notes',35,'',1,'enabled'),
|
||||
('daily_reports','日报','/reports/daily',40,'daily_reports',1,'enabled'),
|
||||
('monthly_reports','月报','/reports/monthly',50,'monthly_reports',1,'enabled'),
|
||||
('executive_mailbox','总经理信箱','/executive-mailbox',60,'executive_mailbox',1,'enabled')
|
||||
@@ -1453,6 +1461,7 @@ func createIMMeetingTables(tx *sql.Tx) error {
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
meeting_id VARCHAR(64) NOT NULL,
|
||||
organizer_id BIGINT NOT NULL,
|
||||
recorder_user_id BIGINT NOT NULL,
|
||||
title VARCHAR(200) NOT NULL,
|
||||
start_at DATETIME(3) NOT NULL,
|
||||
end_at DATETIME(3) NOT NULL,
|
||||
@@ -1468,6 +1477,7 @@ func createIMMeetingTables(tx *sql.Tx) error {
|
||||
UNIQUE INDEX uk_im_meetings_meeting(meeting_id),
|
||||
UNIQUE INDEX uk_im_meetings_idempotency(organizer_id, idempotency_key),
|
||||
INDEX idx_im_meetings_organizer(organizer_id, start_at),
|
||||
INDEX idx_im_meetings_recorder(recorder_user_id, end_at),
|
||||
INDEX idx_im_meetings_state_time(state, start_at)
|
||||
)`,
|
||||
`CREATE TABLE im_meeting_participants(
|
||||
@@ -1484,6 +1494,33 @@ func createIMMeetingTables(tx *sql.Tx) error {
|
||||
INDEX idx_im_meeting_participants_response(meeting_id, response),
|
||||
CONSTRAINT fk_im_meeting_participants_meeting FOREIGN KEY(meeting_id) REFERENCES im_meetings(meeting_id) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE TABLE im_meeting_note_files(
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
note_file_id VARCHAR(64) NOT NULL,
|
||||
meeting_id VARCHAR(64) NOT NULL,
|
||||
file_id VARCHAR(64) NOT NULL,
|
||||
uploader_id BIGINT NOT NULL,
|
||||
file_name VARCHAR(255) NOT NULL DEFAULT '',
|
||||
mime_type VARCHAR(128) NOT NULL DEFAULT '',
|
||||
file_size BIGINT NOT NULL DEFAULT 0,
|
||||
file_etag VARCHAR(128) NOT NULL DEFAULT '',
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'active',
|
||||
preview_status VARCHAR(16) NOT NULL DEFAULT 'ready',
|
||||
preview_mode VARCHAR(16) NOT NULL DEFAULT 'native',
|
||||
preview_file_id VARCHAR(64) NOT NULL DEFAULT '',
|
||||
idempotency_key VARCHAR(128) NOT NULL DEFAULT '',
|
||||
version INT NOT NULL DEFAULT 1,
|
||||
deleted_by BIGINT,
|
||||
deleted_at DATETIME(3),
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY(id),
|
||||
UNIQUE INDEX uk_im_meeting_note_files_id(note_file_id),
|
||||
INDEX idx_im_meeting_note_files_file(meeting_id,file_id,status),
|
||||
UNIQUE INDEX uk_im_meeting_note_files_idempotency(meeting_id,uploader_id,idempotency_key),
|
||||
INDEX idx_im_meeting_note_files_meeting(meeting_id,status,id DESC),
|
||||
INDEX idx_im_meeting_note_files_uploader(uploader_id,id DESC)
|
||||
)`,
|
||||
}
|
||||
for _, stmt := range statements {
|
||||
if _, err := tx.Exec(stmt); err != nil {
|
||||
@@ -1493,6 +1530,63 @@ func createIMMeetingTables(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureIMMeetingNotesTables(db *sqlx.DB) error {
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
var recorderExists bool
|
||||
if err := db.QueryRow(`SELECT EXISTS(
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema=DATABASE() AND table_name='im_meetings' AND column_name='recorder_user_id'
|
||||
)`).Scan(&recorderExists); err != nil {
|
||||
return err
|
||||
}
|
||||
if !recorderExists {
|
||||
if _, err := db.Exec(`ALTER TABLE im_meetings ADD COLUMN recorder_user_id BIGINT NOT NULL DEFAULT 0 AFTER organizer_id`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(`UPDATE im_meetings SET recorder_user_id=organizer_id WHERE recorder_user_id=0`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(`CREATE INDEX idx_im_meetings_recorder ON im_meetings(recorder_user_id,end_at)`); err != nil && !isDupe(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS im_meeting_note_files(
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
note_file_id VARCHAR(64) NOT NULL,
|
||||
meeting_id VARCHAR(64) NOT NULL,
|
||||
file_id VARCHAR(64) NOT NULL,
|
||||
uploader_id BIGINT NOT NULL,
|
||||
file_name VARCHAR(255) NOT NULL DEFAULT '',
|
||||
mime_type VARCHAR(128) NOT NULL DEFAULT '',
|
||||
file_size BIGINT NOT NULL DEFAULT 0,
|
||||
file_etag VARCHAR(128) NOT NULL DEFAULT '',
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'active',
|
||||
preview_status VARCHAR(16) NOT NULL DEFAULT 'ready',
|
||||
preview_mode VARCHAR(16) NOT NULL DEFAULT 'native',
|
||||
preview_file_id VARCHAR(64) NOT NULL DEFAULT '',
|
||||
idempotency_key VARCHAR(128) NOT NULL DEFAULT '',
|
||||
version INT NOT NULL DEFAULT 1,
|
||||
deleted_by BIGINT,
|
||||
deleted_at DATETIME(3),
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY(id),
|
||||
UNIQUE INDEX uk_im_meeting_note_files_id(note_file_id),
|
||||
INDEX idx_im_meeting_note_files_file(meeting_id,file_id,status),
|
||||
UNIQUE INDEX uk_im_meeting_note_files_idempotency(meeting_id,uploader_id,idempotency_key),
|
||||
INDEX idx_im_meeting_note_files_meeting(meeting_id,status,id DESC),
|
||||
INDEX idx_im_meeting_note_files_uploader(uploader_id,id DESC)
|
||||
)`); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := db.Exec(`INSERT INTO im_workbench_apps(code,name,route,sort_order,unread_key,enabled,permission)
|
||||
VALUES('meeting_notes','会议笔记','/meeting-notes',35,'',1,'enabled')
|
||||
ON DUPLICATE KEY UPDATE name=VALUES(name),route=VALUES(route),sort_order=VALUES(sort_order),unread_key=VALUES(unread_key),updated_at=CURRENT_TIMESTAMP(3)`)
|
||||
return err
|
||||
}
|
||||
|
||||
func createIMReportTables(tx *sql.Tx) error {
|
||||
if _, err := tx.Exec(
|
||||
`CREATE TABLE im_daily_reports(
|
||||
@@ -4936,6 +5030,14 @@ func (a *adapter) FileDeleteUnused(olderThan time.Time, limit int) ([]string, er
|
||||
if err = rows.Scan(&id, &loc); err != nil {
|
||||
break
|
||||
}
|
||||
var noteRefs int
|
||||
fileID := store.EncodeUid(int64(id)).String()
|
||||
if err = tx.QueryRow(`SELECT COUNT(*) FROM im_meeting_note_files WHERE file_id=? AND status=? LIMIT 1`, fileID, t.MeetingNoteFileStatusActive).Scan(¬eRefs); err != nil {
|
||||
break
|
||||
}
|
||||
if noteRefs > 0 {
|
||||
continue
|
||||
}
|
||||
if loc != "" {
|
||||
locations = append(locations, loc)
|
||||
}
|
||||
@@ -5075,8 +5177,25 @@ func (a *adapter) FileUserHasAccess(fid string, uid t.Uid) (bool, error) {
|
||||
AND d.low<=m.seqid AND (d.hi=0 OR d.hi>m.seqid)
|
||||
)
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM im_meeting_note_files AS mnf
|
||||
INNER JOIN im_meetings AS mtg
|
||||
ON mtg.meeting_id=mnf.meeting_id
|
||||
WHERE mnf.file_id=? AND mnf.status='active'
|
||||
AND mtg.state<>?
|
||||
AND mtg.end_at<=CURRENT_TIMESTAMP(3)
|
||||
AND (
|
||||
mtg.organizer_id=?
|
||||
OR mtg.recorder_user_id=?
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM im_meeting_participants AS mp
|
||||
WHERE mp.meeting_id=mtg.meeting_id AND mp.user_id=?
|
||||
)
|
||||
)
|
||||
)
|
||||
) LIMIT 1`,
|
||||
store.DecodeUid(fileID), t.UploadCompleted, store.DecodeUid(uid), store.DecodeUid(uid), store.DecodeUid(uid), store.DecodeUid(uid), store.DecodeUid(uid))
|
||||
store.DecodeUid(fileID), t.UploadCompleted, store.DecodeUid(uid), store.DecodeUid(uid), store.DecodeUid(uid), store.DecodeUid(uid), store.DecodeUid(uid),
|
||||
fid, t.MeetingStateCanceled, store.DecodeUid(uid), store.DecodeUid(uid), store.DecodeUid(uid))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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{}
|
||||
|
||||
@@ -12,7 +12,8 @@ const (
|
||||
GroupMemberStatusActive = "active"
|
||||
GroupMemberStatusLeft = "left"
|
||||
|
||||
GroupInvitationStatusJoined = "joined"
|
||||
GroupInvitationStatusPending = "pending"
|
||||
GroupInvitationStatusJoined = "joined"
|
||||
|
||||
GroupJoinRequestStatusPending = "pending"
|
||||
GroupJoinRequestStatusAccepted = "accepted"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import "time"
|
||||
const (
|
||||
PublicUploadPurposeEnterpriseLicense = "enterprise_license"
|
||||
PublicUploadPurposeTenantFeedback = "tenant_entry_feedback"
|
||||
PublicUploadPurposeUserAvatar = "user_avatar"
|
||||
PublicUploadPurposeGroupAvatar = "group_avatar"
|
||||
|
||||
TenantApplicationStatusReceived = "received"
|
||||
|
||||
Reference in New Issue
Block a user