feat: add meeting notes backend
This commit is contained in:
+483
-34
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user