diff --git a/server/auth_audit_test.go b/server/auth_audit_test.go index 0429dcc..9871acc 100644 --- a/server/auth_audit_test.go +++ b/server/auth_audit_test.go @@ -138,7 +138,6 @@ func TestUserQuerySupportsKeywordAndUserIDs(t *testing.T) { "im_fileuploads AS avatar", "JSON_EXTRACT(u.public,'$.avatar')", "JSON_EXTRACT(u.public,'$.photo.data')", - "FROM im_org_members AS om", } { if !strings.Contains(usersInternalQuerySpec().Select+usersInternalQuerySpec().From, field) { t.Fatalf("user query missing list field %q", field) diff --git a/server/db/adapter.go b/server/db/adapter.go index 91a0c4a..fb34db8 100644 --- a/server/db/adapter.go +++ b/server/db/adapter.go @@ -403,44 +403,6 @@ type TenantBusinessAdapter interface { TenantGroupOwnerTransfer(t.TenantID, string, t.Uid, t.Uid, int, time.Time) error TenantGroupCanPost(t.TenantID, string, t.Uid, bool, time.Time) (bool, error) - TenantWorkbenchModules(t.TenantID, t.Uid) ([]t.WorkbenchModule, int, error) - TenantOrganizationDepartments(t.TenantID, t.Uid, string, string, int) ([]t.OrganizationDepartment, string, error) - TenantOrganizationMembers(t.TenantID, t.Uid, string, string, int) ([]t.OrganizationMember, string, error) - TenantOrganizationMembersAll(t.TenantID, t.Uid, string, string, string, int) ([]t.OrganizationMember, string, error) - TenantOrganizationSearch(t.TenantID, t.Uid, string, string, string, int) ([]t.OrganizationSearchItem, string, error) - TenantAttendanceConfig(t.TenantID, t.Uid, time.Time) (*t.AttendanceConfig, error) - TenantAttendanceRecordList(t.TenantID, t.Uid, t.AttendanceRecordListOptions) ([]t.AttendanceRecord, string, *t.AttendanceRecordStats, error) - TenantAttendanceCheckin(t.TenantID, t.Uid, string, t.AttendanceCheckinRequest) (*t.AttendanceRecord, *t.AttendanceCheckin, error) - TenantAttendanceAppealCreate(t.TenantID, t.Uid, *t.AttendanceAppeal, string) (*t.AttendanceAppeal, error) - TenantAttendanceAppealList(t.TenantID, t.Uid, t.AttendanceAppealListOptions) ([]t.AttendanceAppeal, string, error) - TenantMeetingCreate(t.TenantID, t.Uid, *t.Meeting, []t.Uid) (*t.Meeting, error) - TenantMeetingList(t.TenantID, t.Uid, string, int) ([]t.Meeting, string, error) - TenantMeetingGet(t.TenantID, t.Uid, string) (*t.Meeting, error) - TenantMeetingUpdate(t.TenantID, t.Uid, string, t.MeetingPatch, string) (*t.Meeting, error) - 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) - TenantDailyReportSaveDraft(t.TenantID, t.Uid, string, string, []string, int) (*t.DailyReport, error) - TenantDailyReportSubmit(t.TenantID, t.Uid, string, int) (*t.DailyReport, error) - TenantDailyReportStats(t.TenantID, t.Uid, time.Time, string) (*t.DailyReportStats, error) - TenantDailyReportRemind(t.TenantID, t.Uid, time.Time, []t.Uid, string) ([]t.Uid, error) - TenantMonthlyReportList(t.TenantID, t.Uid, string, string, string, string, string, int) ([]t.MonthlyReport, string, error) - TenantMonthlyReportCreateDraft(t.TenantID, t.Uid, string, string) (*t.MonthlyReport, error) - TenantMonthlyReportGet(t.TenantID, t.Uid, string) (*t.MonthlyReport, error) - TenantMonthlyReportSaveDraft(t.TenantID, t.Uid, string, string, string, []string, int) (*t.MonthlyReport, error) - TenantMonthlyReportSubmit(t.TenantID, t.Uid, string, int) (*t.MonthlyReport, error) - TenantMonthlyReportReopen(t.TenantID, t.Uid, string, int) (*t.MonthlyReport, error) - TenantMonthlyReportStats(t.TenantID, t.Uid, string, string) (*t.MonthlyReportStats, error) - TenantMailboxConfigGet(t.TenantID, t.Uid) (*t.MailboxConfig, error) - TenantMailboxSubmissionCreate(t.TenantID, t.Uid, *t.MailboxSubmission, string) (*t.MailboxSubmission, error) TenantUserProfileGet(t.TenantID, t.Uid) (*t.UserProfile, error) TenantUserProfileUpdate(t.TenantID, t.Uid, t.UserProfilePatch) (*t.UserProfile, error) TenantUserPrivacyGet(t.TenantID, t.Uid) (*t.UserPrivacySettings, error) diff --git a/server/db/mysql/adapter.go b/server/db/mysql/adapter.go index a0c02e3..8e2f97b 100644 --- a/server/db/mysql/adapter.go +++ b/server/db/mysql/adapter.go @@ -844,495 +844,6 @@ func createIMCallTables(tx *sql.Tx) error { return err } -func createIMWorkbenchTables(tx *sql.Tx) error { - if _, err := tx.Exec( - `CREATE TABLE im_workbench_apps( - id BIGINT NOT NULL AUTO_INCREMENT, - code VARCHAR(64) NOT NULL, - name VARCHAR(128) NOT NULL, - icon_file_id VARCHAR(64) NOT NULL DEFAULT '', - route VARCHAR(128) NOT NULL, - sort_order INT NOT NULL DEFAULT 0, - unread_key VARCHAR(64) NOT NULL DEFAULT '', - enabled TINYINT NOT NULL DEFAULT 1, - permission VARCHAR(64) NOT NULL DEFAULT 'enabled', - 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_workbench_apps_code(code), - INDEX idx_im_workbench_apps_enabled_order(enabled, sort_order) - )`); err != nil { - return err - } - _, err := tx.Exec( - `INSERT INTO im_workbench_apps(code,name,route,sort_order,unread_key,enabled,permission) - VALUES - ('attendance','打卡','/attendance',10,'attendance',1,'enabled'), - ('monthly_reports','月报','/reports/monthly',20,'monthly_reports',1,'enabled'), - ('daily_reports','日报','/reports/daily',30,'daily_reports',1,'enabled'), - ('organization','部门框架','/organization',40,'organization',1,'enabled'), - ('executive_mailbox','举报信箱','/executive-mailbox',50,'executive_mailbox',1,'enabled'), - ('meetings','会议通知','/meetings',60,'meetings',1,'enabled'), - ('meeting_notes','会议笔记','/meeting-notes',70,'',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 createIMOrganizationTables(tx *sql.Tx) error { - statements := []string{ - `CREATE TABLE im_org_departments( - id BIGINT NOT NULL AUTO_INCREMENT, - department_id VARCHAR(64) NOT NULL, - name VARCHAR(128) NOT NULL, - parent_id VARCHAR(64) NOT NULL DEFAULT 'root', - sort_order INT NOT NULL DEFAULT 0, - version INT NOT NULL DEFAULT 1, - state VARCHAR(16) NOT NULL DEFAULT 'active', - 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_org_departments_department(department_id), - INDEX idx_im_org_departments_parent(parent_id, state, sort_order, id), - INDEX idx_im_org_departments_name(name) - )`, - `CREATE TABLE im_org_members( - id BIGINT NOT NULL AUTO_INCREMENT, - department_id VARCHAR(64) NOT NULL, - user_id BIGINT NOT NULL, - display_name VARCHAR(128) NOT NULL DEFAULT '', - position VARCHAR(128) NOT NULL DEFAULT '', - avatar_file_id VARCHAR(64) NOT NULL DEFAULT '', - mobile VARCHAR(32) NOT NULL DEFAULT '', - email VARCHAR(128) NOT NULL DEFAULT '', - employment_state VARCHAR(16) NOT NULL DEFAULT 'active', - contactable TINYINT NOT NULL DEFAULT 1, - sort_order INT NOT NULL DEFAULT 0, - version INT NOT NULL DEFAULT 1, - 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_org_members_department_user(department_id, user_id), - INDEX idx_im_org_members_department(department_id, employment_state, sort_order, id), - INDEX idx_im_org_members_user(user_id), - INDEX idx_im_org_members_display_name(display_name), - CONSTRAINT fk_im_org_members_department FOREIGN KEY(department_id) REFERENCES im_org_departments(department_id) ON DELETE CASCADE - )`, - `CREATE TABLE im_org_visibility_overrides( - id BIGINT NOT NULL AUTO_INCREMENT, - user_id BIGINT NOT NULL, - visibility_scope VARCHAR(16) NOT NULL, - state VARCHAR(16) NOT NULL DEFAULT 'active', - remark VARCHAR(256) NOT NULL DEFAULT '', - 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_org_visibility_user(user_id), - INDEX idx_im_org_visibility_scope(visibility_scope,state) - )`, - } - for _, stmt := range statements { - if _, err := tx.Exec(stmt); err != nil { - return err - } - } - return nil -} - -func ensureIMOrganizationVisibilityTables(exec sqlExecutor) error { - _, err := exec.Exec(`CREATE TABLE IF NOT EXISTS im_org_visibility_overrides( - id BIGINT NOT NULL AUTO_INCREMENT, - user_id BIGINT NOT NULL, - visibility_scope VARCHAR(16) NOT NULL, - state VARCHAR(16) NOT NULL DEFAULT 'active', - remark VARCHAR(256) NOT NULL DEFAULT '', - 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_org_visibility_user(user_id), - INDEX idx_im_org_visibility_scope(visibility_scope,state) - )`) - return err -} - -func createIMAttendanceTables(tx *sql.Tx) error { - for _, stmt := range imAttendanceTableStatements(false) { - if _, err := tx.Exec(stmt); err != nil { - return err - } - } - return seedIMAttendanceDefaults(tx) -} - -func ensureIMAttendanceTables(db *sqlx.DB) error { - tx, err := db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - for _, stmt := range imAttendanceTableStatements(true) { - if _, err = tx.Exec(stmt); err != nil { - return err - } - } - if err = seedIMAttendanceDefaults(tx); err != nil { - return err - } - return tx.Commit() -} - -func imAttendanceTableStatements(ifNotExists bool) []string { - statements := []string{ - `CREATE TABLE im_attendance_rules( - id BIGINT NOT NULL AUTO_INCREMENT, - rule_id VARCHAR(64) NOT NULL, - name VARCHAR(128) NOT NULL, - workday TINYINT NOT NULL DEFAULT 1, - location_required TINYINT NOT NULL DEFAULT 0, - wifi_required TINYINT NOT NULL DEFAULT 0, - allow_outside_checkin TINYINT NOT NULL DEFAULT 1, - appeal_allowed TINYINT NOT NULL DEFAULT 1, - effective_from DATE, - effective_to DATE, - state VARCHAR(16) NOT NULL DEFAULT 'active', - 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_attendance_rules_rule(rule_id), - INDEX idx_im_attendance_rules_state(state,effective_from,effective_to) - )`, - `CREATE TABLE im_attendance_shifts( - id BIGINT NOT NULL AUTO_INCREMENT, - shift_id VARCHAR(64) NOT NULL, - rule_id VARCHAR(64) NOT NULL, - name VARCHAR(128) NOT NULL, - work_start TIME NOT NULL, - work_end TIME NOT NULL, - checkin_start TIME NOT NULL, - checkout_end TIME NOT NULL, - state VARCHAR(16) NOT NULL DEFAULT 'active', - sort_order INT NOT NULL DEFAULT 0, - 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_attendance_shifts_shift(shift_id), - INDEX idx_im_attendance_shifts_rule(rule_id,state,sort_order) - )`, - `CREATE TABLE im_attendance_records( - id BIGINT NOT NULL AUTO_INCREMENT, - record_id VARCHAR(64) NOT NULL, - user_id BIGINT NOT NULL, - record_date DATE NOT NULL, - rule_id VARCHAR(64) NOT NULL, - shift_id VARCHAR(64) NOT NULL, - status VARCHAR(32) NOT NULL DEFAULT 'not_checked', - exceptions JSON, - appeal_status VARCHAR(32) NOT NULL DEFAULT '', - version INT NOT NULL DEFAULT 1, - 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_attendance_records_record(record_id), - UNIQUE INDEX uk_im_attendance_records_user_date_shift(user_id,record_date,shift_id), - INDEX idx_im_attendance_records_user_id(user_id,id DESC), - INDEX idx_im_attendance_records_date_status(record_date,status,id DESC) - )`, - `CREATE TABLE im_attendance_checkins( - id BIGINT NOT NULL AUTO_INCREMENT, - checkin_id VARCHAR(64) NOT NULL, - record_id VARCHAR(64) NOT NULL, - user_id BIGINT NOT NULL, - record_date DATE NOT NULL, - action VARCHAR(32) NOT NULL, - rule_id VARCHAR(64) NOT NULL, - shift_id VARCHAR(64) NOT NULL, - client_time DATETIME(3) NOT NULL, - server_time DATETIME(3) NOT NULL, - result VARCHAR(32) NOT NULL, - location JSON, - wifi JSON, - device JSON, - idempotency_key VARCHAR(128) NOT NULL, - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), - PRIMARY KEY(id), - UNIQUE INDEX uk_im_attendance_checkins_checkin(checkin_id), - UNIQUE INDEX uk_im_attendance_checkins_idempotency(user_id,idempotency_key), - INDEX idx_im_attendance_checkins_record(record_id,id) - )`, - `CREATE TABLE im_attendance_appeals( - id BIGINT NOT NULL AUTO_INCREMENT, - appeal_id VARCHAR(64) NOT NULL, - record_id VARCHAR(64) NOT NULL, - user_id BIGINT NOT NULL, - appeal_type VARCHAR(64) NOT NULL, - reason VARCHAR(512) NOT NULL, - target_action VARCHAR(32) NOT NULL DEFAULT '', - target_time DATETIME(3), - attachment_file_ids JSON, - status VARCHAR(32) NOT NULL DEFAULT 'pending', - version INT NOT NULL DEFAULT 1, - idempotency_key VARCHAR(128) NOT NULL, - submitted_at DATETIME(3) NOT NULL, - 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_attendance_appeals_appeal(appeal_id), - UNIQUE INDEX uk_im_attendance_appeals_idempotency(user_id,idempotency_key), - INDEX idx_im_attendance_appeals_record(record_id), - INDEX idx_im_attendance_appeals_user_status(user_id,status,id DESC) - )`, - } - if ifNotExists { - for i, stmt := range statements { - statements[i] = strings.Replace(stmt, "CREATE TABLE ", "CREATE TABLE IF NOT EXISTS ", 1) - } - } - return statements -} - -func seedIMAttendanceDefaults(tx *sql.Tx) error { - if _, err := tx.Exec(`INSERT INTO im_attendance_rules(rule_id,name,workday,location_required,wifi_required,allow_outside_checkin,appeal_allowed) - VALUES('att_rule_default','默认考勤规则',1,0,0,1,1) - ON DUPLICATE KEY UPDATE updated_at=updated_at`); err != nil { - return err - } - _, err := tx.Exec(`INSERT INTO im_attendance_shifts(shift_id,rule_id,name,work_start,work_end,checkin_start,checkout_end,sort_order) - VALUES('shift_default','att_rule_default','默认班次','09:00:00','18:00:00','08:30:00','20:00:00',10) - ON DUPLICATE KEY UPDATE updated_at=updated_at`) - return err -} - -func createIMMeetingTables(tx *sql.Tx) error { - statements := []string{ - `CREATE TABLE im_meetings( - id BIGINT NOT NULL AUTO_INCREMENT, - meeting_id VARCHAR(64) NOT NULL, - organizer_id BIGINT NOT NULL, - recorder_user_id BIGINT NOT NULL, - title VARCHAR(200) NOT NULL, - start_at DATETIME(3) NOT NULL, - end_at DATETIME(3) NOT NULL, - location VARCHAR(256) NOT NULL DEFAULT '', - state VARCHAR(16) NOT NULL DEFAULT 'scheduled', - reminder_minutes JSON, - idempotency_key VARCHAR(128), - version INT NOT NULL DEFAULT 1, - canceled_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_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( - id BIGINT NOT NULL AUTO_INCREMENT, - meeting_id VARCHAR(64) NOT NULL, - user_id BIGINT NOT NULL, - response VARCHAR(16) NOT NULL DEFAULT 'pending', - response_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_participants_user(meeting_id, user_id), - INDEX idx_im_meeting_participants_user_time(user_id, meeting_id), - 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 { - return err - } - } - 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',70,'',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( - id BIGINT NOT NULL AUTO_INCREMENT, - report_id VARCHAR(64) NOT NULL, - author_id BIGINT NOT NULL, - report_date DATE NOT NULL, - content TEXT NOT NULL, - attachments JSON, - status VARCHAR(16) NOT NULL DEFAULT 'draft', - read_state VARCHAR(16) NOT NULL DEFAULT 'unread', - version INT NOT NULL DEFAULT 1, - submitted_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_daily_reports_report(report_id), - UNIQUE INDEX uk_im_daily_reports_author_date(author_id, report_date), - INDEX idx_im_daily_reports_author_id(author_id, id DESC), - INDEX idx_im_daily_reports_date_status(report_date, status, id DESC) - )`); err != nil { - return err - } - _, err := tx.Exec( - `CREATE TABLE im_monthly_reports( - id BIGINT NOT NULL AUTO_INCREMENT, - report_id VARCHAR(64) NOT NULL, - author_id BIGINT NOT NULL, - report_month CHAR(7) NOT NULL, - work_summary TEXT NOT NULL, - next_plan TEXT NOT NULL, - attachments JSON, - status VARCHAR(16) NOT NULL DEFAULT 'draft', - read_state VARCHAR(16) NOT NULL DEFAULT 'unread', - version INT NOT NULL DEFAULT 1, - submitted_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_monthly_reports_report(report_id), - UNIQUE INDEX uk_im_monthly_reports_author_month(author_id, report_month), - INDEX idx_im_monthly_reports_author_id(author_id, id DESC), - INDEX idx_im_monthly_reports_month_status(report_month, status, id DESC) - )`) - return err -} - -func createIMMailboxTables(tx *sql.Tx) error { - if _, err := tx.Exec( - `CREATE TABLE im_mailbox_config( - id BIGINT NOT NULL AUTO_INCREMENT, - enabled TINYINT NOT NULL DEFAULT 1, - allow_anonymous TINYINT NOT NULL DEFAULT 1, - required_fields JSON, - max_length INT NOT NULL DEFAULT 5000, - attachment_policy JSON, - notice_title VARCHAR(128) NOT NULL DEFAULT '', - notice_content TEXT NOT NULL, - notice_updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(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) - )`); err != nil { - return err - } - if _, err := tx.Exec( - `INSERT INTO im_mailbox_config(id,enabled,allow_anonymous,required_fields,max_length,attachment_policy,notice_title,notice_content) - VALUES(1,1,1,'["subject","content"]',5000,'{"max_files":5,"purpose":"executive_mailbox"}','总经理信箱','请如实填写诉求,匿名提交将按企业政策保护展示身份。') - ON DUPLICATE KEY UPDATE updated_at=updated_at`); err != nil { - return err - } - _, err := tx.Exec( - `CREATE TABLE im_mailbox_submissions( - id BIGINT NOT NULL AUTO_INCREMENT, - submission_id VARCHAR(64) NOT NULL, - sender_id BIGINT NOT NULL, - subject VARCHAR(200) NOT NULL, - content TEXT NOT NULL, - anonymous TINYINT NOT NULL DEFAULT 0, - attachment_file_ids JSON, - notice_version VARCHAR(64) NOT NULL DEFAULT '', - idempotency_key VARCHAR(128) NOT NULL, - status VARCHAR(32) NOT NULL DEFAULT 'received', - submitted_at DATETIME(3) NOT NULL, - 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_mailbox_submissions_submission(submission_id), - UNIQUE INDEX uk_im_mailbox_submissions_idempotency(sender_id, idempotency_key), - INDEX idx_im_mailbox_submissions_sender(sender_id, id DESC), - INDEX idx_im_mailbox_submissions_status(status, id DESC) - )`) - return err -} - func createIMUserProductTables(tx *sql.Tx) error { statements := []string{ `CREATE TABLE im_user_profiles( @@ -4789,14 +4300,6 @@ func (a *adapter) FileDeleteUnused(olderThan time.Time, limit int) ([]string, er var locations []string var ids []any for _, candidate := range candidates { - var noteRefs int - fileID := store.EncodeUid(int64(candidate.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 candidate.location != "" { locations = append(locations, candidate.location) } @@ -4914,23 +4417,13 @@ func (a *adapter) FileUserHasAccess(fid string, uid t.Uid) (bool, error) { defer cancel() } var allowed int - buildQuery := func(includeRecorder bool) (string, []any) { - recorderClause := "" - if includeRecorder { - recorderClause = ` - OR mtg.recorder_user_id=?` - } + buildQuery := func() (string, []any) { args := []any{ store.DecodeUid(fileID), fileID.String32(), "%/" + fileID.String32(), t.UploadCompleted, store.DecodeUid(uid), store.DecodeUid(uid), store.DecodeUid(uid), t.RelationshipFriend, store.DecodeUid(uid), store.DecodeUid(uid), t.GroupMemberStatusActive, store.DecodeUid(uid), store.DecodeUid(uid), store.DecodeUid(uid), fid, fid, - fid, t.MeetingStateCanceled, store.DecodeUid(uid), } - if includeRecorder { - args = append(args, store.DecodeUid(uid)) - } - args = append(args, store.DecodeUid(uid)) return ` SELECT COUNT(*) FROM im_fileuploads AS fu WHERE (fu.id=? OR fu.location=? OR fu.location LIKE ?) AND fu.status=? AND ( @@ -4977,37 +4470,16 @@ func (a *adapter) FileUserHasAccess(fid string, uid t.Uid) (bool, error) { SELECT 1 FROM im_group_files AS gf INNER JOIN im_subscriptions AS s ON s.topic=gf.topic AND s.userid=? - WHERE (gf.file_id=? OR gf.file_id LIKE CONCAT(?,'.%')) AND gf.status='active' + WHERE (gf.file_id=? OR gf.file_id LIKE CONCAT(?,'.%')) AND gf.status='active' AND s.deletedat IS NULL AND INSTR(s.modewant,'R')>0 AND INSTR(s.modegiven,'R')>0 ) - 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=? - ` + recorderClause + ` - OR EXISTS ( - SELECT 1 FROM im_meeting_participants AS mp - WHERE mp.meeting_id=mtg.meeting_id AND mp.user_id=? - ) - ) - ) ) LIMIT 1`, args } - includeRecorder := true run := func() error { - query, args := buildQuery(includeRecorder) + query, args := buildQuery() return a.db.GetContext(ctx, &allowed, query, args...) } err := run() - if isUnknownColumn(err) && strings.Contains(strings.ToLower(err.Error()), "recorder_user_id") { - includeRecorder = false - err = run() - } for attempt := 0; isRetryableMySQLReadErr(err) && attempt < 2; attempt++ { time.Sleep(time.Duration(attempt+1) * 25 * time.Millisecond) err = run() diff --git a/server/db/mysql/report_group_regression_test.go b/server/db/mysql/report_group_regression_test.go index 4b6a76b..8fb9f3f 100644 --- a/server/db/mysql/report_group_regression_test.go +++ b/server/db/mysql/report_group_regression_test.go @@ -86,8 +86,6 @@ func TestRuntimeOpenDoesNotAutoCreateOrMigrateTables(t *testing.T) { "ensureIMAdminGovernanceTables", "ensureIMFileReviewTables", "ensureIMOrganizationVisibilityTables", - "ensureIMAttendanceTables", - "ensureIMMeetingNotesTables", "ensureSnowflakePrimaryKeys", "ensureUserIdentitySchema", "ensureIMRegistrationInviteUseTable", diff --git a/server/db/mysql/tenant_adapter.go b/server/db/mysql/tenant_adapter.go index ae940b1..9cb5610 100644 --- a/server/db/mysql/tenant_adapter.go +++ b/server/db/mysql/tenant_adapter.go @@ -3154,3270 +3154,6 @@ func (a *adapter) finalizeStaleCalls(ctx context.Context) error { return err } -func (a *adapter) TenantWorkbenchModules(id t.TenantID, uid t.Uid) ([]t.WorkbenchModule, int, error) { - if !a.tenantScopedDB { - tenantAdp, err := a.routedTenantAdapter(id) - if err != nil { - return nil, 0, err - } - if tenantAdp != a { - return tenantAdp.TenantWorkbenchModules(id, uid) - } - } - if err := validTenant(id); err != nil || uid.IsZero() { - return nil, 0, t.ErrMalformed - } - ctx, cancel := a.getContext() - if cancel != nil { - defer cancel() - } - rows, err := a.db.QueryxContext(ctx, - `SELECT id,code,name,icon_file_id,route,sort_order,unread_key,enabled,permission,created_at,updated_at - FROM im_workbench_apps - WHERE enabled=true - ORDER BY sort_order ASC,id ASC`) - if err != nil { - return nil, 0, err - } - defer rows.Close() - modules := make([]t.WorkbenchModule, 0, 8) - layoutVersion := 0 - for rows.Next() { - var module t.WorkbenchModule - if err = rows.StructScan(&module); err != nil { - return nil, 0, err - } - if !isAllowedWorkbenchRoute(module.Route) { - continue - } - if int(module.UpdatedAt.Unix()) > layoutVersion { - layoutVersion = int(module.UpdatedAt.Unix()) - } - modules = append(modules, module) - } - if err = rows.Err(); err != nil { - return nil, 0, err - } - if layoutVersion == 0 { - layoutVersion = 1 - } - return modules, layoutVersion, nil -} - -func (a *adapter) TenantOrganizationDepartments(id t.TenantID, uid t.Uid, parentID, cursor string, limit int) ([]t.OrganizationDepartment, string, error) { - if !a.tenantScopedDB { - tenantAdp, err := a.routedTenantAdapter(id) - if err != nil { - return nil, "", err - } - if tenantAdp != a { - return tenantAdp.TenantOrganizationDepartments(id, uid, parentID, cursor, limit) - } - } - if err := validTenant(id); err != nil || uid.IsZero() { - return nil, "", t.ErrMalformed - } - parentID = strings.TrimSpace(parentID) - if parentID == "" { - parentID = "root" - } - if limit <= 0 || limit > 100 { - limit = 50 - } - where := []string{"d.parent_id=?", "d.state='active'"} - args := []any{parentID} - if cursor != "" { - parsed, err := strconv.ParseInt(cursor, 10, 64) - if err != nil || parsed <= 0 { - return nil, "", t.ErrMalformed - } - where = append(where, "d.id>?") - args = append(args, parsed) - } - args = append(args, limit+1) - ctx, cancel := a.getContext() - if cancel != nil { - defer cancel() - } - visibility, err := a.organizationVisibility(ctx, uid) - if err != nil { - return nil, "", err - } - cte, cteArgs := visibility.departmentCTE() - memberCountSQL, memberCountArgs := visibility.memberCountSQL("m") - if visibility.Scope != orgVisibilityAll { - where = append(where, "d.department_id IN (SELECT department_id FROM visible_depts)") - } - queryArgs := append([]any{}, cteArgs...) - queryArgs = append(queryArgs, memberCountArgs...) - queryArgs = append(queryArgs, args...) - childCountSQL := `(SELECT COUNT(*) FROM im_org_departments AS child WHERE child.parent_id=d.department_id AND child.state='active')` - if visibility.Scope != orgVisibilityAll { - childCountSQL = `(SELECT COUNT(*) FROM im_org_departments AS child WHERE child.parent_id=d.department_id AND child.state='active' AND child.department_id IN (SELECT department_id FROM visible_depts))` - } - rows, err := a.db.QueryxContext(ctx, cte+`SELECT d.id,d.department_id,d.name,d.parent_id,d.sort_order,d.version,d.state,d.created_at,d.updated_at, - `+childCountSQL+` AS child_count, - (SELECT COUNT(*) FROM im_org_members AS m WHERE m.department_id=d.department_id AND m.employment_state='active'`+memberCountSQL+`) AS member_count - FROM im_org_departments AS d - WHERE `+strings.Join(where, " AND ")+` - ORDER BY d.sort_order ASC,d.id ASC LIMIT ?`, queryArgs...) - if err != nil { - return nil, "", err - } - defer rows.Close() - departments := make([]t.OrganizationDepartment, 0, limit) - nextCursor := "" - for rows.Next() { - var department t.OrganizationDepartment - if err = rows.StructScan(&department); err != nil { - return nil, "", err - } - if len(departments) == limit { - nextCursor = strconv.FormatInt(department.ID, 10) - continue - } - departments = append(departments, department) - } - if err = rows.Err(); err != nil { - return nil, "", err - } - return departments, nextCursor, nil -} - -func (a *adapter) TenantOrganizationMembers(id t.TenantID, uid t.Uid, departmentID, cursor string, limit int) ([]t.OrganizationMember, string, error) { - if !a.tenantScopedDB { - tenantAdp, err := a.routedTenantAdapter(id) - if err != nil { - return nil, "", err - } - if tenantAdp != a { - return tenantAdp.TenantOrganizationMembers(id, uid, departmentID, cursor, limit) - } - } - if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(departmentID) == "" { - return nil, "", t.ErrMalformed - } - if limit <= 0 || limit > 100 { - limit = 50 - } - where := []string{"m.department_id=?", "m.employment_state='active'"} - args := []any{strings.TrimSpace(departmentID)} - if cursor != "" { - parsed, err := strconv.ParseInt(cursor, 10, 64) - if err != nil || parsed <= 0 { - return nil, "", t.ErrMalformed - } - where = append(where, "m.id>?") - args = append(args, parsed) - } - args = append(args, limit+1) - ctx, cancel := a.getContext() - if cancel != nil { - defer cancel() - } - visibility, err := a.organizationVisibility(ctx, uid) - if err != nil { - return nil, "", err - } - visibilitySQL, visibilityArgs := visibility.memberWhereSQL("m") - if visibilitySQL != "" { - where = append(where, visibilitySQL) - args = append(args, visibilityArgs...) - } - rows, err := a.db.QueryxContext(ctx, `SELECT m.id,m.department_id,m.user_id,m.display_name,m.position,m.avatar_file_id,m.mobile,m.email, - m.employment_state,m.contactable,m.sort_order,m.version,m.created_at,m.updated_at - FROM im_org_members AS m - WHERE `+strings.Join(where, " AND ")+` - ORDER BY m.sort_order ASC,m.id ASC LIMIT ?`, args...) - if err != nil { - return nil, "", err - } - defer rows.Close() - members := make([]t.OrganizationMember, 0, limit) - nextCursor := "" - for rows.Next() { - member, err := scanOrganizationMember(rows) - if err != nil { - return nil, "", err - } - if len(members) == limit { - nextCursor = strconv.FormatInt(member.ID, 10) - continue - } - members = append(members, *member) - } - if err = rows.Err(); err != nil { - return nil, "", err - } - return members, nextCursor, nil -} - -func (a *adapter) TenantOrganizationMembersAll(id t.TenantID, uid t.Uid, departmentID, queryText, cursor string, limit int) ([]t.OrganizationMember, string, error) { - if !a.tenantScopedDB { - tenantAdp, err := a.routedTenantAdapter(id) - if err != nil { - return nil, "", err - } - if tenantAdp != a { - return tenantAdp.TenantOrganizationMembersAll(id, uid, departmentID, queryText, cursor, limit) - } - } - if err := validTenant(id); err != nil || uid.IsZero() { - return nil, "", t.ErrMalformed - } - if limit <= 0 || limit > 100 { - limit = 50 - } - where := []string{"m.employment_state='active'"} - args := []any{} - if strings.TrimSpace(departmentID) != "" { - where = append(where, "m.department_id=?") - args = append(args, strings.TrimSpace(departmentID)) - } - if q := strings.TrimSpace(queryText); q != "" { - like := "%" + q + "%" - where = append(where, "(m.display_name LIKE ? OR m.mobile LIKE ? OR m.email LIKE ? OR CAST(m.user_id AS CHAR) LIKE ?)") - args = append(args, like, like, like, like) - } - if cursor != "" { - parsed, err := strconv.ParseInt(cursor, 10, 64) - if err != nil || parsed <= 0 { - return nil, "", t.ErrMalformed - } - where = append(where, "m.id>?") - args = append(args, parsed) - } - args = append(args, limit+1) - ctx, cancel := a.getContext() - if cancel != nil { - defer cancel() - } - visibility, err := a.organizationVisibility(ctx, uid) - if err != nil { - return nil, "", err - } - visibilitySQL, visibilityArgs := visibility.memberWhereSQL("m") - if visibilitySQL != "" { - where = append(where, visibilitySQL) - args = append(args, visibilityArgs...) - } - rows, err := a.db.QueryxContext(ctx, `SELECT m.id,m.department_id,m.user_id,m.display_name,m.position,m.avatar_file_id,m.mobile,m.email, - m.employment_state,m.contactable,m.sort_order,m.version,m.created_at,m.updated_at - FROM im_org_members AS m - WHERE `+strings.Join(where, " AND ")+` - ORDER BY m.sort_order ASC,m.id ASC LIMIT ?`, args...) - if err != nil { - return nil, "", err - } - defer rows.Close() - members := make([]t.OrganizationMember, 0, limit) - nextCursor := "" - for rows.Next() { - member, err := scanOrganizationMember(rows) - if err != nil { - return nil, "", err - } - if len(members) == limit { - nextCursor = strconv.FormatInt(member.ID, 10) - continue - } - members = append(members, *member) - } - if err = rows.Err(); err != nil { - return nil, "", err - } - return members, nextCursor, nil -} - -func (a *adapter) TenantOrganizationSearch(id t.TenantID, uid t.Uid, queryText, resultTypes, cursor string, limit int) ([]t.OrganizationSearchItem, string, error) { - if !a.tenantScopedDB { - tenantAdp, err := a.routedTenantAdapter(id) - if err != nil { - return nil, "", err - } - if tenantAdp != a { - return tenantAdp.TenantOrganizationSearch(id, uid, queryText, resultTypes, cursor, limit) - } - } - if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(queryText) == "" { - return nil, "", t.ErrMalformed - } - if limit <= 0 || limit > 100 { - limit = 50 - } - offset := 0 - if cursor != "" { - parsed, err := strconv.Atoi(cursor) - if err != nil || parsed < 0 { - return nil, "", t.ErrMalformed - } - offset = parsed - } - includeDepartments, includeUsers := organizationSearchIncludes(resultTypes) - if !includeDepartments && !includeUsers { - return nil, "", t.ErrMalformed - } - like := "%" + strings.TrimSpace(queryText) + "%" - ctx, cancel := a.getContext() - if cancel != nil { - defer cancel() - } - visibility, err := a.organizationVisibility(ctx, uid) - if err != nil { - return nil, "", err - } - cte, cteArgs := visibility.departmentCTE() - parts := make([]string, 0, 2) - args := make([]any, 0, 10) - args = append(args, cteArgs...) - if includeDepartments { - departmentWhere := "d.state='active' AND d.name LIKE ?" - if visibility.Scope != orgVisibilityAll { - departmentWhere += " AND d.department_id IN (SELECT department_id FROM visible_depts)" - } - parts = append(parts, `SELECT 'department' AS result_type,d.id,d.department_id,d.name,d.parent_id,d.sort_order,d.version,d.state,d.created_at,d.updated_at, - (SELECT COUNT(*) FROM im_org_departments AS child WHERE child.parent_id=d.department_id AND child.state='active') AS child_count, - (SELECT COUNT(*) FROM im_org_members AS m WHERE m.department_id=d.department_id) AS member_count, - 0 AS user_id,'' AS display_name,'' AS position,'' AS avatar_file_id,'' AS mobile,'' AS email,'' AS employment_state,0 AS contactable - FROM im_org_departments AS d WHERE `+departmentWhere) - args = append(args, like) - } - if includeUsers { - userWhere := "(m.display_name LIKE ? OR m.mobile LIKE ? OR m.email LIKE ? OR CAST(m.user_id AS CHAR) LIKE ?) AND m.employment_state='active'" - visibilitySQL, visibilityArgs := visibility.memberWhereSQL("m") - if visibilitySQL != "" { - userWhere += " AND " + visibilitySQL - } - parts = append(parts, `SELECT 'user' AS result_type,m.id,'' AS department_id,'' AS name,m.department_id AS parent_id,m.sort_order,m.version,'' AS state,m.created_at,m.updated_at, - 0 AS child_count,0 AS member_count,m.user_id,m.display_name,m.position,m.avatar_file_id,m.mobile,m.email,m.employment_state,m.contactable - FROM im_org_members AS m - WHERE `+userWhere) - args = append(args, like, like, like, like) - args = append(args, visibilityArgs...) - } - args = append(args, offset, limit+1) - rows, err := a.db.QueryxContext(ctx, cte+`SELECT * FROM (`+strings.Join(parts, " UNION ALL ")+`) AS results - ORDER BY result_type ASC,id ASC LIMIT ?,?`, args...) - if err != nil { - return nil, "", err - } - defer rows.Close() - items := make([]t.OrganizationSearchItem, 0, limit) - nextCursor := "" - for rows.Next() { - item, err := scanOrganizationSearchItem(rows) - if err != nil { - return nil, "", err - } - if len(items) == limit { - nextCursor = strconv.Itoa(offset + limit) - continue - } - items = append(items, *item) - } - if err = rows.Err(); err != nil { - return nil, "", err - } - return items, nextCursor, nil -} - -const ( - orgVisibilityAll = "all" - orgVisibilityFriends = "friends" - orgVisibilitySelf = "self" -) - -type organizationVisibility struct { - Scope string - UserID int64 -} - -func (a *adapter) organizationVisibility(ctx context.Context, uid t.Uid) (organizationVisibility, error) { - userID := decodeUID(uid) - visibility := organizationVisibility{Scope: orgVisibilitySelf, UserID: userID} - if userID <= 0 { - return visibility, t.ErrMalformed - } - - var override string - err := a.db.QueryRowxContext(ctx, `SELECT visibility_scope FROM im_org_visibility_overrides - WHERE user_id=? AND state='active' LIMIT 1`, userID).Scan(&override) - if err != nil && err != sql.ErrNoRows { - return visibility, err - } - if err == nil { - switch strings.TrimSpace(override) { - case orgVisibilityAll, orgVisibilityFriends, orgVisibilitySelf: - visibility.Scope = strings.TrimSpace(override) - return visibility, nil - default: - return visibility, t.ErrMalformed - } - } - - var phone sql.NullString - err = a.db.QueryRowxContext(ctx, `SELECT phone FROM im_users WHERE id=? AND state<>? LIMIT 1`, userID, t.StateDeleted).Scan(&phone) - if err == sql.ErrNoRows { - return visibility, t.ErrNotFound - } - if err != nil { - return visibility, err - } - var inviteUsed int - err = a.db.QueryRowxContext(ctx, `SELECT EXISTS(SELECT 1 FROM im_registration_invite_uses WHERE user_id=?)`, userID).Scan(&inviteUsed) - if err != nil { - return visibility, err - } - if inviteUsed > 0 { - visibility.Scope = orgVisibilitySelf - return visibility, nil - } - if phone.Valid && strings.TrimSpace(phone.String) != "" { - visibility.Scope = orgVisibilityFriends - } - return visibility, nil -} - -func (v organizationVisibility) memberWhereSQL(alias string) (string, []any) { - alias = strings.TrimSpace(alias) - if alias == "" { - alias = "m" - } - switch v.Scope { - case orgVisibilityAll: - return "", nil - case orgVisibilityFriends: - return "(" + alias + ".user_id=? OR EXISTS (SELECT 1 FROM im_friendships AS fs WHERE fs.user_id=? AND fs.peer_id=" + alias + ".user_id AND fs.status=?))", - []any{v.UserID, v.UserID, t.RelationshipFriend} - default: - return alias + ".user_id=?", []any{v.UserID} - } -} - -func (v organizationVisibility) memberCountSQL(alias string) (string, []any) { - where, args := v.memberWhereSQL(alias) - if where == "" { - return "", nil - } - return " AND " + where, args -} - -func (v organizationVisibility) departmentCTE() (string, []any) { - if v.Scope == orgVisibilityAll { - return "", nil - } - memberWhere, args := v.memberWhereSQL("m") - return `WITH RECURSIVE visible_member_depts AS ( - SELECT DISTINCT m.department_id FROM im_org_members AS m - WHERE m.employment_state='active' AND ` + memberWhere + ` - ), visible_depts AS ( - SELECT d.department_id,d.parent_id FROM im_org_departments AS d - JOIN visible_member_depts AS vmd ON vmd.department_id=d.department_id - WHERE d.state='active' - UNION DISTINCT - SELECT parent.department_id,parent.parent_id FROM im_org_departments AS parent - JOIN visible_depts AS child ON child.parent_id=parent.department_id - WHERE parent.state='active' - ) `, args -} - -func organizationSearchIncludes(resultTypes string) (bool, bool) { - if strings.TrimSpace(resultTypes) == "" { - return true, true - } - includeDepartments, includeUsers := false, false - for _, item := range strings.Split(resultTypes, ",") { - switch strings.TrimSpace(item) { - case "department": - includeDepartments = true - case "user": - includeUsers = true - } - } - return includeDepartments, includeUsers -} - -func scanOrganizationMember(rows *sqlx.Rows) (*t.OrganizationMember, error) { - row := struct { - ID int64 - DepartmentID string `db:"department_id"` - UserID int64 `db:"user_id"` - DisplayName string `db:"display_name"` - Position string - AvatarFileID string `db:"avatar_file_id"` - Mobile string - Email string - EmploymentState string `db:"employment_state"` - Contactable bool - SortOrder int `db:"sort_order"` - Version int - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` - }{} - if err := rows.StructScan(&row); err != nil { - return nil, err - } - return &t.OrganizationMember{ - ID: row.ID, - DepartmentID: row.DepartmentID, - UserID: store.EncodeUid(row.UserID), - DisplayName: row.DisplayName, - Position: row.Position, - AvatarFileID: row.AvatarFileID, - Mobile: row.Mobile, - Email: row.Email, - Status: row.EmploymentState, - Contactable: row.Contactable && row.EmploymentState == "active", - SortOrder: row.SortOrder, - Version: row.Version, - CreatedAt: row.CreatedAt, - UpdatedAt: row.UpdatedAt, - }, nil -} - -func scanOrganizationSearchItem(rows *sqlx.Rows) (*t.OrganizationSearchItem, error) { - row := struct { - ResultType string `db:"result_type"` - ID int64 - DepartmentID string `db:"department_id"` - Name string - ParentID string `db:"parent_id"` - SortOrder int `db:"sort_order"` - Version int - State string - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` - ChildCount int `db:"child_count"` - MemberCount int `db:"member_count"` - UserID int64 `db:"user_id"` - DisplayName string `db:"display_name"` - Position string - AvatarFileID string `db:"avatar_file_id"` - Mobile string - Email string - EmploymentState string `db:"employment_state"` - Contactable bool - }{} - if err := rows.StructScan(&row); err != nil { - return nil, err - } - item := &t.OrganizationSearchItem{Type: row.ResultType} - if row.ResultType == "department" { - item.Department = &t.OrganizationDepartment{ - ID: row.ID, - DepartmentID: row.DepartmentID, - Name: row.Name, - ParentID: row.ParentID, - SortOrder: row.SortOrder, - ChildCount: row.ChildCount, - MemberCount: row.MemberCount, - Version: row.Version, - State: row.State, - CreatedAt: row.CreatedAt, - UpdatedAt: row.UpdatedAt, - } - return item, nil - } - item.Member = &t.OrganizationMember{ - ID: row.ID, - DepartmentID: row.ParentID, - UserID: store.EncodeUid(row.UserID), - DisplayName: row.DisplayName, - Position: row.Position, - AvatarFileID: row.AvatarFileID, - Mobile: row.Mobile, - Email: row.Email, - Status: row.EmploymentState, - Contactable: row.Contactable && row.EmploymentState == "active", - SortOrder: row.SortOrder, - Version: row.Version, - CreatedAt: row.CreatedAt, - UpdatedAt: row.UpdatedAt, - } - return item, nil -} - -func (a *adapter) TenantMeetingCreate(id t.TenantID, uid t.Uid, meeting *t.Meeting, attendeeIDs []t.Uid) (*t.Meeting, error) { - if !a.tenantScopedDB { - tenantAdp, err := a.routedTenantAdapter(id) - if err != nil { - return nil, err - } - if tenantAdp != a { - return tenantAdp.TenantMeetingCreate(id, uid, meeting, attendeeIDs) - } - } - if err := validTenant(id); err != nil || uid.IsZero() || meeting == nil || strings.TrimSpace(meeting.MeetingID) == "" { - return nil, t.ErrMalformed - } - ctx, cancel := a.getContext() - if cancel != nil { - defer cancel() - } - if meeting.IdempotencyKey != "" { - existing, err := a.meetingByIdempotency(ctx, uid, meeting.IdempotencyKey) - if err == nil { - return existing, nil - } - if err != t.ErrNotFound { - return nil, err - } - } - tx, err := a.db.BeginTxx(ctx, nil) - if err != nil { - return nil, err - } - defer func() { - if tx != nil { - tx.Rollback() - } - }() - reminderJSON, err := json.Marshal(meeting.ReminderMinutes) - if err != nil { - return nil, err - } - var idempotencyKey any - if meeting.IdempotencyKey != "" { - idempotencyKey = meeting.IdempotencyKey - } - 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 != "" { - tx.Rollback() - tx = nil - return a.meetingByIdempotency(ctx, uid, meeting.IdempotencyKey) - } - return nil, err - } - participants := uniqueMeetingParticipants(uid, append(attendeeIDs, recorderID)) - now := time.Now().UTC().Round(time.Millisecond) - for _, participant := range participants { - response := t.MeetingResponsePending - var responseAt any - if participant == uid { - response = t.MeetingResponseAccepted - responseAt = now - } - if _, err = tx.ExecContext(ctx, `INSERT INTO im_meeting_participants(meeting_id,user_id,response,response_at) - VALUES(?,?,?,?)`, meeting.MeetingID, decodeUID(participant), response, responseAt); err != nil { - return nil, err - } - } - if err = insertMeetingEventsTx(tx, principalMeetingEvent{ - Actor: uid, MeetingID: meeting.MeetingID, Title: strings.TrimSpace(meeting.Title), - State: t.MeetingStateScheduled, Version: 1, EventType: "meeting.created", - }, participants); err != nil { - return nil, err - } - if err = tx.Commit(); err != nil { - return nil, err - } - tx = nil - return a.TenantMeetingGet(id, uid, meeting.MeetingID) -} - -func (a *adapter) TenantMeetingList(id t.TenantID, uid t.Uid, 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.TenantMeetingList(id, uid, cursor, limit) - } - } - if err := validTenant(id); err != nil || uid.IsZero() { - return nil, "", t.ErrMalformed - } - if limit <= 0 || limit > 100 { - limit = 20 - } - 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), decodeUID(uid)} - if strings.TrimSpace(cursor) != "" { - parsed, err := strconv.ParseInt(strings.TrimSpace(cursor), 10, 64) - if err != nil || parsed <= 0 { - return nil, "", t.ErrMalformed - } - where = append(where, "m.id 0 { - _ = json.Unmarshal(row.ReminderMinutes, &meeting.ReminderMinutes) - } - if len(meetings) == limit { - nextCursor = strconv.FormatInt(row.ID, 10) - continue - } - participants, err := a.meetingParticipants(ctx, row.MeetingID) - if err != nil { - return nil, "", err - } - meeting.Participants = participants - meetings = append(meetings, meeting) - } - if err = rows.Err(); err != nil { - return nil, "", err - } - return meetings, nextCursor, nil -} - -func (a *adapter) TenantMeetingGet(id t.TenantID, uid t.Uid, meetingID string) (*t.Meeting, error) { - if !a.tenantScopedDB { - tenantAdp, err := a.routedTenantAdapter(id) - if err != nil { - return nil, err - } - if tenantAdp != a { - return tenantAdp.TenantMeetingGet(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() - } - meeting, err := a.meetingByID(ctx, strings.TrimSpace(meetingID), uid) - if err != nil { - return nil, err - } - participants, err := a.meetingParticipants(ctx, meeting.MeetingID) - if err != nil { - return nil, err - } - meeting.Participants = participants - return meeting, nil -} - -func (a *adapter) TenantMeetingUpdate(id t.TenantID, uid t.Uid, meetingID string, patch t.MeetingPatch, _ string) (*t.Meeting, error) { - if !a.tenantScopedDB { - tenantAdp, err := a.routedTenantAdapter(id) - if err != nil { - return nil, err - } - if tenantAdp != a { - return tenantAdp.TenantMeetingUpdate(id, uid, meetingID, patch, "") - } - } - if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(meetingID) == "" || patch.Version <= 0 { - return nil, t.ErrMalformed - } - ctx, cancel := a.getContext() - if cancel != nil { - defer cancel() - } - meeting, err := a.meetingByID(ctx, strings.TrimSpace(meetingID), uid) - if err != nil { - return nil, err - } - if meeting.OrganizerID != uid { - return nil, t.ErrPermissionDenied - } - if meeting.State != t.MeetingStateScheduled { - return nil, t.ErrPermissionDenied - } - if meeting.Version != patch.Version { - return nil, t.ErrInvalidResponse - } - effectiveStart := meeting.StartAt - effectiveEnd := meeting.EndAt - if patch.StartAt != nil { - effectiveStart = *patch.StartAt - } - if patch.EndAt != nil { - effectiveEnd = *patch.EndAt - } - if !effectiveEnd.After(effectiveStart) { - return nil, t.ErrMalformed - } - sets := []string{"version=version+1", "updated_at=CURRENT_TIMESTAMP(3)"} - args := make([]any, 0, 8) - if patch.Title != nil { - sets = append(sets, "title=?") - args = append(args, strings.TrimSpace(*patch.Title)) - } - if patch.StartAt != nil { - sets = append(sets, "start_at=?") - args = append(args, patch.StartAt.UTC()) - } - if patch.EndAt != nil { - sets = append(sets, "end_at=?") - args = append(args, patch.EndAt.UTC()) - } - if patch.Location != nil { - 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 { - return nil, err - } - sets = append(sets, "reminder_minutes=?") - args = append(args, string(encoded)) - } - args = append(args, meeting.MeetingID, decodeUID(uid), patch.Version) - tx, err := a.db.BeginTxx(ctx, nil) - if err != nil { - return nil, err - } - defer func() { - if tx != nil { - tx.Rollback() - } - }() - res, err := tx.ExecContext(ctx, `UPDATE im_meetings SET `+strings.Join(sets, ",")+` WHERE meeting_id=? AND organizer_id=? AND version=?`, args...) - if err != nil { - return nil, err - } - affected, err := res.RowsAffected() - if err != nil { - return nil, err - } - 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 - } - if err = insertMeetingEventsTx(tx, principalMeetingEvent{ - Actor: uid, MeetingID: meeting.MeetingID, Title: effectiveMeetingTitle(meeting.Title, patch.Title), - State: meeting.State, Version: meeting.Version + 1, EventType: "meeting.updated", - }, participantIDs); err != nil { - return nil, err - } - if err = tx.Commit(); err != nil { - return nil, err - } - tx = nil - return a.TenantMeetingGet(id, uid, meeting.MeetingID) -} - -func (a *adapter) TenantMeetingCancel(id t.TenantID, uid t.Uid, meetingID, _ string) (*t.Meeting, error) { - if !a.tenantScopedDB { - tenantAdp, err := a.routedTenantAdapter(id) - if err != nil { - return nil, err - } - if tenantAdp != a { - return tenantAdp.TenantMeetingCancel(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() - } - meeting, err := a.meetingByID(ctx, strings.TrimSpace(meetingID), uid) - if err != nil { - return nil, err - } - if meeting.OrganizerID != uid { - return nil, t.ErrPermissionDenied - } - if meeting.State == t.MeetingStateCanceled { - return a.TenantMeetingGet(id, uid, meeting.MeetingID) - } - tx, err := a.db.BeginTxx(ctx, nil) - if err != nil { - return nil, err - } - defer func() { - if tx != nil { - tx.Rollback() - } - }() - _, err = tx.ExecContext(ctx, `UPDATE im_meetings SET state=?,version=version+1,canceled_at=?,updated_at=CURRENT_TIMESTAMP(3) - WHERE meeting_id=? AND organizer_id=?`, t.MeetingStateCanceled, time.Now().UTC().Round(time.Millisecond), meeting.MeetingID, decodeUID(uid)) - if err != nil { - return nil, err - } - participantIDs, err := meetingParticipantIDsTx(ctx, tx, meeting.MeetingID, "") - if err != nil { - return nil, err - } - if err = insertMeetingEventsTx(tx, principalMeetingEvent{ - Actor: uid, MeetingID: meeting.MeetingID, Title: meeting.Title, State: t.MeetingStateCanceled, - Version: meeting.Version + 1, EventType: "meeting.canceled", - }, participantIDs); err != nil { - return nil, err - } - if err = tx.Commit(); err != nil { - return nil, err - } - tx = nil - return a.TenantMeetingGet(id, uid, meeting.MeetingID) -} - -func (a *adapter) TenantMeetingRemind(id t.TenantID, uid t.Uid, meetingID, _ string) ([]t.MeetingParticipant, error) { - if !a.tenantScopedDB { - tenantAdp, err := a.routedTenantAdapter(id) - if err != nil { - return nil, err - } - if tenantAdp != a { - return tenantAdp.TenantMeetingRemind(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() - } - meeting, err := a.meetingByID(ctx, strings.TrimSpace(meetingID), uid) - if err != nil { - return nil, err - } - if meeting.OrganizerID != uid { - return nil, t.ErrPermissionDenied - } - participants, err := a.meetingParticipantsByResponse(ctx, meeting.MeetingID, t.MeetingResponsePending) - if err != nil { - return nil, err - } - tx, err := a.db.BeginTxx(ctx, nil) - if err != nil { - return nil, err - } - defer func() { - if tx != nil { - tx.Rollback() - } - }() - participantIDs := make([]t.Uid, 0, len(participants)) - for _, participant := range participants { - participantIDs = append(participantIDs, participant.UserID) - } - if err = insertMeetingEventsTx(tx, principalMeetingEvent{ - Actor: uid, MeetingID: meeting.MeetingID, Title: meeting.Title, State: meeting.State, - Version: meeting.Version, EventType: "meeting.reminded", - }, participantIDs); err != nil { - return nil, err - } - if err = tx.Commit(); err != nil { - return nil, err - } - tx = nil - return participants, nil -} - -func (a *adapter) TenantMeetingRespond(id t.TenantID, uid t.Uid, meetingID, response string, version int) (*t.Meeting, error) { - if !a.tenantScopedDB { - tenantAdp, err := a.routedTenantAdapter(id) - if err != nil { - return nil, err - } - if tenantAdp != a { - return tenantAdp.TenantMeetingRespond(id, uid, meetingID, response, version) - } - } - if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(meetingID) == "" { - return nil, t.ErrMalformed - } - ctx, cancel := a.getContext() - if cancel != nil { - defer cancel() - } - meeting, err := a.meetingByID(ctx, strings.TrimSpace(meetingID), uid) - if err != nil { - return nil, err - } - if meeting.State != t.MeetingStateScheduled || time.Now().UTC().After(meeting.EndAt) { - return nil, t.ErrPermissionDenied - } - if meeting.Version != version { - return nil, t.ErrInvalidResponse - } - tx, err := a.db.BeginTxx(ctx, nil) - if err != nil { - return nil, err - } - defer func() { - if tx != nil { - tx.Rollback() - } - }() - res, err := tx.ExecContext(ctx, `UPDATE im_meeting_participants SET response=?,response_at=?,updated_at=CURRENT_TIMESTAMP(3) - WHERE meeting_id=? AND user_id=?`, response, time.Now().UTC().Round(time.Millisecond), meeting.MeetingID, decodeUID(uid)) - if err != nil { - return nil, err - } - affected, err := res.RowsAffected() - if err != nil { - return nil, err - } - if affected == 0 { - return nil, t.ErrPermissionDenied - } - res, err = tx.ExecContext(ctx, `UPDATE im_meetings SET version=version+1,updated_at=CURRENT_TIMESTAMP(3) WHERE meeting_id=? AND version=?`, meeting.MeetingID, version) - if err != nil { - return nil, err - } - if affected, err = res.RowsAffected(); err != nil { - return nil, err - } - if affected == 0 { - return nil, t.ErrInvalidResponse - } - participantIDs, err := meetingParticipantIDsTx(ctx, tx, meeting.MeetingID, "") - if err != nil { - return nil, err - } - if err = insertMeetingEventsTx(tx, principalMeetingEvent{ - Actor: uid, MeetingID: meeting.MeetingID, Title: meeting.Title, State: meeting.State, - Version: meeting.Version + 1, EventType: "meeting.response.updated", - }, participantIDs); err != nil { - return nil, err - } - if err = tx.Commit(); err != nil { - return nil, err - } - tx = nil - return a.TenantMeetingGet(id, uid, meeting.MeetingID) -} - -type principalMeetingEvent struct { - Actor t.Uid - MeetingID string - Title string - State string - Version int - EventType string -} - -func effectiveMeetingTitle(current string, patch *string) string { - if patch == nil { - return current - } - return strings.TrimSpace(*patch) -} - -func meetingParticipantIDsTx(ctx context.Context, tx *sqlx.Tx, meetingID, response string) ([]t.Uid, error) { - query := `SELECT user_id FROM im_meeting_participants WHERE meeting_id=?` - args := []any{meetingID} - if response != "" { - query += ` AND response=?` - args = append(args, response) - } - rows, err := tx.QueryxContext(ctx, query, args...) - if err != nil { - return nil, err - } - defer rows.Close() - participants := make([]t.Uid, 0, 8) - for rows.Next() { - var userID int64 - if err = rows.Scan(&userID); err != nil { - return nil, err - } - participants = append(participants, store.EncodeUid(userID)) - } - return participants, rows.Err() -} - -func insertMeetingEventsTx(tx *sqlx.Tx, event principalMeetingEvent, participants []t.Uid) error { - for _, participant := range participants { - if participant.IsZero() { - continue - } - eventID := t.StableEventID("meeting", event.MeetingID, event.Version, event.EventType, participant.UserId()) - if participant != event.Actor { - if err := insertSystemNotificationTx(tx, participant, t.StableEventID(eventID, "notification"), - event.EventType, "会议通知", event.Title, t.KVMap{"meeting_id": event.MeetingID}); err != nil { - return err - } - } - } - return nil -} - -func (a *adapter) meetingByIdempotency(ctx context.Context, uid t.Uid, idempotencyKey string) (*t.Meeting, error) { - row := struct { - MeetingID string `db:"meeting_id"` - }{} - if err := sqlx.GetContext(ctx, a.db, &row, `SELECT meeting_id FROM im_meetings WHERE organizer_id=? AND idempotency_key=?`, decodeUID(uid), idempotencyKey); err != nil { - if err == sql.ErrNoRows { - return nil, t.ErrNotFound - } - return nil, err - } - return a.meetingByID(ctx, row.MeetingID, uid) -} - -func (a *adapter) meetingByID(ctx context.Context, meetingID string, viewer t.Uid) (*t.Meeting, 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 := sqlx.GetContext(ctx, a.db, &row, `SELECT m.* FROM im_meetings AS m - 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), decodeUID(viewer)); err != nil { - if err == sql.ErrNoRows { - return nil, t.ErrNotFound - } - return nil, 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 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) - } - return meeting, nil -} - -func (a *adapter) meetingParticipants(ctx context.Context, meetingID string) ([]t.MeetingParticipant, error) { - return a.meetingParticipantsByQuery(ctx, `SELECT * FROM im_meeting_participants WHERE meeting_id=? ORDER BY id ASC`, meetingID) -} - -func (a *adapter) meetingParticipantsByResponse(ctx context.Context, meetingID, response string) ([]t.MeetingParticipant, error) { - return a.meetingParticipantsByQuery(ctx, `SELECT * FROM im_meeting_participants WHERE meeting_id=? AND response=? ORDER BY id ASC`, meetingID, response) -} - -func (a *adapter) meetingParticipantsByQuery(ctx context.Context, query string, args ...any) ([]t.MeetingParticipant, error) { - rows, err := a.db.QueryxContext(ctx, query, args...) - if err != nil { - return nil, err - } - defer rows.Close() - participants := make([]t.MeetingParticipant, 0, 8) - for rows.Next() { - row := struct { - ID int64 - MeetingID string `db:"meeting_id"` - UserID int64 `db:"user_id"` - Response string - ResponseAt *time.Time `db:"response_at"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` - }{} - if err = rows.StructScan(&row); err != nil { - return nil, err - } - participants = append(participants, t.MeetingParticipant{ - ID: row.ID, - MeetingID: row.MeetingID, - UserID: store.EncodeUid(row.UserID), - Response: row.Response, - ResponseAt: row.ResponseAt, - CreatedAt: row.CreatedAt, - UpdatedAt: row.UpdatedAt, - }) - } - if err = rows.Err(); err != nil { - return nil, err - } - return participants, nil -} - -func (a *adapter) TenantMeetingNoteList(id t.TenantID, uid t.Uid, query, cursor string, limit int) ([]t.Meeting, string, error) { - if !a.tenantScopedDB { - tenantAdp, err := a.routedTenantAdapter(id) - if err != nil { - return nil, "", err - } - if tenantAdp != a { - return tenantAdp.TenantMeetingNoteList(id, uid, query, cursor, limit) - } - } - if err := validTenant(id); err != nil || uid.IsZero() { - return nil, "", t.ErrMalformed - } - if limit <= 0 || limit > 100 { - limit = 20 - } - where := []string{`m.state<>?`, `m.end_at<=?`, `(m.organizer_id=? OR m.recorder_user_id=? OR EXISTS ( - SELECT 1 FROM im_meeting_participants AS p WHERE p.meeting_id=m.meeting_id AND p.user_id=? - ))`} - args := []any{t.MeetingStateCanceled, time.Now().UTC(), decodeUID(uid), decodeUID(uid), decodeUID(uid)} - if q := strings.TrimSpace(query); q != "" { - where = append(where, "(m.title LIKE ? OR m.location LIKE ?)") - like := "%" + q + "%" - args = append(args, like, like) - } - if strings.TrimSpace(cursor) != "" { - parsed, err := strconv.ParseInt(strings.TrimSpace(cursor), 10, 64) - if err != nil || parsed <= 0 { - return nil, "", t.ErrMalformed - } - where = append(where, "m.id 0 { - _ = json.Unmarshal(row.ReminderMinutes, &meeting.ReminderMinutes) - } - return meeting, row.ID, nil -} - -func (a *adapter) meetingNoteVisible(ctx context.Context, uid t.Uid, meetingID string) (*t.Meeting, error) { - meeting, err := a.meetingByID(ctx, meetingID, uid) - if err != nil { - return nil, err - } - if meeting.State == t.MeetingStateCanceled || time.Now().UTC().Before(meeting.EndAt) { - return nil, t.ErrPermissionDenied - } - participants, err := a.meetingParticipants(ctx, meeting.MeetingID) - if err != nil { - return nil, err - } - meeting.Participants = participants - meeting.State = t.MeetingStateEnded - return meeting, nil -} - -func (a *adapter) meetingNoteWritable(ctx context.Context, uid t.Uid, meetingID string) (*t.Meeting, error) { - meeting, err := a.meetingNoteVisible(ctx, uid, meetingID) - if err != nil { - return nil, err - } - if meeting.RecorderID != uid { - return nil, t.ErrPermissionDenied - } - return meeting, nil -} - -func (a *adapter) meetingNoteFiles(ctx context.Context, meetingID string) ([]t.MeetingNoteFile, error) { - rows, err := a.db.QueryxContext(ctx, `SELECT * FROM im_meeting_note_files - WHERE meeting_id=? AND status=? ORDER BY id DESC`, meetingID, t.MeetingNoteFileStatusActive) - if err != nil { - return nil, err - } - defer rows.Close() - items := make([]t.MeetingNoteFile, 0, 4) - for rows.Next() { - item, err := scanMeetingNoteFileRow(rows) - if err != nil { - return nil, err - } - items = append(items, item) - } - return items, rows.Err() -} - -func (a *adapter) meetingNoteFileByID(ctx context.Context, meetingID, noteFileID string) (*t.MeetingNoteFile, error) { - row := a.db.QueryRowxContext(ctx, `SELECT * FROM im_meeting_note_files WHERE meeting_id=? AND note_file_id=? LIMIT 1`, meetingID, noteFileID) - item, err := scanMeetingNoteFileRow(row) - if err == sql.ErrNoRows { - return nil, t.ErrNotFound - } - if err != nil { - return nil, err - } - return &item, nil -} - -func (a *adapter) meetingNoteFileByIdempotency(ctx context.Context, meetingID string, uid t.Uid, idempotencyKey string) (*t.MeetingNoteFile, error) { - row := a.db.QueryRowxContext(ctx, `SELECT * FROM im_meeting_note_files WHERE meeting_id=? AND uploader_id=? AND idempotency_key=? LIMIT 1`, meetingID, decodeUID(uid), idempotencyKey) - item, err := scanMeetingNoteFileRow(row) - if err == sql.ErrNoRows { - return nil, t.ErrNotFound - } - if err != nil { - return nil, err - } - return &item, nil -} - -func scanMeetingNoteFileRow(scanner interface{ StructScan(any) error }) (t.MeetingNoteFile, error) { - row := struct { - ID int64 - NoteFileID string `db:"note_file_id"` - MeetingID string `db:"meeting_id"` - FileID string `db:"file_id"` - UploaderID int64 `db:"uploader_id"` - FileName string `db:"file_name"` - MIMEType string `db:"mime_type"` - FileSize int64 `db:"file_size"` - FileETag string `db:"file_etag"` - Status string - PreviewStatus string `db:"preview_status"` - PreviewMode string `db:"preview_mode"` - PreviewFileID string `db:"preview_file_id"` - IdempotencyKey string `db:"idempotency_key"` - Version int - DeletedBy sql.NullInt64 `db:"deleted_by"` - DeletedAt *time.Time `db:"deleted_at"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` - }{} - if err := scanner.StructScan(&row); err != nil { - return t.MeetingNoteFile{}, err - } - item := t.MeetingNoteFile{ - ID: row.ID, - NoteFileID: row.NoteFileID, - MeetingID: row.MeetingID, - FileID: row.FileID, - UploaderID: store.EncodeUid(row.UploaderID), - FileName: row.FileName, - MIMEType: row.MIMEType, - FileSize: row.FileSize, - FileETag: row.FileETag, - Status: row.Status, - PreviewStatus: row.PreviewStatus, - PreviewMode: row.PreviewMode, - PreviewFileID: row.PreviewFileID, - IdempotencyKey: row.IdempotencyKey, - Version: row.Version, - DeletedAt: row.DeletedAt, - CreatedAt: row.CreatedAt, - UpdatedAt: row.UpdatedAt, - } - if row.DeletedBy.Valid { - item.DeletedBy = store.EncodeUid(row.DeletedBy.Int64) - } - return item, nil -} - -func uniqueMeetingParticipants(organizer t.Uid, attendeeIDs []t.Uid) []t.Uid { - seen := map[t.Uid]bool{organizer: true} - participants := []t.Uid{organizer} - for _, uid := range attendeeIDs { - if uid.IsZero() || seen[uid] { - continue - } - seen[uid] = true - participants = append(participants, uid) - } - return participants -} - -func (a *adapter) TenantAttendanceConfig(id t.TenantID, uid t.Uid, date time.Time) (*t.AttendanceConfig, error) { - if !a.tenantScopedDB { - tenantAdp, err := a.routedTenantAdapter(id) - if err != nil { - return nil, err - } - if tenantAdp != a { - return tenantAdp.TenantAttendanceConfig(id, uid, date) - } - } - if err := validTenant(id); err != nil || uid.IsZero() || date.IsZero() { - return nil, t.ErrMalformed - } - ctx, cancel := a.getContext() - if cancel != nil { - defer cancel() - } - rule, err := a.activeAttendanceRule(ctx) - if err != nil { - return nil, err - } - shifts, err := a.attendanceShifts(ctx, rule.RuleID) - if err != nil { - return nil, err - } - record, err := a.attendanceRecordByDate(ctx, uid, date, "") - if err != nil && err != sql.ErrNoRows { - return nil, err - } - if err == sql.ErrNoRows { - record = nil - } - return &t.AttendanceConfig{ - Date: date, - Timezone: "Asia/Shanghai", - Rule: *rule, - Shifts: shifts, - AllowedActions: []string{t.AttendanceActionCheckIn, t.AttendanceActionCheckOut}, - TodayRecord: record, - CanViewManaged: true, - }, nil -} - -func (a *adapter) TenantAttendanceRecordList(id t.TenantID, uid t.Uid, opts t.AttendanceRecordListOptions) ([]t.AttendanceRecord, string, *t.AttendanceRecordStats, error) { - if !a.tenantScopedDB { - tenantAdp, err := a.routedTenantAdapter(id) - if err != nil { - return nil, "", nil, err - } - if tenantAdp != a { - return tenantAdp.TenantAttendanceRecordList(id, uid, opts) - } - } - if err := validTenant(id); err != nil || uid.IsZero() { - return nil, "", nil, t.ErrMalformed - } - where := []string{"1=1"} - args := []any{} - if opts.Scope == "" || opts.Scope == "mine" { - where = append(where, "user_id=?") - args = append(args, decodeUID(uid)) - } else if opts.Scope == "managed" { - if raw := strings.TrimSpace(opts.UserID); raw != "" { - target := t.ParseUserId(raw) - if target.IsZero() { - return nil, "", nil, t.ErrMalformed - } - where = append(where, "user_id=?") - args = append(args, decodeUID(target)) - } - } else { - return nil, "", nil, t.ErrMalformed - } - if opts.Date != "" { - where = append(where, "record_date=?") - args = append(args, opts.Date) - } - if opts.From != "" { - where = append(where, "record_date>=?") - args = append(args, opts.From) - } - if opts.To != "" { - where = append(where, "record_date<=?") - args = append(args, opts.To) - } - if opts.Status != "" { - where = append(where, "status=?") - args = append(args, opts.Status) - } - if opts.Cursor != "" { - parsed, err := strconv.ParseInt(opts.Cursor, 10, 64) - if err != nil || parsed <= 0 { - return nil, "", nil, t.ErrMalformed - } - where = append(where, "id 100 { - limit = 50 - } - ctx, cancel := a.getContext() - if cancel != nil { - defer cancel() - } - query := `SELECT * FROM im_attendance_records WHERE ` + strings.Join(where, " AND ") + ` ORDER BY id DESC LIMIT ?` - rows, err := a.db.QueryxContext(ctx, query, append(args, limit+1)...) - if err != nil { - return nil, "", nil, err - } - defer rows.Close() - records := make([]t.AttendanceRecord, 0, limit) - nextCursor := "" - for rows.Next() { - record, err := scanAttendanceRecord(rows) - if err != nil { - return nil, "", nil, err - } - if len(records) == limit { - nextCursor = strconv.FormatInt(record.ID, 10) - continue - } - checkins, err := a.attendanceCheckins(ctx, record.RecordID) - if err != nil { - return nil, "", nil, err - } - record.Checkins = checkins - records = append(records, *record) - } - if err = rows.Err(); err != nil { - return nil, "", nil, err - } - stats, err := a.attendanceRecordStats(ctx, opts, uid) - if err != nil { - return nil, "", nil, err - } - return records, nextCursor, stats, nil -} - -func (a *adapter) TenantAttendanceCheckin(id t.TenantID, uid t.Uid, checkinID string, req t.AttendanceCheckinRequest) (*t.AttendanceRecord, *t.AttendanceCheckin, error) { - if !a.tenantScopedDB { - tenantAdp, err := a.routedTenantAdapter(id) - if err != nil { - return nil, nil, err - } - if tenantAdp != a { - return tenantAdp.TenantAttendanceCheckin(id, uid, checkinID, req) - } - } - if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(checkinID) == "" || strings.TrimSpace(req.IdempotencyKey) == "" { - return nil, nil, t.ErrMalformed - } - ctx, cancel := a.getContext() - if cancel != nil { - defer cancel() - } - if existing, err := a.attendanceCheckinByIdempotency(ctx, uid, req.IdempotencyKey); err == nil { - record, recErr := a.attendanceRecordByID(ctx, existing.RecordID) - return record, existing, recErr - } else if err != sql.ErrNoRows { - return nil, nil, err - } - rule, err := a.activeAttendanceRule(ctx) - if err != nil { - return nil, nil, err - } - shifts, err := a.attendanceShifts(ctx, rule.RuleID) - if err != nil { - return nil, nil, err - } - shift := shifts[0] - if strings.TrimSpace(req.ShiftID) != "" { - for _, candidate := range shifts { - if candidate.ShiftID == strings.TrimSpace(req.ShiftID) { - shift = candidate - break - } - } - } - recordID := t.StableEventID("attendance_record", uid.UserId(), req.Date.Format("2006-01-02"), shift.ShiftID) - result := attendanceCheckinResult(req.Action, req.ClientTime, req.Date, shift) - status := attendanceRecordStatus(req.Action, result, "") - exceptions := attendanceExceptions(status) - locationJSON, _ := json.Marshal(req.Location) - wifiJSON, _ := json.Marshal(req.Wifi) - deviceJSON, _ := json.Marshal(req.Device) - exceptionsJSON, _ := json.Marshal(exceptions) - now := time.Now().UTC().Round(time.Millisecond) - tx, err := a.db.BeginTxx(ctx, nil) - if err != nil { - return nil, nil, err - } - defer func() { - if tx != nil { - tx.Rollback() - } - }() - if _, err = tx.ExecContext(ctx, `INSERT INTO im_attendance_records(record_id,user_id,record_date,rule_id,shift_id,status,exceptions,appeal_status,version) - VALUES(?,?,?,?,?,?,?,?,1) - ON DUPLICATE KEY UPDATE record_id=record_id`, recordID, decodeUID(uid), req.Date.Format("2006-01-02"), rule.RuleID, shift.ShiftID, - t.AttendanceStatusNotChecked, "[]", ""); err != nil { - return nil, nil, err - } - current := struct { - RecordID string `db:"record_id"` - Status string - Version int - }{} - if err = sqlx.GetContext(ctx, tx, ¤t, `SELECT record_id,status,version FROM im_attendance_records WHERE user_id=? AND record_date=? AND shift_id=?`, - decodeUID(uid), req.Date.Format("2006-01-02"), shift.ShiftID); err != nil { - return nil, nil, err - } - status = attendanceRecordStatus(req.Action, result, current.Status) - exceptionsJSON, _ = json.Marshal(attendanceExceptions(status)) - if _, err = tx.ExecContext(ctx, `INSERT INTO im_attendance_checkins(checkin_id,record_id,user_id,record_date,action,rule_id,shift_id,client_time,server_time,result,location,wifi,device,idempotency_key) - VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, checkinID, current.RecordID, decodeUID(uid), req.Date.Format("2006-01-02"), req.Action, rule.RuleID, shift.ShiftID, - req.ClientTime.UTC().Round(time.Millisecond), now, result, string(locationJSON), string(wifiJSON), string(deviceJSON), req.IdempotencyKey); err != nil { - if isDupe(err) { - tx.Rollback() - tx = nil - existing, lookupErr := a.attendanceCheckinByIdempotency(ctx, uid, req.IdempotencyKey) - if lookupErr != nil { - return nil, nil, lookupErr - } - record, recErr := a.attendanceRecordByID(ctx, existing.RecordID) - return record, existing, recErr - } - return nil, nil, err - } - if _, err = tx.ExecContext(ctx, `UPDATE im_attendance_records SET status=?,exceptions=?,version=version+1,updated_at=CURRENT_TIMESTAMP(3) - WHERE record_id=?`, status, string(exceptionsJSON), current.RecordID); err != nil { - return nil, nil, err - } - if err = tx.Commit(); err != nil { - return nil, nil, err - } - tx = nil - checkin, err := a.attendanceCheckinByIdempotency(ctx, uid, req.IdempotencyKey) - if err != nil { - return nil, nil, err - } - record, err := a.attendanceRecordByID(ctx, checkin.RecordID) - return record, checkin, err -} - -func (a *adapter) TenantAttendanceAppealCreate(id t.TenantID, uid t.Uid, appeal *t.AttendanceAppeal, idempotencyKey string) (*t.AttendanceAppeal, error) { - if !a.tenantScopedDB { - tenantAdp, err := a.routedTenantAdapter(id) - if err != nil { - return nil, err - } - if tenantAdp != a { - return tenantAdp.TenantAttendanceAppealCreate(id, uid, appeal, idempotencyKey) - } - } - if err := validTenant(id); err != nil || uid.IsZero() || appeal == nil || strings.TrimSpace(idempotencyKey) == "" { - return nil, t.ErrMalformed - } - ctx, cancel := a.getContext() - if cancel != nil { - defer cancel() - } - if existing, err := a.attendanceAppealByIdempotency(ctx, uid, idempotencyKey); err == nil { - return existing, nil - } else if err != sql.ErrNoRows { - return nil, err - } - record, err := a.attendanceRecordByID(ctx, strings.TrimSpace(appeal.RecordID)) - if err != nil { - if err == sql.ErrNoRows { - return nil, t.ErrNotFound - } - return nil, err - } - if record.UserID != uid { - return nil, t.ErrPermissionDenied - } - attachmentsJSON, _ := json.Marshal(appeal.AttachmentFileIDs) - now := time.Now().UTC().Round(time.Millisecond) - if appeal.TargetTime != nil { - tm := appeal.TargetTime.UTC().Round(time.Millisecond) - appeal.TargetTime = &tm - } - _, err = a.db.ExecContext(ctx, `INSERT INTO im_attendance_appeals(appeal_id,record_id,user_id,appeal_type,reason,target_action,target_time,attachment_file_ids,status,version,idempotency_key,submitted_at) - VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`, strings.TrimSpace(appeal.AppealID), strings.TrimSpace(appeal.RecordID), decodeUID(uid), strings.TrimSpace(appeal.AppealType), - strings.TrimSpace(appeal.Reason), strings.TrimSpace(appeal.TargetAction), appeal.TargetTime, string(attachmentsJSON), t.AttendanceAppealPending, 1, - idempotencyKey, now) - if err != nil { - if isDupe(err) { - return a.attendanceAppealByIdempotency(ctx, uid, idempotencyKey) - } - return nil, err - } - _, _ = a.db.ExecContext(ctx, `UPDATE im_attendance_records SET appeal_status=?,updated_at=CURRENT_TIMESTAMP(3) WHERE record_id=?`, - t.AttendanceAppealPending, strings.TrimSpace(appeal.RecordID)) - return a.attendanceAppealByIdempotency(ctx, uid, idempotencyKey) -} - -func (a *adapter) TenantAttendanceAppealList(id t.TenantID, uid t.Uid, opts t.AttendanceAppealListOptions) ([]t.AttendanceAppeal, string, error) { - if !a.tenantScopedDB { - tenantAdp, err := a.routedTenantAdapter(id) - if err != nil { - return nil, "", err - } - if tenantAdp != a { - return tenantAdp.TenantAttendanceAppealList(id, uid, opts) - } - } - if err := validTenant(id); err != nil || uid.IsZero() { - return nil, "", t.ErrMalformed - } - where := []string{"1=1"} - args := []any{} - if opts.Scope == "" || opts.Scope == "mine" { - where = append(where, "user_id=?") - args = append(args, decodeUID(uid)) - } else if opts.Scope != "managed" { - return nil, "", t.ErrMalformed - } - if opts.Status != "" { - where = append(where, "status=?") - args = append(args, opts.Status) - } - if opts.Cursor != "" { - parsed, err := strconv.ParseInt(opts.Cursor, 10, 64) - if err != nil || parsed <= 0 { - return nil, "", t.ErrMalformed - } - where = append(where, "id 100 { - limit = 50 - } - ctx, cancel := a.getContext() - if cancel != nil { - defer cancel() - } - rows, err := a.db.QueryxContext(ctx, `SELECT * FROM im_attendance_appeals WHERE `+strings.Join(where, " AND ")+` ORDER BY id DESC LIMIT ?`, append(args, limit+1)...) - if err != nil { - return nil, "", err - } - defer rows.Close() - appeals := make([]t.AttendanceAppeal, 0, limit) - nextCursor := "" - for rows.Next() { - appeal, err := scanAttendanceAppeal(rows) - if err != nil { - return nil, "", err - } - if len(appeals) == limit { - nextCursor = strconv.FormatInt(appeal.ID, 10) - continue - } - appeals = append(appeals, *appeal) - } - if err = rows.Err(); err != nil { - return nil, "", err - } - return appeals, nextCursor, nil -} - -func (a *adapter) activeAttendanceRule(ctx context.Context) (*t.AttendanceRule, error) { - row := struct { - ID int64 - RuleID string `db:"rule_id"` - Name string - Workday bool - LocationRequired bool `db:"location_required"` - WifiRequired bool `db:"wifi_required"` - AllowOutsideCheckin bool `db:"allow_outside_checkin"` - AppealAllowed bool `db:"appeal_allowed"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` - }{} - if err := sqlx.GetContext(ctx, a.db, &row, `SELECT id,rule_id,name,workday,location_required,wifi_required,allow_outside_checkin,appeal_allowed,created_at,updated_at - FROM im_attendance_rules WHERE state='active' ORDER BY id ASC LIMIT 1`); err != nil { - if err == sql.ErrNoRows { - now := time.Now().UTC().Round(time.Millisecond) - return &t.AttendanceRule{RuleID: "att_rule_default", Name: "默认考勤规则", Workday: true, AllowOutsideCheckin: true, AppealAllowed: true, CreatedAt: now, UpdatedAt: now}, nil - } - return nil, err - } - return &t.AttendanceRule{ - ID: row.ID, RuleID: row.RuleID, Name: row.Name, Workday: row.Workday, LocationRequired: row.LocationRequired, - WifiRequired: row.WifiRequired, AllowOutsideCheckin: row.AllowOutsideCheckin, AppealAllowed: row.AppealAllowed, - CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, - }, nil -} - -func (a *adapter) attendanceShifts(ctx context.Context, ruleID string) ([]t.AttendanceShift, error) { - rows, err := a.db.QueryxContext(ctx, `SELECT id,shift_id,rule_id,name,TIME_FORMAT(work_start,'%H:%i') AS work_start,TIME_FORMAT(work_end,'%H:%i') AS work_end, - TIME_FORMAT(checkin_start,'%H:%i') AS checkin_start,TIME_FORMAT(checkout_end,'%H:%i') AS checkout_end,sort_order,created_at,updated_at - FROM im_attendance_shifts WHERE rule_id=? AND state='active' ORDER BY sort_order ASC,id ASC`, ruleID) - if err != nil { - return nil, err - } - defer rows.Close() - shifts := make([]t.AttendanceShift, 0, 2) - for rows.Next() { - row := struct { - ID int64 - ShiftID string `db:"shift_id"` - RuleID string `db:"rule_id"` - Name string `db:"name"` - WorkStart string `db:"work_start"` - WorkEnd string `db:"work_end"` - CheckinStart string `db:"checkin_start"` - CheckoutEnd string `db:"checkout_end"` - SortOrder int `db:"sort_order"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` - }{} - if err = rows.StructScan(&row); err != nil { - return nil, err - } - shift := t.AttendanceShift{ - ID: row.ID, ShiftID: row.ShiftID, RuleID: row.RuleID, Name: row.Name, WorkStart: row.WorkStart, - WorkEnd: row.WorkEnd, CheckinStart: row.CheckinStart, CheckoutEnd: row.CheckoutEnd, SortOrder: row.SortOrder, - CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, - } - shifts = append(shifts, shift) - } - if err = rows.Err(); err != nil { - return nil, err - } - if len(shifts) == 0 { - now := time.Now().UTC().Round(time.Millisecond) - shifts = append(shifts, t.AttendanceShift{ShiftID: "shift_default", RuleID: ruleID, Name: "默认班次", WorkStart: "09:00", WorkEnd: "18:00", CheckinStart: "08:30", CheckoutEnd: "20:00", SortOrder: 10, CreatedAt: now, UpdatedAt: now}) - } - return shifts, nil -} - -func (a *adapter) attendanceRecordByDate(ctx context.Context, uid t.Uid, date time.Time, shiftID string) (*t.AttendanceRecord, error) { - query := `SELECT * FROM im_attendance_records WHERE user_id=? AND record_date=?` - args := []any{decodeUID(uid), date.Format("2006-01-02")} - if shiftID != "" { - query += ` AND shift_id=?` - args = append(args, shiftID) - } - query += ` ORDER BY id ASC LIMIT 1` - rows, err := a.db.QueryxContext(ctx, query, args...) - if err != nil { - return nil, err - } - defer rows.Close() - if rows.Next() { - record, err := scanAttendanceRecord(rows) - if err != nil { - return nil, err - } - record.Checkins, err = a.attendanceCheckins(ctx, record.RecordID) - return record, err - } - if err = rows.Err(); err != nil { - return nil, err - } - return nil, sql.ErrNoRows -} - -func (a *adapter) attendanceRecordByID(ctx context.Context, recordID string) (*t.AttendanceRecord, error) { - rows, err := a.db.QueryxContext(ctx, `SELECT * FROM im_attendance_records WHERE record_id=?`, recordID) - if err != nil { - return nil, err - } - defer rows.Close() - if rows.Next() { - record, err := scanAttendanceRecord(rows) - if err != nil { - return nil, err - } - record.Checkins, err = a.attendanceCheckins(ctx, record.RecordID) - return record, err - } - if err = rows.Err(); err != nil { - return nil, err - } - return nil, sql.ErrNoRows -} - -func (a *adapter) attendanceCheckins(ctx context.Context, recordID string) ([]t.AttendanceCheckin, error) { - rows, err := a.db.QueryxContext(ctx, `SELECT * FROM im_attendance_checkins WHERE record_id=? ORDER BY id ASC`, recordID) - if err != nil { - return nil, err - } - defer rows.Close() - items := make([]t.AttendanceCheckin, 0, 2) - for rows.Next() { - item, err := scanAttendanceCheckin(rows) - if err != nil { - return nil, err - } - items = append(items, *item) - } - return items, rows.Err() -} - -func (a *adapter) attendanceCheckinByIdempotency(ctx context.Context, uid t.Uid, key string) (*t.AttendanceCheckin, error) { - rows, err := a.db.QueryxContext(ctx, `SELECT * FROM im_attendance_checkins WHERE user_id=? AND idempotency_key=?`, decodeUID(uid), key) - if err != nil { - return nil, err - } - defer rows.Close() - if rows.Next() { - return scanAttendanceCheckin(rows) - } - if err = rows.Err(); err != nil { - return nil, err - } - return nil, sql.ErrNoRows -} - -func (a *adapter) attendanceAppealByIdempotency(ctx context.Context, uid t.Uid, key string) (*t.AttendanceAppeal, error) { - rows, err := a.db.QueryxContext(ctx, `SELECT * FROM im_attendance_appeals WHERE user_id=? AND idempotency_key=?`, decodeUID(uid), key) - if err != nil { - return nil, err - } - defer rows.Close() - if rows.Next() { - return scanAttendanceAppeal(rows) - } - if err = rows.Err(); err != nil { - return nil, err - } - return nil, sql.ErrNoRows -} - -func (a *adapter) attendanceRecordStats(ctx context.Context, opts t.AttendanceRecordListOptions, viewer t.Uid) (*t.AttendanceRecordStats, error) { - where := []string{"1=1"} - args := []any{} - if opts.Scope == "" || opts.Scope == "mine" { - where = append(where, "user_id=?") - args = append(args, decodeUID(viewer)) - } - if opts.Date != "" { - where = append(where, "record_date=?") - args = append(args, opts.Date) - } - if opts.From != "" { - where = append(where, "record_date>=?") - args = append(args, opts.From) - } - if opts.To != "" { - where = append(where, "record_date<=?") - args = append(args, opts.To) - } - stats := &t.AttendanceRecordStats{} - if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM im_attendance_records WHERE `+strings.Join(where, " AND "), args...).Scan(&stats.CheckedCount); err != nil { - return nil, err - } - exceptionWhere := append([]string{}, where...) - exceptionWhere = append(exceptionWhere, "status IN (?,?,?)") - exceptionArgs := append(append([]any{}, args...), t.AttendanceStatusLate, t.AttendanceStatusEarlyLeave, t.AttendanceStatusAbnormal) - if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM im_attendance_records WHERE `+strings.Join(exceptionWhere, " AND "), exceptionArgs...).Scan(&stats.ExceptionCount); err != nil { - return nil, err - } - if err := a.db.QueryRowContext(ctx, `SELECT COUNT(DISTINCT user_id) FROM im_org_members WHERE employment_state='active'`).Scan(&stats.ExpectedCount); err != nil { - stats.ExpectedCount = stats.CheckedCount - } - if opts.Scope == "" || opts.Scope == "mine" { - stats.ExpectedCount = 1 - } - stats.NotCheckedCount = stats.ExpectedCount - stats.CheckedCount - if stats.NotCheckedCount < 0 { - stats.NotCheckedCount = 0 - } - return stats, nil -} - -func scanAttendanceRecord(rows *sqlx.Rows) (*t.AttendanceRecord, error) { - row := struct { - ID int64 - RecordID string `db:"record_id"` - UserID int64 `db:"user_id"` - Date time.Time `db:"record_date"` - RuleID string `db:"rule_id"` - ShiftID string `db:"shift_id"` - Status string - Exceptions []byte - AppealStatus string `db:"appeal_status"` - Version int - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` - }{} - if err := rows.StructScan(&row); err != nil { - return nil, err - } - record := &t.AttendanceRecord{ - ID: row.ID, RecordID: row.RecordID, UserID: store.EncodeUid(row.UserID), Date: row.Date, - RuleID: row.RuleID, ShiftID: row.ShiftID, Status: row.Status, AppealStatus: row.AppealStatus, - Version: row.Version, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, - } - if len(row.Exceptions) > 0 { - _ = json.Unmarshal(row.Exceptions, &record.Exceptions) - } - return record, nil -} - -func scanAttendanceCheckin(rows *sqlx.Rows) (*t.AttendanceCheckin, error) { - row := struct { - ID int64 - CheckinID string `db:"checkin_id"` - RecordID string `db:"record_id"` - UserID int64 `db:"user_id"` - Date time.Time `db:"record_date"` - Action string - RuleID string `db:"rule_id"` - ShiftID string `db:"shift_id"` - ClientTime time.Time `db:"client_time"` - ServerTime time.Time `db:"server_time"` - Result string - Location []byte - Wifi []byte - Device []byte - IdempotencyKey string `db:"idempotency_key"` - CreatedAt time.Time `db:"created_at"` - }{} - if err := rows.StructScan(&row); err != nil { - return nil, err - } - item := &t.AttendanceCheckin{ - ID: row.ID, CheckinID: row.CheckinID, RecordID: row.RecordID, UserID: store.EncodeUid(row.UserID), Date: row.Date, - Action: row.Action, RuleID: row.RuleID, ShiftID: row.ShiftID, ClientTime: row.ClientTime, ServerTime: row.ServerTime, - Result: row.Result, IdempotencyKey: row.IdempotencyKey, CreatedAt: row.CreatedAt, - } - _ = json.Unmarshal(row.Location, &item.Location) - _ = json.Unmarshal(row.Wifi, &item.Wifi) - _ = json.Unmarshal(row.Device, &item.Device) - return item, nil -} - -func scanAttendanceAppeal(rows *sqlx.Rows) (*t.AttendanceAppeal, error) { - row := struct { - ID int64 - AppealID string `db:"appeal_id"` - RecordID string `db:"record_id"` - UserID int64 `db:"user_id"` - AppealType string `db:"appeal_type"` - Reason string - TargetAction string `db:"target_action"` - TargetTime *time.Time `db:"target_time"` - AttachmentFileIDs []byte `db:"attachment_file_ids"` - Status string - Version int - IdempotencyKey string `db:"idempotency_key"` - SubmittedAt time.Time `db:"submitted_at"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` - }{} - if err := rows.StructScan(&row); err != nil { - return nil, err - } - appeal := &t.AttendanceAppeal{ - ID: row.ID, AppealID: row.AppealID, RecordID: row.RecordID, UserID: store.EncodeUid(row.UserID), - AppealType: row.AppealType, Reason: row.Reason, TargetAction: row.TargetAction, TargetTime: row.TargetTime, - Status: row.Status, Version: row.Version, SubmittedAt: row.SubmittedAt, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, - } - if len(row.AttachmentFileIDs) > 0 { - _ = json.Unmarshal(row.AttachmentFileIDs, &appeal.AttachmentFileIDs) - } - return appeal, nil -} - -func attendanceCheckinResult(action string, clientTime, date time.Time, shift t.AttendanceShift) string { - switch action { - case t.AttendanceActionCheckIn: - if clientTime.After(attendanceTimeOnDate(date, shift.WorkStart)) { - return t.AttendanceCheckinLate - } - case t.AttendanceActionCheckOut: - if clientTime.Before(attendanceTimeOnDate(date, shift.WorkEnd)) { - return t.AttendanceCheckinEarlyLeave - } - } - return t.AttendanceCheckinNormal -} - -func attendanceRecordStatus(action, result, current string) string { - if result == t.AttendanceCheckinLate { - return t.AttendanceStatusLate - } - if result == t.AttendanceCheckinEarlyLeave { - return t.AttendanceStatusEarlyLeave - } - if current == t.AttendanceStatusLate || current == t.AttendanceStatusEarlyLeave || current == t.AttendanceStatusAbnormal { - return current - } - if action == t.AttendanceActionCheckOut { - return t.AttendanceStatusNormal - } - return t.AttendanceStatusCheckedIn -} - -func attendanceExceptions(status string) []string { - switch status { - case t.AttendanceStatusLate: - return []string{"late"} - case t.AttendanceStatusEarlyLeave: - return []string{"early_leave"} - case t.AttendanceStatusAbnormal: - return []string{"abnormal"} - default: - return []string{} - } -} - -func attendanceTimeOnDate(date time.Time, clock string) time.Time { - parts := strings.Split(clock, ":") - hour, minute := 0, 0 - if len(parts) >= 2 { - hour, _ = strconv.Atoi(parts[0]) - minute, _ = strconv.Atoi(parts[1]) - } - loc := time.FixedZone("CST", 8*3600) - localDate := date.In(loc) - return time.Date(localDate.Year(), localDate.Month(), localDate.Day(), hour, minute, 0, 0, loc).UTC() -} - -func (a *adapter) TenantDailyReportList(id t.TenantID, uid t.Uid, scope, from, to, status, cursor string, limit int) ([]t.DailyReport, string, error) { - if !a.tenantScopedDB { - tenantAdp, err := a.routedTenantAdapter(id) - if err != nil { - return nil, "", err - } - if tenantAdp != a { - return tenantAdp.TenantDailyReportList(id, uid, scope, from, to, status, cursor, limit) - } - } - if err := validTenant(id); err != nil || uid.IsZero() { - return nil, "", t.ErrMalformed - } - if limit <= 0 || limit > 100 { - limit = 50 - } - ctx, cancel := a.getContext() - if cancel != nil { - defer cancel() - } - where := []string{"1=1"} - args := []any{} - if scope == "" || scope == "mine" { - where = append(where, "author_id=?") - args = append(args, decodeUID(uid)) - } else if scope == "managed" { - visibility, err := a.organizationVisibility(ctx, uid) - if err != nil { - return nil, "", err - } - if !canUseManagedReports(visibility) { - return nil, "", t.ErrPermissionDenied - } - } else { - return nil, "", t.ErrMalformed - } - if from != "" { - where = append(where, "report_date>=?") - args = append(args, from) - } - if to != "" { - where = append(where, "report_date<=?") - args = append(args, to) - } - if status != "" { - where = append(where, "status=?") - args = append(args, status) - } - if cursor != "" { - parsed, err := strconv.ParseInt(cursor, 10, 64) - if err != nil || parsed <= 0 { - return nil, "", t.ErrMalformed - } - where = append(where, "id 0 { - submittedWhere = append(submittedWhere, `EXISTS (SELECT 1 FROM im_org_members AS m WHERE m.user_id=r.author_id AND `+strings.Join(memberWhere, " AND ")+`)`) - submittedArgs = append(submittedArgs, memberArgs...) - } - if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM im_daily_reports AS r WHERE `+strings.Join(submittedWhere, " AND "), submittedArgs...).Scan(&stats.SubmittedCount); err != nil { - return nil, err - } - stats.MissingCount = stats.ExpectedCount - stats.SubmittedCount - if stats.MissingCount < 0 { - stats.MissingCount = 0 - } - return stats, nil -} - -func reportMemberScopeWhere(visibility organizationVisibility, departmentID string) ([]string, []any) { - memberWhere := []string{"m.employment_state='active'"} - memberArgs := []any{} - if strings.TrimSpace(departmentID) != "" { - memberWhere = append(memberWhere, "m.department_id=?") - memberArgs = append(memberArgs, strings.TrimSpace(departmentID)) - } - visibilitySQL, visibilityArgs := visibility.memberWhereSQL("m") - if visibilitySQL != "" { - memberWhere = append(memberWhere, visibilitySQL) - memberArgs = append(memberArgs, visibilityArgs...) - } - return memberWhere, memberArgs -} - -func canUseManagedReports(visibility organizationVisibility) bool { - return visibility.Scope == orgVisibilityAll -} - -func (a *adapter) TenantDailyReportRemind(id t.TenantID, uid t.Uid, date time.Time, userIDs []t.Uid, idempotencyKey string) ([]t.Uid, error) { - if !a.tenantScopedDB { - tenantAdp, err := a.routedTenantAdapter(id) - if err != nil { - return nil, err - } - if tenantAdp != a { - return tenantAdp.TenantDailyReportRemind(id, uid, date, userIDs, idempotencyKey) - } - } - if err := validTenant(id); err != nil || uid.IsZero() || date.IsZero() || len(userIDs) == 0 || strings.TrimSpace(idempotencyKey) == "" { - return nil, t.ErrMalformed - } - ctx, cancel := a.getContext() - if cancel != nil { - defer cancel() - } - visibility, err := a.organizationVisibility(ctx, uid) - if err != nil { - return nil, err - } - if !canUseManagedReports(visibility) { - return nil, t.ErrPermissionDenied - } - targetIDs := make([]int64, 0, len(userIDs)) - seen := map[int64]struct{}{} - for _, target := range userIDs { - if target.IsZero() { - return nil, t.ErrMalformed - } - decoded := decodeUID(target) - if decoded <= 0 { - return nil, t.ErrMalformed - } - if _, ok := seen[decoded]; ok { - continue - } - seen[decoded] = struct{}{} - targetIDs = append(targetIDs, decoded) - } - query, args, err := sqlx.In(`SELECT DISTINCT m.user_id FROM im_org_members AS m - WHERE m.employment_state='active' AND m.user_id IN (?) AND NOT EXISTS ( - SELECT 1 FROM im_daily_reports AS r - WHERE r.author_id=m.user_id AND r.report_date=? AND r.status=? - )`, targetIDs, date.Format("2006-01-02"), t.DailyReportStatusSubmitted) - if err != nil { - return nil, err - } - query = a.db.Rebind(query) - rows, err := a.db.QueryxContext(ctx, query, args...) - if err != nil { - return nil, err - } - defer rows.Close() - reminded := make([]t.Uid, 0, len(targetIDs)) - for rows.Next() { - var raw int64 - if err = rows.Scan(&raw); err != nil { - return nil, err - } - target := store.EncodeUid(raw) - reminded = append(reminded, target) - } - if err = rows.Err(); err != nil { - return nil, err - } - tx, err := a.db.BeginTxx(ctx, nil) - if err != nil { - return nil, err - } - committed := false - defer func() { - if !committed { - _ = tx.Rollback() - } - }() - for _, target := range reminded { - eventID := t.StableEventID("daily_report_reminder", date.Format("2006-01-02"), target.UserId(), strings.TrimSpace(idempotencyKey)) - if err = insertSystemNotificationTx(tx, target, eventID, "daily_report.reminded", "日报提醒", date.Format("2006-01-02"), map[string]any{ - "date": date.Format("2006-01-02"), - }); err != nil { - return nil, err - } - } - if err = tx.Commit(); err != nil { - return nil, err - } - committed = true - return reminded, nil -} - -func scanDailyReport(rows *sqlx.Rows) (*t.DailyReport, error) { - row := struct { - ID int64 - ReportID string `db:"report_id"` - AuthorID int64 `db:"author_id"` - ReportDate time.Time `db:"report_date"` - Content string - Attachments []byte - Status string - ReadState string `db:"read_state"` - Version int - SubmittedAt *time.Time `db:"submitted_at"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` - }{} - if err := rows.StructScan(&row); err != nil { - return nil, err - } - report := &t.DailyReport{ - ID: row.ID, - ReportID: row.ReportID, - AuthorID: store.EncodeUid(row.AuthorID), - ReportDate: row.ReportDate, - Content: row.Content, - Status: row.Status, - ReadState: row.ReadState, - Version: row.Version, - SubmittedAt: row.SubmittedAt, - CreatedAt: row.CreatedAt, - UpdatedAt: row.UpdatedAt, - } - if len(row.Attachments) > 0 { - _ = json.Unmarshal(row.Attachments, &report.Attachments) - } - return report, nil -} - -func (a *adapter) TenantMonthlyReportList(id t.TenantID, uid t.Uid, scope, fromMonth, toMonth, status, cursor string, limit int) ([]t.MonthlyReport, string, error) { - if !a.tenantScopedDB { - tenantAdp, err := a.routedTenantAdapter(id) - if err != nil { - return nil, "", err - } - if tenantAdp != a { - return tenantAdp.TenantMonthlyReportList(id, uid, scope, fromMonth, toMonth, status, cursor, limit) - } - } - if err := validTenant(id); err != nil || uid.IsZero() { - return nil, "", t.ErrMalformed - } - if limit <= 0 || limit > 100 { - limit = 50 - } - ctx, cancel := a.getContext() - if cancel != nil { - defer cancel() - } - where := []string{"1=1"} - args := []any{} - if scope == "" || scope == "mine" { - where = append(where, "author_id=?") - args = append(args, decodeUID(uid)) - } else if scope == "managed" { - visibility, err := a.organizationVisibility(ctx, uid) - if err != nil { - return nil, "", err - } - if !canUseManagedReports(visibility) { - return nil, "", t.ErrPermissionDenied - } - } else { - return nil, "", t.ErrMalformed - } - if fromMonth != "" { - where = append(where, "report_month>=?") - args = append(args, fromMonth) - } - if toMonth != "" { - where = append(where, "report_month<=?") - args = append(args, toMonth) - } - if status != "" { - where = append(where, "status=?") - args = append(args, status) - } - if cursor != "" { - parsed, err := strconv.ParseInt(cursor, 10, 64) - if err != nil || parsed <= 0 { - return nil, "", t.ErrMalformed - } - where = append(where, "id 0 { - submittedWhere = append(submittedWhere, `EXISTS (SELECT 1 FROM im_org_members AS m WHERE m.user_id=r.author_id AND `+strings.Join(memberWhere, " AND ")+`)`) - submittedArgs = append(submittedArgs, memberArgs...) - } - if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM im_monthly_reports AS r WHERE `+strings.Join(submittedWhere, " AND "), submittedArgs...).Scan(&stats.SubmittedCount); err != nil { - return nil, err - } - stats.MissingCount = stats.ExpectedCount - stats.SubmittedCount - if stats.MissingCount < 0 { - stats.MissingCount = 0 - } - return stats, nil -} - -func scanMonthlyReport(rows *sqlx.Rows) (*t.MonthlyReport, error) { - row := struct { - ID int64 - ReportID string `db:"report_id"` - AuthorID int64 `db:"author_id"` - Month string `db:"report_month"` - WorkSummary string `db:"work_summary"` - NextPlan string `db:"next_plan"` - Attachments []byte - Status string - ReadState string `db:"read_state"` - Version int - SubmittedAt *time.Time `db:"submitted_at"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` - }{} - if err := rows.StructScan(&row); err != nil { - return nil, err - } - report := &t.MonthlyReport{ - ID: row.ID, - ReportID: row.ReportID, - AuthorID: store.EncodeUid(row.AuthorID), - Month: row.Month, - WorkSummary: row.WorkSummary, - NextPlan: row.NextPlan, - Status: row.Status, - ReadState: row.ReadState, - Version: row.Version, - SubmittedAt: row.SubmittedAt, - CreatedAt: row.CreatedAt, - UpdatedAt: row.UpdatedAt, - } - if len(row.Attachments) > 0 { - _ = json.Unmarshal(row.Attachments, &report.Attachments) - } - return report, nil -} - -func (a *adapter) TenantMailboxConfigGet(id t.TenantID, uid t.Uid) (*t.MailboxConfig, error) { - if !a.tenantScopedDB { - tenantAdp, err := a.routedTenantAdapter(id) - if err != nil { - return nil, err - } - if tenantAdp != a { - return tenantAdp.TenantMailboxConfigGet(id, uid) - } - } - if err := validTenant(id); err != nil || uid.IsZero() { - return nil, t.ErrMalformed - } - ctx, cancel := a.getContext() - if cancel != nil { - defer cancel() - } - return a.mailboxConfig(ctx) -} - -func (a *adapter) TenantMailboxSubmissionCreate(id t.TenantID, uid t.Uid, submission *t.MailboxSubmission, idempotencyKey string) (*t.MailboxSubmission, error) { - if !a.tenantScopedDB { - tenantAdp, err := a.routedTenantAdapter(id) - if err != nil { - return nil, err - } - if tenantAdp != a { - return tenantAdp.TenantMailboxSubmissionCreate(id, uid, submission, idempotencyKey) - } - } - if err := validTenant(id); err != nil || uid.IsZero() || submission == nil { - return nil, t.ErrMalformed - } - ctx, cancel := a.getContext() - if cancel != nil { - defer cancel() - } - existing, err := a.mailboxSubmissionByIdempotency(ctx, uid, idempotencyKey) - if err == nil { - return existing, nil - } - if err != t.ErrNotFound { - return nil, err - } - config, err := a.mailboxConfig(ctx) - if err != nil { - return nil, err - } - if !config.Enabled || (submission.Anonymous && !config.AllowAnonymous) || len([]rune(submission.Content)) > config.MaxLength { - return nil, t.ErrPolicy - } - currentNoticeVersion := config.Notice.UpdatedAt.UTC().Format(time.RFC3339Nano) - if submission.NoticeVersion != "" && submission.NoticeVersion != currentNoticeVersion { - return nil, t.ErrInvalidResponse - } - attachments, err := json.Marshal(submission.AttachmentFileIDs) - if err != nil { - return nil, err - } - now := time.Now().UTC().Round(time.Millisecond) - _, err = a.db.ExecContext(ctx, `INSERT INTO im_mailbox_submissions(submission_id,sender_id,subject,content,anonymous,attachment_file_ids,notice_version,idempotency_key,status,submitted_at) - VALUES(?,?,?,?,?,?,?,?,?,?)`, submission.SubmissionID, decodeUID(uid), strings.TrimSpace(submission.Subject), strings.TrimSpace(submission.Content), submission.Anonymous, - string(attachments), currentNoticeVersion, idempotencyKey, t.MailboxSubmissionStatusReceived, now) - if err != nil { - if isDupe(err) { - return a.mailboxSubmissionByIdempotency(ctx, uid, idempotencyKey) - } - return nil, err - } - return a.mailboxSubmissionById(ctx, submission.SubmissionID) -} - -func (a *adapter) mailboxConfig(ctx context.Context) (*t.MailboxConfig, error) { - row := struct { - Enabled bool - AllowAnonymous bool `db:"allow_anonymous"` - RequiredFields []byte `db:"required_fields"` - MaxLength int `db:"max_length"` - AttachmentPolicy []byte `db:"attachment_policy"` - NoticeTitle string `db:"notice_title"` - NoticeContent string `db:"notice_content"` - NoticeUpdatedAt time.Time `db:"notice_updated_at"` - UpdatedAt time.Time `db:"updated_at"` - }{} - if err := sqlx.GetContext(ctx, a.db, &row, ` - SELECT enabled,allow_anonymous,required_fields,max_length,attachment_policy, - notice_title,notice_content,notice_updated_at,updated_at - FROM im_mailbox_config WHERE id=1`); err != nil { - if err == sql.ErrNoRows { - return nil, t.ErrNotFound - } - return nil, err - } - config := &t.MailboxConfig{ - Enabled: row.Enabled, - AllowAnonymous: row.AllowAnonymous, - MaxLength: row.MaxLength, - Notice: t.MailboxNotice{ - Title: row.NoticeTitle, - Content: row.NoticeContent, - UpdatedAt: row.NoticeUpdatedAt, - }, - UpdatedAt: row.UpdatedAt, - } - _ = json.Unmarshal(row.RequiredFields, &config.RequiredFields) - _ = json.Unmarshal(row.AttachmentPolicy, &config.AttachmentPolicy) - return config, nil -} - -func (a *adapter) mailboxSubmissionByIdempotency(ctx context.Context, uid t.Uid, idempotencyKey string) (*t.MailboxSubmission, error) { - row := struct { - SubmissionID string `db:"submission_id"` - }{} - if err := sqlx.GetContext(ctx, a.db, &row, `SELECT submission_id FROM im_mailbox_submissions WHERE sender_id=? AND idempotency_key=?`, decodeUID(uid), idempotencyKey); err != nil { - if err == sql.ErrNoRows { - return nil, t.ErrNotFound - } - return nil, err - } - return a.mailboxSubmissionById(ctx, row.SubmissionID) -} - -func (a *adapter) mailboxSubmissionById(ctx context.Context, submissionID string) (*t.MailboxSubmission, error) { - row := struct { - ID int64 - SubmissionID string `db:"submission_id"` - SenderID int64 `db:"sender_id"` - Subject string - Content string - Anonymous bool - AttachmentFileIDs []byte `db:"attachment_file_ids"` - NoticeVersion string `db:"notice_version"` - Status string - SubmittedAt time.Time `db:"submitted_at"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` - }{} - if err := sqlx.GetContext(ctx, a.db, &row, ` - SELECT id,submission_id,sender_id,subject,content,anonymous,attachment_file_ids, - notice_version,status,submitted_at,created_at,updated_at - FROM im_mailbox_submissions WHERE submission_id=?`, submissionID); err != nil { - if err == sql.ErrNoRows { - return nil, t.ErrNotFound - } - return nil, err - } - submission := &t.MailboxSubmission{ - ID: row.ID, - SubmissionID: row.SubmissionID, - SenderID: store.EncodeUid(row.SenderID), - Subject: row.Subject, - Content: row.Content, - Anonymous: row.Anonymous, - NoticeVersion: row.NoticeVersion, - Status: row.Status, - SubmittedAt: row.SubmittedAt, - CreatedAt: row.CreatedAt, - UpdatedAt: row.UpdatedAt, - } - _ = json.Unmarshal(row.AttachmentFileIDs, &submission.AttachmentFileIDs) - return submission, nil -} - func (a *adapter) TenantUserProfileGet(id t.TenantID, uid t.Uid) (*t.UserProfile, error) { if !a.tenantScopedDB { tenantAdp, err := a.routedTenantAdapter(id) @@ -7215,15 +3951,6 @@ func (a *adapter) userPrivacyByUID(ctx context.Context, uid t.Uid) (*t.UserPriva return settings, nil } -func isAllowedWorkbenchRoute(route string) bool { - switch route { - case "/organization", "/attendance", "/meetings", "/meeting-notes", "/reports/daily", "/reports/monthly", "/executive-mailbox": - return true - default: - return false - } -} - func tenantMessageGetOne(q sqlx.QueryerContext, ctx context.Context, tenantID t.TenantID, topic string, seq int64, suffix string) (*t.Message, error) { row := struct { t.Message diff --git a/server/hdl_files.go b/server/hdl_files.go index 36ef3eb..477dbe1 100644 --- a/server/hdl_files.go +++ b/server/hdl_files.go @@ -38,8 +38,6 @@ import ( // See https://www.iana.org/assignments/media-types/media-types.xhtml var allowedMimeTypes = []string{"application/", "audio/", "font/", "image/", "text/", "video/"} -const fileUploadPurposeMeetingNote = "meeting_note" - // RequestPrincipal is a tenant-scoped identity authenticated for HTTP/gRPC file access. type RequestPrincipal struct { TenantID types.TenantID @@ -257,12 +255,6 @@ func largeFileReceiveHTTP(wrt http.ResponseWriter, req *http.Request) { writeHttpResponse(ErrAuthRequired(msgID, "", now, now), nil) return } - meetingID, _, err := meetingNoteUploadParams(req, principal) - if err != nil { - writeHttpResponse(decodeStoreError(err, msgID, now, nil), err) - return - } - // Check if uploads are handled elsewhere. headers, statusCode, err := mh.Headers(principal.TenantID, req.Method, req.URL, req.Header, true) if err != nil { @@ -374,36 +366,10 @@ func largeFileReceiveHTTP(wrt http.ResponseWriter, req *http.Request) { // How long this file is guaranteed to exist without being attached to a message or a topic. params["expires"] = now.Add(globals.mediaGcPeriod).Format(types.TimeFormatRFC3339) } - if meetingID != "" { - params["purpose"] = fileUploadPurposeMeetingNote - params["meeting_id"] = meetingID - } - writeHttpResponse(NoErrParams(msgID, "", now, params), nil) logs.Info.Println("media upload: ok", fdef.Id, fdef.Location) } -func meetingNoteUploadParams(req *http.Request, principal RequestPrincipal) (string, string, error) { - if strings.TrimSpace(req.FormValue("purpose")) != fileUploadPurposeMeetingNote { - return "", "", nil - } - if principal.UID.IsZero() { - return "", "", types.ErrPermissionDenied - } - meetingID := strings.TrimSpace(req.FormValue("meeting_id")) - if meetingID == "" { - return "", "", types.ErrMalformed - } - idempotencyKey := strings.TrimSpace(req.Header.Get("Idempotency-Key")) - if idempotencyKey == "" { - idempotencyKey = strings.TrimSpace(req.FormValue("id")) - } - if idempotencyKey == "" { - return "", "", types.ErrMalformed - } - return meetingID, idempotencyKey, nil -} - // LargeFileServe is the gRPC equivalent of largeFileServeHTTP. func (*grpcNodeServer) LargeFileServe(req *pbx.FileDownReq, stream pbx.Node_LargeFileServeServer) error { now := types.TimeNow() diff --git a/server/hdl_files_test.go b/server/hdl_files_test.go index 01ac957..0983d32 100644 --- a/server/hdl_files_test.go +++ b/server/hdl_files_test.go @@ -1,11 +1,8 @@ package main import ( - "bytes" "encoding/base64" - "encoding/json" "io" - "mime/multipart" "net/http" "net/http/httptest" "net/url" @@ -209,91 +206,3 @@ func TestLargeFileGarbageCollectTenantsScopesDeletes(t *testing.T) { largeFileGarbageCollectTenants([]types.TenantID{7, 8}, olderThan, 25) } - -func TestLargeFileReceiveMeetingNoteUploadReturnsFileID(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - oldStore := store.Store - oldFiles := store.Files - oldMeetingNotes := store.MeetingNotes - oldKafkaBus := kafkaBus - defer func() { - store.Store = oldStore - store.Files = oldFiles - store.MeetingNotes = oldMeetingNotes - kafkaBus = oldKafkaBus - }() - - handler := &testLargeFileMediaHandler{} - mockStore := mock_store.NewMockPersistentStorageInterface(ctrl) - tokenAuth := mock_auth.NewMockAuthHandler(ctrl) - files := mock_store.NewMockFilePersistenceInterface(ctrl) - store.Store = mockStore - store.Files = files - store.MeetingNotes = nil - kafkaBus = nil - - fileID := types.Uid(101) - mockStore.EXPECT().GetMediaHandler().Return(handler).AnyTimes() - mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth) - mockStore.EXPECT().GetUidString().Return(fileID.String()) - tokenAuth.EXPECT().Authenticate(auth.AuthContext{RemoteAddr: "192.0.2.1:12345"}, []byte("token-1")). - Return(&auth.Rec{TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth}, nil, nil) - files.EXPECT().FinishUpload(types.TenantID(7), gomock.Any(), true, int64(11)).DoAndReturn( - func(_ types.TenantID, fd *types.FileDef, _ bool, size int64) (*types.FileDef, error) { - fd.Status = types.UploadCompleted - fd.Size = size - return fd, nil - }) - - var body bytes.Buffer - writer := multipart.NewWriter(&body) - if err := writer.WriteField("id", "upload-meeting-note-1"); err != nil { - t.Fatal(err) - } - if err := writer.WriteField("purpose", fileUploadPurposeMeetingNote); err != nil { - t.Fatal(err) - } - if err := writer.WriteField("meeting_id", "mtg_test"); err != nil { - t.Fatal(err) - } - part, err := writer.CreateFormFile("file", "notes.txt") - if err != nil { - t.Fatal(err) - } - if _, err := part.Write([]byte("hello notes")); err != nil { - t.Fatal(err) - } - if err := writer.Close(); err != nil { - t.Fatal(err) - } - - req := httptest.NewRequest(http.MethodPost, "/v0/file/u/", &body) - req.RemoteAddr = "192.0.2.1:12345" - req.Header.Set("Content-Type", writer.FormDataContentType()) - req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t)) - req.Header.Set("X-MeetingIM-Auth", "token "+base64.StdEncoding.EncodeToString([]byte("token-1"))) - rec := httptest.NewRecorder() - - largeFileReceiveHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) - } - var resp ServerComMessage - if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { - t.Fatalf("decode response: %v body=%s", err, rec.Body.String()) - } - params := resp.Ctrl.Params.(map[string]any) - if params["file_id"] != fileID.String() || params["purpose"] != fileUploadPurposeMeetingNote || - params["meeting_id"] != "mtg_test" { - t.Fatalf("unexpected upload params: %#v", params) - } - if _, ok := params["note_file_id"]; ok { - t.Fatalf("upload should not bind meeting note file: %#v", params) - } - if handler.data != "hello notes" || handler.def.OriginalName != "notes.txt" { - t.Fatalf("unexpected side effects: handler=%#v", handler) - } -} diff --git a/server/im_business_events.go b/server/im_business_events.go index 0b83bf9..e875d63 100644 --- a/server/im_business_events.go +++ b/server/im_business_events.go @@ -74,30 +74,6 @@ func createIMSystemNotification(tenantID types.TenantID, uid types.Uid, eventID, }) } -func publishIMMeetingEvent(tenantID types.TenantID, actor types.Uid, meeting *types.Meeting, eventType string) error { - if meeting == nil { - return types.ErrMalformed - } - for _, participant := range meeting.Participants { - uid := participant.UserID - if uid.IsZero() { - continue - } - eventID := stableIMEventID("meeting", meeting.MeetingID, meeting.Version, eventType, uid.UserId()) - if uid != actor { - if err := createIMSystemNotification(tenantID, uid, stableIMEventID(eventID, "notification"), eventType, - "会议通知", meeting.Title, types.KVMap{"meeting_id": meeting.MeetingID}); err != nil { - return err - } - } - if err := enqueueIMBusinessEvent(tenantID, eventID, "user", uid.UserId(), actor, eventType, - map[string]any{"meeting_id": meeting.MeetingID, "version": meeting.Version, "state": meeting.State}); err != nil { - return err - } - } - return nil -} - func publishIMRelationEvent(tenantID types.TenantID, actor, target types.Uid, stableID, eventType, title, summary string) error { if target.IsZero() { return types.ErrMalformed diff --git a/server/im_http.go b/server/im_http.go index fe63ea2..6ea3d29 100644 --- a/server/im_http.go +++ b/server/im_http.go @@ -397,26 +397,6 @@ type imFeedbackRequest struct { IncludeDiagnostics bool `json:"include_diagnostics,omitempty"` } -type imAttendanceCheckinRequest struct { - Date string `json:"date"` - Action string `json:"action"` - RuleID string `json:"rule_id,omitempty"` - ShiftID string `json:"shift_id,omitempty"` - ClientTime string `json:"client_time"` - Location types.KVMap `json:"location,omitempty"` - Wifi types.KVMap `json:"wifi,omitempty"` - Device types.KVMap `json:"device,omitempty"` -} - -type imAttendanceAppealRequest struct { - RecordID string `json:"record_id"` - AppealType string `json:"appeal_type"` - Reason string `json:"reason"` - TargetAction string `json:"target_action,omitempty"` - TargetTime string `json:"target_time,omitempty"` - AttachmentFileIDs []string `json:"attachment_file_ids,omitempty"` -} - type imSystemNotificationReadRequest struct { ReadAt string `json:"read_at,omitempty"` } @@ -1802,22 +1782,7 @@ func serveIMBootstrap(wrt http.ResponseWriter, req *http.Request) { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } - var modules []types.WorkbenchModule - workbenchVersion := 1 - if store.Workbench != nil { - var err error - modules, workbenchVersion, err = store.Workbench.Get(principal.TenantID, principal.Uid) - if err != nil && !errors.Is(err, types.ErrNotFound) && !errors.Is(err, types.ErrUnsupported) { - writeIMRelationError(wrt, req, err) - return - } - } - modulePayloads := workbenchModulePayloads(modules) - if err := populateWorkbenchModuleUnreadCounts(principal.TenantID, principal.Uid, modulePayloads); err != nil { - writeIMRelationError(wrt, req, err) - return - } - entryBadges, entries, err := bootstrapEntryPayloads(principal.TenantID, principal.Uid, modulePayloads) + entryBadges, entries, err := bootstrapEntryPayloads(principal.TenantID, principal.Uid) if err != nil { writeIMRelationError(wrt, req, err) return @@ -1829,7 +1794,6 @@ func serveIMBootstrap(wrt http.ResponseWriter, req *http.Request) { "user_search": true, "scan_qr": true, "system_notifications": true, - "attendance": true, "message_recall": true, "message_edit": true, "audio_call": audioCallEnabled, @@ -1842,10 +1806,8 @@ func serveIMBootstrap(wrt http.ResponseWriter, req *http.Request) { "audio_call": audioCallEnabled, "video_call": videoCallEnabled, }, - "entry_badges": entryBadges, - "entries": entries, - "modules": modulePayloads, - "workbench_version": workbenchVersion, + "entry_badges": entryBadges, + "entries": entries, "limits": map[string]any{ "max_file_size": globals.maxFileUploadSize, "max_text_length": globals.maxMessageSize, @@ -1871,1368 +1833,6 @@ func serveIMBootstrap(wrt http.ResponseWriter, req *http.Request) { }) } -func serveIMWorkbench(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 - } - modules, layoutVersion, err := store.Workbench.Get(principal.TenantID, principal.Uid) - if err != nil { - writeIMRelationError(wrt, req, err) - return - } - modulePayloads := workbenchModulePayloads(modules) - if err := populateWorkbenchModuleUnreadCounts(principal.TenantID, principal.Uid, modulePayloads); err != nil { - writeIMRelationError(wrt, req, err) - return - } - tenant, err := store.Tenants.GetByID(principal.TenantID) - if err != nil || tenant == nil || strings.TrimSpace(tenant.Code) == "" { - writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) - return - } - writeIMSuccess(wrt, req, map[string]any{ - "layout_version": layoutVersion, - "modules": modulePayloads, - "enterprise": map[string]any{ - "tenant_code": tenant.Code, - "feature_flags": map[string]any{ - "organization": true, - "attendance": true, - "meetings": true, - "daily_reports": true, - "monthly_reports": true, - "executive_mailbox": true, - }, - }, - }) -} - -func serveIMOrganizationDepartments(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"), 50, 100) - parentID := strings.TrimSpace(req.URL.Query().Get("parent_id")) - if parentID == "" { - parentID = "root" - } - items, nextCursor, err := store.Organization.ListDepartments(principal.TenantID, principal.Uid, parentID, strings.TrimSpace(req.URL.Query().Get("cursor")), limit) - if err != nil { - writeIMRelationError(wrt, req, err) - return - } - writeIMSuccess(wrt, req, map[string]any{ - "items": organizationDepartmentPayloads(items), - "next_cursor": nextCursor, - "has_more": nextCursor != "", - }) -} - -func serveIMOrganizationDepartmentResource(wrt http.ResponseWriter, req *http.Request) { - departmentID, tail, ok := organizationDepartmentResourcePath(req.URL.Path) - if !ok || tail != "members" { - writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "organization route not found", nil) - return - } - 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"), 50, 100) - items, nextCursor, err := store.Organization.ListMembers(principal.TenantID, principal.Uid, departmentID, strings.TrimSpace(req.URL.Query().Get("cursor")), limit) - if err != nil { - writeIMRelationError(wrt, req, err) - return - } - writeIMSuccess(wrt, req, map[string]any{ - "department_id": departmentID, - "items": organizationMemberPayloads(items), - "next_cursor": nextCursor, - "has_more": nextCursor != "", - }) -} - -func serveIMOrganizationMembers(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"), 50, 100) - query := strings.TrimSpace(req.URL.Query().Get("q")) - if query == "" { - query = strings.TrimSpace(req.URL.Query().Get("keyword")) - } - departmentID := strings.TrimSpace(req.URL.Query().Get("department_id")) - items, nextCursor, err := store.Organization.ListAllMembers(principal.TenantID, principal.Uid, departmentID, query, strings.TrimSpace(req.URL.Query().Get("cursor")), limit) - if err != nil { - writeIMRelationError(wrt, req, err) - return - } - writeIMSuccess(wrt, req, map[string]any{ - "department_id": departmentID, - "items": organizationMemberPayloads(items), - "next_cursor": nextCursor, - "has_more": nextCursor != "", - }) -} - -func serveIMOrganizationSearch(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 - } - query := strings.TrimSpace(req.URL.Query().Get("q")) - if query == "" { - writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "q"}) - return - } - limit := parseIMLimit(req.URL.Query().Get("limit"), 20, 100) - items, nextCursor, err := store.Organization.Search(principal.TenantID, principal.Uid, query, strings.TrimSpace(req.URL.Query().Get("types")), strings.TrimSpace(req.URL.Query().Get("cursor")), limit) - if err != nil { - writeIMRelationError(wrt, req, err) - return - } - writeIMSuccess(wrt, req, map[string]any{ - "items": organizationSearchPayloads(items), - "next_cursor": nextCursor, - "has_more": nextCursor != "", - }) -} - -func serveIMAttendanceConfig(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 - } - date, err := parseIMOptionalDate(req.URL.Query().Get("date"), time.Now().UTC()) - if err != nil { - writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "date"}) - return - } - config, err := store.Attendance.Config(principal.TenantID, principal.Uid, date) - if err != nil { - logs.Warn.Println("im attendance config failed:", principal.TenantID, principal.Uid.UserId(), err) - writeIMRelationError(wrt, req, err) - return - } - writeIMSuccess(wrt, req, attendanceConfigPayload(config)) -} - -func serveIMAttendanceRecords(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 - } - if !validIMOptionalDateQuery(wrt, req, "date") || !validIMOptionalDateQuery(wrt, req, "from") || !validIMOptionalDateQuery(wrt, req, "to") { - return - } - limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100) - records, nextCursor, stats, err := store.Attendance.ListRecords(principal.TenantID, principal.Uid, types.AttendanceRecordListOptions{ - Scope: req.URL.Query().Get("scope"), - From: strings.TrimSpace(req.URL.Query().Get("from")), - To: strings.TrimSpace(req.URL.Query().Get("to")), - Date: strings.TrimSpace(req.URL.Query().Get("date")), - Status: strings.TrimSpace(req.URL.Query().Get("status")), - DepartmentID: strings.TrimSpace(req.URL.Query().Get("department_id")), - UserID: strings.TrimSpace(req.URL.Query().Get("user_id")), - Cursor: strings.TrimSpace(req.URL.Query().Get("cursor")), - Limit: limit, - }) - if err != nil { - logs.Warn.Println("im attendance records failed:", principal.TenantID, principal.Uid.UserId(), err) - writeIMRelationError(wrt, req, err) - return - } - writeIMSuccess(wrt, req, map[string]any{ - "items": attendanceRecordPayloads(records), - "next_cursor": nextCursor, - "has_more": nextCursor != "", - "statistics": attendanceStatsPayload(stats), - "permissions": map[string]any{ - "can_checkin": true, - "can_view_managed": true, - "can_appeal": true, - }, - }) -} - -func serveIMAttendanceCheckins(wrt http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) - return - } - principal, ok := authenticateIMHTTPBearer(wrt, req) - if !ok { - return - } - idempotencyKey, ok := requireIMIdempotencyKey(wrt, req) - if !ok { - return - } - var body imAttendanceCheckinRequest - if !decodeIMJSONBody(wrt, req, &body) { - return - } - date, err := parseIMDate(body.Date) - if err != nil { - writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "date"}) - return - } - clientTime, err := parseIMTime(body.ClientTime) - if err != nil { - writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "client_time"}) - return - } - token, err := randomIMToken(12) - if err != nil { - writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) - return - } - record, checkin, err := store.Attendance.Checkin(principal.TenantID, principal.Uid, "attc_"+token, types.AttendanceCheckinRequest{ - Date: date, - Action: strings.TrimSpace(body.Action), - RuleID: strings.TrimSpace(body.RuleID), - ShiftID: strings.TrimSpace(body.ShiftID), - ClientTime: clientTime, - Location: body.Location, - Wifi: body.Wifi, - Device: body.Device, - IdempotencyKey: idempotencyKey, - }) - if err != nil { - logs.Warn.Println("im attendance checkin failed:", principal.TenantID, principal.Uid.UserId(), err) - writeIMRelationError(wrt, req, err) - return - } - publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "attendance", "attendance.record.updated", record.RecordID, - []types.Uid{principal.Uid}, "打卡", record.Date.Format("2006-01-02"), map[string]any{"record_id": record.RecordID, "checkin_id": checkin.CheckinID, "status": record.Status}) - writeIMSuccess(wrt, req, map[string]any{ - "record": attendanceRecordPayload(record), - "checkin": attendanceCheckinPayload(*checkin), - }) -} - -func serveIMAttendanceAppeals(wrt http.ResponseWriter, req *http.Request) { - principal, ok := authenticateIMHTTPBearer(wrt, req) - if !ok { - return - } - switch req.Method { - case http.MethodGet: - limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100) - items, nextCursor, err := store.Attendance.ListAppeals(principal.TenantID, principal.Uid, types.AttendanceAppealListOptions{ - Scope: req.URL.Query().Get("scope"), - Status: strings.TrimSpace(req.URL.Query().Get("status")), - Cursor: strings.TrimSpace(req.URL.Query().Get("cursor")), - Limit: limit, - }) - if err != nil { - logs.Warn.Println("im attendance appeals failed:", principal.TenantID, principal.Uid.UserId(), err) - writeIMRelationError(wrt, req, err) - return - } - writeIMSuccess(wrt, req, map[string]any{"items": attendanceAppealPayloads(items), "next_cursor": nextCursor, "has_more": nextCursor != ""}) - case http.MethodPost: - idempotencyKey, ok := requireIMIdempotencyKey(wrt, req) - if !ok { - return - } - var body imAttendanceAppealRequest - if !decodeIMJSONBody(wrt, req, &body) { - return - } - if strings.TrimSpace(body.Reason) == "" || len([]rune(body.Reason)) > 500 { - writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "reason"}) - return - } - var targetTime *time.Time - if strings.TrimSpace(body.TargetTime) != "" { - parsed, err := parseIMTime(body.TargetTime) - if err != nil { - writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "target_time"}) - return - } - targetTime = &parsed - } - token, err := randomIMToken(12) - if err != nil { - writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) - return - } - appeal, err := store.Attendance.CreateAppeal(principal.TenantID, principal.Uid, &types.AttendanceAppeal{ - AppealID: "atta_" + token, - RecordID: strings.TrimSpace(body.RecordID), - UserID: principal.Uid, - AppealType: strings.TrimSpace(body.AppealType), - Reason: strings.TrimSpace(body.Reason), - TargetAction: strings.TrimSpace(body.TargetAction), - TargetTime: targetTime, - AttachmentFileIDs: body.AttachmentFileIDs, - }, idempotencyKey) - if err != nil { - logs.Warn.Println("im attendance appeal create failed:", principal.TenantID, principal.Uid.UserId(), err) - writeIMRelationError(wrt, req, err) - return - } - publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "attendance", "attendance.appeal.created", appeal.AppealID, - []types.Uid{principal.Uid}, "考勤申诉", appeal.RecordID, map[string]any{"appeal_id": appeal.AppealID, "record_id": appeal.RecordID, "status": appeal.Status}) - writeIMSuccess(wrt, req, attendanceAppealPayload(appeal)) - default: - writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) - } -} - -func serveIMMeetings(wrt http.ResponseWriter, req *http.Request) { - if req.Method == http.MethodGet { - serveIMMeetingList(wrt, req) - return - } - if req.Method != http.MethodPost { - writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) - return - } - principal, ok := authenticateIMHTTPBearer(wrt, req) - if !ok { - return - } - idempotencyKey, ok := requireIMIdempotencyKey(wrt, req) - if !ok { - return - } - 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"` - AttendeeUserIDs []string `json:"attendee_user_ids"` - ReminderMinutes []int `json:"reminder_minutes"` - } - if !decodeIMJSONBody(wrt, req, &body) { - return - } - startAt, err := parseIMTime(body.StartAt) - if err != nil { - writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "start_at"}) - return - } - endAt, err := parseIMTime(body.EndAt) - if err != nil || !endAt.After(startAt) { - writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "end_at"}) - return - } - meetingID, err := randomIMToken(12) - if err != nil { - writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) - return - } - attendeeIDs, ok := parseIMUserIDList(wrt, req, body.AttendeeUserIDs, "attendee_user_ids") - if !ok { - return - } - for _, attendeeID := range attendeeIDs { - if !ensureIMMeetingUserExists(principal.TenantID, attendeeID) { - writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "attendee_user_ids", "user_id": attendeeID.UserId()}) - 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, - Location: strings.TrimSpace(body.Location), - ReminderMinutes: body.ReminderMinutes, - IdempotencyKey: idempotencyKey, - }, attendeeIDs) - if err != nil { - writeIMRelationError(wrt, req, err) - return - } - if imReliableHTTPEventsEnabled(req) { - if err = publishIMMeetingEvent(principal.TenantID, principal.Uid, meeting, "meeting.created"); err != nil { - writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) - return - } - } - publishIMAuditOnly(req.Context(), principal.TenantID, principal.Uid, "meeting", "meeting.created", meeting.MeetingID, - map[string]any{"meeting_id": meeting.MeetingID, "version": meeting.Version, "state": meeting.State}) - writeIMSuccess(wrt, req, meetingPayloadForTenant(principal.TenantID, meeting, principal.Uid)) -} - -func serveIMMeetingList(wrt http.ResponseWriter, req *http.Request) { - principal, ok := authenticateIMHTTPBearer(wrt, req) - if !ok { - return - } - limit := parseIMLimit(req.URL.Query().Get("limit"), 20, 100) - meetings, nextCursor, err := store.Meetings.List(principal.TenantID, principal.Uid, strings.TrimSpace(req.URL.Query().Get("cursor")), limit) - if err != nil { - writeIMRelationError(wrt, req, err) - return - } - items := make([]map[string]any, 0, len(meetings)) - userSummaries := meetingUserSummaries(principal.TenantID, meetings) - for i := range meetings { - items = append(items, meetingPayloadWithUserSummaries(&meetings[i], principal.Uid, userSummaries)) - } - writeIMSuccess(wrt, req, map[string]any{ - "items": items, - "next_cursor": nextCursor, - "has_more": nextCursor != "", - }) -} - -func serveIMMeetingResource(wrt http.ResponseWriter, req *http.Request) { - meetingID, tail, ok := meetingResourcePath(req.URL.Path) - if !ok { - writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "meeting route not found", nil) - return - } - switch tail { - case "": - if req.Method == http.MethodGet { - serveIMMeetingGet(wrt, req, meetingID) - return - } - if req.Method == http.MethodPatch { - serveIMMeetingUpdate(wrt, req, meetingID) - return - } - case "cancel": - serveIMMeetingCancel(wrt, req, meetingID) - return - case "reminders": - serveIMMeetingReminders(wrt, req, meetingID) - return - 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) -} - -func serveIMMeetingGet(wrt http.ResponseWriter, req *http.Request, meetingID string) { - 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 - } - writeIMSuccess(wrt, req, meetingPayloadForTenant(principal.TenantID, meeting, principal.Uid)) -} - -func serveIMMeetingUpdate(wrt http.ResponseWriter, req *http.Request, meetingID string) { - principal, ok := authenticateIMHTTPBearer(wrt, req) - if !ok { - return - } - idempotencyKey, ok := requireIMIdempotencyKey(wrt, req) - if !ok { - return - } - 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"` - ReminderMinutes []int `json:"reminder_minutes"` - Version int `json:"version"` - } - if !decodeIMJSONBody(wrt, req, &body) { - 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 { - writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "start_at"}) - return - } - patch.StartAt = &startAt - } - if body.EndAt != nil { - endAt, err := parseIMTime(*body.EndAt) - if err != nil { - writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "end_at"}) - return - } - patch.EndAt = &endAt - } - meeting, err := store.Meetings.Update(principal.TenantID, principal.Uid, meetingID, patch, idempotencyKey) - if err != nil { - writeIMRelationError(wrt, req, err) - return - } - if imReliableHTTPEventsEnabled(req) { - if err = publishIMMeetingEvent(principal.TenantID, principal.Uid, meeting, "meeting.updated"); err != nil { - writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) - return - } - } - publishIMAuditOnly(req.Context(), principal.TenantID, principal.Uid, "meeting", "meeting.updated", meeting.MeetingID, - map[string]any{"meeting_id": meeting.MeetingID, "version": meeting.Version, "state": meeting.State}) - writeIMSuccess(wrt, req, meetingPayloadForTenant(principal.TenantID, meeting, principal.Uid)) -} - -func serveIMMeetingCancel(wrt http.ResponseWriter, req *http.Request, meetingID string) { - if req.Method != http.MethodPost { - writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) - return - } - principal, ok := authenticateIMHTTPBearer(wrt, req) - if !ok { - return - } - idempotencyKey, ok := requireIMIdempotencyKey(wrt, req) - if !ok { - return - } - var body struct { - Version int `json:"version"` - } - if req.Body != nil && req.ContentLength != 0 { - if !decodeIMJSONBody(wrt, req, &body) { - return - } - } - if body.Version <= 0 { - writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "version"}) - return - } - current, err := store.Meetings.Get(principal.TenantID, principal.Uid, meetingID) - if err != nil { - writeIMRelationError(wrt, req, err) - return - } - if current.Version != body.Version { - writeIMError(wrt, req, http.StatusConflict, 30006, "VERSION_CONFLICT", "version conflict", map[string]any{"current_version": current.Version}) - return - } - meeting, err := store.Meetings.Cancel(principal.TenantID, principal.Uid, meetingID, idempotencyKey) - if err != nil { - writeIMRelationError(wrt, req, err) - return - } - if imReliableHTTPEventsEnabled(req) { - if err = publishIMMeetingEvent(principal.TenantID, principal.Uid, meeting, "meeting.canceled"); err != nil { - writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) - return - } - } - publishIMAuditOnly(req.Context(), principal.TenantID, principal.Uid, "meeting", "meeting.canceled", meeting.MeetingID, - map[string]any{"meeting_id": meeting.MeetingID, "version": meeting.Version, "state": meeting.State}) - writeIMSuccess(wrt, req, meetingPayloadForTenant(principal.TenantID, meeting, principal.Uid)) -} - -func serveIMMeetingReminders(wrt http.ResponseWriter, req *http.Request, meetingID string) { - if req.Method != http.MethodPost { - writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) - return - } - principal, ok := authenticateIMHTTPBearer(wrt, req) - if !ok { - return - } - idempotencyKey, ok := requireIMIdempotencyKey(wrt, req) - if !ok { - return - } - participants, err := store.Meetings.Remind(principal.TenantID, principal.Uid, meetingID, idempotencyKey) - if err != nil { - writeIMRelationError(wrt, req, err) - return - } - if imReliableHTTPEventsEnabled(req) { - meeting, getErr := store.Meetings.Get(principal.TenantID, principal.Uid, meetingID) - if getErr != nil { - writeIMRelationError(wrt, req, getErr) - return - } - meeting.Participants = participants - if err = publishIMMeetingEvent(principal.TenantID, principal.Uid, meeting, "meeting.reminded"); err != nil { - writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) - return - } - } - publishIMAuditOnly(req.Context(), principal.TenantID, principal.Uid, "meeting", "meeting.reminded", meetingID, - map[string]any{"meeting_id": meetingID, "reminded_count": len(participants)}) - writeIMSuccess(wrt, req, map[string]any{ - "meeting_id": meetingID, - "reminded": meetingParticipantPayloadsWithSummaries(participants, - meetingUserSummaries(principal.TenantID, []types.Meeting{{Participants: participants}})), - }) -} - -func serveIMMeetingMyResponse(wrt http.ResponseWriter, req *http.Request, meetingID string) { - if req.Method != http.MethodPut { - writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) - return - } - principal, ok := authenticateIMHTTPBearer(wrt, req) - if !ok { - return - } - var body struct { - Response string `json:"response"` - Version int `json:"version"` - } - if !decodeIMJSONBody(wrt, req, &body) { - return - } - meeting, err := store.Meetings.Respond(principal.TenantID, principal.Uid, meetingID, strings.TrimSpace(body.Response), body.Version) - if err != nil { - writeIMRelationError(wrt, req, err) - return - } - if imReliableHTTPEventsEnabled(req) { - if err = publishIMMeetingEvent(principal.TenantID, principal.Uid, meeting, "meeting.response.updated"); err != nil { - writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) - return - } - } - publishIMAuditOnly(req.Context(), principal.TenantID, principal.Uid, "meeting", "meeting.response.updated", meeting.MeetingID, - map[string]any{"meeting_id": meeting.MeetingID, "version": meeting.Version, "state": meeting.State}) - writeIMSuccess(wrt, req, meetingPayloadForTenant(principal.TenantID, meeting, principal.Uid)) -} - -func serveIMDailyReports(wrt http.ResponseWriter, req *http.Request) { - principal, ok := authenticateIMHTTPBearer(wrt, req) - if !ok { - return - } - switch req.Method { - case http.MethodGet: - limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100) - if !validIMReportStatusQuery(wrt, req) || !validIMOptionalDateQuery(wrt, req, "from") || !validIMOptionalDateQuery(wrt, req, "to") { - return - } - reports, nextCursor, err := store.DailyReports.List(principal.TenantID, principal.Uid, - req.URL.Query().Get("scope"), - req.URL.Query().Get("from"), - req.URL.Query().Get("to"), - req.URL.Query().Get("status"), - req.URL.Query().Get("cursor"), - limit) - if err != nil { - writeIMRelationError(wrt, req, err) - return - } - writeIMSuccess(wrt, req, map[string]any{ - "items": dailyReportListPayloads(reports), - "next_cursor": nextCursor, - "permissions": dailyReportPermissions(req), - }) - case http.MethodPost: - var body struct { - ReportDate string `json:"report_date"` - } - if !decodeIMJSONBody(wrt, req, &body) { - return - } - reportDate, err := parseIMDate(body.ReportDate) - if err != nil || !validIMReportDateRange(reportDate) { - writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "report_date"}) - return - } - token, err := randomIMToken(12) - if err != nil { - writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) - return - } - report, err := store.DailyReports.CreateDraft(principal.TenantID, principal.Uid, "dr_"+token, reportDate) - if err != nil { - writeIMRelationError(wrt, req, err) - return - } - publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "daily_report", "daily_report.draft.created", report.ReportID, - []types.Uid{principal.Uid}, "日报", report.ReportDate.Format("2006-01-02"), map[string]any{"report_id": report.ReportID, "version": report.Version, "status": report.Status}) - writeIMSuccess(wrt, req, dailyReportDetailPayload(report, principal.Uid)) - default: - writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) - } -} - -func serveIMDailyReportResource(wrt http.ResponseWriter, req *http.Request) { - reportID, tail, ok := dailyReportResourcePath(req.URL.Path) - if !ok { - writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "daily report route not found", nil) - return - } - if reportID == "statistics" && tail == "" { - serveIMDailyReportStatistics(wrt, req) - return - } - if reportID == "reminders" && tail == "" { - serveIMDailyReportReminders(wrt, req) - return - } - switch tail { - case "": - if req.Method == http.MethodGet { - serveIMDailyReportGet(wrt, req, reportID) - return - } - case "draft": - serveIMDailyReportSaveDraft(wrt, req, reportID) - return - case "submit": - serveIMDailyReportSubmit(wrt, req, reportID) - return - } - writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) -} - -func serveIMDailyReportGet(wrt http.ResponseWriter, req *http.Request, reportID string) { - principal, ok := authenticateIMHTTPBearer(wrt, req) - if !ok { - return - } - report, err := store.DailyReports.Get(principal.TenantID, principal.Uid, reportID) - if err != nil { - writeIMRelationError(wrt, req, err) - return - } - publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "daily_report", "daily_report.draft.saved", report.ReportID, - []types.Uid{principal.Uid}, "日报", report.ReportDate.Format("2006-01-02"), map[string]any{"report_id": report.ReportID, "version": report.Version, "status": report.Status}) - writeIMSuccess(wrt, req, dailyReportDetailPayload(report, principal.Uid)) -} - -func serveIMDailyReportSaveDraft(wrt http.ResponseWriter, req *http.Request, reportID string) { - if req.Method != http.MethodPut { - writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) - return - } - principal, ok := authenticateIMHTTPBearer(wrt, req) - if !ok { - return - } - var body struct { - Content string `json:"content"` - Attachments []string `json:"attachments"` - Version int `json:"version"` - } - if !decodeIMJSONBody(wrt, req, &body) { - return - } - report, err := store.DailyReports.SaveDraft(principal.TenantID, principal.Uid, reportID, body.Content, body.Attachments, body.Version) - if err != nil { - writeIMRelationError(wrt, req, err) - return - } - publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "daily_report", "daily_report.draft.saved", report.ReportID, - []types.Uid{principal.Uid}, "日报", report.ReportDate.Format("2006-01-02"), map[string]any{"report_id": report.ReportID, "version": report.Version, "status": report.Status}) - writeIMSuccess(wrt, req, dailyReportDetailPayload(report, principal.Uid)) -} - -func serveIMDailyReportSubmit(wrt http.ResponseWriter, req *http.Request, reportID string) { - if req.Method != http.MethodPost { - writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) - return - } - principal, ok := authenticateIMHTTPBearer(wrt, req) - if !ok { - return - } - var body struct { - Version int `json:"version"` - } - if !decodeIMJSONBody(wrt, req, &body) { - return - } - current, err := store.DailyReports.Get(principal.TenantID, principal.Uid, reportID) - if err != nil { - writeIMRelationError(wrt, req, err) - return - } - if !validIMReportDateRange(current.ReportDate) { - writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "report_date"}) - return - } - if strings.TrimSpace(current.Content) == "" { - writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonValidationFailed, "validation failed", map[string]any{"field": "content"}) - return - } - report, err := store.DailyReports.Submit(principal.TenantID, principal.Uid, reportID, body.Version) - if err != nil { - writeIMRelationError(wrt, req, err) - return - } - writeIMSuccess(wrt, req, dailyReportDetailPayload(report, principal.Uid)) -} - -func serveIMDailyReportStatistics(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 - } - date, err := parseIMDate(req.URL.Query().Get("date")) - if err != nil { - writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "date"}) - return - } - stats, err := store.DailyReports.Stats(principal.TenantID, principal.Uid, date, req.URL.Query().Get("department_id")) - if err != nil { - writeIMRelationError(wrt, req, err) - return - } - writeIMSuccess(wrt, req, map[string]any{ - "expected_count": stats.ExpectedCount, - "submitted_count": stats.SubmittedCount, - "missing_count": stats.MissingCount, - "scope": map[string]any{"department_id": strings.TrimSpace(req.URL.Query().Get("department_id"))}, - }) -} - -func serveIMDailyReportReminders(wrt http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) - return - } - principal, ok := authenticateIMHTTPBearer(wrt, req) - if !ok { - return - } - idempotencyKey, ok := requireIMIdempotencyKey(wrt, req) - if !ok { - return - } - var body struct { - Date string `json:"date"` - UserIDs []string `json:"user_ids"` - } - if !decodeIMJSONBody(wrt, req, &body) { - return - } - date, err := parseIMDate(body.Date) - if err != nil { - writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "date"}) - return - } - userIDs, ok := parseIMUserIDList(wrt, req, body.UserIDs, "user_ids") - if !ok { - return - } - reminded, err := store.DailyReports.Remind(principal.TenantID, principal.Uid, date, userIDs, idempotencyKey) - if err != nil { - writeIMRelationError(wrt, req, err) - return - } - items := make([]map[string]any, 0, len(reminded)) - for _, uid := range reminded { - items = append(items, map[string]any{"user_id": uid.UserId(), "status": "queued"}) - } - publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "daily_report", "daily_report.reminded", date.Format("2006-01-02"), - reminded, "日报提醒", date.Format("2006-01-02"), map[string]any{"date": date.Format("2006-01-02"), "reminded_count": len(reminded)}) - writeIMSuccess(wrt, req, map[string]any{"date": date.Format("2006-01-02"), "items": items}) -} - -func serveIMMonthlyReports(wrt http.ResponseWriter, req *http.Request) { - principal, ok := authenticateIMHTTPBearer(wrt, req) - if !ok { - return - } - switch req.Method { - case http.MethodGet: - limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100) - if !validIMReportStatusQuery(wrt, req) || !validIMOptionalMonthQuery(wrt, req, "from_month") || !validIMOptionalMonthQuery(wrt, req, "to_month") { - return - } - reports, nextCursor, err := store.MonthlyReports.List(principal.TenantID, principal.Uid, - req.URL.Query().Get("scope"), - req.URL.Query().Get("from_month"), - req.URL.Query().Get("to_month"), - req.URL.Query().Get("status"), - req.URL.Query().Get("cursor"), - limit) - if err != nil { - writeIMRelationError(wrt, req, err) - return - } - writeIMSuccess(wrt, req, map[string]any{"items": monthlyReportListPayloads(reports), "next_cursor": nextCursor, "has_more": nextCursor != ""}) - case http.MethodPost: - var body struct { - Month string `json:"month"` - } - if !decodeIMJSONBody(wrt, req, &body) { - return - } - if !validIMMonth(body.Month) || !validIMMonthRange(strings.TrimSpace(body.Month)) { - writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "month"}) - return - } - token, err := randomIMToken(12) - if err != nil { - writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) - return - } - report, err := store.MonthlyReports.CreateDraft(principal.TenantID, principal.Uid, "mr_"+token, strings.TrimSpace(body.Month)) - if err != nil { - writeIMRelationError(wrt, req, err) - return - } - publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "monthly_report", "monthly_report.draft.created", report.ReportID, - []types.Uid{principal.Uid}, "月报", report.Month, map[string]any{"report_id": report.ReportID, "version": report.Version, "status": report.Status}) - writeIMSuccess(wrt, req, monthlyReportDetailPayload(report, principal.Uid)) - default: - writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) - } -} - -func serveIMMonthlyReportResource(wrt http.ResponseWriter, req *http.Request) { - reportID, tail, ok := monthlyReportResourcePath(req.URL.Path) - if !ok { - writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "monthly report route not found", nil) - return - } - if reportID == "statistics" && tail == "" { - serveIMMonthlyReportStatistics(wrt, req) - return - } - switch tail { - case "": - if req.Method == http.MethodGet { - serveIMMonthlyReportGet(wrt, req, reportID) - return - } - case "draft": - serveIMMonthlyReportSaveDraft(wrt, req, reportID) - return - case "submit": - serveIMMonthlyReportSubmit(wrt, req, reportID) - return - case "reopen": - serveIMMonthlyReportReopen(wrt, req, reportID) - return - } - writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) -} - -func serveIMMonthlyReportGet(wrt http.ResponseWriter, req *http.Request, reportID string) { - principal, ok := authenticateIMHTTPBearer(wrt, req) - if !ok { - return - } - report, err := store.MonthlyReports.Get(principal.TenantID, principal.Uid, reportID) - if err != nil { - writeIMRelationError(wrt, req, err) - return - } - publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "monthly_report", "monthly_report.draft.saved", report.ReportID, - []types.Uid{principal.Uid}, "月报", report.Month, map[string]any{"report_id": report.ReportID, "version": report.Version, "status": report.Status}) - writeIMSuccess(wrt, req, monthlyReportDetailPayload(report, principal.Uid)) -} - -func serveIMMonthlyReportSaveDraft(wrt http.ResponseWriter, req *http.Request, reportID string) { - if req.Method != http.MethodPut { - writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) - return - } - principal, ok := authenticateIMHTTPBearer(wrt, req) - if !ok { - return - } - var body struct { - WorkSummary string `json:"work_summary"` - NextPlan string `json:"next_plan"` - Attachments []string `json:"attachments"` - Version int `json:"version"` - } - if !decodeIMJSONBody(wrt, req, &body) { - return - } - report, err := store.MonthlyReports.SaveDraft(principal.TenantID, principal.Uid, reportID, body.WorkSummary, body.NextPlan, body.Attachments, body.Version) - if err != nil { - writeIMRelationError(wrt, req, err) - return - } - publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "monthly_report", "monthly_report.draft.saved", report.ReportID, - []types.Uid{principal.Uid}, "月报", report.Month, map[string]any{"report_id": report.ReportID, "version": report.Version, "status": report.Status}) - writeIMSuccess(wrt, req, monthlyReportDetailPayload(report, principal.Uid)) -} - -func serveIMMonthlyReportSubmit(wrt http.ResponseWriter, req *http.Request, reportID string) { - if req.Method != http.MethodPost { - writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) - return - } - principal, ok := authenticateIMHTTPBearer(wrt, req) - if !ok { - return - } - var body struct { - Version int `json:"version"` - } - if !decodeIMJSONBody(wrt, req, &body) { - return - } - current, err := store.MonthlyReports.Get(principal.TenantID, principal.Uid, reportID) - if err != nil { - writeIMRelationError(wrt, req, err) - return - } - if !validIMMonthRange(current.Month) { - writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "month"}) - return - } - if strings.TrimSpace(current.WorkSummary) == "" { - writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonValidationFailed, "validation failed", map[string]any{"field": "work_summary"}) - return - } - if strings.TrimSpace(current.NextPlan) == "" { - writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonValidationFailed, "validation failed", map[string]any{"field": "next_plan"}) - return - } - report, err := store.MonthlyReports.Submit(principal.TenantID, principal.Uid, reportID, body.Version) - if err != nil { - writeIMRelationError(wrt, req, err) - return - } - publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "monthly_report", "monthly_report.submitted", report.ReportID, - []types.Uid{principal.Uid}, "月报已提交", report.Month, map[string]any{"report_id": report.ReportID, "version": report.Version, "status": report.Status}) - writeIMSuccess(wrt, req, monthlyReportDetailPayload(report, principal.Uid)) -} - -func serveIMMonthlyReportReopen(wrt http.ResponseWriter, req *http.Request, reportID string) { - if req.Method != http.MethodPost { - writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) - return - } - principal, ok := authenticateIMHTTPBearer(wrt, req) - if !ok { - return - } - var body struct { - Version int `json:"version"` - } - if !decodeIMJSONBody(wrt, req, &body) { - return - } - report, err := store.MonthlyReports.Reopen(principal.TenantID, principal.Uid, reportID, body.Version) - if err != nil { - writeIMRelationError(wrt, req, err) - return - } - writeIMSuccess(wrt, req, monthlyReportDetailPayload(report, principal.Uid)) -} - -func serveIMMonthlyReportStatistics(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 - } - month := strings.TrimSpace(req.URL.Query().Get("month")) - if !validIMMonth(month) { - writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "month"}) - return - } - stats, err := store.MonthlyReports.Stats(principal.TenantID, principal.Uid, month, req.URL.Query().Get("department_id")) - if err != nil { - writeIMRelationError(wrt, req, err) - return - } - writeIMSuccess(wrt, req, map[string]any{ - "expected_count": stats.ExpectedCount, - "submitted_count": stats.SubmittedCount, - "missing_count": stats.MissingCount, - "scope": map[string]any{"department_id": strings.TrimSpace(req.URL.Query().Get("department_id"))}, - }) -} - -func serveIMMailboxConfig(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 - } - config, err := store.Mailbox.Config(principal.TenantID, principal.Uid) - if err != nil { - logs.Warn.Println("im mailbox config: using default config after store error", principal.TenantID, err) - config = defaultIMMailboxConfig() - } - writeIMSuccess(wrt, req, mailboxConfigPayload(config)) -} - -func serveIMMailboxSubmissions(wrt http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) - return - } - principal, ok := authenticateIMHTTPBearer(wrt, req) - if !ok { - return - } - idempotencyKey, ok := requireIMIdempotencyKey(wrt, req) - if !ok { - return - } - var body struct { - Subject string `json:"subject"` - Content string `json:"content"` - Anonymous bool `json:"anonymous"` - AttachmentFileIDs []string `json:"attachment_file_ids"` - NoticeVersion string `json:"notice_version"` - } - if !decodeIMJSONBody(wrt, req, &body) { - return - } - subject := strings.TrimSpace(body.Subject) - content := strings.TrimSpace(body.Content) - if len([]rune(subject)) > 200 { - writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonValidationFailed, "validation failed", map[string]any{"field": "subject", "max_length": 200}) - return - } - if len([]rune(content)) > 5000 { - writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonValidationFailed, "validation failed", map[string]any{"field": "content", "max_length": 5000}) - return - } - token, err := randomIMToken(12) - if err != nil { - writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) - return - } - submission, err := store.Mailbox.Submit(principal.TenantID, principal.Uid, &types.MailboxSubmission{ - SubmissionID: "mb_" + token, - SenderID: principal.Uid, - Subject: subject, - Content: content, - Anonymous: body.Anonymous, - AttachmentFileIDs: body.AttachmentFileIDs, - NoticeVersion: strings.TrimSpace(body.NoticeVersion), - }, idempotencyKey) - if err != nil { - if errors.Is(err, types.ErrNotFound) { - writeIMError(wrt, req, http.StatusServiceUnavailable, 30050, "MAILBOX_UNAVAILABLE", "mailbox unavailable", nil) - return - } - if errors.Is(err, types.ErrPolicy) { - writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonValidationFailed, "validation failed", nil) - return - } - writeIMRelationError(wrt, req, err) - return - } - publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "mailbox", "mailbox.submitted", submission.SubmissionID, - []types.Uid{principal.Uid}, "总经理信箱", submission.Subject, map[string]any{"submission_id": submission.SubmissionID, "status": submission.Status, "anonymous": submission.Anonymous}) - writeIMSuccess(wrt, req, mailboxSubmissionResultPayload(submission)) -} - func serveIMMe(wrt http.ResponseWriter, req *http.Request) { if req.Method != http.MethodGet { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) @@ -7983,68 +6583,6 @@ func parseIMDate(raw string) (time.Time, error) { return parsed, nil } -func validIMReportDateRange(date time.Time) bool { - year := date.UTC().Year() - nowYear := time.Now().UTC().Year() - return year >= nowYear-1 && year <= nowYear+1 -} - -func validIMReportStatusQuery(wrt http.ResponseWriter, req *http.Request) bool { - status := strings.TrimSpace(req.URL.Query().Get("status")) - if status == "" || status == types.DailyReportStatusDraft || status == types.DailyReportStatusSubmitted { - return true - } - writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "status"}) - return false -} - -func parseIMOptionalDate(raw string, fallback time.Time) (time.Time, error) { - if strings.TrimSpace(raw) == "" { - return time.Date(fallback.UTC().Year(), fallback.UTC().Month(), fallback.UTC().Day(), 0, 0, 0, 0, time.UTC), nil - } - return parseIMDate(raw) -} - -func validIMOptionalDateQuery(wrt http.ResponseWriter, req *http.Request, field string) bool { - value := strings.TrimSpace(req.URL.Query().Get(field)) - if value == "" { - return true - } - date, err := parseIMDate(value) - if err != nil || !validIMReportDateRange(date) { - writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": field}) - return false - } - return true -} - -func validIMMonth(raw string) bool { - if _, err := time.ParseInLocation("2006-01", strings.TrimSpace(raw), time.UTC); err != nil { - return false - } - return true -} - -func validIMMonthRange(month string) bool { - parsed, err := time.ParseInLocation("2006-01", strings.TrimSpace(month), time.UTC) - if err != nil { - return false - } - return validIMReportDateRange(parsed) -} - -func validIMOptionalMonthQuery(wrt http.ResponseWriter, req *http.Request, field string) bool { - value := strings.TrimSpace(req.URL.Query().Get(field)) - if value == "" { - return true - } - if !validIMMonth(value) || !validIMMonthRange(value) { - writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": field}) - return false - } - return true -} - func requireIMIdempotencyKey(wrt http.ResponseWriter, req *http.Request) (string, bool) { key := strings.TrimSpace(req.Header.Get("Idempotency-Key")) if key == "" { @@ -8054,44 +6592,6 @@ func requireIMIdempotencyKey(wrt http.ResponseWriter, req *http.Request) (string return key, true } -func parseIMUserIDList(wrt http.ResponseWriter, req *http.Request, values []string, field string) ([]types.Uid, bool) { - uids := make([]types.Uid, 0, len(values)) - seen := make(map[types.Uid]bool, len(values)) - for _, value := range values { - uid := types.ParseUserId(strings.TrimSpace(value)) - if uid.IsZero() { - writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": field}) - return nil, false - } - if seen[uid] { - continue - } - seen[uid] = true - uids = append(uids, uid) - } - 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 == "" { @@ -9281,47 +7781,7 @@ func callRecordPayload(record types.CallRecord, viewer types.Uid) map[string]any return data } -func workbenchModulePayloads(modules []types.WorkbenchModule) []map[string]any { - items := make([]map[string]any, 0, len(modules)) - for _, module := range modules { - if !isAllowedIMWorkbenchRoute(module.Route) { - continue - } - items = append(items, map[string]any{ - "code": module.Code, - "name": module.Name, - "icon_file_id": module.IconFileID, - "route": module.Route, - "sort_order": module.SortOrder, - "unread_count": module.UnreadCount, - "enabled": module.Enabled, - "permission": module.Permission, - }) - } - return items -} - -func populateWorkbenchModuleUnreadCounts(tenantID types.TenantID, uid types.Uid, modules []map[string]any) error { - if store.Security == nil { - return nil - } - for _, module := range modules { - code, _ := module["code"].(string) - switch code { - case "meetings": - count, err := store.Security.CountUnreadSystemNotificationsByEventPrefix(tenantID, uid, "meeting.") - if err != nil && !errors.Is(err, types.ErrNotFound) && !errors.Is(err, types.ErrUnsupported) { - return err - } - if err == nil { - module["unread_count"] = count - } - } - } - return nil -} - -func bootstrapEntryPayloads(tenantID types.TenantID, uid types.Uid, modules []map[string]any) (map[string]any, map[string]any, error) { +func bootstrapEntryPayloads(tenantID types.TenantID, uid types.Uid) (map[string]any, map[string]any, error) { newFriendsPending := 0 if store.Relations != nil { count, err := store.Relations.CountPendingFriendRequests(tenantID, uid) @@ -9344,22 +7804,12 @@ func bootstrapEntryPayloads(tenantID types.TenantID, uid types.Uid, modules []ma } } - workbenchTotalUnread := 0 - for _, module := range modules { - enabled, _ := module["enabled"].(bool) - if !enabled { - continue - } - workbenchTotalUnread += intFromAny(module["unread_count"]) - } - entryBadges := map[string]any{ "contacts_total_unread": newFriendsPending, "new_friends_pending_count": newFriendsPending, "groups_unread_count": 0, "file_assistant_unread_count": 0, "system_unread_count": systemUnread, - "workbench_total_unread": workbenchTotalUnread, } entries := map[string]any{ "new_friends": map[string]any{ @@ -9392,690 +7842,6 @@ func bootstrapEntryPayloads(tenantID types.TenantID, uid types.Uid, modules []ma return entryBadges, entries, nil } -func organizationDepartmentPayloads(departments []types.OrganizationDepartment) []map[string]any { - items := make([]map[string]any, 0, len(departments)) - for _, department := range departments { - items = append(items, organizationDepartmentPayload(department)) - } - return items -} - -func organizationDepartmentPayload(department types.OrganizationDepartment) map[string]any { - return map[string]any{ - "department_id": department.DepartmentID, - "name": department.Name, - "parent_id": department.ParentID, - "child_count": department.ChildCount, - "member_count": department.MemberCount, - "version": department.Version, - } -} - -func organizationMemberPayloads(members []types.OrganizationMember) []map[string]any { - items := make([]map[string]any, 0, len(members)) - for _, member := range members { - items = append(items, organizationMemberPayload(member)) - } - return items -} - -func organizationMemberPayload(member types.OrganizationMember) map[string]any { - payload := map[string]any{ - "user_id": member.UserID.UserId(), - "department_id": member.DepartmentID, - "display_name": member.DisplayName, - "position": member.Position, - "avatar_file_id": member.AvatarFileID, - "status": member.Status, - "contactable": member.Contactable, - "version": member.Version, - } - if member.Mobile != "" { - payload["mobile"] = member.Mobile - } - if member.Email != "" { - payload["email"] = member.Email - } - return payload -} - -func organizationSearchPayloads(items []types.OrganizationSearchItem) []map[string]any { - payload := make([]map[string]any, 0, len(items)) - for _, item := range items { - switch item.Type { - case "department": - if item.Department != nil { - payload = append(payload, map[string]any{ - "type": "department", - "department": organizationDepartmentPayload(*item.Department), - }) - } - case "user": - if item.Member != nil { - payload = append(payload, map[string]any{ - "type": "user", - "user": organizationMemberPayload(*item.Member), - }) - } - } - } - return payload -} - -func attendanceConfigPayload(config *types.AttendanceConfig) map[string]any { - return map[string]any{ - "date": config.Date.Format("2006-01-02"), - "timezone": config.Timezone, - "rule": attendanceRulePayload(config.Rule), - "shifts": attendanceShiftPayloads(config.Shifts), - "allowed_actions": config.AllowedActions, - "today_record": attendanceRecordPayload(config.TodayRecord), - "permissions": map[string]any{ - "can_checkin": true, - "can_view_managed": config.CanViewManaged, - "can_appeal": config.Rule.AppealAllowed, - }, - } -} - -func attendanceRulePayload(rule types.AttendanceRule) map[string]any { - data := map[string]any{ - "rule_id": rule.RuleID, - "name": rule.Name, - "workday": rule.Workday, - "location_required": rule.LocationRequired, - "wifi_required": rule.WifiRequired, - "allow_outside_checkin": rule.AllowOutsideCheckin, - "appeal_allowed": rule.AppealAllowed, - } - if rule.AppealDeadline != nil { - data["appeal_deadline"] = rule.AppealDeadline.UTC().Format(time.RFC3339Nano) - } - return data -} - -func attendanceShiftPayloads(shifts []types.AttendanceShift) []map[string]any { - items := make([]map[string]any, 0, len(shifts)) - for _, shift := range shifts { - items = append(items, map[string]any{ - "shift_id": shift.ShiftID, - "name": shift.Name, - "work_start": shift.WorkStart, - "work_end": shift.WorkEnd, - "checkin_start": shift.CheckinStart, - "checkout_end": shift.CheckoutEnd, - }) - } - return items -} - -func attendanceRecordPayloads(records []types.AttendanceRecord) []map[string]any { - items := make([]map[string]any, 0, len(records)) - for i := range records { - items = append(items, attendanceRecordPayload(&records[i])) - } - return items -} - -func attendanceRecordPayload(record *types.AttendanceRecord) map[string]any { - if record == nil { - return nil - } - return map[string]any{ - "record_id": record.RecordID, - "user_id": record.UserID.UserId(), - "date": record.Date.Format("2006-01-02"), - "rule_id": record.RuleID, - "shift_id": record.ShiftID, - "status": record.Status, - "checkins": attendanceCheckinPayloads(record.Checkins), - "exceptions": record.Exceptions, - "appeal_status": record.AppealStatus, - "version": record.Version, - "created_at": record.CreatedAt.UTC().Format(time.RFC3339Nano), - "updated_at": record.UpdatedAt.UTC().Format(time.RFC3339Nano), - } -} - -func attendanceCheckinPayloads(checkins []types.AttendanceCheckin) []map[string]any { - items := make([]map[string]any, 0, len(checkins)) - for _, checkin := range checkins { - items = append(items, attendanceCheckinPayload(checkin)) - } - return items -} - -func attendanceCheckinPayload(checkin types.AttendanceCheckin) map[string]any { - return map[string]any{ - "checkin_id": checkin.CheckinID, - "action": checkin.Action, - "client_time": checkin.ClientTime.UTC().Format(time.RFC3339Nano), - "server_time": checkin.ServerTime.UTC().Format(time.RFC3339Nano), - "result": checkin.Result, - "location": checkin.Location, - "wifi": checkin.Wifi, - "device": checkin.Device, - } -} - -func attendanceStatsPayload(stats *types.AttendanceRecordStats) map[string]any { - if stats == nil { - return map[string]any{"expected_count": 0, "checked_count": 0, "exception_count": 0, "not_checked_count": 0} - } - return map[string]any{ - "expected_count": stats.ExpectedCount, - "checked_count": stats.CheckedCount, - "exception_count": stats.ExceptionCount, - "not_checked_count": stats.NotCheckedCount, - } -} - -func attendanceAppealPayloads(appeals []types.AttendanceAppeal) []map[string]any { - items := make([]map[string]any, 0, len(appeals)) - for i := range appeals { - items = append(items, attendanceAppealPayload(&appeals[i])) - } - return items -} - -func attendanceAppealPayload(appeal *types.AttendanceAppeal) map[string]any { - if appeal == nil { - return nil - } - data := map[string]any{ - "appeal_id": appeal.AppealID, - "record_id": appeal.RecordID, - "user_id": appeal.UserID.UserId(), - "appeal_type": appeal.AppealType, - "reason": appeal.Reason, - "target_action": appeal.TargetAction, - "attachment_file_ids": appeal.AttachmentFileIDs, - "status": appeal.Status, - "version": appeal.Version, - "submitted_at": appeal.SubmittedAt.UTC().Format(time.RFC3339Nano), - "created_at": appeal.CreatedAt.UTC().Format(time.RFC3339Nano), - "updated_at": appeal.UpdatedAt.UTC().Format(time.RFC3339Nano), - } - if appeal.TargetTime != nil { - data["target_time"] = appeal.TargetTime.UTC().Format(time.RFC3339Nano) - } - return data -} - -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{ - "meeting_id": meeting.MeetingID, - "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), - "time_range": meetingTimeRange(startAt, endAt), - "location": meeting.Location, - "state": meeting.State, - "reminder_minutes": meeting.ReminderMinutes, - "version": meeting.Version, - "participants": participants, - "participant_count": len(participants), - "participant_names": meetingParticipantNames(participants), - "permissions": meetingPermissionsPayload(meeting, viewer), - "created_at": meeting.CreatedAt.UTC().Format(time.RFC3339Nano), - "updated_at": meeting.UpdatedAt.UTC().Format(time.RFC3339Nano), - } - 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) - } - for _, participant := range meeting.Participants { - if participant.UserID == viewer { - data["my_response"] = participant.Response - break - } - } - return data -} - -func meetingParticipantPayloads(participants []types.MeetingParticipant) []map[string]any { - return meetingParticipantPayloadsWithSummaries(participants, nil) -} - -func meetingParticipantPayloadsWithSummaries(participants []types.MeetingParticipant, userSummaries map[types.Uid]map[string]any) []map[string]any { - items := make([]map[string]any, 0, len(participants)) - for _, participant := range participants { - item := map[string]any{ - "user_id": participant.UserID.UserId(), - "response": participant.Response, - } - if summary := meetingUserSummary(participant.UserID, userSummaries); summary != nil { - if displayName, ok := summary["display_name"]; ok { - item["display_name"] = displayName - } - if avatar, ok := summary["avatar_file_id"]; ok { - item["avatar_file_id"] = avatar - } - } - if participant.ResponseAt != nil { - item["response_at"] = participant.ResponseAt.UTC().Format(time.RFC3339Nano) - } - items = append(items, item) - } - return items -} - -func meetingUserSummaries(tenantID types.TenantID, meetings []types.Meeting) map[types.Uid]map[string]any { - if store.Users == nil { - return nil - } - seen := make(map[types.Uid]bool) - uids := make([]types.Uid, 0, len(meetings)*2) - for _, meeting := range meetings { - if !meeting.OrganizerID.IsZero() && !seen[meeting.OrganizerID] { - 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 - uids = append(uids, participant.UserID) - } - } - } - if len(uids) == 0 { - return nil - } - users, err := store.Users.GetAll(tenantID, uids...) - if err != nil { - logs.Warn.Printf("im http: failed to load meeting user summaries: %v", err) - return nil - } - summaries := make(map[types.Uid]map[string]any, len(users)) - for i := range users { - uid := users[i].Uid() - if uid.IsZero() { - uid = types.ParseUid(users[i].Id) - } - if !uid.IsZero() { - summaries[uid] = userSummaryPayload(&users[i], "") - } - } - return summaries -} - -func meetingUserSummary(uid types.Uid, summaries map[types.Uid]map[string]any) map[string]any { - if uid.IsZero() { - return nil - } - if summary, ok := summaries[uid]; ok && summary != nil { - return summary - } - return map[string]any{ - "user_id": uid.UserId(), - "display_name": uid.UserId(), - } -} - -func meetingParticipantNames(participants []map[string]any) []string { - names := make([]string, 0, len(participants)) - for _, participant := range participants { - if name := strings.TrimSpace(fmt.Sprint(participant["display_name"])); name != "" { - names = append(names, name) - } - } - 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}) - meetingData := meetingPayloadWithUserSummaries(meeting, viewer, userSummaries) - data := make(map[string]any, len(meetingData)+4) - for k, v := range meetingData { - data[k] = v - } - data["meeting"] = meetingData - data["note_status"] = types.MeetingNoteStatusEmpty - if len(files) > 0 { - data["note_status"] = types.MeetingNoteStatusAvailable - } - data["file_count"] = len(files) - data["permissions"] = meetingNotePermissionsPayload(meeting, viewer) - 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 "" - } - if startAt.Format("2006-01-02") == endAt.Format("2006-01-02") { - return startAt.Format("2006-01-02 15:04") + "-" + endAt.Format("15:04") - } - return startAt.Format("2006-01-02 15:04") + "-" + endAt.Format("2006-01-02 15:04") -} - -func meetingParticipantUIDs(meeting *types.Meeting) []types.Uid { - if meeting == nil { - return nil - } - 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 { - items := make([]types.Uid, 0, len(participants)) - for _, participant := range participants { - if !participant.UserID.IsZero() { - items = append(items, participant.UserID) - } - } - return items -} - -func publishIMWorkbenchAndAudit(ctx context.Context, tenantID types.TenantID, actor types.Uid, module, action, resourceID string, - targets []types.Uid, title, summary string, data map[string]any) { - if err := enqueueKafkaWorkbenchEvent(ctx, tenantID, actor, module, action, resourceID, targets, title, summary, data); err != nil { - logs.Warn.Printf("im http: failed to enqueue workbench event action=%s resource=%s: %v", action, resourceID, err) - } - publishIMAuditOnly(ctx, tenantID, actor, module, action, resourceID, data) -} - -func publishIMAuditOnly(ctx context.Context, tenantID types.TenantID, actor types.Uid, module, action, resourceID string, data map[string]any) { - if err := enqueueKafkaAuditEvent(ctx, tenantID, "user", actor, action, module, resourceID, "succeeded", "", data); err != nil { - logs.Warn.Printf("im http: failed to enqueue audit event action=%s resource=%s: %v", action, resourceID, err) - } -} - -func meetingPermissionsPayload(meeting *types.Meeting, viewer types.Uid) map[string]any { - isOrganizer := meeting.OrganizerID == viewer - isScheduled := meeting.State == types.MeetingStateScheduled - canRespond := false - for _, participant := range meeting.Participants { - if participant.UserID == viewer && !isOrganizer { - canRespond = true - break - } - } - if time.Now().UTC().After(meeting.EndAt) { - canRespond = false - } - return map[string]any{ - "can_update": isOrganizer && isScheduled, - "can_cancel": isOrganizer && isScheduled, - "can_remind": isOrganizer && isScheduled, - "can_respond": canRespond && isScheduled, - } -} - -func dailyReportListPayloads(reports []types.DailyReport) []map[string]any { - items := make([]map[string]any, 0, len(reports)) - for _, report := range reports { - items = append(items, dailyReportListPayload(report)) - } - return items -} - -func dailyReportListPayload(report types.DailyReport) map[string]any { - data := map[string]any{ - "report_id": report.ReportID, - "author_id": report.AuthorID.UserId(), - "report_date": report.ReportDate.Format("2006-01-02"), - "content_short": reportPreview(report.Content), - "status": report.Status, - "draft_updated_at": report.UpdatedAt.UTC().Format(time.RFC3339Nano), - "read_state": report.ReadState, - "version": report.Version, - } - if report.SubmittedAt != nil { - data["submitted_at"] = report.SubmittedAt.UTC().Format(time.RFC3339Nano) - } - return data -} - -func dailyReportDetailPayload(report *types.DailyReport, viewer types.Uid) map[string]any { - data := dailyReportListPayload(*report) - data["content"] = report.Content - data["attachments"] = report.Attachments - data["updated_at"] = report.UpdatedAt.UTC().Format(time.RFC3339Nano) - data["created_at"] = report.CreatedAt.UTC().Format(time.RFC3339Nano) - data["permissions"] = map[string]any{ - "can_edit": report.AuthorID == viewer && report.Status == types.DailyReportStatusDraft, - "can_submit": report.AuthorID == viewer && report.Status == types.DailyReportStatusDraft, - "can_view": report.AuthorID == viewer, - } - return data -} - -func dailyReportPermissions(req *http.Request) map[string]any { - scope := strings.ToLower(strings.TrimSpace(req.URL.Query().Get("scope"))) - departmentID := strings.TrimSpace(req.URL.Query().Get("department_id")) - managed := scope == "managed" || scope == "department" || departmentID != "" - return map[string]any{ - "can_create": scope == "" || scope == "mine" || scope == "self", - "makeup_allowed_days": 7, - "can_view_managed": managed, - "can_remind_unsubmitted": managed, - } -} - -func monthlyReportListPayloads(reports []types.MonthlyReport) []map[string]any { - items := make([]map[string]any, 0, len(reports)) - for _, report := range reports { - items = append(items, monthlyReportListPayload(report)) - } - return items -} - -func monthlyReportListPayload(report types.MonthlyReport) map[string]any { - action := "view" - if report.Status == types.DailyReportStatusDraft { - action = "edit" - } - data := map[string]any{ - "report_id": report.ReportID, - "author_id": report.AuthorID.UserId(), - "month": report.Month, - "work_summary_short": reportPreview(report.WorkSummary), - "next_plan_short": reportPreview(report.NextPlan), - "status": report.Status, - "read_state": report.ReadState, - "version": report.Version, - "action": action, - "updated_at": report.UpdatedAt.UTC().Format(time.RFC3339Nano), - } - if report.SubmittedAt != nil { - data["submitted_at"] = report.SubmittedAt.UTC().Format(time.RFC3339Nano) - } - return data -} - -func reportPreview(text string) string { - text = strings.Join(strings.Fields(text), " ") - runes := []rune(text) - if len(runes) <= reportPreviewMaxRunes { - return text - } - return string(runes[:reportPreviewMaxRunes]) + "..." -} - -func monthlyReportDetailPayload(report *types.MonthlyReport, viewer types.Uid) map[string]any { - data := monthlyReportListPayload(*report) - data["work_summary"] = report.WorkSummary - data["next_plan"] = report.NextPlan - data["attachments"] = report.Attachments - data["created_at"] = report.CreatedAt.UTC().Format(time.RFC3339Nano) - data["permissions"] = map[string]any{ - "can_edit": report.AuthorID == viewer && report.Status == types.DailyReportStatusDraft, - "can_submit": report.AuthorID == viewer && report.Status == types.DailyReportStatusDraft, - "can_view": report.AuthorID == viewer, - } - data["validation"] = map[string]any{ - "work_summary_required": true, - "next_plan_required": true, - "max_length": 10000, - } - return data -} - -func mailboxConfigPayload(config *types.MailboxConfig) map[string]any { - return map[string]any{ - "enabled": config.Enabled, - "allow_anonymous": config.AllowAnonymous, - "required_fields": config.RequiredFields, - "max_length": config.MaxLength, - "attachment_policy": config.AttachmentPolicy, - "notice": map[string]any{ - "title": config.Notice.Title, - "content": config.Notice.Content, - "updated_at": config.Notice.UpdatedAt.UTC().Format(time.RFC3339Nano), - "version": config.Notice.UpdatedAt.UTC().Format(time.RFC3339Nano), - }, - } -} - -func defaultIMMailboxConfig() *types.MailboxConfig { - now := time.Now().UTC().Round(time.Millisecond) - return &types.MailboxConfig{ - Enabled: false, - AllowAnonymous: false, - RequiredFields: []string{"subject", "content"}, - MaxLength: 1000, - AttachmentPolicy: map[string]any{ - "max_count": 0, - "enabled": false, - }, - Notice: types.MailboxNotice{ - Title: "总经理信箱", - Content: "当前暂未开放。", - UpdatedAt: now, - }, - UpdatedAt: now, - } -} - -func mailboxSubmissionResultPayload(submission *types.MailboxSubmission) map[string]any { - return map[string]any{ - "submission_id": submission.SubmissionID, - "status": submission.Status, - "submitted_at": submission.SubmittedAt.UTC().Format(time.RFC3339Nano), - } -} - func userProfilePayload10(profile *types.UserProfile) map[string]any { data := map[string]any{ "avatar_file_id": profile.AvatarFileID, @@ -10443,15 +8209,6 @@ func tenantEntryFeedbackPayload(feedback *types.TenantEntryFeedback) map[string] } } -func isAllowedIMWorkbenchRoute(route string) bool { - switch route { - case "/organization", "/attendance", "/meetings", "/meeting-notes", "/reports/daily", "/reports/monthly", "/executive-mailbox": - return true - default: - return false - } -} - func relationshipContextPayload(ctx *types.RelationshipContext) map[string]any { data := map[string]any{ "topic": ctx.Topic, @@ -12575,71 +10332,6 @@ func userResourcePath(path string) (string, string, bool) { return identifier, strings.Trim(tail, "/"), true } -func organizationDepartmentResourcePath(path string) (string, string, bool) { - const prefix = "/v1/im/organization/departments/" - idx := strings.Index(path, prefix) - if idx < 0 { - return "", "", false - } - rest := strings.TrimPrefix(path[idx:], prefix) - departmentID, tail, ok := strings.Cut(rest, "/") - if !ok || departmentID == "" || tail == "" { - return "", "", false - } - return strings.TrimSpace(departmentID), strings.Trim(tail, "/"), true -} - -func meetingResourcePath(path string) (string, string, bool) { - const prefix = "/v1/im/meetings/" - idx := strings.Index(path, prefix) - if idx < 0 { - return "", "", false - } - rest := strings.TrimPrefix(path[idx:], prefix) - meetingID, tail, ok := strings.Cut(rest, "/") - if meetingID == "" { - return "", "", false - } - if !ok { - return strings.TrimSpace(meetingID), "", true - } - return strings.TrimSpace(meetingID), strings.Trim(tail, "/"), true -} - -func dailyReportResourcePath(path string) (string, string, bool) { - const prefix = "/v1/im/reports/daily/" - idx := strings.Index(path, prefix) - if idx < 0 { - return "", "", false - } - rest := strings.TrimPrefix(path[idx:], prefix) - reportID, tail, ok := strings.Cut(rest, "/") - if reportID == "" { - return "", "", false - } - if !ok { - return strings.TrimSpace(reportID), "", true - } - return strings.TrimSpace(reportID), strings.Trim(tail, "/"), true -} - -func monthlyReportResourcePath(path string) (string, string, bool) { - const prefix = "/v1/im/reports/monthly/" - idx := strings.Index(path, prefix) - if idx < 0 { - return "", "", false - } - rest := strings.TrimPrefix(path[idx:], prefix) - reportID, tail, ok := strings.Cut(rest, "/") - if reportID == "" { - return "", "", false - } - if !ok { - return strings.TrimSpace(reportID), "", true - } - return strings.TrimSpace(reportID), strings.Trim(tail, "/"), true -} - func groupMemberProfilePath(tail string) (types.Uid, bool) { parts := strings.Split(strings.Trim(tail, "/"), "/") if len(parts) != 3 || parts[0] != "members" || parts[2] != "profile" { diff --git a/server/im_http_test.go b/server/im_http_test.go index 50932ef..6606ec5 100644 --- a/server/im_http_test.go +++ b/server/im_http_test.go @@ -229,75 +229,6 @@ type testConversationsStore struct { putDraftCalled bool } -type testWorkbenchStore struct { - modules []types.WorkbenchModule - version int - tenantID types.TenantID - uid types.Uid - calledOnce bool -} - -type testOrganizationStore struct { - departments []types.OrganizationDepartment - members []types.OrganizationMember - searchItems []types.OrganizationSearchItem - parentID string - departmentID string - query string - resultTypes string -} - -type testMeetingsStore struct { - meeting *types.Meeting - list []types.Meeting - listCursor string - listLimit int - attendeeIDs []types.Uid - created *types.Meeting - response string - responseVersion int -} - -type testMeetingNotesStore struct { - meetings []types.Meeting - files []types.MeetingNoteFile - query string - cursor string - limit int - createdMeetingID string - createdFileID string - createdIdempotencyKey string - createdFile *types.MeetingNoteFile -} - -type testDailyReportsStore struct { - report *types.DailyReport - saved bool - submitted bool - remindKeys string -} - -type testAttendanceStore struct { - config *types.AttendanceConfig - records []types.AttendanceRecord - checkinRequest types.AttendanceCheckinRequest - appeal *types.AttendanceAppeal - idempotencyKey string -} - -type testMonthlyReportsStore struct { - report *types.MonthlyReport - saved bool - submitted bool -} - -type testMailboxStore struct { - config *types.MailboxConfig - submission *types.MailboxSubmission - idempotencyKey string - submitErr error -} - type testUserSecurityStore struct { unreadByPrefix map[string]int notifications []types.SystemNotification @@ -649,337 +580,6 @@ func defaultStringPtr(value *string, fallback string) string { return *value } -func (s *testWorkbenchStore) Get(tenantID types.TenantID, uid types.Uid) ([]types.WorkbenchModule, int, error) { - s.tenantID = tenantID - s.uid = uid - s.calledOnce = true - if s.version == 0 { - s.version = 1 - } - return s.modules, s.version, nil -} - -func (s *testOrganizationStore) ListDepartments(_ types.TenantID, _ types.Uid, parentID, _ string, _ int) ([]types.OrganizationDepartment, string, error) { - s.parentID = parentID - return s.departments, "", nil -} - -func (s *testOrganizationStore) ListMembers(_ types.TenantID, _ types.Uid, departmentID, _ string, _ int) ([]types.OrganizationMember, string, error) { - s.departmentID = departmentID - return s.members, "", nil -} - -func (s *testOrganizationStore) ListAllMembers(_ types.TenantID, _ types.Uid, departmentID, queryText, _ string, _ int) ([]types.OrganizationMember, string, error) { - s.departmentID = departmentID - s.query = queryText - return s.members, "", nil -} - -func (s *testOrganizationStore) Search(_ types.TenantID, _ types.Uid, queryText, resultTypes, _ string, _ int) ([]types.OrganizationSearchItem, string, error) { - s.query = queryText - s.resultTypes = resultTypes - return s.searchItems, "", nil -} - -func (s *testMeetingsStore) Create(_ types.TenantID, uid types.Uid, meeting *types.Meeting, attendeeIDs []types.Uid) (*types.Meeting, error) { - s.created = meeting - s.attendeeIDs = attendeeIDs - now := time.Now().UTC().Round(time.Millisecond) - meeting.OrganizerID = uid - meeting.State = types.MeetingStateScheduled - meeting.Version = 1 - meeting.CreatedAt = now - meeting.UpdatedAt = now - meeting.Participants = []types.MeetingParticipant{ - {MeetingID: meeting.MeetingID, UserID: uid, Response: types.MeetingResponseAccepted, ResponseAt: &now}, - } - for _, attendeeID := range attendeeIDs { - meeting.Participants = append(meeting.Participants, types.MeetingParticipant{MeetingID: meeting.MeetingID, UserID: attendeeID, Response: types.MeetingResponsePending}) - } - s.meeting = meeting - return meeting, nil -} - -func (s *testMeetingsStore) List(_ types.TenantID, _ types.Uid, cursor string, limit int) ([]types.Meeting, string, error) { - s.listCursor = cursor - s.listLimit = limit - return s.list, "next-1", nil -} - -func (s *testMeetingsStore) Get(_ types.TenantID, _ types.Uid, _ string) (*types.Meeting, error) { - return s.meeting, nil -} - -func (s *testMeetingsStore) Update(_ types.TenantID, _ types.Uid, _ string, _ types.MeetingPatch, _ string) (*types.Meeting, error) { - return s.meeting, nil -} - -func (s *testMeetingsStore) Cancel(_ types.TenantID, _ types.Uid, _ string, _ string) (*types.Meeting, error) { - s.meeting.State = types.MeetingStateCanceled - return s.meeting, nil -} - -func (s *testMeetingsStore) Remind(_ types.TenantID, _ types.Uid, _ string, _ string) ([]types.MeetingParticipant, error) { - return s.meeting.Participants, nil -} - -func (s *testMeetingsStore) Respond(_ types.TenantID, uid types.Uid, _ string, response string, version int) (*types.Meeting, error) { - s.response = response - s.responseVersion = version - s.meeting.Version++ - for i := range s.meeting.Participants { - if s.meeting.Participants[i].UserID == uid { - s.meeting.Participants[i].Response = response - } - } - 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) - s.createdMeetingID = meetingID - s.createdFileID = fileID - s.createdIdempotencyKey = idempotencyKey - if s.createdFile != nil && s.createdFile.MeetingID == meetingID && s.createdFile.UploaderID == uid && s.createdFile.IdempotencyKey == idempotencyKey { - file := *s.createdFile - return &file, nil - } - file := &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, - } - s.createdFile = file - s.files = append(s.files, *file) - return file, 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 - } - return []types.DailyReport{*s.report}, "", nil -} - -func (s *testDailyReportsStore) CreateDraft(_ types.TenantID, uid types.Uid, reportID string, reportDate time.Time) (*types.DailyReport, error) { - now := time.Now().UTC().Round(time.Millisecond) - s.report = &types.DailyReport{ - ReportID: reportID, - AuthorID: uid, - ReportDate: reportDate, - Status: types.DailyReportStatusDraft, - ReadState: "unread", - Version: 1, - CreatedAt: now, - UpdatedAt: now, - } - return s.report, nil -} - -func (s *testDailyReportsStore) Get(_ types.TenantID, _ types.Uid, _ string) (*types.DailyReport, error) { - return s.report, nil -} - -func (s *testDailyReportsStore) SaveDraft(_ types.TenantID, _ types.Uid, _ string, content string, attachments []string, version int) (*types.DailyReport, error) { - s.saved = true - s.report.Content = content - s.report.Attachments = attachments - s.report.Version = version + 1 - return s.report, nil -} - -func (s *testDailyReportsStore) Submit(_ types.TenantID, _ types.Uid, _ string, version int) (*types.DailyReport, error) { - s.submitted = true - now := time.Now().UTC().Round(time.Millisecond) - s.report.Status = types.DailyReportStatusSubmitted - s.report.SubmittedAt = &now - s.report.Version = version + 1 - return s.report, nil -} - -func (s *testDailyReportsStore) Stats(_ types.TenantID, _ types.Uid, _ time.Time, _ string) (*types.DailyReportStats, error) { - return &types.DailyReportStats{ExpectedCount: 3, SubmittedCount: 1, MissingCount: 2}, nil -} - -func (s *testDailyReportsStore) Remind(_ types.TenantID, _ types.Uid, _ time.Time, userIDs []types.Uid, idempotencyKey string) ([]types.Uid, error) { - s.remindKeys = idempotencyKey - return userIDs, nil -} - -func (s *testAttendanceStore) Config(_ types.TenantID, uid types.Uid, date time.Time) (*types.AttendanceConfig, error) { - if s.config != nil { - return s.config, nil - } - now := time.Now().UTC().Round(time.Millisecond) - return &types.AttendanceConfig{ - Date: date, - Timezone: "Asia/Shanghai", - Rule: types.AttendanceRule{ - RuleID: "att_rule_default", - Name: "默认考勤规则", - Workday: true, - AllowOutsideCheckin: true, - AppealAllowed: true, - CreatedAt: now, - UpdatedAt: now, - }, - Shifts: []types.AttendanceShift{{ - ShiftID: "shift_default", RuleID: "att_rule_default", Name: "默认班次", - WorkStart: "09:00", WorkEnd: "18:00", CheckinStart: "08:30", CheckoutEnd: "20:00", - CreatedAt: now, UpdatedAt: now, - }}, - AllowedActions: []string{types.AttendanceActionCheckIn, types.AttendanceActionCheckOut}, - TodayRecord: &types.AttendanceRecord{ - RecordID: "evt_record", UserID: uid, Date: date, RuleID: "att_rule_default", ShiftID: "shift_default", - Status: types.AttendanceStatusNotChecked, Version: 1, CreatedAt: now, UpdatedAt: now, - }, - CanViewManaged: true, - }, nil -} - -func (s *testAttendanceStore) ListRecords(_ types.TenantID, _ types.Uid, opts types.AttendanceRecordListOptions) ([]types.AttendanceRecord, string, *types.AttendanceRecordStats, error) { - return s.records, opts.Cursor, &types.AttendanceRecordStats{ExpectedCount: 1, CheckedCount: len(s.records)}, nil -} - -func (s *testAttendanceStore) Checkin(_ types.TenantID, uid types.Uid, checkinID string, req types.AttendanceCheckinRequest) (*types.AttendanceRecord, *types.AttendanceCheckin, error) { - s.checkinRequest = req - now := time.Now().UTC().Round(time.Millisecond) - record := &types.AttendanceRecord{ - RecordID: "evt_record", UserID: uid, Date: req.Date, RuleID: "att_rule_default", ShiftID: "shift_default", - Status: types.AttendanceStatusCheckedIn, Version: 2, CreatedAt: now, UpdatedAt: now, - } - checkin := &types.AttendanceCheckin{ - CheckinID: checkinID, RecordID: record.RecordID, UserID: uid, Date: req.Date, Action: req.Action, - RuleID: record.RuleID, ShiftID: record.ShiftID, ClientTime: req.ClientTime, ServerTime: now, - Result: types.AttendanceCheckinNormal, Location: req.Location, Wifi: req.Wifi, Device: req.Device, - IdempotencyKey: req.IdempotencyKey, CreatedAt: now, - } - record.Checkins = []types.AttendanceCheckin{*checkin} - return record, checkin, nil -} - -func (s *testAttendanceStore) CreateAppeal(_ types.TenantID, uid types.Uid, appeal *types.AttendanceAppeal, idempotencyKey string) (*types.AttendanceAppeal, error) { - s.appeal = appeal - s.idempotencyKey = idempotencyKey - now := time.Now().UTC().Round(time.Millisecond) - appeal.UserID = uid - appeal.Status = types.AttendanceAppealPending - appeal.Version = 1 - appeal.SubmittedAt = now - appeal.CreatedAt = now - appeal.UpdatedAt = now - return appeal, nil -} - -func (s *testAttendanceStore) ListAppeals(_ types.TenantID, _ types.Uid, opts types.AttendanceAppealListOptions) ([]types.AttendanceAppeal, string, error) { - if s.appeal == nil { - return nil, opts.Cursor, nil - } - return []types.AttendanceAppeal{*s.appeal}, opts.Cursor, nil -} - -func (s *testMonthlyReportsStore) List(_ types.TenantID, _ types.Uid, _, _, _, _, _ string, _ int) ([]types.MonthlyReport, string, error) { - if s.report == nil { - return nil, "", nil - } - return []types.MonthlyReport{*s.report}, "", nil -} - -func (s *testMonthlyReportsStore) CreateDraft(_ types.TenantID, uid types.Uid, reportID, month string) (*types.MonthlyReport, error) { - now := time.Now().UTC().Round(time.Millisecond) - s.report = &types.MonthlyReport{ - ReportID: reportID, - AuthorID: uid, - Month: month, - Status: types.DailyReportStatusDraft, - ReadState: "unread", - Version: 1, - CreatedAt: now, - UpdatedAt: now, - } - return s.report, nil -} - -func (s *testMonthlyReportsStore) Get(_ types.TenantID, _ types.Uid, _ string) (*types.MonthlyReport, error) { - return s.report, nil -} - -func (s *testMonthlyReportsStore) SaveDraft(_ types.TenantID, _ types.Uid, _ string, workSummary, nextPlan string, attachments []string, version int) (*types.MonthlyReport, error) { - s.saved = true - s.report.WorkSummary = workSummary - s.report.NextPlan = nextPlan - s.report.Attachments = attachments - s.report.Version = version + 1 - return s.report, nil -} - -func (s *testMonthlyReportsStore) Submit(_ types.TenantID, _ types.Uid, _ string, version int) (*types.MonthlyReport, error) { - s.submitted = true - now := time.Now().UTC().Round(time.Millisecond) - s.report.Status = types.DailyReportStatusSubmitted - s.report.SubmittedAt = &now - s.report.Version = version + 1 - return s.report, nil -} - -func (s *testMonthlyReportsStore) Reopen(_ types.TenantID, _ types.Uid, _ string, version int) (*types.MonthlyReport, error) { - s.report.Status = types.DailyReportStatusDraft - s.report.SubmittedAt = nil - s.report.Version = version + 1 - return s.report, nil -} - -func (s *testMonthlyReportsStore) Stats(_ types.TenantID, _ types.Uid, _ string, _ string) (*types.MonthlyReportStats, error) { - return &types.MonthlyReportStats{ExpectedCount: 4, SubmittedCount: 2, MissingCount: 2}, nil -} - -func (s *testMailboxStore) Config(_ types.TenantID, _ types.Uid) (*types.MailboxConfig, error) { - return s.config, nil -} - -func (s *testMailboxStore) Submit(_ types.TenantID, uid types.Uid, submission *types.MailboxSubmission, idempotencyKey string) (*types.MailboxSubmission, error) { - if s.submitErr != nil { - return nil, s.submitErr - } - s.idempotencyKey = idempotencyKey - now := time.Now().UTC().Round(time.Millisecond) - submission.SenderID = uid - submission.Status = types.MailboxSubmissionStatusReceived - submission.SubmittedAt = now - submission.CreatedAt = now - submission.UpdatedAt = now - s.submission = submission - return submission, nil -} - func (s *testUserSecurityStore) GetAuthVersion(_ types.TenantID, _ types.Uid) (int, error) { return 0, nil } @@ -2722,29 +2322,21 @@ func TestServeIMBootstrapIncludesHomeCapabilities(t *testing.T) { defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldSecurity := store.Security - oldWorkbench := store.Workbench oldRelations := store.Relations oldThirdParty := store.ThirdPartyConfig readAt := time.Now().UTC() security := &testSecurityStore{ realName: &types.RealNameVerification{Status: "verified"}, notifications: []types.SystemNotification{ - {EventID: "evt-meeting-created", EventType: "meeting.created"}, - {EventID: "evt-meeting-updated", EventType: "meeting.updated"}, + {EventID: "evt-security-created", EventType: "security.notice.created"}, + {EventID: "evt-profile-updated", EventType: "profile.updated"}, {EventID: "evt-friend-request", EventType: "friend.request.created"}, - {EventID: "evt-daily-report", EventType: "daily_report.reminded"}, - {EventID: "evt-meeting-read", EventType: "meeting.created", ReadAt: &readAt}, + {EventID: "evt-security-read", EventType: "security.notice.created", ReadAt: &readAt}, }, } relations := &testRelationsStore{pendingCount: 2} - workbench := &testWorkbenchStore{version: 9, modules: []types.WorkbenchModule{ - {Code: "organization", Name: "组织架构", Route: "/organization", UnreadCount: 3, Enabled: true, Permission: "enabled"}, - {Code: "meetings", Name: "会议通知", Route: "/meetings", UnreadCount: 0, Enabled: true, Permission: "enabled"}, - {Code: "external", Name: "外链", Route: "https://example.com", UnreadCount: 9, Enabled: true, Permission: "enabled"}, - }} store.Security = security store.Relations = relations - store.Workbench = workbench store.ThirdPartyConfig = testThirdPartyConfigStore{ "agora_enabled": "1", "agora_auth_mode": "app_id", @@ -2756,7 +2348,6 @@ func TestServeIMBootstrapIncludesHomeCapabilities(t *testing.T) { defer func() { store.Security = oldSecurity store.Relations = oldRelations - store.Workbench = oldWorkbench store.ThirdPartyConfig = oldThirdParty }() @@ -2786,27 +2377,10 @@ func TestServeIMBootstrapIncludesHomeCapabilities(t *testing.T) { if relations.countTenant != 7 || relations.countUser != 42 { t.Fatalf("bootstrap friend request count scope: tenant=%d uid=%s", relations.countTenant, relations.countUser) } - if !workbench.calledOnce || workbench.tenantID != 7 || workbench.uid != 42 { - t.Fatalf("bootstrap workbench lookup scope: store=%#v", workbench) - } - if data["workbench_version"].(float64) != 9 { - t.Fatalf("unexpected bootstrap workbench version: %#v", data) - } - modules := data["modules"].([]any) - if len(modules) != 2 || modules[0].(map[string]any)["code"] != "organization" || modules[1].(map[string]any)["code"] != "meetings" { - t.Fatalf("unexpected bootstrap modules: %#v", modules) - } - if modules[0].(map[string]any)["unread_count"].(float64) != 3 { - t.Fatalf("unexpected bootstrap module unread: %#v", modules[0]) - } - if modules[1].(map[string]any)["unread_count"].(float64) != 2 { - t.Fatalf("unexpected bootstrap meetings unread: %#v", modules[1]) - } badges := data["entry_badges"].(map[string]any) if badges["new_friends_pending_count"].(float64) != 2 || badges["contacts_total_unread"].(float64) != 2 || - badges["system_unread_count"].(float64) != 4 || - badges["workbench_total_unread"].(float64) != 5 { + badges["system_unread_count"].(float64) != 3 { t.Fatalf("unexpected bootstrap badges: %#v", badges) } entries := data["entries"].(map[string]any) @@ -2815,7 +2389,7 @@ func TestServeIMBootstrapIncludesHomeCapabilities(t *testing.T) { t.Fatalf("unexpected new friends entry: %#v", newFriends) } systemMessages := entries["system_messages"].(map[string]any) - if systemMessages["topic"] != "sys" || systemMessages["unread_count"].(float64) != 4 || systemMessages["unread_source"] != "business" { + if systemMessages["topic"] != "sys" || systemMessages["unread_count"].(float64) != 3 || systemMessages["unread_source"] != "business" { t.Fatalf("unexpected system messages entry: %#v", systemMessages) } fileAssistant := entries["file_assistant"].(map[string]any) @@ -2828,998 +2402,6 @@ func TestServeIMBootstrapIncludesHomeCapabilities(t *testing.T) { } } -func TestServeIMWorkbench(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - expectIMBearer(t, ctrl, "token-1", 7, 42) - oldWorkbench := store.Workbench - oldTenants := store.Tenants - oldSecurity := store.Security - store.Workbench = &testWorkbenchStore{version: 3, modules: []types.WorkbenchModule{ - {Code: "organization", Name: "部门框架", Route: "/organization", SortOrder: 40, Enabled: true, Permission: "enabled"}, - {Code: "meetings", Name: "会议", Route: "/meetings", SortOrder: 41, Enabled: true, Permission: "enabled"}, - {Code: "external", Name: "外部链接", Route: "https://example.com", Enabled: true, Permission: "enabled"}, - }} - store.Tenants = testTenantStore{tenant: &types.Tenant{ID: 7, Code: "acme", State: types.TenantStateActive}} - store.Security = &testUserSecurityStore{unreadByPrefix: map[string]int{"meeting.": 4}} - defer func() { - store.Workbench = oldWorkbench - store.Tenants = oldTenants - store.Security = oldSecurity - }() - - req := httptest.NewRequest(http.MethodGet, "/v1/im/workbench", nil) - req.Header.Set("Authorization", "Bearer token-1") - rec := httptest.NewRecorder() - - serveIMWorkbench(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) - } - data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) - if data["layout_version"].(float64) != 3 { - t.Fatalf("unexpected layout version: %#v", data) - } - items := data["modules"].([]any) - if len(items) != 2 { - t.Fatalf("expected external route to be filtered, got %#v", items) - } - item := items[0].(map[string]any) - if item["code"] != "organization" || item["route"] != "/organization" || item["sort_order"].(float64) != 40 { - t.Fatalf("unexpected module: %#v", item) - } - meetingItem := items[1].(map[string]any) - if meetingItem["code"] != "meetings" || meetingItem["unread_count"].(float64) != 4 { - t.Fatalf("workbench unread count not populated: %#v", meetingItem) - } - enterprise := data["enterprise"].(map[string]any) - if enterprise["tenant_code"] != "acme" { - t.Fatalf("tenant_code: got %#v, want acme", enterprise["tenant_code"]) - } -} - -func TestServeIMOrganizationDepartments(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - expectIMBearer(t, ctrl, "token-1", 7, 42) - oldOrganization := store.Organization - org := &testOrganizationStore{departments: []types.OrganizationDepartment{ - {DepartmentID: "dep-sales", Name: "销售部", ParentID: "root", ChildCount: 1, MemberCount: 12, Version: 2}, - }} - store.Organization = org - defer func() { store.Organization = oldOrganization }() - - req := httptest.NewRequest(http.MethodGet, "/v1/im/organization/departments?parent_id=root", nil) - req.Header.Set("Authorization", "Bearer token-1") - rec := httptest.NewRecorder() - serveIMOrganizationDepartments(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) - } - data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) - items := data["items"].([]any) - if len(items) != 1 || org.parentID != "root" { - t.Fatalf("unexpected departments response: %#v parent=%q", items, org.parentID) - } - item := items[0].(map[string]any) - if item["department_id"] != "dep-sales" || item["name"] != "销售部" { - t.Fatalf("unexpected department payload: %#v", item) - } -} - -func TestServeIMOrganizationDepartmentMembers(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - expectIMBearer(t, ctrl, "token-1", 7, 42) - oldOrganization := store.Organization - org := &testOrganizationStore{members: []types.OrganizationMember{ - {DepartmentID: "dep-sales", UserID: types.ParseUserId("usrAAAAAAAAAAA"), DisplayName: "张三", Position: "销售经理", Status: "active", Contactable: true, Version: 1}, - }} - store.Organization = org - defer func() { store.Organization = oldOrganization }() - - req := httptest.NewRequest(http.MethodGet, "/v1/im/organization/departments/dep-sales/members", nil) - req.Header.Set("Authorization", "Bearer token-1") - rec := httptest.NewRecorder() - serveIMOrganizationDepartmentResource(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) - } - data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) - items := data["items"].([]any) - if len(items) != 1 || org.departmentID != "dep-sales" || data["department_id"] != "dep-sales" { - t.Fatalf("unexpected members response: %#v department=%q", data, org.departmentID) - } - item := items[0].(map[string]any) - if item["display_name"] != "张三" || item["position"] != "销售经理" || item["contactable"] != true { - t.Fatalf("unexpected member payload: %#v", item) - } -} - -func TestServeIMOrganizationMembers(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - expectIMBearer(t, ctrl, "token-1", 7, 42) - oldOrganization := store.Organization - org := &testOrganizationStore{members: []types.OrganizationMember{ - {DepartmentID: "dep-sales", UserID: types.ParseUserId("usrAAAAAAAAAAA"), DisplayName: "张三", Position: "销售经理", Status: "active", Contactable: true, Version: 1}, - }} - store.Organization = org - defer func() { store.Organization = oldOrganization }() - - req := httptest.NewRequest(http.MethodGet, "/v1/im/organization/members?department_id=dep-sales&q=%E5%BC%A0", nil) - req.Header.Set("Authorization", "Bearer token-1") - rec := httptest.NewRecorder() - serveIMOrganizationMembers(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) - } - data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) - items := data["items"].([]any) - if len(items) != 1 || org.departmentID != "dep-sales" || org.query != "张" || data["has_more"] != false { - t.Fatalf("unexpected organization members response: %#v department=%q query=%q", data, org.departmentID, org.query) - } -} - -func TestServeIMOrganizationSearch(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - expectIMBearer(t, ctrl, "token-1", 7, 42) - oldOrganization := store.Organization - member := types.OrganizationMember{DepartmentID: "dep-sales", UserID: types.ParseUserId("usrAAAAAAAAAAA"), DisplayName: "张三", Status: "active", Contactable: true, Version: 1} - org := &testOrganizationStore{searchItems: []types.OrganizationSearchItem{ - {Type: "department", Department: &types.OrganizationDepartment{DepartmentID: "dep-sales", Name: "销售部", ParentID: "root", Version: 1}}, - {Type: "user", Member: &member}, - }} - store.Organization = org - defer func() { store.Organization = oldOrganization }() - - req := httptest.NewRequest(http.MethodGet, "/v1/im/organization/search?q=%E9%94%80%E5%94%AE&types=department,user", nil) - req.Header.Set("Authorization", "Bearer token-1") - rec := httptest.NewRecorder() - serveIMOrganizationSearch(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) - } - data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) - items := data["items"].([]any) - if len(items) != 2 || org.query != "销售" || org.resultTypes != "department,user" { - t.Fatalf("unexpected search response: %#v query=%q types=%q", items, org.query, org.resultTypes) - } - if items[0].(map[string]any)["type"] != "department" || items[1].(map[string]any)["type"] != "user" { - t.Fatalf("unexpected search payload: %#v", items) - } -} - -func TestServeIMAttendanceConfigAndRecords(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - expectIMBearer(t, ctrl, "token-1", 7, 42) - oldAttendance := store.Attendance - now := time.Now().UTC().Round(time.Millisecond) - attendance := &testAttendanceStore{records: []types.AttendanceRecord{{ - RecordID: "evt_record", UserID: 42, Date: time.Date(2026, 8, 31, 0, 0, 0, 0, time.UTC), - RuleID: "att_rule_default", ShiftID: "shift_default", Status: types.AttendanceStatusCheckedIn, - Version: 2, CreatedAt: now, UpdatedAt: now, - }}} - store.Attendance = attendance - defer func() { store.Attendance = oldAttendance }() - - req := httptest.NewRequest(http.MethodGet, "/v1/im/attendance/config?date=2026-08-31", nil) - req.Header.Set("Authorization", "Bearer token-1") - rec := httptest.NewRecorder() - serveIMAttendanceConfig(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("config status=%d body=%s", rec.Code, rec.Body.String()) - } - data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) - if data["date"] != "2026-08-31" || data["timezone"] != "Asia/Shanghai" { - t.Fatalf("unexpected config: %#v", data) - } - - req = httptest.NewRequest(http.MethodGet, "/v1/im/attendance/records?scope=mine&from=2026-08-01&to=2026-08-31", nil) - req.Header.Set("Authorization", "Bearer token-1") - rec = httptest.NewRecorder() - serveIMAttendanceRecords(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("records status=%d body=%s", rec.Code, rec.Body.String()) - } - data = decodeIMHTTPResponse(t, rec).Data.(map[string]any) - items := data["items"].([]any) - if len(items) != 1 || items[0].(map[string]any)["record_id"] != "evt_record" { - t.Fatalf("unexpected records: %#v", data) - } -} - -func TestServeIMAttendanceCheckinAndAppeal(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - expectIMBearer(t, ctrl, "token-1", 7, 42) - oldAttendance := store.Attendance - attendance := &testAttendanceStore{} - store.Attendance = attendance - defer func() { store.Attendance = oldAttendance }() - - req := httptest.NewRequest(http.MethodPost, "/v1/im/attendance/checkins", strings.NewReader(`{"date":"2026-08-31","action":"check_in","client_time":"2026-08-31T01:00:00Z","location":{"name":"总部"}}`)) - req.Header.Set("Authorization", "Bearer token-1") - req.Header.Set("Idempotency-Key", "att-1") - rec := httptest.NewRecorder() - serveIMAttendanceCheckins(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("checkin status=%d body=%s", rec.Code, rec.Body.String()) - } - data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) - if attendance.checkinRequest.Action != types.AttendanceActionCheckIn || data["record"] == nil || data["checkin"] == nil { - t.Fatalf("unexpected checkin: data=%#v req=%#v", data, attendance.checkinRequest) - } - - req = httptest.NewRequest(http.MethodPost, "/v1/im/attendance/appeals", strings.NewReader(`{"record_id":"evt_record","appeal_type":"late","reason":"地铁延误","target_action":"check_in","target_time":"2026-08-31T00:58:00Z"}`)) - req.Header.Set("Authorization", "Bearer token-1") - req.Header.Set("Idempotency-Key", "appeal-1") - rec = httptest.NewRecorder() - serveIMAttendanceAppeals(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("appeal status=%d body=%s", rec.Code, rec.Body.String()) - } - data = decodeIMHTTPResponse(t, rec).Data.(map[string]any) - if attendance.idempotencyKey != "appeal-1" || data["status"] != types.AttendanceAppealPending { - t.Fatalf("unexpected appeal: data=%#v key=%q", data, attendance.idempotencyKey) - } -} - -func TestServeIMMeetingsCreate(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - expectIMBearer(t, ctrl, "token-1", 7, 42) - oldKafkaBus := kafkaBus - oldPublisher := publishKafkaAuditEventEnvelope - oldSecurity := store.Security - oldMeetings := store.Meetings - meetings := &testMeetingsStore{} - security := &testSecurityStore{} - var auditPublished bool - kafkaBus = &kafkaRuntime{} - publishKafkaAuditEventEnvelope = func(_ context.Context, topicName, key string, envelope kafkaEnvelope) error { - auditPublished = topicName == "audit_event" && key == envelope.BizID && - envelope.EventType == "audit.meeting.created" && envelope.BizType == "audit" - return nil - } - store.Security = security - store.Meetings = meetings - defer func() { - kafkaBus = oldKafkaBus - publishKafkaAuditEventEnvelope = oldPublisher - store.Security = oldSecurity - store.Meetings = oldMeetings - }() - - body := `{"title":"周会","start_at":"2026-08-14T09:00:00Z","end_at":"2026-08-14T10:00:00Z","location":"A101","attendee_user_ids":["` + types.Uid(43).UserId() + `"],"reminder_minutes":[10]}` - req := httptest.NewRequest(http.MethodPost, "/v1/im/meetings", strings.NewReader(body)) - req.Header.Set("Authorization", "Bearer token-1") - req.Header.Set("Idempotency-Key", "mtg-1") - rec := httptest.NewRecorder() - serveIMMeetings(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) - } - data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) - if meetings.created == nil || meetings.created.IdempotencyKey != "mtg-1" || meetings.created.Title != "周会" { - t.Fatalf("unexpected created meeting: %#v", meetings.created) - } - if len(meetings.attendeeIDs) != 1 || data["state"] != types.MeetingStateScheduled || data["my_response"] != types.MeetingResponseAccepted { - t.Fatalf("unexpected meeting response: %#v attendees=%#v", data, meetings.attendeeIDs) - } - if !auditPublished { - t.Fatalf("meeting create should still publish audit event") - } - if len(security.notifications) != 0 { - t.Fatalf("meeting create HTTP layer must not duplicate workbench notifications: notifications=%#v", security.notifications) - } -} - -func TestServeIMMeetingsList(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - viewer := types.Uid(42) - expectIMBearer(t, ctrl, "token-1", 7, viewer) - oldMeetings := store.Meetings - oldUsers := store.Users - now := time.Now().UTC().Round(time.Millisecond) - meetings := &testMeetingsStore{list: []types.Meeting{{ - MeetingID: "mtg_test", - OrganizerID: viewer, - Title: "周会", - StartAt: now.Add(time.Hour), - EndAt: now.Add(2 * time.Hour), - Location: "A101", - State: types.MeetingStateScheduled, - Version: 1, - CreatedAt: now, - UpdatedAt: now, - Participants: []types.MeetingParticipant{ - {MeetingID: "mtg_test", UserID: viewer, Response: types.MeetingResponseAccepted}, - }, - }}} - store.Meetings = meetings - users := mock_store.NewMockUsersPersistenceInterface(ctrl) - organizer := types.User{Public: map[string]any{"fn": "mzb"}} - organizer.SetUid(viewer) - users.EXPECT().GetAll(types.TenantID(7), viewer).Return([]types.User{organizer}, nil) - store.Users = users - defer func() { - store.Meetings = oldMeetings - store.Users = oldUsers - }() - - req := httptest.NewRequest(http.MethodGet, "/v1/im/meetings?limit=20&cursor=c1", nil) - req.Header.Set("Authorization", "Bearer token-1") - rec := httptest.NewRecorder() - serveIMMeetings(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) - } - data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) - items := data["items"].([]any) - if len(items) != 1 || meetings.listCursor != "c1" || meetings.listLimit != 20 { - t.Fatalf("unexpected list response: data=%#v cursor=%q limit=%d", data, meetings.listCursor, meetings.listLimit) - } - if items[0].(map[string]any)["meeting_id"] != "mtg_test" || data["has_more"] != true { - t.Fatalf("unexpected meeting payload: %#v", data) - } - item := items[0].(map[string]any) - if item["organizer_display_name"] != "mzb" || item["participant_count"].(float64) != 1 { - t.Fatalf("missing meeting display fields: %#v", item) - } - participants := item["participants"].([]any) - if participants[0].(map[string]any)["display_name"] != "mzb" { - t.Fatalf("missing participant display name: %#v", participants) - } -} - -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() - attendee := types.Uid(43) - expectIMBearer(t, ctrl, "token-1", 7, attendee) - oldMeetings := store.Meetings - now := time.Now().UTC().Round(time.Millisecond) - meetings := &testMeetingsStore{meeting: &types.Meeting{ - MeetingID: "mtg_test", - OrganizerID: 42, - Title: "周会", - StartAt: now.Add(time.Hour), - EndAt: now.Add(2 * time.Hour), - State: types.MeetingStateScheduled, - Version: 3, - CreatedAt: now, - UpdatedAt: now, - Participants: []types.MeetingParticipant{ - {MeetingID: "mtg_test", UserID: 42, Response: types.MeetingResponseAccepted}, - {MeetingID: "mtg_test", UserID: attendee, Response: types.MeetingResponsePending}, - }, - }} - store.Meetings = meetings - defer func() { store.Meetings = oldMeetings }() - - req := httptest.NewRequest(http.MethodPut, "/v1/im/meetings/mtg_test/my-response", strings.NewReader(`{"response":"accepted","version":3}`)) - req.Header.Set("Authorization", "Bearer token-1") - rec := httptest.NewRecorder() - serveIMMeetingResource(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) - } - data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) - if meetings.response != types.MeetingResponseAccepted || meetings.responseVersion != 3 || data["my_response"] != types.MeetingResponseAccepted { - t.Fatalf("unexpected response: data=%#v store=%#v", data, meetings) - } -} - -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) - meetingData := data["meeting"].(map[string]any) - if data["recorder_user_id"] != viewer.UserId() || meetingData["recorder_user_id"] != viewer.UserId() || meetingData["start_at"] == "" || - 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") - mockStore.EXPECT().GetUidString().Return("note-2") - 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: 1, - CreatedAt: now.Add(-3 * time.Hour), - UpdatedAt: now, - Participants: []types.MeetingParticipant{ - {MeetingID: "mtg_test", UserID: viewer, Response: types.MeetingResponseAccepted}, - }, - } - oldMeetings := store.Meetings - oldMeetingNotes := store.MeetingNotes - notes := &testMeetingNotesStore{} - store.Meetings = &testMeetingsStore{meeting: &meeting} - store.MeetingNotes = notes - defer func() { - store.Meetings = oldMeetings - 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) - } - - 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("duplicate status=%d body=%s", rec.Code, rec.Body.String()) - } - duplicate := decodeIMHTTPResponse(t, rec).Data.(map[string]any) - if duplicate["note_file_id"] != "mnote_note-1" || len(notes.files) != 1 { - t.Fatalf("unexpected idempotent note file payload: data=%#v files=%#v", duplicate, notes.files) - } - - 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()) - } - detail := decodeIMHTTPResponse(t, rec).Data.(map[string]any) - files := detail["files"].([]any) - meetingData := detail["meeting"].(map[string]any) - if len(files) != 1 || files[0].(map[string]any)["note_file_id"] != "mnote_note-1" || - meetingData["meeting_id"] != "mtg_test" || meetingData["recorder_user_id"] != viewer.UserId() { - t.Fatalf("unexpected detail after create: %#v", detail) - } -} - -func TestServeIMDailyReportsCreateAndList(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - expectIMBearer(t, ctrl, "token-1", 7, 42) - oldDailyReports := store.DailyReports - reports := &testDailyReportsStore{} - store.DailyReports = reports - defer func() { store.DailyReports = oldDailyReports }() - - req := httptest.NewRequest(http.MethodPost, "/v1/im/reports/daily", strings.NewReader(`{"report_date":"2026-08-14"}`)) - req.Header.Set("Authorization", "Bearer token-1") - rec := httptest.NewRecorder() - serveIMDailyReports(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) - } - data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) - if reports.report == nil || data["report_date"] != "2026-08-14" || data["status"] != types.DailyReportStatusDraft { - t.Fatalf("unexpected daily report response: %#v report=%#v", data, reports.report) - } -} - -func TestServeIMDailyReportsListIncludesContentPreview(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - expectIMBearer(t, ctrl, "token-1", 7, 42) - oldDailyReports := store.DailyReports - now := time.Now().UTC().Round(time.Millisecond) - reports := &testDailyReportsStore{report: &types.DailyReport{ - ReportID: "dr_test", - AuthorID: 42, - ReportDate: time.Date(2026, 8, 14, 0, 0, 0, 0, time.UTC), - Content: strings.Repeat("日报", 80), - Status: types.DailyReportStatusDraft, - ReadState: "unread", - Version: 1, - CreatedAt: now, - UpdatedAt: now, - }} - store.DailyReports = reports - defer func() { store.DailyReports = oldDailyReports }() - - req := httptest.NewRequest(http.MethodGet, "/v1/im/reports/daily?scope=mine", nil) - req.Header.Set("Authorization", "Bearer token-1") - rec := httptest.NewRecorder() - serveIMDailyReports(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) - } - data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) - items := data["items"].([]any) - item := items[0].(map[string]any) - if item["content_short"] != strings.Repeat("日报", 60)+"..." { - t.Fatalf("unexpected daily report preview: %#v", item["content_short"]) - } -} - -func TestServeIMDailyReportDraftAndSubmit(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - expectIMBearer(t, ctrl, "token-1", 7, 42) - oldDailyReports := store.DailyReports - now := time.Now().UTC().Round(time.Millisecond) - reports := &testDailyReportsStore{report: &types.DailyReport{ - ReportID: "dr_test", - AuthorID: 42, - ReportDate: time.Date(2026, 8, 14, 0, 0, 0, 0, time.UTC), - Status: types.DailyReportStatusDraft, - ReadState: "unread", - Version: 1, - CreatedAt: now, - UpdatedAt: now, - }} - store.DailyReports = reports - defer func() { store.DailyReports = oldDailyReports }() - - req := httptest.NewRequest(http.MethodPut, "/v1/im/reports/daily/dr_test/draft", strings.NewReader(`{"content":"今日完成接口","attachments":["file1"],"version":1}`)) - req.Header.Set("Authorization", "Bearer token-1") - rec := httptest.NewRecorder() - serveIMDailyReportResource(rec, req) - if rec.Code != http.StatusOK || !reports.saved { - t.Fatalf("draft status=%d saved=%v body=%s", rec.Code, reports.saved, rec.Body.String()) - } - - req = httptest.NewRequest(http.MethodPost, "/v1/im/reports/daily/dr_test/submit", strings.NewReader(`{"version":2}`)) - req.Header.Set("Authorization", "Bearer token-1") - rec = httptest.NewRecorder() - serveIMDailyReportResource(rec, req) - if rec.Code != http.StatusOK || !reports.submitted { - t.Fatalf("submit status=%d submitted=%v body=%s", rec.Code, reports.submitted, rec.Body.String()) - } - data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) - if data["status"] != types.DailyReportStatusSubmitted || data["submitted_at"] == nil { - t.Fatalf("unexpected submit response: %#v", data) - } -} - -func TestServeIMDailyReportStatisticsAndReminders(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - expectIMBearer(t, ctrl, "token-1", 7, 42) - oldDailyReports := store.DailyReports - reports := &testDailyReportsStore{} - store.DailyReports = reports - defer func() { store.DailyReports = oldDailyReports }() - - req := httptest.NewRequest(http.MethodGet, "/v1/im/reports/daily/statistics?date=2026-08-14", nil) - req.Header.Set("Authorization", "Bearer token-1") - rec := httptest.NewRecorder() - serveIMDailyReportStatistics(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("stats status=%d body=%s", rec.Code, rec.Body.String()) - } - data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) - if data["expected_count"].(float64) != 3 || data["missing_count"].(float64) != 2 { - t.Fatalf("unexpected stats: %#v", data) - } - - req = httptest.NewRequest(http.MethodPost, "/v1/im/reports/daily/reminders", strings.NewReader(`{"date":"2026-08-14","user_ids":["`+types.Uid(43).UserId()+`"]}`)) - req.Header.Set("Authorization", "Bearer token-1") - req.Header.Set("Idempotency-Key", "daily-remind-1") - rec = httptest.NewRecorder() - serveIMDailyReportReminders(rec, req) - if rec.Code != http.StatusOK || reports.remindKeys != "daily-remind-1" { - t.Fatalf("remind status=%d key=%q body=%s", rec.Code, reports.remindKeys, rec.Body.String()) - } -} - -func TestServeIMMonthlyReportsCreate(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - expectIMBearer(t, ctrl, "token-1", 7, 42) - oldMonthlyReports := store.MonthlyReports - reports := &testMonthlyReportsStore{} - store.MonthlyReports = reports - defer func() { store.MonthlyReports = oldMonthlyReports }() - - req := httptest.NewRequest(http.MethodPost, "/v1/im/reports/monthly", strings.NewReader(`{"month":"2026-08"}`)) - req.Header.Set("Authorization", "Bearer token-1") - rec := httptest.NewRecorder() - serveIMMonthlyReports(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) - } - data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) - if reports.report == nil || data["month"] != "2026-08" || data["action"] != "edit" { - t.Fatalf("unexpected monthly report response: %#v report=%#v", data, reports.report) - } -} - -func TestServeIMMonthlyReportsListIncludesPreviews(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - expectIMBearer(t, ctrl, "token-1", 7, 42) - oldMonthlyReports := store.MonthlyReports - now := time.Now().UTC().Round(time.Millisecond) - reports := &testMonthlyReportsStore{report: &types.MonthlyReport{ - ReportID: "mr_test", - AuthorID: 42, - Month: "2026-08", - WorkSummary: strings.Repeat("工作", 80), - NextPlan: " 下月计划一 \n 下月计划二 ", - Status: types.DailyReportStatusDraft, - ReadState: "unread", - Version: 1, - CreatedAt: now, - UpdatedAt: now, - }} - store.MonthlyReports = reports - defer func() { store.MonthlyReports = oldMonthlyReports }() - - req := httptest.NewRequest(http.MethodGet, "/v1/im/reports/monthly?scope=mine", nil) - req.Header.Set("Authorization", "Bearer token-1") - rec := httptest.NewRecorder() - serveIMMonthlyReports(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) - } - data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) - items := data["items"].([]any) - item := items[0].(map[string]any) - if item["work_summary_short"] != strings.Repeat("工作", 60)+"..." { - t.Fatalf("unexpected work summary preview: %#v", item["work_summary_short"]) - } - if item["next_plan_short"] != "下月计划一 下月计划二" { - t.Fatalf("unexpected next plan preview: %#v", item["next_plan_short"]) - } -} - -func TestServeIMMonthlyReportDraftAndSubmit(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - expectIMBearer(t, ctrl, "token-1", 7, 42) - oldMonthlyReports := store.MonthlyReports - now := time.Now().UTC().Round(time.Millisecond) - reports := &testMonthlyReportsStore{report: &types.MonthlyReport{ - ReportID: "mr_test", - AuthorID: 42, - Month: "2026-08", - Status: types.DailyReportStatusDraft, - ReadState: "unread", - Version: 1, - CreatedAt: now, - UpdatedAt: now, - }} - store.MonthlyReports = reports - defer func() { store.MonthlyReports = oldMonthlyReports }() - - req := httptest.NewRequest(http.MethodPut, "/v1/im/reports/monthly/mr_test/draft", strings.NewReader(`{"work_summary":"本月总结","next_plan":"下月计划","attachments":["file1"],"version":1}`)) - req.Header.Set("Authorization", "Bearer token-1") - rec := httptest.NewRecorder() - serveIMMonthlyReportResource(rec, req) - if rec.Code != http.StatusOK || !reports.saved { - t.Fatalf("draft status=%d saved=%v body=%s", rec.Code, reports.saved, rec.Body.String()) - } - - req = httptest.NewRequest(http.MethodPost, "/v1/im/reports/monthly/mr_test/submit", strings.NewReader(`{"version":2}`)) - req.Header.Set("Authorization", "Bearer token-1") - rec = httptest.NewRecorder() - serveIMMonthlyReportResource(rec, req) - if rec.Code != http.StatusOK || !reports.submitted { - t.Fatalf("submit status=%d submitted=%v body=%s", rec.Code, reports.submitted, rec.Body.String()) - } - data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) - if data["status"] != types.DailyReportStatusSubmitted || data["action"] != "view" { - t.Fatalf("unexpected submit response: %#v", data) - } -} - -func TestServeIMMonthlyReportReopen(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - expectIMBearer(t, ctrl, "token-1", 7, 42) - oldMonthlyReports := store.MonthlyReports - now := time.Now().UTC().Round(time.Millisecond) - submittedAt := now.Add(-time.Hour) - reports := &testMonthlyReportsStore{report: &types.MonthlyReport{ - ReportID: "mr_test", - AuthorID: 42, - Month: "2026-08", - Status: types.DailyReportStatusSubmitted, - ReadState: "unread", - Version: 3, - SubmittedAt: &submittedAt, - CreatedAt: now, - UpdatedAt: now, - }} - store.MonthlyReports = reports - defer func() { store.MonthlyReports = oldMonthlyReports }() - - req := httptest.NewRequest(http.MethodPost, "/v1/im/reports/monthly/mr_test/reopen", strings.NewReader(`{"version":3}`)) - req.Header.Set("Authorization", "Bearer token-1") - rec := httptest.NewRecorder() - serveIMMonthlyReportResource(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) - } - data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) - if data["status"] != types.DailyReportStatusDraft || data["action"] != "edit" || reports.report.SubmittedAt != nil { - t.Fatalf("unexpected reopen response: %#v report=%#v", data, reports.report) - } -} - -func TestServeIMMonthlyReportStatistics(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - expectIMBearer(t, ctrl, "token-1", 7, 42) - oldMonthlyReports := store.MonthlyReports - reports := &testMonthlyReportsStore{} - store.MonthlyReports = reports - defer func() { store.MonthlyReports = oldMonthlyReports }() - - req := httptest.NewRequest(http.MethodGet, "/v1/im/reports/monthly/statistics?month=2026-08", nil) - req.Header.Set("Authorization", "Bearer token-1") - rec := httptest.NewRecorder() - serveIMMonthlyReportStatistics(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("stats status=%d body=%s", rec.Code, rec.Body.String()) - } - data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) - if data["expected_count"].(float64) != 4 || data["submitted_count"].(float64) != 2 { - t.Fatalf("unexpected stats: %#v", data) - } -} - -func TestServeIMMailboxConfig(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - expectIMBearer(t, ctrl, "token-1", 7, 42) - oldMailbox := store.Mailbox - now := time.Now().UTC().Round(time.Millisecond) - mailbox := &testMailboxStore{config: &types.MailboxConfig{ - Enabled: true, - AllowAnonymous: true, - RequiredFields: []string{"subject", "content"}, - MaxLength: 5000, - AttachmentPolicy: map[string]any{"max_files": float64(5)}, - Notice: types.MailboxNotice{Title: "总经理信箱", Content: "请确认文案", UpdatedAt: now}, - UpdatedAt: now, - }} - store.Mailbox = mailbox - defer func() { store.Mailbox = oldMailbox }() - - req := httptest.NewRequest(http.MethodGet, "/v1/im/executive-mailbox/config", nil) - req.Header.Set("Authorization", "Bearer token-1") - rec := httptest.NewRecorder() - serveIMMailboxConfig(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["enabled"] != true || data["allow_anonymous"] != true { - t.Fatalf("unexpected mailbox config: %#v", data) - } - notice := data["notice"].(map[string]any) - if notice["version"] == "" || notice["title"] != "总经理信箱" { - t.Fatalf("unexpected notice: %#v", notice) - } -} - -func TestServeIMMailboxSubmission(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - expectIMBearer(t, ctrl, "token-1", 7, 42) - oldMailbox := store.Mailbox - mailbox := &testMailboxStore{} - store.Mailbox = mailbox - defer func() { store.Mailbox = oldMailbox }() - - body := `{"subject":"建议","content":"希望优化流程","anonymous":true,"attachment_file_ids":["file1"],"notice_version":"v1"}` - req := httptest.NewRequest(http.MethodPost, "/v1/im/executive-mailbox/submissions", strings.NewReader(body)) - req.Header.Set("Authorization", "Bearer token-1") - req.Header.Set("Idempotency-Key", "mailbox-1") - rec := httptest.NewRecorder() - serveIMMailboxSubmissions(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 mailbox.submission == nil || mailbox.idempotencyKey != "mailbox-1" || data["status"] != types.MailboxSubmissionStatusReceived { - t.Fatalf("unexpected submission: data=%#v store=%#v", data, mailbox) - } -} - -func TestServeIMMailboxSubmissionRejectsTooLongContent(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - expectIMBearer(t, ctrl, "token-1", 7, 42) - oldMailbox := store.Mailbox - store.Mailbox = &testMailboxStore{} - defer func() { store.Mailbox = oldMailbox }() - - body := `{"subject":"建议","content":"` + strings.Repeat("a", 5001) + `","notice_version":"v1"}` - req := httptest.NewRequest(http.MethodPost, "/v1/im/executive-mailbox/submissions", strings.NewReader(body)) - req.Header.Set("Authorization", "Bearer token-1") - req.Header.Set("Idempotency-Key", "mailbox-long") - rec := httptest.NewRecorder() - serveIMMailboxSubmissions(rec, req) - - if rec.Code != http.StatusBadRequest { - t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) - } - resp := decodeIMHTTPResponse(t, rec) - if resp.Reason != imReasonValidationFailed { - t.Fatalf("reason=%q body=%s", resp.Reason, rec.Body.String()) - } -} - -func TestServeIMMailboxSubmissionMapsPolicyToValidationFailed(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - expectIMBearer(t, ctrl, "token-1", 7, 42) - oldMailbox := store.Mailbox - store.Mailbox = &testMailboxStore{submitErr: types.ErrPolicy} - defer func() { store.Mailbox = oldMailbox }() - - req := httptest.NewRequest(http.MethodPost, "/v1/im/executive-mailbox/submissions", strings.NewReader(`{"subject":"建议","content":"希望优化流程"}`)) - req.Header.Set("Authorization", "Bearer token-1") - req.Header.Set("Idempotency-Key", "mailbox-policy") - rec := httptest.NewRecorder() - serveIMMailboxSubmissions(rec, req) - - if rec.Code != http.StatusBadRequest { - t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) - } - resp := decodeIMHTTPResponse(t, rec) - if resp.Reason != imReasonValidationFailed { - t.Fatalf("reason=%q body=%s", resp.Reason, rec.Body.String()) - } -} - func TestServeIMMeIncludesUsername(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() diff --git a/server/internal_admin_query_resources.go b/server/internal_admin_query_resources.go index e4e9fdb..3dc3002 100644 --- a/server/internal_admin_query_resources.go +++ b/server/internal_admin_query_resources.go @@ -96,12 +96,7 @@ var internalTenantResourceSpecs = map[string]*internalQuerySpec{ "feedback-config": { Select: "SELECT id,categories,max_length,attachment_policy,allow_contact,diagnostics_notice,created_at,updated_at", From: "FROM im_feedback_config", DefaultSort: "ORDER BY id DESC", DetailKey: "id", - }, - "mailbox-config": { - Select: "SELECT id,enabled,allow_anonymous,required_fields,max_length,attachment_policy,notice_title,notice_content,notice_updated_at,created_at,updated_at", - From: "FROM im_mailbox_config", DefaultSort: "ORDER BY id DESC", DetailKey: "id", - }, - "abuse-reasons": { + }, "abuse-reasons": { Select: "SELECT id,target_type,reason_code,label,requires_description,allows_evidence,enabled,sort_order,created_at,updated_at", From: "FROM im_abuse_reasons", DefaultSort: "ORDER BY sort_order ASC,id ASC", DetailKey: "id", Filters: []internalQueryFilter{{"target_type", "target_type", "=", "string"}}, @@ -109,28 +104,10 @@ var internalTenantResourceSpecs = map[string]*internalQuerySpec{ "app-releases": { Select: "SELECT id,platform,channel,latest_version,min_supported_version,update_type,release_notes,store_url,package_url,checksum,remind_after_seconds,published_at,created_at,updated_at", From: "FROM im_app_releases", DefaultSort: "ORDER BY id DESC", DetailKey: "id", - }, - "workbench/apps": { - Select: "SELECT id,code AS app_code,name,icon_file_id,route,sort_order,unread_key,enabled,permission,created_at,updated_at", - From: "FROM im_workbench_apps", DefaultSort: "ORDER BY sort_order ASC,id ASC", DetailKey: "id", - }, -} + }} var internalOutboxResourceSpecs = map[string]*internalQuerySpec{} -var internalOrgResourceSpecs = map[string]*internalQuerySpec{ - "departments": { - Select: "SELECT d.id,d.department_id,d.parent_id,d.name,d.sort_order,d.version,d.state,d.created_at,d.updated_at,(SELECT COUNT(*) FROM im_org_departments child WHERE child.parent_id=d.department_id AND child.state='active') AS child_count,(SELECT COUNT(*) FROM im_org_members m WHERE m.department_id=d.department_id) AS member_count", - From: "FROM im_org_departments d", DefaultSort: "ORDER BY d.sort_order ASC,d.id ASC", - Filters: []internalQueryFilter{{"department_id", "d.department_id", "=", "string"}, {"parent_id", "d.parent_id", "=", "string"}, {"name", "d.name", "LIKE", "string"}, {"state", "d.state", "=", "string"}}, - }, - "members": { - Select: "SELECT m.id,m.department_id,COALESCE(d.name,'') AS department_name,m.user_id,m.display_name,m.position,m.avatar_file_id,m.mobile,m.email,m.employment_state,m.contactable,m.sort_order,m.version,m.created_at,m.updated_at", - From: "FROM im_org_members m LEFT JOIN im_org_departments d ON d.department_id=m.department_id", DefaultSort: "ORDER BY m.sort_order ASC,m.id ASC", - Filters: []internalQueryFilter{{"department_id", "m.department_id", "=", "string"}, {"user_id", "m.user_id", "=", "uid"}, {"q", "m.display_name", "LIKE", "string"}, {"employment_state", "m.employment_state", "=", "string"}}, - }, -} - func serveInternalAdminResourceList(wrt http.ResponseWriter, req *http.Request, tenantID types.TenantID, resource, scope string) { spec, ok := internalAdminQuerySpec(resource) if !ok { @@ -255,10 +232,6 @@ func internalAdminQuerySpec(resource string) (*internalQuerySpec, bool) { spec := internalOutboxResourceSpecs[strings.TrimPrefix(resource, "outbox_")] return spec, spec != nil } - if strings.HasPrefix(resource, "org_") { - spec := internalOrgResourceSpecs[strings.TrimPrefix(resource, "org_")] - return spec, spec != nil - } spec := internalTenantResourceSpecs[resource] return spec, spec != nil } @@ -275,7 +248,6 @@ func usersInternalQuerySpec() *internalQuerySpec { CASE WHEN JSON_TYPE(JSON_EXTRACT(u.public,'$.photo'))='OBJECT' AND NULLIF(JSON_UNQUOTE(JSON_EXTRACT(u.public,'$.photo.data')),'') IS NOT NULL THEN CONCAT('data:image/',COALESCE(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(u.public,'$.photo.type')),''),'jpeg'),';base64,',JSON_UNQUOTE(JSON_EXTRACT(u.public,'$.photo.data'))) ELSE NULL END, NULLIF(CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(u.public,'$.avatar_file_id')) REGEXP '^(https?://|data:)' THEN JSON_UNQUOTE(JSON_EXTRACT(u.public,'$.avatar_file_id')) ELSE '' END,''), - NULLIF((SELECT CASE WHEN om.avatar_file_id REGEXP '^(https?://|data:)' THEN om.avatar_file_id ELSE '' END FROM im_org_members AS om WHERE om.user_id=u.id AND om.avatar_file_id<>'' ORDER BY om.id LIMIT 1),''), '' ) AS avatar, p.signature,sec.auth_version,sec.password_updated_at,u.createdat AS created_at,u.updatedat AS updated_at, diff --git a/server/internal_api.go b/server/internal_api.go index 749a72f..a4acf1e 100644 --- a/server/internal_api.go +++ b/server/internal_api.go @@ -150,10 +150,6 @@ func serveInternalAdminQuery(wrt http.ResponseWriter, req *http.Request, prefix serveInternalAdminResourceList(wrt, req, tenant.ID, "outbox_"+parts[3], "") case len(parts) == 5 && parts[2] == "outbox" && internalOutboxResourceSpecs[parts[3]] != nil: serveInternalAdminResourceDetail(wrt, req, tenant.ID, "outbox_"+parts[3], parts[4]) - case len(parts) == 4 && parts[2] == "org" && internalOrgResourceSpecs[parts[3]] != nil: - serveInternalAdminResourceList(wrt, req, tenant.ID, "org_"+parts[3], "") - case len(parts) == 4 && parts[2] == "workbench" && parts[3] == "apps": - serveInternalAdminResourceList(wrt, req, tenant.ID, "workbench/apps", "") case len(parts) == 4 && parts[2] == "dashboard" && parts[3] == "summary": serveInternalDashboardSummary(wrt, req, tenant.ID) case len(parts) == 4 && parts[2] == "kafka" && parts[3] == "dead-letters": diff --git a/server/kafka_audit.go b/server/kafka_audit.go new file mode 100644 index 0000000..dfcb4f8 --- /dev/null +++ b/server/kafka_audit.go @@ -0,0 +1,64 @@ +package main + +import ( + "context" + "encoding/json" + "time" + + "github.com/tinode/chat/server/store/types" +) + +type kafkaAuditPayload struct { + ActorType string `json:"actor_type"` + ActorID string `json:"actor_id"` + Action string `json:"action"` + Resource string `json:"resource"` + ResourceID string `json:"resource_id"` + Result string `json:"result"` + Reason string `json:"reason,omitempty"` + Data map[string]any `json:"data,omitempty"` + OccurredAt time.Time `json:"occurred_at"` +} + +var publishKafkaAuditEventEnvelope = publishKafkaEnvelope + +func enqueueKafkaAuditEvent(ctx context.Context, tenantID types.TenantID, actorType string, actor types.Uid, + action, resource, resourceID, result, reason string, data map[string]any) error { + if !kafkaEnabled() { + return nil + } + actorID := "" + if !actor.IsZero() { + actorID = actor.UserId() + } + payload := kafkaAuditPayload{ + ActorType: actorType, + ActorID: actorID, + Action: action, + Resource: resource, + ResourceID: resourceID, + Result: result, + Reason: reason, + Data: data, + OccurredAt: time.Now().UTC().Round(time.Millisecond), + } + encoded, err := json.Marshal(payload) + if err != nil { + return err + } + envelope := kafkaEnvelope{ + EventID: stableIMEventID("audit_event", tenantID, "audit."+action, resource+":"+resourceID, encoded), + TenantID: tenantID, + SourceService: "im-server", + TargetService: "hotgo-admin", + EventType: "audit." + action, + BizType: "audit", + BizID: resource + ":" + resourceID, + OccurredAt: payload.OccurredAt, + Payload: encoded, + } + if !actor.IsZero() { + envelope.Actor = &kafkaActor{Type: actorType, ID: actorID} + } + return publishKafkaAuditEventEnvelope(ctx, "audit_event", envelope.BizID, envelope) +} diff --git a/server/kafka_workbench_audit_test.go b/server/kafka_audit_test.go similarity index 78% rename from server/kafka_workbench_audit_test.go rename to server/kafka_audit_test.go index aba1041..28155b4 100644 --- a/server/kafka_workbench_audit_test.go +++ b/server/kafka_audit_test.go @@ -23,7 +23,7 @@ func TestKafkaTopicsKeepOnlyMessageInternalAsyncTopics(t *testing.T) { if topics["audit_event"] != "im.audit.event" { t.Fatalf("unexpected audit event topic: %q", topics["audit_event"]) } - for _, removed := range []string{"file_lifecycle", "workbench_event"} { + for _, removed := range []string{"file_lifecycle"} { if _, ok := topics[removed]; ok { t.Fatalf("internal sync topic %q must not be configured", removed) } @@ -116,30 +116,3 @@ func TestEnqueueKafkaFileAttachmentsBindRunsSynchronously(t *testing.T) { t.Fatalf("enqueueKafkaFileAttachmentsBind returned error: %v", err) } } - -func TestEnqueueKafkaWorkbenchEventRunsSynchronously(t *testing.T) { - oldKafkaBus := kafkaBus - oldInfrastructure := store.HTTPInfrastructure - oldSecurity := store.Security - defer func() { - kafkaBus = oldKafkaBus - store.HTTPInfrastructure = oldInfrastructure - store.Security = oldSecurity - }() - - kafkaBus = &kafkaRuntime{} - security := &testSecurityStore{} - store.Security = security - - err := enqueueKafkaWorkbenchEvent(context.Background(), 7, types.Uid(42), "meeting", "meeting.created", "meeting-1", - []types.Uid{42, 43}, "会议通知", "周会", map[string]any{"meeting_id": "meeting-1"}) - if err != nil { - t.Fatalf("enqueueKafkaWorkbenchEvent returned error: %v", err) - } - if len(security.notifications) != 1 { - t.Fatalf("expected one notification for non-actor target, got %#v", security.notifications) - } - if security.notifications[0].UserID != types.Uid(43) || security.notifications[0].EventType != "meeting.created" { - t.Fatalf("unexpected notification: %#v", security.notifications[0]) - } -} diff --git a/server/kafka_runtime.go b/server/kafka_runtime.go index 9d58a6a..69be8e4 100644 --- a/server/kafka_runtime.go +++ b/server/kafka_runtime.go @@ -24,7 +24,6 @@ const ( kafkaTopicMessagePersist = "im.message.persist" kafkaTopicGroupMessagePersist = "im.group.message.persist" kafkaTopicMessagePush = "im.message.push" - kafkaTopicWorkbenchExternal = "im.workbench.event" kafkaTopicFileSecurity = "im.file.security" kafkaTopicCacheInvalidate = "im.cache.invalidate" kafkaTopicAuditEvent = "im.audit.event" @@ -211,7 +210,6 @@ func kafkaTopics(overrides map[string]string) map[string]string { "message_persist": kafkaTopicMessagePersist, "group_message_persist": kafkaTopicGroupMessagePersist, "message_push": kafkaTopicMessagePush, - "workbench_external": kafkaTopicWorkbenchExternal, "file_security": kafkaTopicFileSecurity, "cache_invalidate": kafkaTopicCacheInvalidate, "audit_event": kafkaTopicAuditEvent, diff --git a/server/kafka_workbench_audit.go b/server/kafka_workbench_audit.go deleted file mode 100644 index b1c144f..0000000 --- a/server/kafka_workbench_audit.go +++ /dev/null @@ -1,129 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "time" - - "github.com/tinode/chat/server/store/types" -) - -type kafkaWorkbenchPayload struct { - Module string `json:"module"` - Action string `json:"action"` - ResourceID string `json:"resource_id"` - ActorUserID string `json:"actor_user_id"` - TargetUserIDs []string `json:"target_user_ids,omitempty"` - Title string `json:"title,omitempty"` - Summary string `json:"summary,omitempty"` - Data map[string]any `json:"data,omitempty"` - OccurredAt time.Time `json:"occurred_at"` -} - -type kafkaAuditPayload struct { - ActorType string `json:"actor_type"` - ActorID string `json:"actor_id"` - Action string `json:"action"` - Resource string `json:"resource"` - ResourceID string `json:"resource_id"` - Result string `json:"result"` - Reason string `json:"reason,omitempty"` - Data map[string]any `json:"data,omitempty"` - OccurredAt time.Time `json:"occurred_at"` -} - -var publishKafkaAuditEventEnvelope = publishKafkaEnvelope - -func enqueueKafkaWorkbenchEvent(ctx context.Context, tenantID types.TenantID, actor types.Uid, module, action, resourceID string, - targets []types.Uid, title, summary string, data map[string]any) error { - targetUserIDs := make([]string, 0, len(targets)) - for _, target := range targets { - if !target.IsZero() { - targetUserIDs = append(targetUserIDs, target.UserId()) - } - } - payload := kafkaWorkbenchPayload{ - Module: module, - Action: action, - ResourceID: resourceID, - ActorUserID: actor.UserId(), - TargetUserIDs: targetUserIDs, - Title: title, - Summary: summary, - Data: data, - OccurredAt: time.Now().UTC().Round(time.Millisecond), - } - envelope := kafkaEnvelope{ - EventID: stableIMEventID("workbench-sync", tenantID, "workbench."+action, resourceID, payload.OccurredAt), - TenantID: tenantID, - EventType: "workbench." + action, - BizType: "workbench", - BizID: resourceID, - } - return executeKafkaWorkbenchEvent(tenantID, envelope, payload) -} - -func enqueueKafkaAuditEvent(ctx context.Context, tenantID types.TenantID, actorType string, actor types.Uid, - action, resource, resourceID, result, reason string, data map[string]any) error { - if !kafkaEnabled() { - return nil - } - actorID := "" - if !actor.IsZero() { - actorID = actor.UserId() - } - payload := kafkaAuditPayload{ - ActorType: actorType, - ActorID: actorID, - Action: action, - Resource: resource, - ResourceID: resourceID, - Result: result, - Reason: reason, - Data: data, - OccurredAt: time.Now().UTC().Round(time.Millisecond), - } - encoded, err := json.Marshal(payload) - if err != nil { - return err - } - envelope := kafkaEnvelope{ - EventID: stableIMEventID("audit_event", tenantID, "audit."+action, resource+":"+resourceID, encoded), - TenantID: tenantID, - SourceService: "im-server", - TargetService: "hotgo-admin", - EventType: "audit." + action, - BizType: "audit", - BizID: resource + ":" + resourceID, - OccurredAt: payload.OccurredAt, - Payload: encoded, - } - if !actor.IsZero() { - envelope.Actor = &kafkaActor{Type: actorType, ID: actorID} - } - return publishKafkaAuditEventEnvelope(ctx, "audit_event", envelope.BizID, envelope) -} - -func executeKafkaWorkbenchEvent(tenantID types.TenantID, envelope kafkaEnvelope, payload kafkaWorkbenchPayload) error { - actor := types.ParseUserId(payload.ActorUserID) - if actor.IsZero() { - actor = types.ParseUid(payload.ActorUserID) - } - for _, raw := range payload.TargetUserIDs { - target := types.ParseUserId(raw) - if target.IsZero() { - continue - } - eventID := stableIMEventID("workbench", envelope.EventID, target.UserId()) - if target != actor && payload.Title != "" { - if err := createIMSystemNotification(tenantID, target, stableIMEventID(eventID, "notification"), payload.Action, - payload.Title, payload.Summary, types.KVMap{"module": payload.Module, "resource_id": payload.ResourceID}); err != nil { - return err - } - } - if err := enqueueIMBusinessEvent(tenantID, eventID, "user", target.UserId(), actor, payload.Action, payload.Data); err != nil { - return err - } - } - return nil -} diff --git a/server/kafka_workbench_events.go b/server/kafka_workbench_events.go deleted file mode 100644 index 2c6f64c..0000000 --- a/server/kafka_workbench_events.go +++ /dev/null @@ -1,163 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "strings" - "time" - - "github.com/segmentio/kafka-go" - "github.com/tinode/chat/server/logs" - "github.com/tinode/chat/server/store" - "github.com/tinode/chat/server/store/types" -) - -type kafkaExternalWorkbenchEvent struct { - EventID string `json:"event_id"` - TraceID string `json:"trace_id,omitempty"` - TenantID types.TenantID `json:"tenant_id,omitempty"` - TenantCode string `json:"tenant_code,omitempty"` - ActorUserID int64 `json:"actor_user_id"` - Module string `json:"module"` - Action string `json:"action"` - ResourceID string `json:"resource_id"` - TargetUserIDs []int64 `json:"target_user_ids,omitempty"` - Title string `json:"title,omitempty"` - Summary string `json:"summary,omitempty"` - OccurredAt time.Time `json:"occurred_at"` - Data map[string]any `json:"data,omitempty"` -} - -func startKafkaWorkbenchEventConsumer() { - startKafkaConsumer("workbench_external", handleKafkaWorkbenchEvent) -} - -func handleKafkaWorkbenchEvent(ctx context.Context, envelope kafkaEnvelope, msg kafka.Message) error { - payload, normalized, err := decodeKafkaWorkbenchEvent(envelope, msg) - if err != nil { - logs.Warn.Printf("kafka workbench event malformed: topic=%s offset=%d err=%v", msg.Topic, msg.Offset, err) - return err - } - tenantID, err := kafkaTenantIDFromEnvelope(normalized) - if err != nil { - return err - } - consume, claimed, err := beginKafkaConsumeForTopic(tenantID, normalized, "workbench_external") - if err != nil { - return err - } - if !claimed { - logs.Info.Printf("kafka workbench event skipped: event=%s group=%s status=%s", normalized.EventID, kafkaBus.config.ConsumerGroup, consume.Status) - return nil - } - if err = executeKafkaExternalWorkbenchEvent(tenantID, normalized, payload); err != nil { - reason := kafkaCommandErrorReason(err) - failed, markErr := store.HTTPInfrastructure.MarkKafkaConsumeFailed(tenantID, normalized.EventID, kafkaBus.config.ConsumerGroup, reason, err.Error(), time.Now().UTC().Round(time.Millisecond)) - if markErr != nil { - return markErr - } - if failed != nil && failed.RetryCount >= kafkaBus.config.MaxRetryCount { - return markKafkaDeadLetter(ctx, tenantID, normalized, failed, reason, err.Error()) - } - if failed != nil { - sleepKafkaRetryBackoff(failed.RetryCount) - } - return err - } - return store.HTTPInfrastructure.MarkKafkaConsumeSucceeded(tenantID, normalized.EventID, kafkaBus.config.ConsumerGroup, time.Now().UTC().Round(time.Millisecond)) -} - -func decodeKafkaWorkbenchEvent(envelope kafkaEnvelope, msg kafka.Message) (kafkaExternalWorkbenchEvent, kafkaEnvelope, error) { - var payload kafkaExternalWorkbenchEvent - if len(envelope.Payload) > 0 { - if err := json.Unmarshal(envelope.Payload, &payload); err != nil { - return payload, envelope, err - } - } else if err := json.Unmarshal(msg.Value, &payload); err != nil { - return payload, envelope, err - } - if strings.TrimSpace(payload.EventID) == "" { - payload.EventID = envelope.EventID - } - if strings.TrimSpace(payload.Action) == "" && envelope.EventType != "" { - payload.Action = strings.TrimPrefix(envelope.EventType, "workbench.") - } - if strings.TrimSpace(payload.EventID) == "" || strings.TrimSpace(payload.Action) == "" || strings.TrimSpace(payload.ResourceID) == "" { - return payload, envelope, types.ErrMalformed - } - if payload.OccurredAt.IsZero() { - payload.OccurredAt = time.Now().UTC().Round(time.Millisecond) - } - normalized := kafkaEnvelope{ - EventID: payload.EventID, - TraceID: firstNonEmpty(payload.TraceID, envelope.TraceID), - TenantCode: firstNonEmpty(payload.TenantCode, envelope.TenantCode), - TenantID: firstTenantID(payload.TenantID, envelope.TenantID), - SourceService: firstNonEmpty(envelope.SourceService, "im-workbench"), - TargetService: firstNonEmpty(envelope.TargetService, "im-server"), - EventType: "workbench." + payload.Action, - BizType: firstNonEmpty(payload.Module, envelope.BizType, "workbench"), - BizID: firstNonEmpty(payload.ResourceID, envelope.BizID), - OccurredAt: payload.OccurredAt, - Payload: msg.Value, - } - if payload.ActorUserID > 0 { - normalized.Actor = &kafkaActor{Type: "user", ID: types.Uid(payload.ActorUserID).UserId()} - } - return payload, normalized, nil -} - -func executeKafkaExternalWorkbenchEvent(tenantID types.TenantID, envelope kafkaEnvelope, payload kafkaExternalWorkbenchEvent) error { - if store.Security == nil { - return types.ErrUnsupported - } - actor := types.Uid(payload.ActorUserID) - data := types.KVMap{ - "module": payload.Module, - "resource_id": payload.ResourceID, - } - for k, v := range payload.Data { - data[k] = v - } - targets := compactWorkbenchTargets(payload.TargetUserIDs, actor) - for _, target := range targets { - targetID := target.UserId() - eventID := stableIMEventID("workbench", envelope.EventID, targetID) - if target != actor && payload.Title != "" { - if err := createIMSystemNotification(tenantID, target, stableIMEventID(eventID, "notification"), payload.Action, - payload.Title, payload.Summary, data); err != nil { - return err - } - } - if err := enqueueIMBusinessEvent(tenantID, eventID, "user", targetID, actor, payload.Action, data); err != nil { - return err - } - } - return nil -} - -func compactWorkbenchTargets(raw []int64, actor types.Uid) []types.Uid { - seen := make(map[types.Uid]bool, len(raw)+1) - targets := make([]types.Uid, 0, len(raw)+1) - for _, value := range raw { - uid := types.Uid(value) - if uid.IsZero() || seen[uid] { - continue - } - seen[uid] = true - targets = append(targets, uid) - } - if len(targets) == 0 && !actor.IsZero() { - targets = append(targets, actor) - } - return targets -} - -func firstTenantID(values ...types.TenantID) types.TenantID { - for _, value := range values { - if !value.IsZero() { - return value - } - } - return types.ZeroTenantID -} diff --git a/server/kafka_workbench_events_test.go b/server/kafka_workbench_events_test.go deleted file mode 100644 index ccfe36c..0000000 --- a/server/kafka_workbench_events_test.go +++ /dev/null @@ -1,89 +0,0 @@ -package main - -import ( - "testing" - "time" - - "github.com/segmentio/kafka-go" - "github.com/tinode/chat/server/store" - "github.com/tinode/chat/server/store/types" -) - -func TestKafkaTopicsIncludeExternalWorkbenchEvent(t *testing.T) { - topics := kafkaTopics(nil) - if topics["workbench_external"] != "im.workbench.event" { - t.Fatalf("unexpected workbench external topic: %q", topics["workbench_external"]) - } - if _, ok := topics["workbench_event"]; ok { - t.Fatal("legacy in-process workbench_event topic key must not be configured") - } -} - -func TestDecodeKafkaWorkbenchEventDirectPayload(t *testing.T) { - msg := kafka.Message{Value: []byte(`{ - "event_id":"wbe_1", - "trace_id":"trace-1", - "tenant_id":7, - "tenant_code":"default", - "actor_user_id":42, - "module":"meeting", - "action":"meeting.created", - "resource_id":"meeting-1", - "target_user_ids":[42,43], - "title":"会议通知", - "summary":"周会", - "occurred_at":"2026-09-13T10:00:00Z", - "data":{"meeting_id":"meeting-1"} - }`)} - - payload, envelope, err := decodeKafkaWorkbenchEvent(kafkaEnvelope{}, msg) - if err != nil { - t.Fatalf("decodeKafkaWorkbenchEvent returned error: %v", err) - } - if payload.EventID != "wbe_1" || payload.ActorUserID != 42 || len(payload.TargetUserIDs) != 2 { - t.Fatalf("unexpected payload: %#v", payload) - } - if envelope.EventID != "wbe_1" || envelope.EventType != "workbench.meeting.created" || - envelope.BizType != "meeting" || envelope.BizID != "meeting-1" { - t.Fatalf("unexpected normalized envelope: %#v", envelope) - } - if envelope.Actor == nil || envelope.Actor.ID != types.Uid(42).UserId() { - t.Fatalf("unexpected actor: %#v", envelope.Actor) - } - if envelope.OccurredAt.IsZero() || envelope.TenantID != types.TenantID(7) { - t.Fatalf("unexpected occurrence or tenant: %#v", envelope) - } -} - -func TestExecuteKafkaExternalWorkbenchEventCreatesNotificationForNonActor(t *testing.T) { - oldSecurity := store.Security - defer func() { store.Security = oldSecurity }() - - security := &testSecurityStore{} - store.Security = security - - payload := kafkaExternalWorkbenchEvent{ - EventID: "wbe_1", - TenantID: 7, - ActorUserID: 42, - Module: "meeting", - Action: "meeting.created", - ResourceID: "meeting-1", - TargetUserIDs: []int64{42, 43}, - Title: "会议通知", - Summary: "周会", - OccurredAt: time.Date(2026, 9, 13, 10, 0, 0, 0, time.UTC), - Data: map[string]any{"meeting_id": "meeting-1"}, - } - envelope := kafkaEnvelope{EventID: payload.EventID} - - if err := executeKafkaExternalWorkbenchEvent(7, envelope, payload); err != nil { - t.Fatalf("executeKafkaExternalWorkbenchEvent returned error: %v", err) - } - if len(security.notifications) != 1 { - t.Fatalf("expected one notification for non-actor target, got %#v", security.notifications) - } - if security.notifications[0].UserID != types.Uid(43) || security.notifications[0].EventType != "meeting.created" { - t.Fatalf("unexpected notification: %#v", security.notifications[0]) - } -} diff --git a/server/main.go b/server/main.go index 3fd87f7..69da5f3 100644 --- a/server/main.go +++ b/server/main.go @@ -758,7 +758,6 @@ func main() { startKafkaConfigEventConsumer() startKafkaCacheInvalidateConsumer() startKafkaMessagePersistConsumer() - startKafkaWorkbenchEventConsumer() startKafkaDeadLetterConsumer() // Start accepting cluster traffic. @@ -886,29 +885,8 @@ func main() { mux.HandleFunc(config.ApiPath+"v1/im/security/realname/appeals", serveIMRealNameAppeals) mux.HandleFunc(config.ApiPath+"v1/im/security/realname/submissions", serveIMRealNameSubmissions) mux.HandleFunc(config.ApiPath+"v1/im/bootstrap", serveIMBootstrap) - mux.HandleFunc(config.ApiPath+"v1/im/workbench", serveIMWorkbench) - mux.HandleFunc(config.ApiPath+"v1/im/organization/departments", serveIMOrganizationDepartments) - mux.HandleFunc(config.ApiPath+"v1/im/organization/departments/", serveIMOrganizationDepartmentResource) - mux.HandleFunc(config.ApiPath+"v1/im/organization/members", serveIMOrganizationMembers) - mux.HandleFunc(config.ApiPath+"v1/im/organization/search", serveIMOrganizationSearch) - mux.HandleFunc(config.ApiPath+"v1/im/attendance/config", serveIMAttendanceConfig) - 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) mux.HandleFunc(config.ApiPath+"v1/im/reports/abuse", serveIMAbuseReports) - mux.HandleFunc(config.ApiPath+"v1/im/reports/daily/statistics", serveIMDailyReportStatistics) - mux.HandleFunc(config.ApiPath+"v1/im/reports/daily/reminders", serveIMDailyReportReminders) - mux.HandleFunc(config.ApiPath+"v1/im/reports/daily", serveIMDailyReports) - mux.HandleFunc(config.ApiPath+"v1/im/reports/daily/", serveIMDailyReportResource) - mux.HandleFunc(config.ApiPath+"v1/im/reports/monthly/statistics", serveIMMonthlyReportStatistics) - mux.HandleFunc(config.ApiPath+"v1/im/reports/monthly", serveIMMonthlyReports) - mux.HandleFunc(config.ApiPath+"v1/im/reports/monthly/", serveIMMonthlyReportResource) - mux.HandleFunc(config.ApiPath+"v1/im/executive-mailbox/config", serveIMMailboxConfig) - mux.HandleFunc(config.ApiPath+"v1/im/executive-mailbox/submissions", serveIMMailboxSubmissions) mux.HandleFunc(config.ApiPath+"v1/im/feedback/config", serveIMFeedbackConfig) mux.HandleFunc(config.ApiPath+"v1/im/feedback", serveIMFeedback) mux.HandleFunc(config.ApiPath+"v1/im/app/releases/latest", serveIMAppReleaseLatest) diff --git a/server/meetingim.conf b/server/meetingim.conf index 0427332..8f2cf91 100644 --- a/server/meetingim.conf +++ b/server/meetingim.conf @@ -137,7 +137,6 @@ "message_persist": "im.message.persist", "group_message_persist": "im.group.message.persist", "message_push": "im.message.push", - "workbench_external": "im.workbench.event", "file_security": "im.file.security", "cache_invalidate": "im.cache.invalidate", "audit_event": "im.audit.event", diff --git a/server/store/store.go b/server/store/store.go index 2a4ef1c..8e67259 100644 --- a/server/store/store.go +++ b/server/store/store.go @@ -2307,601 +2307,6 @@ func groupMemberCacheID(topic string, uid types.Uid) string { return strings.TrimSpace(topic) + ":" + uid.String() } -type WorkbenchPersistenceInterface interface { - Get(tenantID types.TenantID, uid types.Uid) ([]types.WorkbenchModule, int, error) -} - -type workbenchMapper struct{} - -var Workbench WorkbenchPersistenceInterface - -func (workbenchMapper) Get(tenantID types.TenantID, uid types.Uid) ([]types.WorkbenchModule, int, error) { - if uid.IsZero() { - return nil, 0, types.ErrMalformed - } - tenantAdp, err := tenantBusinessAdapter(tenantID) - if err != nil { - return nil, 0, err - } - return tenantAdp.TenantWorkbenchModules(tenantID, uid) -} - -type OrganizationPersistenceInterface interface { - ListDepartments(tenantID types.TenantID, uid types.Uid, parentID, cursor string, limit int) ([]types.OrganizationDepartment, string, error) - ListMembers(tenantID types.TenantID, uid types.Uid, departmentID, cursor string, limit int) ([]types.OrganizationMember, string, error) - ListAllMembers(tenantID types.TenantID, uid types.Uid, departmentID, queryText, cursor string, limit int) ([]types.OrganizationMember, string, error) - Search(tenantID types.TenantID, uid types.Uid, queryText, resultTypes, cursor string, limit int) ([]types.OrganizationSearchItem, string, error) -} - -type organizationMapper struct{} - -var Organization OrganizationPersistenceInterface - -func (organizationMapper) ListDepartments(tenantID types.TenantID, uid types.Uid, parentID, cursor string, limit int) ([]types.OrganizationDepartment, string, error) { - if uid.IsZero() { - return nil, "", types.ErrMalformed - } - if limit <= 0 || limit > 100 { - limit = 50 - } - tenantAdp, err := tenantBusinessAdapter(tenantID) - if err != nil { - return nil, "", err - } - return tenantAdp.TenantOrganizationDepartments(tenantID, uid, strings.TrimSpace(parentID), strings.TrimSpace(cursor), limit) -} - -func (organizationMapper) ListMembers(tenantID types.TenantID, uid types.Uid, departmentID, cursor string, limit int) ([]types.OrganizationMember, string, error) { - if uid.IsZero() || strings.TrimSpace(departmentID) == "" { - return nil, "", types.ErrMalformed - } - if limit <= 0 || limit > 100 { - limit = 50 - } - tenantAdp, err := tenantBusinessAdapter(tenantID) - if err != nil { - return nil, "", err - } - return tenantAdp.TenantOrganizationMembers(tenantID, uid, strings.TrimSpace(departmentID), strings.TrimSpace(cursor), limit) -} - -func (organizationMapper) ListAllMembers(tenantID types.TenantID, uid types.Uid, departmentID, queryText, cursor string, limit int) ([]types.OrganizationMember, string, error) { - if uid.IsZero() { - return nil, "", types.ErrMalformed - } - if limit <= 0 || limit > 100 { - limit = 50 - } - tenantAdp, err := tenantBusinessAdapter(tenantID) - if err != nil { - return nil, "", err - } - return tenantAdp.TenantOrganizationMembersAll(tenantID, uid, strings.TrimSpace(departmentID), strings.TrimSpace(queryText), strings.TrimSpace(cursor), limit) -} - -func (organizationMapper) Search(tenantID types.TenantID, uid types.Uid, queryText, resultTypes, cursor string, limit int) ([]types.OrganizationSearchItem, string, error) { - if uid.IsZero() || strings.TrimSpace(queryText) == "" { - return nil, "", types.ErrMalformed - } - if limit <= 0 || limit > 100 { - limit = 50 - } - tenantAdp, err := tenantBusinessAdapter(tenantID) - if err != nil { - return nil, "", err - } - return tenantAdp.TenantOrganizationSearch(tenantID, uid, strings.TrimSpace(queryText), strings.TrimSpace(resultTypes), strings.TrimSpace(cursor), limit) -} - -type AttendancePersistenceInterface interface { - Config(tenantID types.TenantID, uid types.Uid, date time.Time) (*types.AttendanceConfig, error) - ListRecords(tenantID types.TenantID, uid types.Uid, opts types.AttendanceRecordListOptions) ([]types.AttendanceRecord, string, *types.AttendanceRecordStats, error) - Checkin(tenantID types.TenantID, uid types.Uid, checkinID string, req types.AttendanceCheckinRequest) (*types.AttendanceRecord, *types.AttendanceCheckin, error) - CreateAppeal(tenantID types.TenantID, uid types.Uid, appeal *types.AttendanceAppeal, idempotencyKey string) (*types.AttendanceAppeal, error) - ListAppeals(tenantID types.TenantID, uid types.Uid, opts types.AttendanceAppealListOptions) ([]types.AttendanceAppeal, string, error) -} - -type attendanceMapper struct{} - -var Attendance AttendancePersistenceInterface - -func (attendanceMapper) Config(tenantID types.TenantID, uid types.Uid, date time.Time) (*types.AttendanceConfig, error) { - if uid.IsZero() || date.IsZero() { - return nil, types.ErrMalformed - } - tenantAdp, err := tenantBusinessAdapter(tenantID) - if err != nil { - return nil, err - } - return tenantAdp.TenantAttendanceConfig(tenantID, uid, date) -} - -func (attendanceMapper) ListRecords(tenantID types.TenantID, uid types.Uid, opts types.AttendanceRecordListOptions) ([]types.AttendanceRecord, string, *types.AttendanceRecordStats, error) { - if uid.IsZero() { - return nil, "", nil, types.ErrMalformed - } - if opts.Limit <= 0 || opts.Limit > 100 { - opts.Limit = 50 - } - opts.Scope = strings.TrimSpace(opts.Scope) - if opts.Scope == "" { - opts.Scope = "mine" - } - if opts.Scope != "mine" && opts.Scope != "managed" { - return nil, "", nil, types.ErrMalformed - } - tenantAdp, err := tenantBusinessAdapter(tenantID) - if err != nil { - return nil, "", nil, err - } - return tenantAdp.TenantAttendanceRecordList(tenantID, uid, opts) -} - -func (attendanceMapper) Checkin(tenantID types.TenantID, uid types.Uid, checkinID string, req types.AttendanceCheckinRequest) (*types.AttendanceRecord, *types.AttendanceCheckin, error) { - if uid.IsZero() || strings.TrimSpace(checkinID) == "" || req.Date.IsZero() || req.ClientTime.IsZero() || !validAttendanceAction(req.Action) { - return nil, nil, types.ErrMalformed - } - tenantAdp, err := tenantBusinessAdapter(tenantID) - if err != nil { - return nil, nil, err - } - return tenantAdp.TenantAttendanceCheckin(tenantID, uid, strings.TrimSpace(checkinID), req) -} - -func (attendanceMapper) CreateAppeal(tenantID types.TenantID, uid types.Uid, appeal *types.AttendanceAppeal, idempotencyKey string) (*types.AttendanceAppeal, error) { - if uid.IsZero() || appeal == nil || strings.TrimSpace(appeal.AppealID) == "" || strings.TrimSpace(appeal.RecordID) == "" || strings.TrimSpace(appeal.Reason) == "" { - return nil, types.ErrMalformed - } - tenantAdp, err := tenantBusinessAdapter(tenantID) - if err != nil { - return nil, err - } - return tenantAdp.TenantAttendanceAppealCreate(tenantID, uid, appeal, strings.TrimSpace(idempotencyKey)) -} - -func (attendanceMapper) ListAppeals(tenantID types.TenantID, uid types.Uid, opts types.AttendanceAppealListOptions) ([]types.AttendanceAppeal, string, error) { - if uid.IsZero() { - return nil, "", types.ErrMalformed - } - if opts.Limit <= 0 || opts.Limit > 100 { - opts.Limit = 50 - } - opts.Scope = strings.TrimSpace(opts.Scope) - if opts.Scope == "" { - opts.Scope = "mine" - } - if opts.Scope != "mine" && opts.Scope != "managed" { - return nil, "", types.ErrMalformed - } - tenantAdp, err := tenantBusinessAdapter(tenantID) - if err != nil { - return nil, "", err - } - return tenantAdp.TenantAttendanceAppealList(tenantID, uid, opts) -} - -func validAttendanceAction(action string) bool { - switch strings.TrimSpace(action) { - case types.AttendanceActionCheckIn, types.AttendanceActionCheckOut: - return true - default: - return false - } -} - -type MeetingsPersistenceInterface interface { - Create(tenantID types.TenantID, uid types.Uid, meeting *types.Meeting, attendeeIDs []types.Uid) (*types.Meeting, error) - List(tenantID types.TenantID, uid types.Uid, cursor string, limit int) ([]types.Meeting, string, error) - Get(tenantID types.TenantID, uid types.Uid, meetingID string) (*types.Meeting, error) - Update(tenantID types.TenantID, uid types.Uid, meetingID string, patch types.MeetingPatch, idempotencyKey string) (*types.Meeting, error) - Cancel(tenantID types.TenantID, uid types.Uid, meetingID, idempotencyKey string) (*types.Meeting, error) - Remind(tenantID types.TenantID, uid types.Uid, meetingID, idempotencyKey string) ([]types.MeetingParticipant, error) - Respond(tenantID types.TenantID, uid types.Uid, meetingID, response string, version int) (*types.Meeting, error) -} - -type MeetingNotesPersistenceInterface interface { - List(tenantID types.TenantID, uid types.Uid, query, cursor string, limit int) ([]types.Meeting, string, error) - ListFiles(tenantID types.TenantID, uid types.Uid, meetingID string) ([]types.MeetingNoteFile, error) - CreateFile(tenantID types.TenantID, uid types.Uid, meetingID, noteFileID, fileID, idempotencyKey string) (*types.MeetingNoteFile, error) - ReplaceFile(tenantID types.TenantID, uid types.Uid, meetingID, noteFileID, fileID, idempotencyKey string, version int) (*types.MeetingNoteFile, error) - DeleteFile(tenantID types.TenantID, uid types.Uid, meetingID, noteFileID, idempotencyKey string, version int) (*types.MeetingNoteFile, error) -} - -type meetingsMapper struct{} -type 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) { - return nil, types.ErrMalformed - } - tenantAdp, err := tenantBusinessAdapter(tenantID) - if err != nil { - return nil, err - } - return tenantAdp.TenantMeetingCreate(tenantID, uid, meeting, attendeeIDs) -} - -func (meetingsMapper) List(tenantID types.TenantID, uid types.Uid, 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.TenantMeetingList(tenantID, uid, strings.TrimSpace(cursor), limit) -} - -func (meetingsMapper) Get(tenantID types.TenantID, uid types.Uid, meetingID string) (*types.Meeting, error) { - if uid.IsZero() || strings.TrimSpace(meetingID) == "" { - return nil, types.ErrMalformed - } - tenantAdp, err := tenantBusinessAdapter(tenantID) - if err != nil { - return nil, err - } - return tenantAdp.TenantMeetingGet(tenantID, uid, strings.TrimSpace(meetingID)) -} - -func (meetingsMapper) Update(tenantID types.TenantID, uid types.Uid, meetingID string, patch types.MeetingPatch, idempotencyKey string) (*types.Meeting, error) { - if uid.IsZero() || strings.TrimSpace(meetingID) == "" || patch.Version <= 0 || !validMeetingPatch(patch) { - return nil, types.ErrMalformed - } - tenantAdp, err := tenantBusinessAdapter(tenantID) - if err != nil { - return nil, err - } - return tenantAdp.TenantMeetingUpdate(tenantID, uid, strings.TrimSpace(meetingID), patch, strings.TrimSpace(idempotencyKey)) -} - -func (meetingsMapper) Cancel(tenantID types.TenantID, uid types.Uid, meetingID, idempotencyKey string) (*types.Meeting, error) { - if uid.IsZero() || strings.TrimSpace(meetingID) == "" { - return nil, types.ErrMalformed - } - tenantAdp, err := tenantBusinessAdapter(tenantID) - if err != nil { - return nil, err - } - return tenantAdp.TenantMeetingCancel(tenantID, uid, strings.TrimSpace(meetingID), strings.TrimSpace(idempotencyKey)) -} - -func (meetingsMapper) Remind(tenantID types.TenantID, uid types.Uid, meetingID, idempotencyKey string) ([]types.MeetingParticipant, error) { - if uid.IsZero() || strings.TrimSpace(meetingID) == "" { - return nil, types.ErrMalformed - } - tenantAdp, err := tenantBusinessAdapter(tenantID) - if err != nil { - return nil, err - } - return tenantAdp.TenantMeetingRemind(tenantID, uid, strings.TrimSpace(meetingID), strings.TrimSpace(idempotencyKey)) -} - -func (meetingsMapper) Respond(tenantID types.TenantID, uid types.Uid, meetingID, response string, version int) (*types.Meeting, error) { - if uid.IsZero() || strings.TrimSpace(meetingID) == "" || !validMeetingResponse(response) || version <= 0 { - return nil, types.ErrMalformed - } - tenantAdp, err := tenantBusinessAdapter(tenantID) - if err != nil { - return nil, err - } - 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) != "" && - !meeting.StartAt.IsZero() && - !meeting.EndAt.IsZero() && - meeting.EndAt.After(meeting.StartAt) -} - -func validMeetingPatch(patch types.MeetingPatch) bool { - 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) == "" { - return false - } - if patch.StartAt != nil && patch.EndAt != nil && !patch.EndAt.After(*patch.StartAt) { - return false - } - return true -} - -func validMeetingResponse(response string) bool { - switch strings.TrimSpace(response) { - case types.MeetingResponseAccepted, types.MeetingResponseDeclined, types.MeetingResponseTentative: - return true - default: - return false - } -} - -type DailyReportsPersistenceInterface interface { - List(tenantID types.TenantID, uid types.Uid, scope, from, to, status, cursor string, limit int) ([]types.DailyReport, string, error) - CreateDraft(tenantID types.TenantID, uid types.Uid, reportID string, reportDate time.Time) (*types.DailyReport, error) - Get(tenantID types.TenantID, uid types.Uid, reportID string) (*types.DailyReport, error) - SaveDraft(tenantID types.TenantID, uid types.Uid, reportID, content string, attachments []string, version int) (*types.DailyReport, error) - Submit(tenantID types.TenantID, uid types.Uid, reportID string, version int) (*types.DailyReport, error) - Stats(tenantID types.TenantID, uid types.Uid, date time.Time, departmentID string) (*types.DailyReportStats, error) - Remind(tenantID types.TenantID, uid types.Uid, date time.Time, userIDs []types.Uid, idempotencyKey string) ([]types.Uid, error) -} - -type dailyReportsMapper struct{} - -var DailyReports DailyReportsPersistenceInterface - -func (dailyReportsMapper) List(tenantID types.TenantID, uid types.Uid, scope, from, to, status, cursor string, limit int) ([]types.DailyReport, string, error) { - if uid.IsZero() { - return nil, "", types.ErrMalformed - } - if limit <= 0 || limit > 100 { - limit = 50 - } - tenantAdp, err := tenantBusinessAdapter(tenantID) - if err != nil { - return nil, "", err - } - return tenantAdp.TenantDailyReportList(tenantID, uid, strings.TrimSpace(scope), strings.TrimSpace(from), strings.TrimSpace(to), strings.TrimSpace(status), strings.TrimSpace(cursor), limit) -} - -func (dailyReportsMapper) CreateDraft(tenantID types.TenantID, uid types.Uid, reportID string, reportDate time.Time) (*types.DailyReport, error) { - if uid.IsZero() || strings.TrimSpace(reportID) == "" || reportDate.IsZero() { - return nil, types.ErrMalformed - } - tenantAdp, err := tenantBusinessAdapter(tenantID) - if err != nil { - return nil, err - } - return tenantAdp.TenantDailyReportCreateDraft(tenantID, uid, strings.TrimSpace(reportID), reportDate) -} - -func (dailyReportsMapper) Get(tenantID types.TenantID, uid types.Uid, reportID string) (*types.DailyReport, error) { - if uid.IsZero() || strings.TrimSpace(reportID) == "" { - return nil, types.ErrMalformed - } - tenantAdp, err := tenantBusinessAdapter(tenantID) - if err != nil { - return nil, err - } - return tenantAdp.TenantDailyReportGet(tenantID, uid, strings.TrimSpace(reportID)) -} - -func (dailyReportsMapper) SaveDraft(tenantID types.TenantID, uid types.Uid, reportID, content string, attachments []string, version int) (*types.DailyReport, error) { - if uid.IsZero() || strings.TrimSpace(reportID) == "" || version <= 0 { - return nil, types.ErrMalformed - } - tenantAdp, err := tenantBusinessAdapter(tenantID) - if err != nil { - return nil, err - } - return tenantAdp.TenantDailyReportSaveDraft(tenantID, uid, strings.TrimSpace(reportID), content, attachments, version) -} - -func (dailyReportsMapper) Submit(tenantID types.TenantID, uid types.Uid, reportID string, version int) (*types.DailyReport, error) { - if uid.IsZero() || strings.TrimSpace(reportID) == "" || version <= 0 { - return nil, types.ErrMalformed - } - tenantAdp, err := tenantBusinessAdapter(tenantID) - if err != nil { - return nil, err - } - return tenantAdp.TenantDailyReportSubmit(tenantID, uid, strings.TrimSpace(reportID), version) -} - -func (dailyReportsMapper) Stats(tenantID types.TenantID, uid types.Uid, date time.Time, departmentID string) (*types.DailyReportStats, error) { - if uid.IsZero() || date.IsZero() { - return nil, types.ErrMalformed - } - tenantAdp, err := tenantBusinessAdapter(tenantID) - if err != nil { - return nil, err - } - return tenantAdp.TenantDailyReportStats(tenantID, uid, date, strings.TrimSpace(departmentID)) -} - -func (dailyReportsMapper) Remind(tenantID types.TenantID, uid types.Uid, date time.Time, userIDs []types.Uid, idempotencyKey string) ([]types.Uid, error) { - if uid.IsZero() || date.IsZero() || strings.TrimSpace(idempotencyKey) == "" { - return nil, types.ErrMalformed - } - tenantAdp, err := tenantBusinessAdapter(tenantID) - if err != nil { - return nil, err - } - return tenantAdp.TenantDailyReportRemind(tenantID, uid, date, userIDs, strings.TrimSpace(idempotencyKey)) -} - -type MonthlyReportsPersistenceInterface interface { - List(tenantID types.TenantID, uid types.Uid, scope, fromMonth, toMonth, status, cursor string, limit int) ([]types.MonthlyReport, string, error) - CreateDraft(tenantID types.TenantID, uid types.Uid, reportID, month string) (*types.MonthlyReport, error) - Get(tenantID types.TenantID, uid types.Uid, reportID string) (*types.MonthlyReport, error) - SaveDraft(tenantID types.TenantID, uid types.Uid, reportID, workSummary, nextPlan string, attachments []string, version int) (*types.MonthlyReport, error) - Submit(tenantID types.TenantID, uid types.Uid, reportID string, version int) (*types.MonthlyReport, error) - Reopen(tenantID types.TenantID, uid types.Uid, reportID string, version int) (*types.MonthlyReport, error) - Stats(tenantID types.TenantID, uid types.Uid, month, departmentID string) (*types.MonthlyReportStats, error) -} - -type monthlyReportsMapper struct{} - -var MonthlyReports MonthlyReportsPersistenceInterface - -func (monthlyReportsMapper) List(tenantID types.TenantID, uid types.Uid, scope, fromMonth, toMonth, status, cursor string, limit int) ([]types.MonthlyReport, string, error) { - if uid.IsZero() { - return nil, "", types.ErrMalformed - } - if limit <= 0 || limit > 100 { - limit = 50 - } - tenantAdp, err := tenantBusinessAdapter(tenantID) - if err != nil { - return nil, "", err - } - return tenantAdp.TenantMonthlyReportList(tenantID, uid, strings.TrimSpace(scope), strings.TrimSpace(fromMonth), strings.TrimSpace(toMonth), strings.TrimSpace(status), strings.TrimSpace(cursor), limit) -} - -func (monthlyReportsMapper) CreateDraft(tenantID types.TenantID, uid types.Uid, reportID, month string) (*types.MonthlyReport, error) { - if uid.IsZero() || strings.TrimSpace(reportID) == "" || strings.TrimSpace(month) == "" { - return nil, types.ErrMalformed - } - tenantAdp, err := tenantBusinessAdapter(tenantID) - if err != nil { - return nil, err - } - return tenantAdp.TenantMonthlyReportCreateDraft(tenantID, uid, strings.TrimSpace(reportID), strings.TrimSpace(month)) -} - -func (monthlyReportsMapper) Get(tenantID types.TenantID, uid types.Uid, reportID string) (*types.MonthlyReport, error) { - if uid.IsZero() || strings.TrimSpace(reportID) == "" { - return nil, types.ErrMalformed - } - tenantAdp, err := tenantBusinessAdapter(tenantID) - if err != nil { - return nil, err - } - return tenantAdp.TenantMonthlyReportGet(tenantID, uid, strings.TrimSpace(reportID)) -} - -func (monthlyReportsMapper) SaveDraft(tenantID types.TenantID, uid types.Uid, reportID, workSummary, nextPlan string, attachments []string, version int) (*types.MonthlyReport, error) { - if uid.IsZero() || strings.TrimSpace(reportID) == "" || version <= 0 { - return nil, types.ErrMalformed - } - tenantAdp, err := tenantBusinessAdapter(tenantID) - if err != nil { - return nil, err - } - return tenantAdp.TenantMonthlyReportSaveDraft(tenantID, uid, strings.TrimSpace(reportID), workSummary, nextPlan, attachments, version) -} - -func (monthlyReportsMapper) Submit(tenantID types.TenantID, uid types.Uid, reportID string, version int) (*types.MonthlyReport, error) { - if uid.IsZero() || strings.TrimSpace(reportID) == "" || version <= 0 { - return nil, types.ErrMalformed - } - tenantAdp, err := tenantBusinessAdapter(tenantID) - if err != nil { - return nil, err - } - return tenantAdp.TenantMonthlyReportSubmit(tenantID, uid, strings.TrimSpace(reportID), version) -} - -func (monthlyReportsMapper) Reopen(tenantID types.TenantID, uid types.Uid, reportID string, version int) (*types.MonthlyReport, error) { - if uid.IsZero() || strings.TrimSpace(reportID) == "" || version <= 0 { - return nil, types.ErrMalformed - } - tenantAdp, err := tenantBusinessAdapter(tenantID) - if err != nil { - return nil, err - } - return tenantAdp.TenantMonthlyReportReopen(tenantID, uid, strings.TrimSpace(reportID), version) -} - -func (monthlyReportsMapper) Stats(tenantID types.TenantID, uid types.Uid, month, departmentID string) (*types.MonthlyReportStats, error) { - if uid.IsZero() || strings.TrimSpace(month) == "" { - return nil, types.ErrMalformed - } - tenantAdp, err := tenantBusinessAdapter(tenantID) - if err != nil { - return nil, err - } - return tenantAdp.TenantMonthlyReportStats(tenantID, uid, strings.TrimSpace(month), strings.TrimSpace(departmentID)) -} - -type MailboxPersistenceInterface interface { - Config(tenantID types.TenantID, uid types.Uid) (*types.MailboxConfig, error) - Submit(tenantID types.TenantID, uid types.Uid, submission *types.MailboxSubmission, idempotencyKey string) (*types.MailboxSubmission, error) -} - -type mailboxMapper struct{} - -var Mailbox MailboxPersistenceInterface - -func (mailboxMapper) Config(tenantID types.TenantID, uid types.Uid) (*types.MailboxConfig, error) { - if uid.IsZero() { - return nil, types.ErrMalformed - } - tenantAdp, err := tenantBusinessAdapter(tenantID) - if err != nil { - return nil, err - } - return tenantAdp.TenantMailboxConfigGet(tenantID, uid) -} - -func (mailboxMapper) Submit(tenantID types.TenantID, uid types.Uid, submission *types.MailboxSubmission, idempotencyKey string) (*types.MailboxSubmission, error) { - if uid.IsZero() || submission == nil || strings.TrimSpace(submission.SubmissionID) == "" || - strings.TrimSpace(submission.Subject) == "" || strings.TrimSpace(submission.Content) == "" || - strings.TrimSpace(idempotencyKey) == "" { - return nil, types.ErrMalformed - } - tenantAdp, err := tenantBusinessAdapter(tenantID) - if err != nil { - return nil, err - } - return tenantAdp.TenantMailboxSubmissionCreate(tenantID, uid, submission, strings.TrimSpace(idempotencyKey)) -} - type ProfilePersistenceInterface interface { Get(tenantID types.TenantID, uid types.Uid) (*types.UserProfile, error) Update(tenantID types.TenantID, uid types.Uid, patch types.UserProfilePatch) (*types.UserProfile, error) @@ -5175,14 +4580,6 @@ func init() { Conversations = conversationsMapper{} Calls = callsMapper{} GroupBusiness = groupBusinessMapper{} - Workbench = workbenchMapper{} - Organization = organizationMapper{} - Attendance = attendanceMapper{} - Meetings = meetingsMapper{} - MeetingNotes = meetingNotesMapper{} - DailyReports = dailyReportsMapper{} - MonthlyReports = monthlyReportsMapper{} - Mailbox = mailboxMapper{} Profiles = profileMapper{} AppReleases = appReleaseMapper{} DevicePushTokens = devicePushTokenMapper{} diff --git a/server/store/types/attendance.go b/server/store/types/attendance.go deleted file mode 100644 index b4b675a..0000000 --- a/server/store/types/attendance.go +++ /dev/null @@ -1,153 +0,0 @@ -package types - -import "time" - -const ( - AttendanceActionCheckIn = "check_in" - AttendanceActionCheckOut = "check_out" - - AttendanceStatusNotChecked = "not_checked" - AttendanceStatusCheckedIn = "checked_in" - AttendanceStatusNormal = "normal" - AttendanceStatusLate = "late" - AttendanceStatusEarlyLeave = "early_leave" - AttendanceStatusAbnormal = "abnormal" - - AttendanceCheckinNormal = "normal" - AttendanceCheckinLate = "late" - AttendanceCheckinEarlyLeave = "early_leave" - AttendanceCheckinOutsideRange = "outside_range" - - AttendanceAppealPending = "pending" - AttendanceAppealApproved = "approved" - AttendanceAppealRejected = "rejected" - AttendanceAppealCanceled = "canceled" -) - -type AttendanceRule struct { - ID int64 - RuleID string - Name string - Workday bool - LocationRequired bool - WifiRequired bool - AllowOutsideCheckin bool - AppealAllowed bool - AppealDeadline *time.Time - CreatedAt time.Time - UpdatedAt time.Time -} - -type AttendanceShift struct { - ID int64 - ShiftID string - RuleID string - Name string - WorkStart string - WorkEnd string - CheckinStart string - CheckoutEnd string - SortOrder int - CreatedAt time.Time - UpdatedAt time.Time -} - -type AttendanceCheckin struct { - ID int64 - CheckinID string - RecordID string - UserID Uid - Date time.Time - Action string - RuleID string - ShiftID string - ClientTime time.Time - ServerTime time.Time - Result string - Location KVMap - Wifi KVMap - Device KVMap - IdempotencyKey string - CreatedAt time.Time -} - -type AttendanceRecord struct { - ID int64 - RecordID string - UserID Uid - Date time.Time - RuleID string - ShiftID string - Status string - Checkins []AttendanceCheckin - Exceptions []string - AppealStatus string - Version int - CreatedAt time.Time - UpdatedAt time.Time -} - -type AttendanceConfig struct { - Date time.Time - Timezone string - Rule AttendanceRule - Shifts []AttendanceShift - AllowedActions []string - TodayRecord *AttendanceRecord - CanViewManaged bool -} - -type AttendanceRecordListOptions struct { - Scope string - From string - To string - Date string - Status string - DepartmentID string - UserID string - Cursor string - Limit int -} - -type AttendanceRecordStats struct { - ExpectedCount int - CheckedCount int - ExceptionCount int - NotCheckedCount int -} - -type AttendanceCheckinRequest struct { - Date time.Time - Action string - RuleID string - ShiftID string - ClientTime time.Time - Location KVMap - Wifi KVMap - Device KVMap - IdempotencyKey string -} - -type AttendanceAppeal struct { - ID int64 - AppealID string - RecordID string - UserID Uid - AppealType string - Reason string - TargetAction string - TargetTime *time.Time - AttachmentFileIDs []string - Status string - Version int - SubmittedAt time.Time - CreatedAt time.Time - UpdatedAt time.Time -} - -type AttendanceAppealListOptions struct { - Scope string - Status string - Cursor string - Limit int -} diff --git a/server/store/types/mailbox.go b/server/store/types/mailbox.go deleted file mode 100644 index 5c23118..0000000 --- a/server/store/types/mailbox.go +++ /dev/null @@ -1,36 +0,0 @@ -package types - -import "time" - -const MailboxSubmissionStatusReceived = "received" - -type MailboxNotice struct { - Title string - Content string - UpdatedAt time.Time -} - -type MailboxConfig struct { - Enabled bool - AllowAnonymous bool - RequiredFields []string - MaxLength int - AttachmentPolicy map[string]any - Notice MailboxNotice - UpdatedAt time.Time -} - -type MailboxSubmission struct { - ID int64 - SubmissionID string - SenderID Uid - Subject string - Content string - Anonymous bool - AttachmentFileIDs []string - NoticeVersion string - Status string - SubmittedAt time.Time - CreatedAt time.Time - UpdatedAt time.Time -} diff --git a/server/store/types/meetings.go b/server/store/types/meetings.go deleted file mode 100644 index b2b93c3..0000000 --- a/server/store/types/meetings.go +++ /dev/null @@ -1,83 +0,0 @@ -package types - -import "time" - -const ( - MeetingStateScheduled = "scheduled" - MeetingStateEnded = "ended" - MeetingStateCanceled = "canceled" - - MeetingResponsePending = "pending" - MeetingResponseAccepted = "accepted" - MeetingResponseDeclined = "declined" - MeetingResponseTentative = "tentative" -) - -type Meeting struct { - ID int64 - MeetingID string - OrganizerID Uid - RecorderID Uid - Title string - StartAt time.Time - EndAt time.Time - Location string - State string - ReminderMinutes []int - IdempotencyKey string - Version int - CanceledAt *time.Time - CreatedAt time.Time - UpdatedAt time.Time - Participants []MeetingParticipant -} - -type MeetingParticipant struct { - ID int64 - MeetingID string - UserID Uid - Response string - ResponseAt *time.Time - CreatedAt time.Time - UpdatedAt time.Time -} - -type MeetingPatch struct { - Title *string - RecorderID *Uid - StartAt *time.Time - EndAt *time.Time - Location *string - ReminderMinutes []int - Version int -} - -const ( - MeetingNoteStatusEmpty = "empty" - MeetingNoteStatusAvailable = "available" - - MeetingNoteFileStatusActive = "active" - MeetingNoteFileStatusDeleted = "deleted" -) - -type MeetingNoteFile struct { - ID int64 - NoteFileID string - MeetingID string - FileID string - UploaderID Uid - FileName string - MIMEType string - FileSize int64 - FileETag string - Status string - PreviewStatus string - PreviewMode string - PreviewFileID string - IdempotencyKey string - Version int - DeletedBy Uid - DeletedAt *time.Time - CreatedAt time.Time - UpdatedAt time.Time -} diff --git a/server/store/types/organization.go b/server/store/types/organization.go deleted file mode 100644 index 290615b..0000000 --- a/server/store/types/organization.go +++ /dev/null @@ -1,40 +0,0 @@ -package types - -import "time" - -type OrganizationDepartment struct { - ID int64 `db:"id"` - DepartmentID string `db:"department_id"` - Name string `db:"name"` - ParentID string `db:"parent_id"` - SortOrder int `db:"sort_order"` - ChildCount int `db:"child_count"` - MemberCount int `db:"member_count"` - Version int `db:"version"` - State string `db:"state"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` -} - -type OrganizationMember struct { - ID int64 `db:"id"` - DepartmentID string `db:"department_id"` - UserID Uid `db:"-"` - DisplayName string `db:"display_name"` - Position string `db:"position"` - AvatarFileID string `db:"avatar_file_id"` - Mobile string `db:"mobile"` - Email string `db:"email"` - Status string `db:"employment_state"` - Contactable bool `db:"contactable"` - SortOrder int `db:"sort_order"` - Version int `db:"version"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` -} - -type OrganizationSearchItem struct { - Type string - Department *OrganizationDepartment - Member *OrganizationMember -} diff --git a/server/store/types/reports.go b/server/store/types/reports.go deleted file mode 100644 index 8eb4db5..0000000 --- a/server/store/types/reports.go +++ /dev/null @@ -1,51 +0,0 @@ -package types - -import "time" - -const ( - DailyReportStatusDraft = "draft" - DailyReportStatusSubmitted = "submitted" -) - -type DailyReport struct { - ID int64 - ReportID string - AuthorID Uid - ReportDate time.Time - Content string - Attachments []string - Status string - ReadState string - Version int - SubmittedAt *time.Time - CreatedAt time.Time - UpdatedAt time.Time -} - -type DailyReportStats struct { - ExpectedCount int - SubmittedCount int - MissingCount int -} - -type MonthlyReport struct { - ID int64 - ReportID string - AuthorID Uid - Month string - WorkSummary string - NextPlan string - Attachments []string - Status string - ReadState string - Version int - SubmittedAt *time.Time - CreatedAt time.Time - UpdatedAt time.Time -} - -type MonthlyReportStats struct { - ExpectedCount int - SubmittedCount int - MissingCount int -} diff --git a/server/store/types/workbench.go b/server/store/types/workbench.go deleted file mode 100644 index 0ed89d1..0000000 --- a/server/store/types/workbench.go +++ /dev/null @@ -1,18 +0,0 @@ -package types - -import "time" - -type WorkbenchModule struct { - ID int64 `db:"id"` - Code string `db:"code"` - Name string `db:"name"` - IconFileID string `db:"icon_file_id"` - Route string `db:"route"` - SortOrder int `db:"sort_order"` - UnreadKey string `db:"unread_key"` - UnreadCount int `db:"unread_count"` - Enabled bool `db:"enabled"` - Permission string `db:"permission"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` -} diff --git a/server/tinode.conf b/server/tinode.conf index 0427332..8f2cf91 100644 --- a/server/tinode.conf +++ b/server/tinode.conf @@ -137,7 +137,6 @@ "message_persist": "im.message.persist", "group_message_persist": "im.group.message.persist", "message_push": "im.message.push", - "workbench_external": "im.workbench.event", "file_security": "im.file.security", "cache_invalidate": "im.cache.invalidate", "audit_event": "im.audit.event",