fix: align meeting note payloads
This commit is contained in:
+64
-26
@@ -226,27 +226,32 @@ func meetingPayload(meeting store.Meeting, viewer int64) map[string]any {
|
|||||||
if recorderID == 0 {
|
if recorderID == 0 {
|
||||||
recorderID = meeting.OrganizerID
|
recorderID = meeting.OrganizerID
|
||||||
}
|
}
|
||||||
|
organizer := userSummary(meeting.OrganizerID, meeting.Organizer)
|
||||||
|
recorder := userSummary(recorderID, meeting.Recorder)
|
||||||
|
state := meetingState(meeting)
|
||||||
data := map[string]any{
|
data := map[string]any{
|
||||||
"meeting_id": meeting.MeetingID,
|
"meeting_id": meeting.MeetingID,
|
||||||
"title": meeting.Title,
|
"title": meeting.Title,
|
||||||
"organizer_id": formatUserID(meeting.OrganizerID),
|
"organizer_id": formatUserID(meeting.OrganizerID),
|
||||||
"organizer": userSummary(meeting.OrganizerID),
|
"organizer": organizer,
|
||||||
"recorder_user_id": formatUserID(recorderID),
|
"organizer_display_name": organizer["display_name"],
|
||||||
"recorder": userSummary(recorderID),
|
"recorder_user_id": formatUserID(recorderID),
|
||||||
"start_at": meeting.StartAt.UTC().Format(time.RFC3339Nano),
|
"recorder": recorder,
|
||||||
"end_at": meeting.EndAt.UTC().Format(time.RFC3339Nano),
|
"recorder_display_name": recorder["display_name"],
|
||||||
"meeting_time": timeRange(meeting.StartAt, meeting.EndAt),
|
"start_at": meeting.StartAt.UTC().Format(time.RFC3339Nano),
|
||||||
"time_range": timeRange(meeting.StartAt, meeting.EndAt),
|
"end_at": meeting.EndAt.UTC().Format(time.RFC3339Nano),
|
||||||
"location": meeting.Location,
|
"meeting_time": timeRange(meeting.StartAt, meeting.EndAt),
|
||||||
"state": meeting.State,
|
"time_range": timeRange(meeting.StartAt, meeting.EndAt),
|
||||||
"reminder_minutes": jsonList(meeting.ReminderMinutes),
|
"location": meeting.Location,
|
||||||
"version": meeting.Version,
|
"state": state,
|
||||||
"participants": participants,
|
"reminder_minutes": jsonList(meeting.ReminderMinutes),
|
||||||
"participant_count": len(participants),
|
"version": meeting.Version,
|
||||||
"participant_names": []string{},
|
"participants": participants,
|
||||||
"permissions": meetingPermissions(meeting, viewer),
|
"participant_count": len(participants),
|
||||||
"created_at": meeting.CreatedAt.UTC().Format(time.RFC3339Nano),
|
"participant_names": meetingParticipantNames(participants),
|
||||||
"updated_at": meeting.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
"permissions": meetingPermissions(meeting, viewer),
|
||||||
|
"created_at": meeting.CreatedAt.UTC().Format(time.RFC3339Nano),
|
||||||
|
"updated_at": meeting.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
||||||
}
|
}
|
||||||
if meeting.CanceledAt != nil {
|
if meeting.CanceledAt != nil {
|
||||||
data["canceled_at"] = meeting.CanceledAt.UTC().Format(time.RFC3339Nano)
|
data["canceled_at"] = meeting.CanceledAt.UTC().Format(time.RFC3339Nano)
|
||||||
@@ -263,7 +268,11 @@ func meetingPayload(meeting store.Meeting, viewer int64) map[string]any {
|
|||||||
func meetingParticipantPayloads(participants []store.MeetingParticipant) []map[string]any {
|
func meetingParticipantPayloads(participants []store.MeetingParticipant) []map[string]any {
|
||||||
items := make([]map[string]any, 0, len(participants))
|
items := make([]map[string]any, 0, len(participants))
|
||||||
for _, participant := range participants {
|
for _, participant := range participants {
|
||||||
item := map[string]any{"user_id": formatUserID(participant.UserID), "response": participant.Response}
|
summary := userSummary(participant.UserID, participant.User)
|
||||||
|
item := map[string]any{"user_id": formatUserID(participant.UserID), "response": participant.Response, "display_name": summary["display_name"]}
|
||||||
|
if avatarFileID, ok := summary["avatar_file_id"]; ok {
|
||||||
|
item["avatar_file_id"] = avatarFileID
|
||||||
|
}
|
||||||
if participant.ResponseAt != nil {
|
if participant.ResponseAt != nil {
|
||||||
item["response_at"] = participant.ResponseAt.UTC().Format(time.RFC3339Nano)
|
item["response_at"] = participant.ResponseAt.UTC().Format(time.RFC3339Nano)
|
||||||
}
|
}
|
||||||
@@ -319,7 +328,7 @@ func meetingNoteFilePayload(file store.MeetingNoteFile, viewer int64) map[string
|
|||||||
"preview_mode": file.PreviewMode,
|
"preview_mode": file.PreviewMode,
|
||||||
"preview_file_id": file.PreviewFileID,
|
"preview_file_id": file.PreviewFileID,
|
||||||
"uploader_user_id": formatUserID(file.UploaderID),
|
"uploader_user_id": formatUserID(file.UploaderID),
|
||||||
"uploader": userSummary(file.UploaderID),
|
"uploader": userSummary(file.UploaderID, store.UserSummary{}),
|
||||||
"version": file.Version,
|
"version": file.Version,
|
||||||
"permissions": map[string]any{
|
"permissions": map[string]any{
|
||||||
"can_preview": file.Status == "active",
|
"can_preview": file.Status == "active",
|
||||||
@@ -420,7 +429,7 @@ func mailboxSubmissionPayload(submission store.MailboxSubmission) map[string]any
|
|||||||
|
|
||||||
func meetingPermissions(meeting store.Meeting, viewer int64) map[string]any {
|
func meetingPermissions(meeting store.Meeting, viewer int64) map[string]any {
|
||||||
isOrganizer := meeting.OrganizerID == viewer
|
isOrganizer := meeting.OrganizerID == viewer
|
||||||
isScheduled := meeting.State == "scheduled"
|
isScheduled := meetingState(meeting) == "scheduled"
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"can_update": isOrganizer && isScheduled,
|
"can_update": isOrganizer && isScheduled,
|
||||||
"can_cancel": isOrganizer && isScheduled,
|
"can_cancel": isOrganizer && isScheduled,
|
||||||
@@ -434,12 +443,41 @@ func meetingNotePermissions(meeting store.Meeting, viewer int64) map[string]any
|
|||||||
if recorderID == 0 {
|
if recorderID == 0 {
|
||||||
recorderID = meeting.OrganizerID
|
recorderID = meeting.OrganizerID
|
||||||
}
|
}
|
||||||
canWrite := recorderID == viewer || meeting.OrganizerID == viewer
|
readable := meetingState(meeting) == "ended"
|
||||||
|
canWrite := readable && (recorderID == viewer || meeting.OrganizerID == viewer)
|
||||||
return map[string]any{"can_view": true, "can_upload": canWrite, "can_replace": canWrite, "can_delete": canWrite, "can_download": true}
|
return map[string]any{"can_view": true, "can_upload": canWrite, "can_replace": canWrite, "can_delete": canWrite, "can_download": true}
|
||||||
}
|
}
|
||||||
|
|
||||||
func userSummary(userID int64) map[string]any {
|
func userSummary(userID int64, summary store.UserSummary) map[string]any {
|
||||||
return map[string]any{"user_id": formatUserID(userID), "display_name": formatUserID(userID)}
|
displayName := strings.TrimSpace(summary.DisplayName)
|
||||||
|
if displayName == "" {
|
||||||
|
displayName = formatUserID(userID)
|
||||||
|
}
|
||||||
|
data := map[string]any{"user_id": formatUserID(userID), "display_name": displayName}
|
||||||
|
if strings.TrimSpace(summary.AvatarFileID) != "" {
|
||||||
|
data["avatar_file_id"] = strings.TrimSpace(summary.AvatarFileID)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
func meetingState(meeting store.Meeting) string {
|
||||||
|
state := strings.TrimSpace(meeting.State)
|
||||||
|
if state == "scheduled" && !meeting.EndAt.IsZero() && !time.Now().UTC().Before(meeting.EndAt.UTC()) {
|
||||||
|
return "ended"
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
func meetingParticipantNames(participants []map[string]any) []string {
|
||||||
|
names := make([]string, 0, len(participants))
|
||||||
|
for _, participant := range participants {
|
||||||
|
name, _ := participant["display_name"].(string)
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name != "" {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return names
|
||||||
}
|
}
|
||||||
|
|
||||||
func jsonList(raw string) []any {
|
func jsonList(raw string) []any {
|
||||||
|
|||||||
@@ -47,6 +47,50 @@ func TestPayloadUserIDsAreStrings(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMeetingPayloadIncludesNamesAndDerivedEndedState(t *testing.T) {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
userA, err := parseUserID("usrag7w9xJ6hto")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
userB, err := parseUserID("usr1RqTFgHSenM")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := meetingPayload(store.Meeting{
|
||||||
|
MeetingID: "mtg_1",
|
||||||
|
Title: "周会",
|
||||||
|
OrganizerID: userA,
|
||||||
|
RecorderID: userA,
|
||||||
|
Organizer: store.UserSummary{UserID: userA, DisplayName: "张三"},
|
||||||
|
Recorder: store.UserSummary{UserID: userA, DisplayName: "张三"},
|
||||||
|
StartAt: now.Add(-2 * time.Hour),
|
||||||
|
EndAt: now.Add(-time.Hour),
|
||||||
|
State: "scheduled",
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
Participants: []store.MeetingParticipant{
|
||||||
|
{UserID: userB, Response: "pending", User: store.UserSummary{UserID: userB, DisplayName: "李四"}},
|
||||||
|
},
|
||||||
|
}, userA)
|
||||||
|
|
||||||
|
if payload["state"] != "ended" {
|
||||||
|
t.Fatalf("state=%v, want ended", payload["state"])
|
||||||
|
}
|
||||||
|
if payload["organizer_display_name"] != "张三" {
|
||||||
|
t.Fatalf("organizer_display_name=%v", payload["organizer_display_name"])
|
||||||
|
}
|
||||||
|
names, ok := payload["participant_names"].([]string)
|
||||||
|
if !ok || len(names) != 1 || names[0] != "李四" {
|
||||||
|
t.Fatalf("participant_names=%#v", payload["participant_names"])
|
||||||
|
}
|
||||||
|
participants, ok := payload["participants"].([]map[string]any)
|
||||||
|
if !ok || len(participants) != 1 || participants[0]["display_name"] != "李四" {
|
||||||
|
t.Fatalf("participants=%#v", payload["participants"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func assertNoNumericUserIDs(t *testing.T, value any) {
|
func assertNoNumericUserIDs(t *testing.T, value any) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
switch typed := value.(type) {
|
switch typed := value.(type) {
|
||||||
|
|||||||
@@ -331,6 +331,7 @@ func (s *Store) Meetings(ctx context.Context, userID int64, cursor string, limit
|
|||||||
}
|
}
|
||||||
for i := range items {
|
for i := range items {
|
||||||
items[i].Participants, _ = s.meetingParticipants(ctx, items[i].MeetingID)
|
items[i].Participants, _ = s.meetingParticipants(ctx, items[i].MeetingID)
|
||||||
|
_ = s.hydrateMeetingUsers(ctx, &items[i])
|
||||||
}
|
}
|
||||||
return items, next, nil
|
return items, next, nil
|
||||||
}
|
}
|
||||||
@@ -345,6 +346,7 @@ func (s *Store) Meeting(ctx context.Context, userID int64, meetingID string) (*M
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
meeting.Participants, _ = s.meetingParticipants(ctx, meeting.MeetingID)
|
meeting.Participants, _ = s.meetingParticipants(ctx, meeting.MeetingID)
|
||||||
|
_ = s.hydrateMeetingUsers(ctx, &meeting)
|
||||||
if !meetingVisible(meeting, userID) {
|
if !meetingVisible(meeting, userID) {
|
||||||
return nil, ErrForbidden
|
return nil, ErrForbidden
|
||||||
}
|
}
|
||||||
@@ -791,6 +793,149 @@ func (s *Store) meetingParticipants(ctx context.Context, meetingID string) ([]Me
|
|||||||
return items, err
|
return items, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) hydrateMeetingUsers(ctx context.Context, meeting *Meeting) error {
|
||||||
|
if meeting == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
seen := map[int64]bool{}
|
||||||
|
userIDs := make([]int64, 0, len(meeting.Participants)+2)
|
||||||
|
add := func(userID int64) {
|
||||||
|
if userID > 0 && !seen[userID] {
|
||||||
|
seen[userID] = true
|
||||||
|
userIDs = append(userIDs, userID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
add(meeting.OrganizerID)
|
||||||
|
add(meeting.RecorderID)
|
||||||
|
for _, participant := range meeting.Participants {
|
||||||
|
add(participant.UserID)
|
||||||
|
}
|
||||||
|
summaries, err := s.UserSummaries(ctx, userIDs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
meeting.Organizer = summaryOrDefault(meeting.OrganizerID, summaries)
|
||||||
|
meeting.Recorder = summaryOrDefault(meeting.RecorderID, summaries)
|
||||||
|
for i := range meeting.Participants {
|
||||||
|
meeting.Participants[i].User = summaryOrDefault(meeting.Participants[i].UserID, summaries)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) UserSummaries(ctx context.Context, userIDs []int64) (map[int64]UserSummary, error) {
|
||||||
|
result := make(map[int64]UserSummary, len(userIDs))
|
||||||
|
ids := uniquePositiveInt64s(userIDs)
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
query, args, err := sqlx.In(`SELECT user_id,display_name,IFNULL(avatar_file_id,'') AS avatar_file_id
|
||||||
|
FROM im_org_members WHERE employment_state='active' AND user_id IN (?) ORDER BY user_id ASC,id ASC`, ids)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var orgRows []struct {
|
||||||
|
UserID int64 `db:"user_id"`
|
||||||
|
DisplayName string `db:"display_name"`
|
||||||
|
AvatarFileID string `db:"avatar_file_id"`
|
||||||
|
}
|
||||||
|
if err := s.db.SelectContext(ctx, &orgRows, s.db.Rebind(query), args...); err != nil && !isMissingTable(err) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, row := range orgRows {
|
||||||
|
if _, ok := result[row.UserID]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result[row.UserID] = UserSummary{UserID: row.UserID, DisplayName: strings.TrimSpace(row.DisplayName), AvatarFileID: row.AvatarFileID}
|
||||||
|
}
|
||||||
|
|
||||||
|
query, args, err = sqlx.In(`SELECT user_id,nickname AS display_name,IFNULL(avatar_file_id,'') AS avatar_file_id
|
||||||
|
FROM im_user_profiles WHERE user_id IN (?) ORDER BY user_id ASC`, ids)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var profileRows []struct {
|
||||||
|
UserID int64 `db:"user_id"`
|
||||||
|
DisplayName string `db:"display_name"`
|
||||||
|
AvatarFileID string `db:"avatar_file_id"`
|
||||||
|
}
|
||||||
|
if err := s.db.SelectContext(ctx, &profileRows, s.db.Rebind(query), args...); err != nil && !isMissingTable(err) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, row := range profileRows {
|
||||||
|
summary := result[row.UserID]
|
||||||
|
if summary.UserID == 0 {
|
||||||
|
summary.UserID = row.UserID
|
||||||
|
}
|
||||||
|
if summary.DisplayName == "" {
|
||||||
|
summary.DisplayName = strings.TrimSpace(row.DisplayName)
|
||||||
|
}
|
||||||
|
if summary.AvatarFileID == "" {
|
||||||
|
summary.AvatarFileID = row.AvatarFileID
|
||||||
|
}
|
||||||
|
result[row.UserID] = summary
|
||||||
|
}
|
||||||
|
|
||||||
|
query, args, err = sqlx.In(`SELECT id AS user_id,
|
||||||
|
COALESCE(
|
||||||
|
NULLIF(JSON_UNQUOTE(JSON_EXTRACT(public,'$.display_name')),''),
|
||||||
|
NULLIF(JSON_UNQUOTE(JSON_EXTRACT(public,'$.fn')),''),
|
||||||
|
NULLIF(JSON_UNQUOTE(JSON_EXTRACT(public,'$.nickname')),''),
|
||||||
|
NULLIF(JSON_UNQUOTE(JSON_EXTRACT(public,'$.name')),''),
|
||||||
|
''
|
||||||
|
) AS display_name
|
||||||
|
FROM im_users WHERE id IN (?)`, ids)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var userRows []struct {
|
||||||
|
UserID int64 `db:"user_id"`
|
||||||
|
DisplayName string `db:"display_name"`
|
||||||
|
}
|
||||||
|
if err := s.db.SelectContext(ctx, &userRows, s.db.Rebind(query), args...); err != nil && !isMissingTable(err) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, row := range userRows {
|
||||||
|
summary := result[row.UserID]
|
||||||
|
if summary.UserID == 0 {
|
||||||
|
summary.UserID = row.UserID
|
||||||
|
}
|
||||||
|
if summary.DisplayName == "" {
|
||||||
|
summary.DisplayName = strings.TrimSpace(row.DisplayName)
|
||||||
|
}
|
||||||
|
result[row.UserID] = summary
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, id := range ids {
|
||||||
|
result[id] = summaryOrDefault(id, result)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func uniquePositiveInt64s(values []int64) []int64 {
|
||||||
|
seen := map[int64]bool{}
|
||||||
|
out := make([]int64, 0, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
if value <= 0 || seen[value] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[value] = true
|
||||||
|
out = append(out, value)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func summaryOrDefault(userID int64, summaries map[int64]UserSummary) UserSummary {
|
||||||
|
if userID <= 0 {
|
||||||
|
return UserSummary{}
|
||||||
|
}
|
||||||
|
summary := summaries[userID]
|
||||||
|
if summary.UserID == 0 {
|
||||||
|
summary.UserID = userID
|
||||||
|
}
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) meetingNoteFile(ctx context.Context, meetingID, noteFileID string) (*MeetingNoteFile, error) {
|
func (s *Store) meetingNoteFile(ctx context.Context, meetingID, noteFileID string) (*MeetingNoteFile, error) {
|
||||||
var file MeetingNoteFile
|
var file MeetingNoteFile
|
||||||
if err := s.db.GetContext(ctx, &file, `SELECT note_file_id,meeting_id,file_id,uploader_id,file_name,mime_type,file_size,file_etag,status,preview_status,preview_mode,preview_file_id,version,created_at,updated_at
|
if err := s.db.GetContext(ctx, &file, `SELECT note_file_id,meeting_id,file_id,uploader_id,file_name,mime_type,file_size,file_etag,status,preview_status,preview_mode,preview_file_id,version,created_at,updated_at
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ type OrganizationMember struct {
|
|||||||
Version int `db:"version"`
|
Version int `db:"version"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UserSummary struct {
|
||||||
|
UserID int64
|
||||||
|
DisplayName string
|
||||||
|
AvatarFileID string
|
||||||
|
}
|
||||||
|
|
||||||
type AttendanceRule struct {
|
type AttendanceRule struct {
|
||||||
RuleID string `db:"rule_id"`
|
RuleID string `db:"rule_id"`
|
||||||
Name string `db:"name"`
|
Name string `db:"name"`
|
||||||
@@ -128,6 +134,8 @@ type Meeting struct {
|
|||||||
CanceledAt *time.Time `db:"canceled_at"`
|
CanceledAt *time.Time `db:"canceled_at"`
|
||||||
CreatedAt time.Time `db:"created_at"`
|
CreatedAt time.Time `db:"created_at"`
|
||||||
UpdatedAt time.Time `db:"updated_at"`
|
UpdatedAt time.Time `db:"updated_at"`
|
||||||
|
Organizer UserSummary
|
||||||
|
Recorder UserSummary
|
||||||
Participants []MeetingParticipant
|
Participants []MeetingParticipant
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,6 +144,7 @@ type MeetingParticipant struct {
|
|||||||
UserID int64 `db:"user_id"`
|
UserID int64 `db:"user_id"`
|
||||||
Response string `db:"response"`
|
Response string `db:"response"`
|
||||||
ResponseAt *time.Time `db:"response_at"`
|
ResponseAt *time.Time `db:"response_at"`
|
||||||
|
User UserSummary
|
||||||
}
|
}
|
||||||
|
|
||||||
type MeetingNoteFile struct {
|
type MeetingNoteFile struct {
|
||||||
|
|||||||
Reference in New Issue
Block a user