refactor: remove workbench code from im server
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
+3
-531
@@ -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()
|
||||
|
||||
@@ -86,8 +86,6 @@ func TestRuntimeOpenDoesNotAutoCreateOrMigrateTables(t *testing.T) {
|
||||
"ensureIMAdminGovernanceTables",
|
||||
"ensureIMFileReviewTables",
|
||||
"ensureIMOrganizationVisibilityTables",
|
||||
"ensureIMAttendanceTables",
|
||||
"ensureIMMeetingNotesTables",
|
||||
"ensureSnowflakePrimaryKeys",
|
||||
"ensureUserIdentitySchema",
|
||||
"ensureIMRegistrationInviteUseTable",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+4
-2312
File diff suppressed because it is too large
Load Diff
+5
-1423
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user