feat: add meeting notes backend

This commit is contained in:
richjohnrambo
2026-09-05 17:55:27 +08:00
parent 9e290b76b3
commit baf15848d3
12 changed files with 1633 additions and 52 deletions
+430 -7
View File
@@ -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}