feat: add meeting notes backend
This commit is contained in:
@@ -412,6 +412,11 @@ type TenantBusinessAdapter interface {
|
||||
TenantMeetingCancel(t.TenantID, t.Uid, string, string) (*t.Meeting, error)
|
||||
TenantMeetingRemind(t.TenantID, t.Uid, string, string) ([]t.MeetingParticipant, error)
|
||||
TenantMeetingRespond(t.TenantID, t.Uid, string, string, int) (*t.Meeting, error)
|
||||
TenantMeetingNoteList(t.TenantID, t.Uid, string, string, int) ([]t.Meeting, string, error)
|
||||
TenantMeetingNoteFiles(t.TenantID, t.Uid, string) ([]t.MeetingNoteFile, error)
|
||||
TenantMeetingNoteFileCreate(t.TenantID, t.Uid, string, string, string, string) (*t.MeetingNoteFile, error)
|
||||
TenantMeetingNoteFileReplace(t.TenantID, t.Uid, string, string, string, string, int) (*t.MeetingNoteFile, error)
|
||||
TenantMeetingNoteFileDelete(t.TenantID, t.Uid, string, string, string, int) (*t.MeetingNoteFile, error)
|
||||
TenantDailyReportList(t.TenantID, t.Uid, string, string, string, string, string, int) ([]t.DailyReport, string, error)
|
||||
TenantDailyReportCreateDraft(t.TenantID, t.Uid, string, time.Time) (*t.DailyReport, error)
|
||||
TenantDailyReportGet(t.TenantID, t.Uid, string) (*t.DailyReport, error)
|
||||
|
||||
+120
-1
@@ -281,6 +281,9 @@ func (a *adapter) Open(jsonconfig json.RawMessage) error {
|
||||
if err = ensureIMAttendanceTables(a.db); err != nil {
|
||||
return errors.New("mysql adapter failed to ensure attendance schema: " + err.Error())
|
||||
}
|
||||
if err = ensureIMMeetingNotesTables(a.db); err != nil {
|
||||
return errors.New("mysql adapter failed to ensure meeting notes schema: " + err.Error())
|
||||
}
|
||||
if err = ensureSnowflakePrimaryKeys(a.db); err != nil {
|
||||
return errors.New("mysql adapter failed to ensure snowflake primary keys: " + err.Error())
|
||||
}
|
||||
@@ -521,6 +524,10 @@ func (a *adapter) TenantAdapterFor(tenantID t.TenantID) (dbadapter.TenantBusines
|
||||
db.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
if err = ensureIMMeetingNotesTables(db); err != nil {
|
||||
db.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
if err = ensureSnowflakePrimaryKeys(db); err != nil {
|
||||
db.Close()
|
||||
return nil, nil, err
|
||||
@@ -1212,6 +1219,7 @@ func createIMWorkbenchTables(tx *sql.Tx) error {
|
||||
('organization','组织架构','/organization',10,'organization',1,'enabled'),
|
||||
('attendance','打卡','/attendance',20,'attendance',1,'enabled'),
|
||||
('meetings','会议通知','/meetings',30,'meetings',1,'enabled'),
|
||||
('meeting_notes','会议笔记','/meeting-notes',35,'',1,'enabled'),
|
||||
('daily_reports','日报','/reports/daily',40,'daily_reports',1,'enabled'),
|
||||
('monthly_reports','月报','/reports/monthly',50,'monthly_reports',1,'enabled'),
|
||||
('executive_mailbox','总经理信箱','/executive-mailbox',60,'executive_mailbox',1,'enabled')
|
||||
@@ -1453,6 +1461,7 @@ func createIMMeetingTables(tx *sql.Tx) error {
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
meeting_id VARCHAR(64) NOT NULL,
|
||||
organizer_id BIGINT NOT NULL,
|
||||
recorder_user_id BIGINT NOT NULL,
|
||||
title VARCHAR(200) NOT NULL,
|
||||
start_at DATETIME(3) NOT NULL,
|
||||
end_at DATETIME(3) NOT NULL,
|
||||
@@ -1468,6 +1477,7 @@ func createIMMeetingTables(tx *sql.Tx) error {
|
||||
UNIQUE INDEX uk_im_meetings_meeting(meeting_id),
|
||||
UNIQUE INDEX uk_im_meetings_idempotency(organizer_id, idempotency_key),
|
||||
INDEX idx_im_meetings_organizer(organizer_id, start_at),
|
||||
INDEX idx_im_meetings_recorder(recorder_user_id, end_at),
|
||||
INDEX idx_im_meetings_state_time(state, start_at)
|
||||
)`,
|
||||
`CREATE TABLE im_meeting_participants(
|
||||
@@ -1484,6 +1494,33 @@ func createIMMeetingTables(tx *sql.Tx) error {
|
||||
INDEX idx_im_meeting_participants_response(meeting_id, response),
|
||||
CONSTRAINT fk_im_meeting_participants_meeting FOREIGN KEY(meeting_id) REFERENCES im_meetings(meeting_id) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE TABLE im_meeting_note_files(
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
note_file_id VARCHAR(64) NOT NULL,
|
||||
meeting_id VARCHAR(64) NOT NULL,
|
||||
file_id VARCHAR(64) NOT NULL,
|
||||
uploader_id BIGINT NOT NULL,
|
||||
file_name VARCHAR(255) NOT NULL DEFAULT '',
|
||||
mime_type VARCHAR(128) NOT NULL DEFAULT '',
|
||||
file_size BIGINT NOT NULL DEFAULT 0,
|
||||
file_etag VARCHAR(128) NOT NULL DEFAULT '',
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'active',
|
||||
preview_status VARCHAR(16) NOT NULL DEFAULT 'ready',
|
||||
preview_mode VARCHAR(16) NOT NULL DEFAULT 'native',
|
||||
preview_file_id VARCHAR(64) NOT NULL DEFAULT '',
|
||||
idempotency_key VARCHAR(128) NOT NULL DEFAULT '',
|
||||
version INT NOT NULL DEFAULT 1,
|
||||
deleted_by BIGINT,
|
||||
deleted_at DATETIME(3),
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY(id),
|
||||
UNIQUE INDEX uk_im_meeting_note_files_id(note_file_id),
|
||||
INDEX idx_im_meeting_note_files_file(meeting_id,file_id,status),
|
||||
UNIQUE INDEX uk_im_meeting_note_files_idempotency(meeting_id,uploader_id,idempotency_key),
|
||||
INDEX idx_im_meeting_note_files_meeting(meeting_id,status,id DESC),
|
||||
INDEX idx_im_meeting_note_files_uploader(uploader_id,id DESC)
|
||||
)`,
|
||||
}
|
||||
for _, stmt := range statements {
|
||||
if _, err := tx.Exec(stmt); err != nil {
|
||||
@@ -1493,6 +1530,63 @@ func createIMMeetingTables(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureIMMeetingNotesTables(db *sqlx.DB) error {
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
var recorderExists bool
|
||||
if err := db.QueryRow(`SELECT EXISTS(
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema=DATABASE() AND table_name='im_meetings' AND column_name='recorder_user_id'
|
||||
)`).Scan(&recorderExists); err != nil {
|
||||
return err
|
||||
}
|
||||
if !recorderExists {
|
||||
if _, err := db.Exec(`ALTER TABLE im_meetings ADD COLUMN recorder_user_id BIGINT NOT NULL DEFAULT 0 AFTER organizer_id`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(`UPDATE im_meetings SET recorder_user_id=organizer_id WHERE recorder_user_id=0`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(`CREATE INDEX idx_im_meetings_recorder ON im_meetings(recorder_user_id,end_at)`); err != nil && !isDupe(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS im_meeting_note_files(
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
note_file_id VARCHAR(64) NOT NULL,
|
||||
meeting_id VARCHAR(64) NOT NULL,
|
||||
file_id VARCHAR(64) NOT NULL,
|
||||
uploader_id BIGINT NOT NULL,
|
||||
file_name VARCHAR(255) NOT NULL DEFAULT '',
|
||||
mime_type VARCHAR(128) NOT NULL DEFAULT '',
|
||||
file_size BIGINT NOT NULL DEFAULT 0,
|
||||
file_etag VARCHAR(128) NOT NULL DEFAULT '',
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'active',
|
||||
preview_status VARCHAR(16) NOT NULL DEFAULT 'ready',
|
||||
preview_mode VARCHAR(16) NOT NULL DEFAULT 'native',
|
||||
preview_file_id VARCHAR(64) NOT NULL DEFAULT '',
|
||||
idempotency_key VARCHAR(128) NOT NULL DEFAULT '',
|
||||
version INT NOT NULL DEFAULT 1,
|
||||
deleted_by BIGINT,
|
||||
deleted_at DATETIME(3),
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY(id),
|
||||
UNIQUE INDEX uk_im_meeting_note_files_id(note_file_id),
|
||||
INDEX idx_im_meeting_note_files_file(meeting_id,file_id,status),
|
||||
UNIQUE INDEX uk_im_meeting_note_files_idempotency(meeting_id,uploader_id,idempotency_key),
|
||||
INDEX idx_im_meeting_note_files_meeting(meeting_id,status,id DESC),
|
||||
INDEX idx_im_meeting_note_files_uploader(uploader_id,id DESC)
|
||||
)`); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := db.Exec(`INSERT INTO im_workbench_apps(code,name,route,sort_order,unread_key,enabled,permission)
|
||||
VALUES('meeting_notes','会议笔记','/meeting-notes',35,'',1,'enabled')
|
||||
ON DUPLICATE KEY UPDATE name=VALUES(name),route=VALUES(route),sort_order=VALUES(sort_order),unread_key=VALUES(unread_key),updated_at=CURRENT_TIMESTAMP(3)`)
|
||||
return err
|
||||
}
|
||||
|
||||
func createIMReportTables(tx *sql.Tx) error {
|
||||
if _, err := tx.Exec(
|
||||
`CREATE TABLE im_daily_reports(
|
||||
@@ -4936,6 +5030,14 @@ func (a *adapter) FileDeleteUnused(olderThan time.Time, limit int) ([]string, er
|
||||
if err = rows.Scan(&id, &loc); err != nil {
|
||||
break
|
||||
}
|
||||
var noteRefs int
|
||||
fileID := store.EncodeUid(int64(id)).String()
|
||||
if err = tx.QueryRow(`SELECT COUNT(*) FROM im_meeting_note_files WHERE file_id=? AND status=? LIMIT 1`, fileID, t.MeetingNoteFileStatusActive).Scan(¬eRefs); err != nil {
|
||||
break
|
||||
}
|
||||
if noteRefs > 0 {
|
||||
continue
|
||||
}
|
||||
if loc != "" {
|
||||
locations = append(locations, loc)
|
||||
}
|
||||
@@ -5075,8 +5177,25 @@ func (a *adapter) FileUserHasAccess(fid string, uid t.Uid) (bool, error) {
|
||||
AND d.low<=m.seqid AND (d.hi=0 OR d.hi>m.seqid)
|
||||
)
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM im_meeting_note_files AS mnf
|
||||
INNER JOIN im_meetings AS mtg
|
||||
ON mtg.meeting_id=mnf.meeting_id
|
||||
WHERE mnf.file_id=? AND mnf.status='active'
|
||||
AND mtg.state<>?
|
||||
AND mtg.end_at<=CURRENT_TIMESTAMP(3)
|
||||
AND (
|
||||
mtg.organizer_id=?
|
||||
OR mtg.recorder_user_id=?
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM im_meeting_participants AS mp
|
||||
WHERE mp.meeting_id=mtg.meeting_id AND mp.user_id=?
|
||||
)
|
||||
)
|
||||
)
|
||||
) LIMIT 1`,
|
||||
store.DecodeUid(fileID), t.UploadCompleted, store.DecodeUid(uid), store.DecodeUid(uid), store.DecodeUid(uid), store.DecodeUid(uid), store.DecodeUid(uid))
|
||||
store.DecodeUid(fileID), t.UploadCompleted, store.DecodeUid(uid), store.DecodeUid(uid), store.DecodeUid(uid), store.DecodeUid(uid), store.DecodeUid(uid),
|
||||
fid, t.MeetingStateCanceled, store.DecodeUid(uid), store.DecodeUid(uid), store.DecodeUid(uid))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
@@ -3346,8 +3346,12 @@ func (a *adapter) TenantMeetingCreate(id t.TenantID, uid t.Uid, meeting *t.Meeti
|
||||
if meeting.IdempotencyKey != "" {
|
||||
idempotencyKey = meeting.IdempotencyKey
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO im_meetings(meeting_id,organizer_id,title,start_at,end_at,location,state,reminder_minutes,idempotency_key,version)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,1)`, meeting.MeetingID, decodeUID(uid), strings.TrimSpace(meeting.Title), meeting.StartAt.UTC(), meeting.EndAt.UTC(), strings.TrimSpace(meeting.Location),
|
||||
recorderID := meeting.RecorderID
|
||||
if recorderID.IsZero() {
|
||||
recorderID = uid
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO im_meetings(meeting_id,organizer_id,recorder_user_id,title,start_at,end_at,location,state,reminder_minutes,idempotency_key,version)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,1)`, meeting.MeetingID, decodeUID(uid), decodeUID(recorderID), strings.TrimSpace(meeting.Title), meeting.StartAt.UTC(), meeting.EndAt.UTC(), strings.TrimSpace(meeting.Location),
|
||||
t.MeetingStateScheduled, string(reminderJSON), idempotencyKey)
|
||||
if err != nil {
|
||||
if isDupe(err) && meeting.IdempotencyKey != "" {
|
||||
@@ -3357,7 +3361,7 @@ func (a *adapter) TenantMeetingCreate(id t.TenantID, uid t.Uid, meeting *t.Meeti
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
participants := uniqueMeetingParticipants(uid, attendeeIDs)
|
||||
participants := uniqueMeetingParticipants(uid, append(attendeeIDs, recorderID))
|
||||
now := time.Now().UTC().Round(time.Millisecond)
|
||||
for _, participant := range participants {
|
||||
response := t.MeetingResponsePending
|
||||
@@ -3400,10 +3404,10 @@ func (a *adapter) TenantMeetingList(id t.TenantID, uid t.Uid, cursor string, lim
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
where := []string{`(m.organizer_id=? OR EXISTS (
|
||||
where := []string{`(m.organizer_id=? OR m.recorder_user_id=? OR EXISTS (
|
||||
SELECT 1 FROM im_meeting_participants AS p WHERE p.meeting_id=m.meeting_id AND p.user_id=?
|
||||
))`}
|
||||
args := []any{decodeUID(uid), decodeUID(uid)}
|
||||
args := []any{decodeUID(uid), decodeUID(uid), decodeUID(uid)}
|
||||
if strings.TrimSpace(cursor) != "" {
|
||||
parsed, err := strconv.ParseInt(strings.TrimSpace(cursor), 10, 64)
|
||||
if err != nil || parsed <= 0 {
|
||||
@@ -3431,6 +3435,7 @@ func (a *adapter) TenantMeetingList(id t.TenantID, uid t.Uid, cursor string, lim
|
||||
ID int64
|
||||
MeetingID string `db:"meeting_id"`
|
||||
OrganizerID int64 `db:"organizer_id"`
|
||||
RecorderID int64 `db:"recorder_user_id"`
|
||||
Title string
|
||||
StartAt time.Time `db:"start_at"`
|
||||
EndAt time.Time `db:"end_at"`
|
||||
@@ -3450,6 +3455,7 @@ func (a *adapter) TenantMeetingList(id t.TenantID, uid t.Uid, cursor string, lim
|
||||
ID: row.ID,
|
||||
MeetingID: row.MeetingID,
|
||||
OrganizerID: store.EncodeUid(row.OrganizerID),
|
||||
RecorderID: store.EncodeUid(row.RecorderID),
|
||||
Title: row.Title,
|
||||
StartAt: row.StartAt,
|
||||
EndAt: row.EndAt,
|
||||
@@ -3463,6 +3469,9 @@ func (a *adapter) TenantMeetingList(id t.TenantID, uid t.Uid, cursor string, lim
|
||||
if row.IdempotencyKey.Valid {
|
||||
meeting.IdempotencyKey = row.IdempotencyKey.String
|
||||
}
|
||||
if meeting.RecorderID.IsZero() {
|
||||
meeting.RecorderID = meeting.OrganizerID
|
||||
}
|
||||
if len(row.ReminderMinutes) > 0 {
|
||||
_ = json.Unmarshal(row.ReminderMinutes, &meeting.ReminderMinutes)
|
||||
}
|
||||
@@ -3571,6 +3580,13 @@ func (a *adapter) TenantMeetingUpdate(id t.TenantID, uid t.Uid, meetingID string
|
||||
sets = append(sets, "location=?")
|
||||
args = append(args, strings.TrimSpace(*patch.Location))
|
||||
}
|
||||
if patch.RecorderID != nil {
|
||||
if patch.RecorderID.IsZero() {
|
||||
return nil, t.ErrMalformed
|
||||
}
|
||||
sets = append(sets, "recorder_user_id=?")
|
||||
args = append(args, decodeUID(*patch.RecorderID))
|
||||
}
|
||||
if patch.ReminderMinutes != nil {
|
||||
encoded, err := json.Marshal(patch.ReminderMinutes)
|
||||
if err != nil {
|
||||
@@ -3600,6 +3616,12 @@ func (a *adapter) TenantMeetingUpdate(id t.TenantID, uid t.Uid, meetingID string
|
||||
if affected == 0 {
|
||||
return nil, t.ErrInvalidResponse
|
||||
}
|
||||
if patch.RecorderID != nil {
|
||||
if _, err = tx.ExecContext(ctx, `INSERT IGNORE INTO im_meeting_participants(meeting_id,user_id,response,response_at)
|
||||
VALUES(?,?,?,?)`, meeting.MeetingID, decodeUID(*patch.RecorderID), t.MeetingResponsePending, nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
participantIDs, err := meetingParticipantIDsTx(ctx, tx, meeting.MeetingID, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -3881,6 +3903,7 @@ func (a *adapter) meetingByID(ctx context.Context, meetingID string, viewer t.Ui
|
||||
ID int64
|
||||
MeetingID string `db:"meeting_id"`
|
||||
OrganizerID int64 `db:"organizer_id"`
|
||||
RecorderID int64 `db:"recorder_user_id"`
|
||||
Title string
|
||||
StartAt time.Time `db:"start_at"`
|
||||
EndAt time.Time `db:"end_at"`
|
||||
@@ -3894,9 +3917,9 @@ func (a *adapter) meetingByID(ctx context.Context, meetingID string, viewer t.Ui
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}{}
|
||||
if err := sqlx.GetContext(ctx, a.db, &row, `SELECT m.* FROM im_meetings AS m
|
||||
WHERE m.meeting_id=? AND (m.organizer_id=? OR EXISTS (
|
||||
WHERE m.meeting_id=? AND (m.organizer_id=? OR m.recorder_user_id=? OR EXISTS (
|
||||
SELECT 1 FROM im_meeting_participants AS p WHERE p.meeting_id=m.meeting_id AND p.user_id=?
|
||||
))`, meetingID, decodeUID(viewer), decodeUID(viewer)); err != nil {
|
||||
))`, meetingID, decodeUID(viewer), decodeUID(viewer), decodeUID(viewer)); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, t.ErrNotFound
|
||||
}
|
||||
@@ -3906,6 +3929,7 @@ func (a *adapter) meetingByID(ctx context.Context, meetingID string, viewer t.Ui
|
||||
ID: row.ID,
|
||||
MeetingID: row.MeetingID,
|
||||
OrganizerID: store.EncodeUid(row.OrganizerID),
|
||||
RecorderID: store.EncodeUid(row.RecorderID),
|
||||
Title: row.Title,
|
||||
StartAt: row.StartAt,
|
||||
EndAt: row.EndAt,
|
||||
@@ -3919,6 +3943,9 @@ func (a *adapter) meetingByID(ctx context.Context, meetingID string, viewer t.Ui
|
||||
if row.IdempotencyKey.Valid {
|
||||
meeting.IdempotencyKey = row.IdempotencyKey.String
|
||||
}
|
||||
if meeting.RecorderID.IsZero() {
|
||||
meeting.RecorderID = meeting.OrganizerID
|
||||
}
|
||||
if len(row.ReminderMinutes) > 0 {
|
||||
_ = json.Unmarshal(row.ReminderMinutes, &meeting.ReminderMinutes)
|
||||
}
|
||||
@@ -3969,6 +3996,402 @@ func (a *adapter) meetingParticipantsByQuery(ctx context.Context, query string,
|
||||
return participants, nil
|
||||
}
|
||||
|
||||
func (a *adapter) TenantMeetingNoteList(id t.TenantID, uid t.Uid, query, cursor string, limit int) ([]t.Meeting, string, error) {
|
||||
if !a.tenantScopedDB {
|
||||
tenantAdp, err := a.routedTenantAdapter(id)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if tenantAdp != a {
|
||||
return tenantAdp.TenantMeetingNoteList(id, uid, query, cursor, limit)
|
||||
}
|
||||
}
|
||||
if err := validTenant(id); err != nil || uid.IsZero() {
|
||||
return nil, "", t.ErrMalformed
|
||||
}
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
where := []string{`m.state<>?`, `m.end_at<=?`, `(m.organizer_id=? OR m.recorder_user_id=? OR EXISTS (
|
||||
SELECT 1 FROM im_meeting_participants AS p WHERE p.meeting_id=m.meeting_id AND p.user_id=?
|
||||
))`}
|
||||
args := []any{t.MeetingStateCanceled, time.Now().UTC(), decodeUID(uid), decodeUID(uid), decodeUID(uid)}
|
||||
if q := strings.TrimSpace(query); q != "" {
|
||||
where = append(where, "(m.title LIKE ? OR m.location LIKE ?)")
|
||||
like := "%" + q + "%"
|
||||
args = append(args, like, like)
|
||||
}
|
||||
if strings.TrimSpace(cursor) != "" {
|
||||
parsed, err := strconv.ParseInt(strings.TrimSpace(cursor), 10, 64)
|
||||
if err != nil || parsed <= 0 {
|
||||
return nil, "", t.ErrMalformed
|
||||
}
|
||||
where = append(where, "m.id<?")
|
||||
args = append(args, parsed)
|
||||
}
|
||||
ctx, cancel := a.getContext()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
args = append(args, limit+1)
|
||||
rows, err := a.db.QueryxContext(ctx, `SELECT m.* FROM im_meetings AS m
|
||||
WHERE `+strings.Join(where, " AND ")+`
|
||||
ORDER BY m.end_at DESC,m.id DESC LIMIT ?`, args...)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
defer rows.Close()
|
||||
meetings := make([]t.Meeting, 0, limit)
|
||||
nextCursor := ""
|
||||
for rows.Next() {
|
||||
meeting, rowID, err := scanMeetingRow(rows)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if len(meetings) == limit {
|
||||
nextCursor = strconv.FormatInt(rowID, 10)
|
||||
continue
|
||||
}
|
||||
participants, err := a.meetingParticipants(ctx, meeting.MeetingID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
meeting.Participants = participants
|
||||
meeting.State = t.MeetingStateEnded
|
||||
meetings = append(meetings, *meeting)
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return meetings, nextCursor, nil
|
||||
}
|
||||
|
||||
func (a *adapter) TenantMeetingNoteFiles(id t.TenantID, uid t.Uid, meetingID string) ([]t.MeetingNoteFile, error) {
|
||||
if !a.tenantScopedDB {
|
||||
tenantAdp, err := a.routedTenantAdapter(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tenantAdp != a {
|
||||
return tenantAdp.TenantMeetingNoteFiles(id, uid, meetingID)
|
||||
}
|
||||
}
|
||||
if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(meetingID) == "" {
|
||||
return nil, t.ErrMalformed
|
||||
}
|
||||
ctx, cancel := a.getContext()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
if _, err := a.meetingNoteVisible(ctx, uid, strings.TrimSpace(meetingID)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a.meetingNoteFiles(ctx, strings.TrimSpace(meetingID))
|
||||
}
|
||||
|
||||
func (a *adapter) TenantMeetingNoteFileCreate(id t.TenantID, uid t.Uid, meetingID, noteFileID, fileID, idempotencyKey string) (*t.MeetingNoteFile, error) {
|
||||
if !a.tenantScopedDB {
|
||||
tenantAdp, err := a.routedTenantAdapter(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tenantAdp != a {
|
||||
return tenantAdp.TenantMeetingNoteFileCreate(id, uid, meetingID, noteFileID, fileID, idempotencyKey)
|
||||
}
|
||||
}
|
||||
if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(meetingID) == "" || strings.TrimSpace(noteFileID) == "" || strings.TrimSpace(fileID) == "" || strings.TrimSpace(idempotencyKey) == "" {
|
||||
return nil, t.ErrMalformed
|
||||
}
|
||||
ctx, cancel := a.getContext()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
meeting, err := a.meetingNoteWritable(ctx, uid, strings.TrimSpace(meetingID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
file, err := a.TenantFileGet(id, strings.TrimSpace(fileID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if file == nil || file.Status != t.UploadCompleted {
|
||||
return nil, t.ErrNotFound
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx, `INSERT INTO im_meeting_note_files(note_file_id,meeting_id,file_id,uploader_id,file_name,mime_type,file_size,file_etag,status,preview_status,preview_mode,idempotency_key,version)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,1)`, strings.TrimSpace(noteFileID), meeting.MeetingID, strings.TrimSpace(fileID), decodeUID(uid), strings.TrimSpace(fileID),
|
||||
file.MimeType, file.Size, file.ETag, t.MeetingNoteFileStatusActive, "ready", "native", strings.TrimSpace(idempotencyKey))
|
||||
if err != nil {
|
||||
if isDupe(err) {
|
||||
return a.meetingNoteFileByIdempotency(ctx, meeting.MeetingID, uid, strings.TrimSpace(idempotencyKey))
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return a.meetingNoteFileByID(ctx, meeting.MeetingID, strings.TrimSpace(noteFileID))
|
||||
}
|
||||
|
||||
func (a *adapter) TenantMeetingNoteFileReplace(id t.TenantID, uid t.Uid, meetingID, noteFileID, fileID, idempotencyKey string, version int) (*t.MeetingNoteFile, error) {
|
||||
if !a.tenantScopedDB {
|
||||
tenantAdp, err := a.routedTenantAdapter(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tenantAdp != a {
|
||||
return tenantAdp.TenantMeetingNoteFileReplace(id, uid, meetingID, noteFileID, fileID, idempotencyKey, version)
|
||||
}
|
||||
}
|
||||
if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(meetingID) == "" || strings.TrimSpace(noteFileID) == "" || strings.TrimSpace(fileID) == "" || strings.TrimSpace(idempotencyKey) == "" || version <= 0 {
|
||||
return nil, t.ErrMalformed
|
||||
}
|
||||
ctx, cancel := a.getContext()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
meeting, err := a.meetingNoteWritable(ctx, uid, strings.TrimSpace(meetingID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
file, err := a.TenantFileGet(id, strings.TrimSpace(fileID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if file == nil || file.Status != t.UploadCompleted {
|
||||
return nil, t.ErrNotFound
|
||||
}
|
||||
res, err := a.db.ExecContext(ctx, `UPDATE im_meeting_note_files
|
||||
SET file_id=?,file_name=?,mime_type=?,file_size=?,file_etag=?,idempotency_key=?,version=version+1,updated_at=CURRENT_TIMESTAMP(3)
|
||||
WHERE meeting_id=? AND note_file_id=? AND uploader_id=? AND status=? AND version=?`,
|
||||
strings.TrimSpace(fileID), strings.TrimSpace(fileID), file.MimeType, file.Size, file.ETag, strings.TrimSpace(idempotencyKey),
|
||||
meeting.MeetingID, strings.TrimSpace(noteFileID), decodeUID(uid), t.MeetingNoteFileStatusActive, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
affected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if affected == 0 {
|
||||
return nil, t.ErrInvalidResponse
|
||||
}
|
||||
return a.meetingNoteFileByID(ctx, meeting.MeetingID, strings.TrimSpace(noteFileID))
|
||||
}
|
||||
|
||||
func (a *adapter) TenantMeetingNoteFileDelete(id t.TenantID, uid t.Uid, meetingID, noteFileID, _ string, version int) (*t.MeetingNoteFile, error) {
|
||||
if !a.tenantScopedDB {
|
||||
tenantAdp, err := a.routedTenantAdapter(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tenantAdp != a {
|
||||
return tenantAdp.TenantMeetingNoteFileDelete(id, uid, meetingID, noteFileID, "", version)
|
||||
}
|
||||
}
|
||||
if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(meetingID) == "" || strings.TrimSpace(noteFileID) == "" || version <= 0 {
|
||||
return nil, t.ErrMalformed
|
||||
}
|
||||
ctx, cancel := a.getContext()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
meeting, err := a.meetingNoteWritable(ctx, uid, strings.TrimSpace(meetingID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item, err := a.meetingNoteFileByID(ctx, meeting.MeetingID, strings.TrimSpace(noteFileID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res, err := a.db.ExecContext(ctx, `UPDATE im_meeting_note_files
|
||||
SET status=?,deleted_by=?,deleted_at=?,version=version+1,updated_at=CURRENT_TIMESTAMP(3)
|
||||
WHERE meeting_id=? AND note_file_id=? AND uploader_id=? AND status=? AND version=?`,
|
||||
t.MeetingNoteFileStatusDeleted, decodeUID(uid), time.Now().UTC().Round(time.Millisecond),
|
||||
meeting.MeetingID, strings.TrimSpace(noteFileID), decodeUID(uid), t.MeetingNoteFileStatusActive, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
affected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if affected == 0 {
|
||||
return nil, t.ErrInvalidResponse
|
||||
}
|
||||
deletedAt := time.Now().UTC().Round(time.Millisecond)
|
||||
item.Status = t.MeetingNoteFileStatusDeleted
|
||||
item.DeletedBy = uid
|
||||
item.DeletedAt = &deletedAt
|
||||
item.Version++
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func scanMeetingRow(scanner interface{ StructScan(any) error }) (*t.Meeting, int64, error) {
|
||||
row := struct {
|
||||
ID int64
|
||||
MeetingID string `db:"meeting_id"`
|
||||
OrganizerID int64 `db:"organizer_id"`
|
||||
RecorderID int64 `db:"recorder_user_id"`
|
||||
Title string
|
||||
StartAt time.Time `db:"start_at"`
|
||||
EndAt time.Time `db:"end_at"`
|
||||
Location string
|
||||
State string
|
||||
ReminderMinutes []byte `db:"reminder_minutes"`
|
||||
IdempotencyKey sql.NullString `db:"idempotency_key"`
|
||||
Version int
|
||||
CanceledAt *time.Time `db:"canceled_at"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}{}
|
||||
if err := scanner.StructScan(&row); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
meeting := &t.Meeting{
|
||||
ID: row.ID,
|
||||
MeetingID: row.MeetingID,
|
||||
OrganizerID: store.EncodeUid(row.OrganizerID),
|
||||
RecorderID: store.EncodeUid(row.RecorderID),
|
||||
Title: row.Title,
|
||||
StartAt: row.StartAt,
|
||||
EndAt: row.EndAt,
|
||||
Location: row.Location,
|
||||
State: row.State,
|
||||
Version: row.Version,
|
||||
CanceledAt: row.CanceledAt,
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
}
|
||||
if meeting.RecorderID.IsZero() {
|
||||
meeting.RecorderID = meeting.OrganizerID
|
||||
}
|
||||
if row.IdempotencyKey.Valid {
|
||||
meeting.IdempotencyKey = row.IdempotencyKey.String
|
||||
}
|
||||
if len(row.ReminderMinutes) > 0 {
|
||||
_ = json.Unmarshal(row.ReminderMinutes, &meeting.ReminderMinutes)
|
||||
}
|
||||
return meeting, row.ID, nil
|
||||
}
|
||||
|
||||
func (a *adapter) meetingNoteVisible(ctx context.Context, uid t.Uid, meetingID string) (*t.Meeting, error) {
|
||||
meeting, err := a.meetingByID(ctx, meetingID, uid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if meeting.State == t.MeetingStateCanceled || time.Now().UTC().Before(meeting.EndAt) {
|
||||
return nil, t.ErrPermissionDenied
|
||||
}
|
||||
participants, err := a.meetingParticipants(ctx, meeting.MeetingID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
meeting.Participants = participants
|
||||
meeting.State = t.MeetingStateEnded
|
||||
return meeting, nil
|
||||
}
|
||||
|
||||
func (a *adapter) meetingNoteWritable(ctx context.Context, uid t.Uid, meetingID string) (*t.Meeting, error) {
|
||||
meeting, err := a.meetingNoteVisible(ctx, uid, meetingID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if meeting.RecorderID != uid {
|
||||
return nil, t.ErrPermissionDenied
|
||||
}
|
||||
return meeting, nil
|
||||
}
|
||||
|
||||
func (a *adapter) meetingNoteFiles(ctx context.Context, meetingID string) ([]t.MeetingNoteFile, error) {
|
||||
rows, err := a.db.QueryxContext(ctx, `SELECT * FROM im_meeting_note_files
|
||||
WHERE meeting_id=? AND status=? ORDER BY id DESC`, meetingID, t.MeetingNoteFileStatusActive)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := make([]t.MeetingNoteFile, 0, 4)
|
||||
for rows.Next() {
|
||||
item, err := scanMeetingNoteFileRow(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (a *adapter) meetingNoteFileByID(ctx context.Context, meetingID, noteFileID string) (*t.MeetingNoteFile, error) {
|
||||
row := a.db.QueryRowxContext(ctx, `SELECT * FROM im_meeting_note_files WHERE meeting_id=? AND note_file_id=? LIMIT 1`, meetingID, noteFileID)
|
||||
item, err := scanMeetingNoteFileRow(row)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, t.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (a *adapter) meetingNoteFileByIdempotency(ctx context.Context, meetingID string, uid t.Uid, idempotencyKey string) (*t.MeetingNoteFile, error) {
|
||||
row := a.db.QueryRowxContext(ctx, `SELECT * FROM im_meeting_note_files WHERE meeting_id=? AND uploader_id=? AND idempotency_key=? LIMIT 1`, meetingID, decodeUID(uid), idempotencyKey)
|
||||
item, err := scanMeetingNoteFileRow(row)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, t.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func scanMeetingNoteFileRow(scanner interface{ StructScan(any) error }) (t.MeetingNoteFile, error) {
|
||||
row := struct {
|
||||
ID int64
|
||||
NoteFileID string `db:"note_file_id"`
|
||||
MeetingID string `db:"meeting_id"`
|
||||
FileID string `db:"file_id"`
|
||||
UploaderID int64 `db:"uploader_id"`
|
||||
FileName string `db:"file_name"`
|
||||
MIMEType string `db:"mime_type"`
|
||||
FileSize int64 `db:"file_size"`
|
||||
FileETag string `db:"file_etag"`
|
||||
Status string
|
||||
PreviewStatus string `db:"preview_status"`
|
||||
PreviewMode string `db:"preview_mode"`
|
||||
PreviewFileID string `db:"preview_file_id"`
|
||||
IdempotencyKey string `db:"idempotency_key"`
|
||||
Version int
|
||||
DeletedBy sql.NullInt64 `db:"deleted_by"`
|
||||
DeletedAt *time.Time `db:"deleted_at"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}{}
|
||||
if err := scanner.StructScan(&row); err != nil {
|
||||
return t.MeetingNoteFile{}, err
|
||||
}
|
||||
item := t.MeetingNoteFile{
|
||||
ID: row.ID,
|
||||
NoteFileID: row.NoteFileID,
|
||||
MeetingID: row.MeetingID,
|
||||
FileID: row.FileID,
|
||||
UploaderID: store.EncodeUid(row.UploaderID),
|
||||
FileName: row.FileName,
|
||||
MIMEType: row.MIMEType,
|
||||
FileSize: row.FileSize,
|
||||
FileETag: row.FileETag,
|
||||
Status: row.Status,
|
||||
PreviewStatus: row.PreviewStatus,
|
||||
PreviewMode: row.PreviewMode,
|
||||
PreviewFileID: row.PreviewFileID,
|
||||
IdempotencyKey: row.IdempotencyKey,
|
||||
Version: row.Version,
|
||||
DeletedAt: row.DeletedAt,
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
}
|
||||
if row.DeletedBy.Valid {
|
||||
item.DeletedBy = store.EncodeUid(row.DeletedBy.Int64)
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func uniqueMeetingParticipants(organizer t.Uid, attendeeIDs []t.Uid) []t.Uid {
|
||||
seen := map[t.Uid]bool{organizer: true}
|
||||
participants := []t.Uid{organizer}
|
||||
|
||||
@@ -380,7 +380,7 @@ func (a *adapter) TenantGroupInvitationCreate(id t.TenantID, inv *t.GroupInvitat
|
||||
inv.IdempotencyKey = inv.InvitationID
|
||||
}
|
||||
if inv.Status == "" {
|
||||
inv.Status = t.GroupInvitationStatusJoined
|
||||
inv.Status = t.GroupInvitationStatusPending
|
||||
}
|
||||
if inv.ExpiresAt.IsZero() {
|
||||
inv.ExpiresAt = now.Add(7 * 24 * time.Hour)
|
||||
|
||||
Reference in New Issue
Block a user