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",
|
"im_fileuploads AS avatar",
|
||||||
"JSON_EXTRACT(u.public,'$.avatar')",
|
"JSON_EXTRACT(u.public,'$.avatar')",
|
||||||
"JSON_EXTRACT(u.public,'$.photo.data')",
|
"JSON_EXTRACT(u.public,'$.photo.data')",
|
||||||
"FROM im_org_members AS om",
|
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(usersInternalQuerySpec().Select+usersInternalQuerySpec().From, field) {
|
if !strings.Contains(usersInternalQuerySpec().Select+usersInternalQuerySpec().From, field) {
|
||||||
t.Fatalf("user query missing list field %q", 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
|
TenantGroupOwnerTransfer(t.TenantID, string, t.Uid, t.Uid, int, time.Time) error
|
||||||
TenantGroupCanPost(t.TenantID, string, t.Uid, bool, time.Time) (bool, 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)
|
TenantUserProfileGet(t.TenantID, t.Uid) (*t.UserProfile, error)
|
||||||
TenantUserProfileUpdate(t.TenantID, t.Uid, t.UserProfilePatch) (*t.UserProfile, error)
|
TenantUserProfileUpdate(t.TenantID, t.Uid, t.UserProfilePatch) (*t.UserProfile, error)
|
||||||
TenantUserPrivacyGet(t.TenantID, t.Uid) (*t.UserPrivacySettings, error)
|
TenantUserPrivacyGet(t.TenantID, t.Uid) (*t.UserPrivacySettings, error)
|
||||||
|
|||||||
+3
-531
@@ -844,495 +844,6 @@ func createIMCallTables(tx *sql.Tx) error {
|
|||||||
return err
|
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 {
|
func createIMUserProductTables(tx *sql.Tx) error {
|
||||||
statements := []string{
|
statements := []string{
|
||||||
`CREATE TABLE im_user_profiles(
|
`CREATE TABLE im_user_profiles(
|
||||||
@@ -4789,14 +4300,6 @@ func (a *adapter) FileDeleteUnused(olderThan time.Time, limit int) ([]string, er
|
|||||||
var locations []string
|
var locations []string
|
||||||
var ids []any
|
var ids []any
|
||||||
for _, candidate := range candidates {
|
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 != "" {
|
if candidate.location != "" {
|
||||||
locations = append(locations, candidate.location)
|
locations = append(locations, candidate.location)
|
||||||
}
|
}
|
||||||
@@ -4914,23 +4417,13 @@ func (a *adapter) FileUserHasAccess(fid string, uid t.Uid) (bool, error) {
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
}
|
}
|
||||||
var allowed int
|
var allowed int
|
||||||
buildQuery := func(includeRecorder bool) (string, []any) {
|
buildQuery := func() (string, []any) {
|
||||||
recorderClause := ""
|
|
||||||
if includeRecorder {
|
|
||||||
recorderClause = `
|
|
||||||
OR mtg.recorder_user_id=?`
|
|
||||||
}
|
|
||||||
args := []any{
|
args := []any{
|
||||||
store.DecodeUid(fileID), fileID.String32(), "%/" + fileID.String32(), t.UploadCompleted,
|
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), store.DecodeUid(uid), t.RelationshipFriend,
|
||||||
store.DecodeUid(uid), store.DecodeUid(uid), t.GroupMemberStatusActive, store.DecodeUid(uid), store.DecodeUid(uid),
|
store.DecodeUid(uid), store.DecodeUid(uid), t.GroupMemberStatusActive, store.DecodeUid(uid), store.DecodeUid(uid),
|
||||||
store.DecodeUid(uid), fid, fid,
|
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 `
|
return `
|
||||||
SELECT COUNT(*) FROM im_fileuploads AS fu
|
SELECT COUNT(*) FROM im_fileuploads AS fu
|
||||||
WHERE (fu.id=? OR fu.location=? OR fu.location LIKE ?) AND fu.status=? AND (
|
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
|
SELECT 1 FROM im_group_files AS gf
|
||||||
INNER JOIN im_subscriptions AS s
|
INNER JOIN im_subscriptions AS s
|
||||||
ON s.topic=gf.topic AND s.userid=?
|
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
|
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
|
) LIMIT 1`, args
|
||||||
}
|
}
|
||||||
includeRecorder := true
|
|
||||||
run := func() error {
|
run := func() error {
|
||||||
query, args := buildQuery(includeRecorder)
|
query, args := buildQuery()
|
||||||
return a.db.GetContext(ctx, &allowed, query, args...)
|
return a.db.GetContext(ctx, &allowed, query, args...)
|
||||||
}
|
}
|
||||||
err := run()
|
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++ {
|
for attempt := 0; isRetryableMySQLReadErr(err) && attempt < 2; attempt++ {
|
||||||
time.Sleep(time.Duration(attempt+1) * 25 * time.Millisecond)
|
time.Sleep(time.Duration(attempt+1) * 25 * time.Millisecond)
|
||||||
err = run()
|
err = run()
|
||||||
|
|||||||
@@ -86,8 +86,6 @@ func TestRuntimeOpenDoesNotAutoCreateOrMigrateTables(t *testing.T) {
|
|||||||
"ensureIMAdminGovernanceTables",
|
"ensureIMAdminGovernanceTables",
|
||||||
"ensureIMFileReviewTables",
|
"ensureIMFileReviewTables",
|
||||||
"ensureIMOrganizationVisibilityTables",
|
"ensureIMOrganizationVisibilityTables",
|
||||||
"ensureIMAttendanceTables",
|
|
||||||
"ensureIMMeetingNotesTables",
|
|
||||||
"ensureSnowflakePrimaryKeys",
|
"ensureSnowflakePrimaryKeys",
|
||||||
"ensureUserIdentitySchema",
|
"ensureUserIdentitySchema",
|
||||||
"ensureIMRegistrationInviteUseTable",
|
"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
|
// See https://www.iana.org/assignments/media-types/media-types.xhtml
|
||||||
var allowedMimeTypes = []string{"application/", "audio/", "font/", "image/", "text/", "video/"}
|
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.
|
// RequestPrincipal is a tenant-scoped identity authenticated for HTTP/gRPC file access.
|
||||||
type RequestPrincipal struct {
|
type RequestPrincipal struct {
|
||||||
TenantID types.TenantID
|
TenantID types.TenantID
|
||||||
@@ -257,12 +255,6 @@ func largeFileReceiveHTTP(wrt http.ResponseWriter, req *http.Request) {
|
|||||||
writeHttpResponse(ErrAuthRequired(msgID, "", now, now), nil)
|
writeHttpResponse(ErrAuthRequired(msgID, "", now, now), nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
meetingID, _, err := meetingNoteUploadParams(req, principal)
|
|
||||||
if err != nil {
|
|
||||||
writeHttpResponse(decodeStoreError(err, msgID, now, nil), err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if uploads are handled elsewhere.
|
// Check if uploads are handled elsewhere.
|
||||||
headers, statusCode, err := mh.Headers(principal.TenantID, req.Method, req.URL, req.Header, true)
|
headers, statusCode, err := mh.Headers(principal.TenantID, req.Method, req.URL, req.Header, true)
|
||||||
if err != nil {
|
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.
|
// 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)
|
params["expires"] = now.Add(globals.mediaGcPeriod).Format(types.TimeFormatRFC3339)
|
||||||
}
|
}
|
||||||
if meetingID != "" {
|
|
||||||
params["purpose"] = fileUploadPurposeMeetingNote
|
|
||||||
params["meeting_id"] = meetingID
|
|
||||||
}
|
|
||||||
|
|
||||||
writeHttpResponse(NoErrParams(msgID, "", now, params), nil)
|
writeHttpResponse(NoErrParams(msgID, "", now, params), nil)
|
||||||
logs.Info.Println("media upload: ok", fdef.Id, fdef.Location)
|
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.
|
// LargeFileServe is the gRPC equivalent of largeFileServeHTTP.
|
||||||
func (*grpcNodeServer) LargeFileServe(req *pbx.FileDownReq, stream pbx.Node_LargeFileServeServer) error {
|
func (*grpcNodeServer) LargeFileServe(req *pbx.FileDownReq, stream pbx.Node_LargeFileServeServer) error {
|
||||||
now := types.TimeNow()
|
now := types.TimeNow()
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
|
||||||
"io"
|
"io"
|
||||||
"mime/multipart"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -209,91 +206,3 @@ func TestLargeFileGarbageCollectTenantsScopesDeletes(t *testing.T) {
|
|||||||
|
|
||||||
largeFileGarbageCollectTenants([]types.TenantID{7, 8}, olderThan, 25)
|
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 {
|
func publishIMRelationEvent(tenantID types.TenantID, actor, target types.Uid, stableID, eventType, title, summary string) error {
|
||||||
if target.IsZero() {
|
if target.IsZero() {
|
||||||
return types.ErrMalformed
|
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": {
|
"feedback-config": {
|
||||||
Select: "SELECT id,categories,max_length,attachment_policy,allow_contact,diagnostics_notice,created_at,updated_at",
|
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",
|
From: "FROM im_feedback_config", DefaultSort: "ORDER BY id DESC", DetailKey: "id",
|
||||||
},
|
}, "abuse-reasons": {
|
||||||
"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": {
|
|
||||||
Select: "SELECT id,target_type,reason_code,label,requires_description,allows_evidence,enabled,sort_order,created_at,updated_at",
|
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",
|
From: "FROM im_abuse_reasons", DefaultSort: "ORDER BY sort_order ASC,id ASC", DetailKey: "id",
|
||||||
Filters: []internalQueryFilter{{"target_type", "target_type", "=", "string"}},
|
Filters: []internalQueryFilter{{"target_type", "target_type", "=", "string"}},
|
||||||
@@ -109,28 +104,10 @@ var internalTenantResourceSpecs = map[string]*internalQuerySpec{
|
|||||||
"app-releases": {
|
"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",
|
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",
|
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 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) {
|
func serveInternalAdminResourceList(wrt http.ResponseWriter, req *http.Request, tenantID types.TenantID, resource, scope string) {
|
||||||
spec, ok := internalAdminQuerySpec(resource)
|
spec, ok := internalAdminQuerySpec(resource)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -255,10 +232,6 @@ func internalAdminQuerySpec(resource string) (*internalQuerySpec, bool) {
|
|||||||
spec := internalOutboxResourceSpecs[strings.TrimPrefix(resource, "outbox_")]
|
spec := internalOutboxResourceSpecs[strings.TrimPrefix(resource, "outbox_")]
|
||||||
return spec, spec != nil
|
return spec, spec != nil
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(resource, "org_") {
|
|
||||||
spec := internalOrgResourceSpecs[strings.TrimPrefix(resource, "org_")]
|
|
||||||
return spec, spec != nil
|
|
||||||
}
|
|
||||||
spec := internalTenantResourceSpecs[resource]
|
spec := internalTenantResourceSpecs[resource]
|
||||||
return spec, spec != nil
|
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
|
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,
|
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(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,
|
) AS avatar,
|
||||||
p.signature,sec.auth_version,sec.password_updated_at,u.createdat AS created_at,u.updatedat AS updated_at,
|
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], "")
|
serveInternalAdminResourceList(wrt, req, tenant.ID, "outbox_"+parts[3], "")
|
||||||
case len(parts) == 5 && parts[2] == "outbox" && internalOutboxResourceSpecs[parts[3]] != nil:
|
case len(parts) == 5 && parts[2] == "outbox" && internalOutboxResourceSpecs[parts[3]] != nil:
|
||||||
serveInternalAdminResourceDetail(wrt, req, tenant.ID, "outbox_"+parts[3], parts[4])
|
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":
|
case len(parts) == 4 && parts[2] == "dashboard" && parts[3] == "summary":
|
||||||
serveInternalDashboardSummary(wrt, req, tenant.ID)
|
serveInternalDashboardSummary(wrt, req, tenant.ID)
|
||||||
case len(parts) == 4 && parts[2] == "kafka" && parts[3] == "dead-letters":
|
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" {
|
if topics["audit_event"] != "im.audit.event" {
|
||||||
t.Fatalf("unexpected audit event topic: %q", topics["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 {
|
if _, ok := topics[removed]; ok {
|
||||||
t.Fatalf("internal sync topic %q must not be configured", removed)
|
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)
|
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"
|
kafkaTopicMessagePersist = "im.message.persist"
|
||||||
kafkaTopicGroupMessagePersist = "im.group.message.persist"
|
kafkaTopicGroupMessagePersist = "im.group.message.persist"
|
||||||
kafkaTopicMessagePush = "im.message.push"
|
kafkaTopicMessagePush = "im.message.push"
|
||||||
kafkaTopicWorkbenchExternal = "im.workbench.event"
|
|
||||||
kafkaTopicFileSecurity = "im.file.security"
|
kafkaTopicFileSecurity = "im.file.security"
|
||||||
kafkaTopicCacheInvalidate = "im.cache.invalidate"
|
kafkaTopicCacheInvalidate = "im.cache.invalidate"
|
||||||
kafkaTopicAuditEvent = "im.audit.event"
|
kafkaTopicAuditEvent = "im.audit.event"
|
||||||
@@ -211,7 +210,6 @@ func kafkaTopics(overrides map[string]string) map[string]string {
|
|||||||
"message_persist": kafkaTopicMessagePersist,
|
"message_persist": kafkaTopicMessagePersist,
|
||||||
"group_message_persist": kafkaTopicGroupMessagePersist,
|
"group_message_persist": kafkaTopicGroupMessagePersist,
|
||||||
"message_push": kafkaTopicMessagePush,
|
"message_push": kafkaTopicMessagePush,
|
||||||
"workbench_external": kafkaTopicWorkbenchExternal,
|
|
||||||
"file_security": kafkaTopicFileSecurity,
|
"file_security": kafkaTopicFileSecurity,
|
||||||
"cache_invalidate": kafkaTopicCacheInvalidate,
|
"cache_invalidate": kafkaTopicCacheInvalidate,
|
||||||
"audit_event": kafkaTopicAuditEvent,
|
"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()
|
startKafkaConfigEventConsumer()
|
||||||
startKafkaCacheInvalidateConsumer()
|
startKafkaCacheInvalidateConsumer()
|
||||||
startKafkaMessagePersistConsumer()
|
startKafkaMessagePersistConsumer()
|
||||||
startKafkaWorkbenchEventConsumer()
|
|
||||||
startKafkaDeadLetterConsumer()
|
startKafkaDeadLetterConsumer()
|
||||||
|
|
||||||
// Start accepting cluster traffic.
|
// 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/appeals", serveIMRealNameAppeals)
|
||||||
mux.HandleFunc(config.ApiPath+"v1/im/security/realname/submissions", serveIMRealNameSubmissions)
|
mux.HandleFunc(config.ApiPath+"v1/im/security/realname/submissions", serveIMRealNameSubmissions)
|
||||||
mux.HandleFunc(config.ApiPath+"v1/im/bootstrap", serveIMBootstrap)
|
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/reasons", serveIMAbuseReasons)
|
||||||
mux.HandleFunc(config.ApiPath+"v1/im/reports/abuse", serveIMAbuseReports)
|
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/config", serveIMFeedbackConfig)
|
||||||
mux.HandleFunc(config.ApiPath+"v1/im/feedback", serveIMFeedback)
|
mux.HandleFunc(config.ApiPath+"v1/im/feedback", serveIMFeedback)
|
||||||
mux.HandleFunc(config.ApiPath+"v1/im/app/releases/latest", serveIMAppReleaseLatest)
|
mux.HandleFunc(config.ApiPath+"v1/im/app/releases/latest", serveIMAppReleaseLatest)
|
||||||
|
|||||||
@@ -137,7 +137,6 @@
|
|||||||
"message_persist": "im.message.persist",
|
"message_persist": "im.message.persist",
|
||||||
"group_message_persist": "im.group.message.persist",
|
"group_message_persist": "im.group.message.persist",
|
||||||
"message_push": "im.message.push",
|
"message_push": "im.message.push",
|
||||||
"workbench_external": "im.workbench.event",
|
|
||||||
"file_security": "im.file.security",
|
"file_security": "im.file.security",
|
||||||
"cache_invalidate": "im.cache.invalidate",
|
"cache_invalidate": "im.cache.invalidate",
|
||||||
"audit_event": "im.audit.event",
|
"audit_event": "im.audit.event",
|
||||||
|
|||||||
@@ -2307,601 +2307,6 @@ func groupMemberCacheID(topic string, uid types.Uid) string {
|
|||||||
return strings.TrimSpace(topic) + ":" + 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 {
|
type ProfilePersistenceInterface interface {
|
||||||
Get(tenantID types.TenantID, uid types.Uid) (*types.UserProfile, error)
|
Get(tenantID types.TenantID, uid types.Uid) (*types.UserProfile, error)
|
||||||
Update(tenantID types.TenantID, uid types.Uid, patch types.UserProfilePatch) (*types.UserProfile, error)
|
Update(tenantID types.TenantID, uid types.Uid, patch types.UserProfilePatch) (*types.UserProfile, error)
|
||||||
@@ -5175,14 +4580,6 @@ func init() {
|
|||||||
Conversations = conversationsMapper{}
|
Conversations = conversationsMapper{}
|
||||||
Calls = callsMapper{}
|
Calls = callsMapper{}
|
||||||
GroupBusiness = groupBusinessMapper{}
|
GroupBusiness = groupBusinessMapper{}
|
||||||
Workbench = workbenchMapper{}
|
|
||||||
Organization = organizationMapper{}
|
|
||||||
Attendance = attendanceMapper{}
|
|
||||||
Meetings = meetingsMapper{}
|
|
||||||
MeetingNotes = meetingNotesMapper{}
|
|
||||||
DailyReports = dailyReportsMapper{}
|
|
||||||
MonthlyReports = monthlyReportsMapper{}
|
|
||||||
Mailbox = mailboxMapper{}
|
|
||||||
Profiles = profileMapper{}
|
Profiles = profileMapper{}
|
||||||
AppReleases = appReleaseMapper{}
|
AppReleases = appReleaseMapper{}
|
||||||
DevicePushTokens = devicePushTokenMapper{}
|
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",
|
"message_persist": "im.message.persist",
|
||||||
"group_message_persist": "im.group.message.persist",
|
"group_message_persist": "im.group.message.persist",
|
||||||
"message_push": "im.message.push",
|
"message_push": "im.message.push",
|
||||||
"workbench_external": "im.workbench.event",
|
|
||||||
"file_security": "im.file.security",
|
"file_security": "im.file.security",
|
||||||
"cache_invalidate": "im.cache.invalidate",
|
"cache_invalidate": "im.cache.invalidate",
|
||||||
"audit_event": "im.audit.event",
|
"audit_event": "im.audit.event",
|
||||||
|
|||||||
Reference in New Issue
Block a user