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
+483 -34
View File
@@ -1356,6 +1356,10 @@ func serveIMAbuseReasons(wrt http.ResponseWriter, req *http.Request) {
return
}
targetType := strings.ToLower(strings.TrimSpace(req.URL.Query().Get("target_type")))
if !isAllowedIMAbuseTargetType(targetType) {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "target_type"})
return
}
reasons, err := store.Safety.AbuseReasons(principal.TenantID, targetType)
if err != nil {
logs.Warn.Println("im abuse reasons: using defaults after store error", principal.TenantID, targetType, err)
@@ -2116,6 +2120,7 @@ func serveIMMeetings(wrt http.ResponseWriter, req *http.Request) {
}
var body struct {
Title string `json:"title"`
RecorderUserID string `json:"recorder_user_id"`
StartAt string `json:"start_at"`
EndAt string `json:"end_at"`
Location string `json:"location"`
@@ -2144,9 +2149,25 @@ func serveIMMeetings(wrt http.ResponseWriter, req *http.Request) {
if !ok {
return
}
recorderID := principal.Uid
if value := strings.TrimSpace(body.RecorderUserID); value != "" {
recorderID = types.ParseUserId(value)
if recorderID.IsZero() {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "recorder_user_id"})
return
}
}
if !ensureIMMeetingUserExists(principal.TenantID, recorderID) {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "recorder_user_id"})
return
}
if !uidListContains(attendeeIDs, recorderID) && recorderID != principal.Uid {
attendeeIDs = append(attendeeIDs, recorderID)
}
meeting, err := store.Meetings.Create(principal.TenantID, principal.Uid, &types.Meeting{
MeetingID: "mtg_" + meetingID,
OrganizerID: principal.Uid,
RecorderID: recorderID,
Title: strings.TrimSpace(body.Title),
StartAt: startAt,
EndAt: endAt,
@@ -2166,7 +2187,7 @@ func serveIMMeetings(wrt http.ResponseWriter, req *http.Request) {
}
publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "meeting", "meeting.created", meeting.MeetingID,
meetingParticipantUIDs(meeting), "会议通知", meeting.Title, map[string]any{"meeting_id": meeting.MeetingID, "version": meeting.Version, "state": meeting.State})
writeIMSuccess(wrt, req, meetingPayload(meeting, principal.Uid))
writeIMSuccess(wrt, req, meetingPayloadForTenant(principal.TenantID, meeting, principal.Uid))
}
func serveIMMeetingList(wrt http.ResponseWriter, req *http.Request) {
@@ -2217,6 +2238,148 @@ func serveIMMeetingResource(wrt http.ResponseWriter, req *http.Request) {
case "my-response":
serveIMMeetingMyResponse(wrt, req, meetingID)
return
case "notes":
serveIMMeetingNotesDetail(wrt, req, meetingID)
return
}
if strings.HasPrefix(tail, "notes/files") {
serveIMMeetingNoteFiles(wrt, req, meetingID, tail)
return
}
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
}
func serveIMMeetingNotes(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
limit := parseIMLimit(req.URL.Query().Get("limit"), 20, 100)
meetings, nextCursor, err := store.MeetingNotes.List(principal.TenantID, principal.Uid, strings.TrimSpace(req.URL.Query().Get("query")), strings.TrimSpace(req.URL.Query().Get("cursor")), limit)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
userSummaries := meetingUserSummaries(principal.TenantID, meetings)
items := make([]map[string]any, 0, len(meetings))
for i := range meetings {
files, err := store.MeetingNotes.ListFiles(principal.TenantID, principal.Uid, meetings[i].MeetingID)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
items = append(items, meetingNoteListItemPayload(&meetings[i], files, principal.Uid, userSummaries))
}
writeIMSuccess(wrt, req, map[string]any{
"items": items,
"next_cursor": nextCursor,
"has_more": nextCursor != "",
})
}
func serveIMMeetingNotesDetail(wrt http.ResponseWriter, req *http.Request, meetingID string) {
if req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
meeting, err := store.Meetings.Get(principal.TenantID, principal.Uid, meetingID)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
if !meetingNotesReadable(meeting, principal.Uid) {
writeIMRelationError(wrt, req, types.ErrPermissionDenied)
return
}
files, err := store.MeetingNotes.ListFiles(principal.TenantID, principal.Uid, meetingID)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, meetingNotesDetailPayload(principal.TenantID, meeting, files, principal.Uid))
}
func serveIMMeetingNoteFiles(wrt http.ResponseWriter, req *http.Request, meetingID, tail string) {
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
switch {
case tail == "notes/files" && req.Method == http.MethodPost:
idempotencyKey, ok := requireIMIdempotencyKey(wrt, req)
if !ok {
return
}
var body struct {
FileID string `json:"file_id"`
}
if !decodeIMJSONBody(wrt, req, &body) {
return
}
if strings.TrimSpace(body.FileID) == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "file_id"})
return
}
noteFileID := "mnote_" + store.Store.GetUidString()
file, err := store.MeetingNotes.CreateFile(principal.TenantID, principal.Uid, meetingID, noteFileID, body.FileID, idempotencyKey)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, meetingNoteFilePayload(file, principal.Uid, nil))
return
case strings.HasPrefix(tail, "notes/files/"):
noteFileID := strings.TrimSpace(strings.TrimPrefix(tail, "notes/files/"))
if noteFileID == "" || strings.Contains(noteFileID, "/") {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "meeting note file route not found", nil)
return
}
if req.Method == http.MethodPut {
idempotencyKey, ok := requireIMIdempotencyKey(wrt, req)
if !ok {
return
}
var body struct {
FileID string `json:"file_id"`
Version int `json:"version"`
}
if !decodeIMJSONBody(wrt, req, &body) {
return
}
if strings.TrimSpace(body.FileID) == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "file_id"})
return
}
file, err := store.MeetingNotes.ReplaceFile(principal.TenantID, principal.Uid, meetingID, noteFileID, body.FileID, idempotencyKey, body.Version)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, meetingNoteFilePayload(file, principal.Uid, nil))
return
}
if req.Method == http.MethodDelete {
version, err := strconv.Atoi(strings.TrimSpace(req.URL.Query().Get("version")))
if err != nil || version <= 0 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "version"})
return
}
file, err := store.MeetingNotes.DeleteFile(principal.TenantID, principal.Uid, meetingID, noteFileID, strings.TrimSpace(req.Header.Get("Idempotency-Key")), version)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, meetingNoteFilePayload(file, principal.Uid, nil))
return
}
}
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
}
@@ -2231,7 +2394,7 @@ func serveIMMeetingGet(wrt http.ResponseWriter, req *http.Request, meetingID str
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, meetingPayload(meeting, principal.Uid))
writeIMSuccess(wrt, req, meetingPayloadForTenant(principal.TenantID, meeting, principal.Uid))
}
func serveIMMeetingUpdate(wrt http.ResponseWriter, req *http.Request, meetingID string) {
@@ -2245,6 +2408,7 @@ func serveIMMeetingUpdate(wrt http.ResponseWriter, req *http.Request, meetingID
}
var body struct {
Title *string `json:"title"`
RecorderUserID *string `json:"recorder_user_id"`
StartAt *string `json:"start_at"`
EndAt *string `json:"end_at"`
Location *string `json:"location"`
@@ -2255,6 +2419,18 @@ func serveIMMeetingUpdate(wrt http.ResponseWriter, req *http.Request, meetingID
return
}
patch := types.MeetingPatch{Title: body.Title, Location: body.Location, ReminderMinutes: body.ReminderMinutes, Version: body.Version}
if body.RecorderUserID != nil {
recorderID := types.ParseUserId(strings.TrimSpace(*body.RecorderUserID))
if recorderID.IsZero() {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "recorder_user_id"})
return
}
if !ensureIMMeetingUserExists(principal.TenantID, recorderID) {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "recorder_user_id"})
return
}
patch.RecorderID = &recorderID
}
if body.StartAt != nil {
startAt, err := parseIMTime(*body.StartAt)
if err != nil {
@@ -2284,7 +2460,7 @@ func serveIMMeetingUpdate(wrt http.ResponseWriter, req *http.Request, meetingID
}
publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "meeting", "meeting.updated", meeting.MeetingID,
meetingParticipantUIDs(meeting), "会议通知", meeting.Title, map[string]any{"meeting_id": meeting.MeetingID, "version": meeting.Version, "state": meeting.State})
writeIMSuccess(wrt, req, meetingPayload(meeting, principal.Uid))
writeIMSuccess(wrt, req, meetingPayloadForTenant(principal.TenantID, meeting, principal.Uid))
}
func serveIMMeetingCancel(wrt http.ResponseWriter, req *http.Request, meetingID string) {
@@ -2313,7 +2489,7 @@ func serveIMMeetingCancel(wrt http.ResponseWriter, req *http.Request, meetingID
}
publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "meeting", "meeting.canceled", meeting.MeetingID,
meetingParticipantUIDs(meeting), "会议通知", meeting.Title, map[string]any{"meeting_id": meeting.MeetingID, "version": meeting.Version, "state": meeting.State})
writeIMSuccess(wrt, req, meetingPayload(meeting, principal.Uid))
writeIMSuccess(wrt, req, meetingPayloadForTenant(principal.TenantID, meeting, principal.Uid))
}
func serveIMMeetingReminders(wrt http.ResponseWriter, req *http.Request, meetingID string) {
@@ -2383,7 +2559,7 @@ func serveIMMeetingMyResponse(wrt http.ResponseWriter, req *http.Request, meetin
}
publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "meeting", "meeting.response.updated", meeting.MeetingID,
meetingParticipantUIDs(meeting), "会议通知", meeting.Title, map[string]any{"meeting_id": meeting.MeetingID, "version": meeting.Version, "state": meeting.State})
writeIMSuccess(wrt, req, meetingPayload(meeting, principal.Uid))
writeIMSuccess(wrt, req, meetingPayloadForTenant(principal.TenantID, meeting, principal.Uid))
}
func serveIMDailyReports(wrt http.ResponseWriter, req *http.Request) {
@@ -3351,7 +3527,10 @@ func serveIMFavoriteList(wrt http.ResponseWriter, req *http.Request) {
}
payload := make([]map[string]any, 0, len(items))
for _, item := range items {
payload = append(payload, favoritePayload(item))
itemPayload, ok := favoritePayloadForPrincipal(principal, item)
if ok {
payload = append(payload, itemPayload)
}
}
writeIMSuccess(wrt, req, map[string]any{"items": payload, "next_cursor": nextCursor})
}
@@ -3373,13 +3552,18 @@ func serveIMFavoriteBatchCreate(wrt http.ResponseWriter, req *http.Request) {
created, existing := 0, 0
failed := make([]map[string]any, 0)
for _, src := range body.Messages {
if src.Topic == "" || src.Seq <= 0 {
sourceTopic, sourceOK := normalizeIMFavoriteSourceTopic(principal.Uid, src.Topic)
if !sourceOK || src.Seq <= 0 {
failed = append(failed, map[string]any{"topic": src.Topic, "seq": src.Seq, "reason": imReasonMalformedRequest})
continue
}
msg, err := store.Messages.GetOne(principal.TenantID, src.Topic, src.Seq)
if err := requireTopicReader(principal, sourceTopic); err != nil {
failed = append(failed, map[string]any{"topic": sourceTopic, "seq": src.Seq, "reason": relationErrorReason(err)})
continue
}
msg, err := store.Messages.GetOne(principal.TenantID, sourceTopic, src.Seq)
if err != nil {
failed = append(failed, map[string]any{"topic": src.Topic, "seq": src.Seq, "reason": relationErrorReason(err)})
failed = append(failed, map[string]any{"topic": sourceTopic, "seq": src.Seq, "reason": relationErrorReason(err)})
continue
}
fav := &types.FavoriteItem{
@@ -3387,11 +3571,11 @@ func serveIMFavoriteBatchCreate(wrt http.ResponseWriter, req *http.Request) {
FavoriteID: "fav_" + store.Store.GetUidString(),
UserID: principal.Uid,
ItemType: imMessageType(msg),
SourceTopic: src.Topic,
SourceTopic: sourceTopic,
SourceSeq: src.Seq,
Snapshot: favoriteSnapshot(msg),
AttachmentRefs: favoriteAttachmentRefs(msg),
Availability: "available",
Availability: messageAvailability(*msg),
IdempotencyKey: strings.TrimSpace(req.Header.Get("Idempotency-Key")),
CollectedAt: now,
CreatedAt: now,
@@ -3443,7 +3627,12 @@ func serveIMFavoriteDetail(wrt http.ResponseWriter, req *http.Request, favoriteI
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, favoritePayload(*item))
payload, ok := favoritePayloadForPrincipal(principal, *item)
if !ok {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "topic read permission required", nil)
return
}
writeIMSuccess(wrt, req, payload)
case http.MethodDelete:
if err := store.Favorites.Delete(principal.TenantID, principal.Uid, favoriteID); err != nil {
writeIMRelationError(wrt, req, err)
@@ -4316,6 +4505,9 @@ func serveIMGroupList(wrt http.ResponseWriter, req *http.Request) {
continue
}
item := groupListItemPayload(sub)
if settings, err := store.GroupBusiness.GetSettings(principal.TenantID, sub.Topic); err == nil {
item = mergeGroupListSettingsPayload(item, settings)
}
if category != "" && groupCategory(groupMemberRole(sub.ModeGiven&sub.ModeWant)) != category {
continue
}
@@ -4357,7 +4549,7 @@ func serveIMGroupCreate(wrt http.ResponseWriter, req *http.Request) {
}
topicName := genTopicName()
now := time.Now().UTC().Round(time.Millisecond)
public := map[string]any{"name": name, "version": 1}
public := map[string]any{"name": name, "fn": name, "version": 1}
if avatar := strings.TrimSpace(body.AvatarFileID); avatar != "" {
public["avatar_file_id"] = avatar
}
@@ -4667,6 +4859,7 @@ func serveIMGroupDetail(wrt http.ResponseWriter, req *http.Request, topic string
return
}
public["name"] = name
public["fn"] = name
}
if body.AvatarFileID != nil {
public["avatar_file_id"] = strings.TrimSpace(*body.AvatarFileID)
@@ -4923,6 +5116,7 @@ func serveIMGroupInvitations(wrt http.ResponseWriter, req *http.Request, topic s
return
}
results := make([]map[string]any, 0, len(body.UserIDs))
now := time.Now().UTC().Round(time.Millisecond)
for _, rawID := range body.UserIDs {
uid := types.ParseUserId(strings.TrimSpace(rawID))
item := map[string]any{"user_id": strings.TrimSpace(rawID)}
@@ -4933,22 +5127,54 @@ func serveIMGroupInvitations(wrt http.ResponseWriter, req *http.Request, topic s
continue
}
item["user_id"] = uid.UserId()
now := time.Now().UTC().Round(time.Millisecond)
joined, err := store.GroupBusiness.InviteMember(principal.TenantID, topic, principal.Uid, uid,
strings.TrimSpace(body.Message), strings.TrimSpace(req.Header.Get("Idempotency-Key")),
groupMemberPrivate("", false, false, 1, now), now)
targetSub, err := store.Subs.Get(principal.TenantID, topic, uid, false)
if err != nil && !errors.Is(err, types.ErrNotFound) {
item["status"] = "failed"
item["reason"] = relationErrorReason(err)
results = append(results, item)
continue
}
if targetSub != nil && targetSub.DeletedAt == nil {
item["status"] = "already_member"
results = append(results, item)
continue
}
invitationID := "ginv_" + store.Store.GetUidString()
idempotencyKey := strings.TrimSpace(req.Header.Get("Idempotency-Key"))
if idempotencyKey != "" {
idempotencyKey += ":" + uid.UserId()
} else {
idempotencyKey = invitationID
}
inv, err := store.GroupBusiness.CreateInvitation(principal.TenantID, &types.GroupInvitation{
InvitationID: invitationID,
Topic: topic,
InviterUserID: principal.Uid,
TargetUserID: uid,
Message: strings.TrimSpace(body.Message),
Status: types.GroupInvitationStatusPending,
Result: "",
IdempotencyKey: idempotencyKey,
ExpiresAt: now.Add(7 * 24 * time.Hour),
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
item["status"] = "failed"
item["reason"] = relationErrorReason(err)
results = append(results, item)
continue
}
if !joined {
item["status"] = "already_member"
results = append(results, item)
continue
item["invitation_id"] = inv.InvitationID
item["status"] = inv.Status
item["expires_at"] = inv.ExpiresAt.Format(time.RFC3339Nano)
if imReliableHTTPEventsEnabled(req) {
publishIMConversationSystemNotice(principal.TenantID, principal.Uid.P2PName(uid),
"system_action", "group_invitation_created", "邀请你加入群聊。",
[]imSystemMessageActor{{UserID: principal.Uid.UserId(), Click: map[string]any{"type": "user_profile", "target_id": principal.Uid.UserId()}}},
[]imSystemMessageAction{{Type: "group_invitation_accept", Label: "立即加入", TargetID: inv.InvitationID}},
map[string]any{"invitation_id": inv.InvitationID, "topic": topic, "message": inv.Message})
}
item["status"] = "joined"
results = append(results, item)
}
dispatchIMGroupEvents(principal.TenantID)
@@ -6667,6 +6893,26 @@ func parseIMUserIDList(wrt http.ResponseWriter, req *http.Request, values []stri
return uids, true
}
func uidListContains(uids []types.Uid, uid types.Uid) bool {
for _, item := range uids {
if item == uid {
return true
}
}
return false
}
func ensureIMMeetingUserExists(tenantID types.TenantID, uid types.Uid) bool {
if uid.IsZero() || store.Users == nil {
return !uid.IsZero()
}
user, err := store.Users.Get(tenantID, uid)
if err != nil {
return errors.Is(err, types.ErrUnsupported)
}
return user != nil
}
func parseIMNonNegativeCursor(raw string) (int, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
@@ -6829,6 +7075,21 @@ func groupListItemPayload(sub types.Subscription) map[string]any {
}
}
func mergeGroupListSettingsPayload(payload map[string]any, settings *types.GroupSettings) map[string]any {
if payload == nil {
payload = map[string]any{}
}
if settings == nil {
return payload
}
payload["topic"] = settings.Topic
payload["name"] = settings.Name
payload["avatar_file_id"] = settings.AvatarFileID
payload["member_count"] = settings.MemberCount
payload["updated_at"] = settings.UpdatedAt.UTC().Format(time.RFC3339Nano)
return payload
}
func groupDetailPayload(group *types.Topic, selfSub *types.Subscription) map[string]any {
public := map[string]any{}
topic := ""
@@ -7459,15 +7720,45 @@ func favoritePayload(item types.FavoriteItem) map[string]any {
}
}
func favoritePayloadForPrincipal(principal *imPrincipal, item types.FavoriteItem) (map[string]any, bool) {
if principal == nil {
return nil, false
}
sourceTopic, ok := normalizeIMFavoriteSourceTopic(principal.Uid, item.SourceTopic)
if !ok {
return nil, false
}
if err := requireTopicReader(principal, sourceTopic); err != nil {
return nil, false
}
item.SourceTopic = sourceTopic
if store.Messages != nil {
msg, err := store.Messages.GetOne(principal.TenantID, sourceTopic, item.SourceSeq)
if err == nil && msg != nil {
item.Availability = messageAvailability(*msg)
if item.Availability == "available" {
item.ItemType = imMessageType(msg)
item.Snapshot = favoriteSnapshot(msg)
item.AttachmentRefs = favoriteAttachmentRefs(msg)
}
} else if errors.Is(err, types.ErrNotFound) {
item.Availability = "removed"
}
}
return favoritePayload(item), true
}
func messageSearchPayload(msg types.Message, query string) map[string]any {
text := messageText(msg)
snippet := textSnippet(text, query)
return map[string]any{
"seq": msg.SeqId,
"from": msg.From,
"ts": msg.CreatedAt.UTC().Format(time.RFC3339Nano),
"type": imMessageType(&msg),
"snippet": textSnippet(text, query),
"matches": textMatches(text, query),
"version": msg.Version,
"snippet": snippet,
"matches": textMatches(snippet, query),
"snapshot": favoriteSnapshot(&msg),
"availability": messageAvailability(msg),
}
@@ -7479,6 +7770,7 @@ func messageContextPayload(msg types.Message) map[string]any {
"from": msg.From,
"ts": msg.CreatedAt.UTC().Format(time.RFC3339Nano),
"type": imMessageType(&msg),
"version": msg.Version,
"head": msg.Head,
"content": msg.Content,
"availability": messageAvailability(msg),
@@ -7848,9 +8140,24 @@ func meetingPayload(meeting *types.Meeting, viewer types.Uid) map[string]any {
return meetingPayloadWithUserSummaries(meeting, viewer, nil)
}
func meetingPayloadForTenant(tenantID types.TenantID, meeting *types.Meeting, viewer types.Uid) map[string]any {
if meeting == nil {
return map[string]any{}
}
return meetingPayloadWithUserSummaries(meeting, viewer, meetingUserSummaries(tenantID, []types.Meeting{*meeting}))
}
func meetingPayloadWithUserSummaries(meeting *types.Meeting, viewer types.Uid, userSummaries map[types.Uid]map[string]any) map[string]any {
if meeting == nil {
return map[string]any{}
}
participants := meetingParticipantPayloadsWithSummaries(meeting.Participants, userSummaries)
organizer := meetingUserSummary(meeting.OrganizerID, userSummaries)
recorderID := meeting.RecorderID
if recorderID.IsZero() {
recorderID = meeting.OrganizerID
}
recorder := meetingUserSummary(recorderID, userSummaries)
startAt := meeting.StartAt.UTC()
endAt := meeting.EndAt.UTC()
data := map[string]any{
@@ -7858,6 +8165,8 @@ func meetingPayloadWithUserSummaries(meeting *types.Meeting, viewer types.Uid, u
"title": meeting.Title,
"organizer_id": meeting.OrganizerID.UserId(),
"organizer": organizer,
"recorder_user_id": recorderID.UserId(),
"recorder": recorder,
"start_at": startAt.Format(time.RFC3339Nano),
"end_at": endAt.Format(time.RFC3339Nano),
"meeting_time": meetingTimeRange(startAt, endAt),
@@ -7876,6 +8185,9 @@ func meetingPayloadWithUserSummaries(meeting *types.Meeting, viewer types.Uid, u
if displayName, ok := organizer["display_name"]; ok {
data["organizer_display_name"] = displayName
}
if displayName, ok := recorder["display_name"]; ok {
data["recorder_display_name"] = displayName
}
if meeting.CanceledAt != nil {
data["canceled_at"] = meeting.CanceledAt.UTC().Format(time.RFC3339Nano)
}
@@ -7926,6 +8238,10 @@ func meetingUserSummaries(tenantID types.TenantID, meetings []types.Meeting) map
seen[meeting.OrganizerID] = true
uids = append(uids, meeting.OrganizerID)
}
if !meeting.RecorderID.IsZero() && !seen[meeting.RecorderID] {
seen[meeting.RecorderID] = true
uids = append(uids, meeting.RecorderID)
}
for _, participant := range meeting.Participants {
if !participant.UserID.IsZero() && !seen[participant.UserID] {
seen[participant.UserID] = true
@@ -7977,6 +8293,89 @@ func meetingParticipantNames(participants []map[string]any) []string {
return names
}
func meetingNoteListItemPayload(meeting *types.Meeting, files []types.MeetingNoteFile, viewer types.Uid, userSummaries map[types.Uid]map[string]any) map[string]any {
data := meetingPayloadWithUserSummaries(meeting, viewer, userSummaries)
noteStatus := types.MeetingNoteStatusEmpty
if len(files) > 0 {
noteStatus = types.MeetingNoteStatusAvailable
}
data["note_status"] = noteStatus
data["file_count"] = len(files)
data["permissions"] = meetingNotePermissionsPayload(meeting, viewer)
return data
}
func meetingNotesDetailPayload(tenantID types.TenantID, meeting *types.Meeting, files []types.MeetingNoteFile, viewer types.Uid) map[string]any {
userSummaries := meetingUserSummaries(tenantID, []types.Meeting{*meeting})
data := meetingNoteListItemPayload(meeting, files, viewer, userSummaries)
fileItems := make([]map[string]any, 0, len(files))
for i := range files {
fileItems = append(fileItems, meetingNoteFilePayload(&files[i], viewer, userSummaries))
}
data["files"] = fileItems
return data
}
func meetingNoteFilePayload(file *types.MeetingNoteFile, viewer types.Uid, userSummaries map[types.Uid]map[string]any) map[string]any {
if file == nil {
return map[string]any{}
}
return map[string]any{
"note_file_id": file.NoteFileID,
"meeting_id": file.MeetingID,
"file_id": file.FileID,
"file_name": file.FileName,
"mime_type": file.MIMEType,
"file_size": file.FileSize,
"file_etag": file.FileETag,
"status": file.Status,
"preview_status": file.PreviewStatus,
"preview_mode": file.PreviewMode,
"preview_file_id": file.PreviewFileID,
"uploader_user_id": file.UploaderID.UserId(),
"uploader": meetingUserSummary(file.UploaderID, userSummaries),
"version": file.Version,
"permissions": map[string]any{
"can_preview": file.Status == types.MeetingNoteFileStatusActive,
"can_download": file.Status == types.MeetingNoteFileStatusActive,
"can_replace": file.Status == types.MeetingNoteFileStatusActive && file.UploaderID == viewer,
"can_delete": file.Status == types.MeetingNoteFileStatusActive && file.UploaderID == viewer,
},
"created_at": file.CreatedAt.UTC().Format(time.RFC3339Nano),
"updated_at": file.UpdatedAt.UTC().Format(time.RFC3339Nano),
}
}
func meetingNotePermissionsPayload(meeting *types.Meeting, viewer types.Uid) map[string]any {
recorderID := meeting.RecorderID
if recorderID.IsZero() {
recorderID = meeting.OrganizerID
}
readable := meetingNotesReadable(meeting, viewer)
return map[string]any{
"can_view": readable,
"can_upload": readable && recorderID == viewer,
"can_replace": readable && recorderID == viewer,
"can_delete": readable && recorderID == viewer,
"can_download": readable,
}
}
func meetingNotesReadable(meeting *types.Meeting, viewer types.Uid) bool {
if meeting == nil || viewer.IsZero() || meeting.State == types.MeetingStateCanceled || time.Now().UTC().Before(meeting.EndAt) {
return false
}
if meeting.OrganizerID == viewer || meeting.RecorderID == viewer {
return true
}
for _, participant := range meeting.Participants {
if participant.UserID == viewer {
return true
}
}
return false
}
func meetingTimeRange(startAt, endAt time.Time) string {
if startAt.IsZero() || endAt.IsZero() {
return ""
@@ -7991,7 +8390,14 @@ func meetingParticipantUIDs(meeting *types.Meeting) []types.Uid {
if meeting == nil {
return nil
}
return meetingParticipantUserIDs(meeting.Participants)
items := meetingParticipantUserIDs(meeting.Participants)
if !meeting.OrganizerID.IsZero() && !uidListContains(items, meeting.OrganizerID) {
items = append(items, meeting.OrganizerID)
}
if !meeting.RecorderID.IsZero() && !uidListContains(items, meeting.RecorderID) {
items = append(items, meeting.RecorderID)
}
return items
}
func meetingParticipantUserIDs(participants []types.MeetingParticipant) []types.Uid {
@@ -8392,6 +8798,15 @@ func defaultIMAbuseReasons(targetType string) []types.AbuseReason {
}
}
func isAllowedIMAbuseTargetType(targetType string) bool {
switch strings.ToLower(strings.TrimSpace(targetType)) {
case "user", "group", "message":
return true
default:
return false
}
}
func abuseReportPayload(report *types.AbuseReport) map[string]any {
return map[string]any{
"report_id": report.ReportID,
@@ -8448,7 +8863,7 @@ func tenantEntryFeedbackPayload(feedback *types.TenantEntryFeedback) map[string]
func isAllowedIMWorkbenchRoute(route string) bool {
switch route {
case "/organization", "/attendance", "/meetings", "/reports/daily", "/reports/monthly", "/executive-mailbox":
case "/organization", "/attendance", "/meetings", "/meeting-notes", "/reports/daily", "/reports/monthly", "/executive-mailbox":
return true
default:
return false
@@ -8857,6 +9272,7 @@ func favoriteSnapshot(msg *types.Message) map[string]any {
"from": msg.From,
"ts": msg.CreatedAt.UTC().Format(time.RFC3339Nano),
"type": imMessageType(msg),
"version": msg.Version,
"head": msg.Head,
"content": msg.Content,
"text": messageText(*msg),
@@ -8906,6 +9322,14 @@ func forwardFailurePayload(topic string, seq int64, target, reason string) map[s
}
func normalizeIMForwardTopic(viewer types.Uid, topic string) (string, bool) {
return normalizeIMUserVisibleTopic(viewer, topic)
}
func normalizeIMFavoriteSourceTopic(viewer types.Uid, topic string) (string, bool) {
return normalizeIMUserVisibleTopic(viewer, topic)
}
func normalizeIMUserVisibleTopic(viewer types.Uid, topic string) (string, bool) {
topic = strings.TrimSpace(topic)
if topic == "" {
return "", false
@@ -9175,7 +9599,7 @@ func messageText(msg types.Message) string {
case string:
return content
case map[string]any:
for _, key := range []string{"text", "content", "title", "summary", "name", "url"} {
for _, key := range []string{"text", "txt", "content", "title", "summary", "name", "url"} {
if value := stringFromAny(content[key]); value != "" {
return value
}
@@ -9194,11 +9618,11 @@ func messageAvailability(msg types.Message) string {
func textSnippet(text, query string) string {
text = strings.TrimSpace(text)
if len([]rune(text)) <= 120 || strings.TrimSpace(query) == "" {
runes := []rune(text)
if len(runes) <= 120 || strings.TrimSpace(query) == "" {
return text
}
lower := strings.ToLower(text)
idx := strings.Index(lower, strings.ToLower(strings.TrimSpace(query)))
idx := runeIndexFold(text, query)
if idx < 0 {
idx = 0
}
@@ -9207,7 +9631,6 @@ func textSnippet(text, query string) string {
start = 0
}
end := start + 120
runes := []rune(text)
if end > len(runes) {
end = len(runes)
}
@@ -9219,11 +9642,36 @@ func textMatches(text, query string) []map[string]any {
if query == "" {
return []map[string]any{}
}
idx := strings.Index(strings.ToLower(text), strings.ToLower(query))
idx := runeIndexFold(text, query)
if idx < 0 {
return []map[string]any{}
}
return []map[string]any{{"start": idx, "end": idx + len(query)}}
return []map[string]any{{"start": idx, "end": idx + len([]rune(query))}}
}
func runeIndexFold(text, query string) int {
query = strings.TrimSpace(query)
if text == "" || query == "" {
return -1
}
textRunes := []rune(strings.ToLower(text))
queryRunes := []rune(strings.ToLower(query))
if len(queryRunes) == 0 || len(queryRunes) > len(textRunes) {
return -1
}
for i := 0; i <= len(textRunes)-len(queryRunes); i++ {
matched := true
for j := range queryRunes {
if textRunes[i+j] != queryRunes[j] {
matched = false
break
}
}
if matched {
return i
}
}
return -1
}
func validIMRealName(name, idType, idNumber string) bool {
@@ -9555,6 +10003,7 @@ func cleanStringList(values []string, limit int) []string {
func isAllowedPublicUploadPurpose(purpose string) bool {
switch strings.TrimSpace(purpose) {
case types.PublicUploadPurposeEnterpriseLicense, types.PublicUploadPurposeTenantFeedback,
types.PublicUploadPurposeUserAvatar,
types.PublicUploadPurposeGroupAvatar:
return true
default:
@@ -9574,7 +10023,7 @@ func isAllowedPublicUploadMime(purpose, mimeType string) bool {
return strings.HasPrefix(mimeType, "image/jpeg") ||
strings.HasPrefix(mimeType, "image/png") ||
strings.HasPrefix(mimeType, "image/webp")
case types.PublicUploadPurposeGroupAvatar:
case types.PublicUploadPurposeUserAvatar, types.PublicUploadPurposeGroupAvatar:
return strings.HasPrefix(mimeType, "image/jpeg") ||
strings.HasPrefix(mimeType, "image/png") ||
strings.HasPrefix(mimeType, "image/webp")