From 3fc275b6396e510588f8df2ba36e1a15ad6a1986 Mon Sep 17 00:00:00 2001 From: Rogee Date: Fri, 5 Jun 2026 22:01:51 +0800 Subject: [PATCH] feat(notifications): align chatwoot notification payloads --- docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md | 14 +- .../handler/api/v1/notification_handler.go | 147 +++++++++++++++-- .../api/v1/notification_handler_test.go | 149 ++++++++++++++++-- internal/repository/notification_repo.go | 62 +++++++- internal/service/notification_service.go | 39 ++++- 5 files changed, 369 insertions(+), 42 deletions(-) diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index 980e6d01..c5ce833f 100644 --- a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md +++ b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md @@ -16,10 +16,10 @@ Build GoChat as a Go backend that can directly reuse the frontend from `referenc ## Current Baseline -- Current tracking checkpoint: 2026-06-05 after `43aa2f8 feat(reports): add analytics timeseries rollups`, with this implementation checkpoint prepared as `feat(automation): close delayed action parity`. -- Latest implementation checkpoint: this checkpoint, prepared as `feat(automation): close delayed action parity`. +- Current tracking checkpoint: 2026-06-05 after `ddf2694 feat(automation): close delayed action parity`, with this implementation checkpoint prepared as `feat(notifications): align chatwoot notification payloads`. +- Latest implementation checkpoint: this checkpoint, prepared as `feat(notifications): align chatwoot notification payloads`. - Latest documentation-only checkpoint: `2923aae docs: land parity execution tracker`; this document is now the active follow-up plan and supersedes `.hermes/plans/*`. -- Worktree status at this implementation checkpoint: B11.1a aligns Captain assistant CRUD/tools/inbox bindings; B11.1b aligns Captain scenarios and custom tools; B11.1c aligns Captain documents, assistant responses, bulk actions, and custom-tool test payloads; B11.2 aligns Copilot thread/message create/list/get/delete payloads, account/user scoping, and no-LLM fallback persistence; B11.3a aligns Captain preferences show/update payloads and account-level model/feature storage; B11.3b aligns Captain playground request/response payloads, account scoping, v2 history handling, and no-LLM fallback; B11.3c adds the fakeable Captain document sync backend gate with disabled, failed, and fake-success states; B11.3d aligns Captain task request/response payloads, no-provider disabled states, follow-up context, suggestion persistence, and Copilot message tool-call key validation; B11.3e aligns Captain stream DTOs/disabled SSE fallbacks and Copilot push-event payload shapes; B12.1 adds the reusable GoChat server/seed entrypoint plus a Meilisearch-first reused Chatwoot frontend smoke harness and report; B12.2a adds API smoke assertions for auth/profile, inbox, conversation/messages, contact/company, widget config/message, and public CSAT; B12.2b adds a zero-dependency Chrome DevTools browser smoke that loads the reused Chatwoot login and dashboard entrypoints through Vite and checks browser auth/dashboard API requests; B12.3a adds enterprise API smoke assertions for SLA reports/download, CSAT reports/download, automation/macros, audit/custom roles, capacity, Captain, and Copilot; B12.3b adds reused-frontend enterprise browser route navigation for SLA, CSAT, automation, macros, audit logs, custom roles, capacity, Captain, and Copilot request coverage; P5.1 adds the PostgreSQL-backed durable `background_jobs` model/migration plus WorkerPool enqueue, schedule, retry/backoff, dead-letter, idempotency, stale-lock recovery, and focused tests; P5.2 wires `channel.Dispatcher` and `dispatch.EventDispatcher` async paths into durable event jobs with worker replay tests; P5.3 queues Meilisearch write-side index/delete jobs for conversations, messages, contacts, companies, and articles while keeping search reads Meilisearch-first; P5.4 queues automation webhook and email transcript side effects as durable jobs while preserving fakeable delivery boundaries; P5.5 queues Chatwoot-style macro execute fan-out through durable `automation:macro_execution` jobs; P5.6 queues resolve-triggered CSAT survey sends and WhatsApp/Twilio CSAT template creation through durable jobs; P5.7 queues Chatwoot enterprise SLA account scans and applied-SLA evaluation jobs through the durable worker; P5.8 queues Chatwoot-style contact export artifact generation through durable `contact:export` jobs; P5.9 queues normalized provider inbound message persistence/dispatch through durable `webhook:incoming_message_persist` jobs; P5.10 queues Chatwoot `SendReplyJob`-style outbound message delivery through durable `message:send_reply` jobs and provider delivery-status/read-receipt updates through durable webhook status jobs; P5.11 queues Captain document sync, crawl/parser, schedule-sync, response-builder, embedding-update, Copilot response, and Captain conversation response-builder work through durable jobs; P5.12 queues scheduled item fan-out, one-off campaigns, snoozed conversation reopening, account auto-resolution, widget/public message status updates, and account conversation bulk actions through durable jobs; P5.13a replaces the live report, bot report, conversation summary, inbox-label matrix, first-response distribution, and outgoing-message placeholder responses with persisted conversation/message/reporting-event aggregations; P5.13b routes `GET /reports` to Chatwoot-style metric timeseries, adds lazy rollup freshness/idempotency, registers durable `reporting:rollup_day` jobs, and makes rollup replacement hard-delete soft-deleted rows before recompute; B9.3 verifies the current reference exposes no delayed automation action params, preserves scheduled-item coverage under P5.12, and completes remaining automation action side effects by queueing `send_email_to_team` plus applying enterprise `add_sla`. Next active implementation slice is Phase 2/3 drift audit, followed by Phase 6 placeholder audit. +- Worktree status at this implementation checkpoint: B11.1a aligns Captain assistant CRUD/tools/inbox bindings; B11.1b aligns Captain scenarios and custom tools; B11.1c aligns Captain documents, assistant responses, bulk actions, and custom-tool test payloads; B11.2 aligns Copilot thread/message create/list/get/delete payloads, account/user scoping, and no-LLM fallback persistence; B11.3a aligns Captain preferences show/update payloads and account-level model/feature storage; B11.3b aligns Captain playground request/response payloads, account scoping, v2 history handling, and no-LLM fallback; B11.3c adds the fakeable Captain document sync backend gate with disabled, failed, and fake-success states; B11.3d aligns Captain task request/response payloads, no-provider disabled states, follow-up context, suggestion persistence, and Copilot message tool-call key validation; B11.3e aligns Captain stream DTOs/disabled SSE fallbacks and Copilot push-event payload shapes; B12.1 adds the reusable GoChat server/seed entrypoint plus a Meilisearch-first reused Chatwoot frontend smoke harness and report; B12.2a adds API smoke assertions for auth/profile, inbox, conversation/messages, contact/company, widget config/message, and public CSAT; B12.2b adds a zero-dependency Chrome DevTools browser smoke that loads the reused Chatwoot login and dashboard entrypoints through Vite and checks browser auth/dashboard API requests; B12.3a adds enterprise API smoke assertions for SLA reports/download, CSAT reports/download, automation/macros, audit/custom roles, capacity, Captain, and Copilot; B12.3b adds reused-frontend enterprise browser route navigation for SLA, CSAT, automation, macros, audit logs, custom roles, capacity, Captain, and Copilot request coverage; P5.1 adds the PostgreSQL-backed durable `background_jobs` model/migration plus WorkerPool enqueue, schedule, retry/backoff, dead-letter, idempotency, stale-lock recovery, and focused tests; P5.2 wires `channel.Dispatcher` and `dispatch.EventDispatcher` async paths into durable event jobs with worker replay tests; P5.3 queues Meilisearch write-side index/delete jobs for conversations, messages, contacts, companies, and articles while keeping search reads Meilisearch-first; P5.4 queues automation webhook and email transcript side effects as durable jobs while preserving fakeable delivery boundaries; P5.5 queues Chatwoot-style macro execute fan-out through durable `automation:macro_execution` jobs; P5.6 queues resolve-triggered CSAT survey sends and WhatsApp/Twilio CSAT template creation through durable jobs; P5.7 queues Chatwoot enterprise SLA account scans and applied-SLA evaluation jobs through the durable worker; P5.8 queues Chatwoot-style contact export artifact generation through durable `contact:export` jobs; P5.9 queues normalized provider inbound message persistence/dispatch through durable `webhook:incoming_message_persist` jobs; P5.10 queues Chatwoot `SendReplyJob`-style outbound message delivery through durable `message:send_reply` jobs and provider delivery-status/read-receipt updates through durable webhook status jobs; P5.11 queues Captain document sync, crawl/parser, schedule-sync, response-builder, embedding-update, Copilot response, and Captain conversation response-builder work through durable jobs; P5.12 queues scheduled item fan-out, one-off campaigns, snoozed conversation reopening, account auto-resolution, widget/public message status updates, and account conversation bulk actions through durable jobs; P5.13a replaces the live report, bot report, conversation summary, inbox-label matrix, first-response distribution, and outgoing-message placeholder responses with persisted conversation/message/reporting-event aggregations; P5.13b routes `GET /reports` to Chatwoot-style metric timeseries, adds lazy rollup freshness/idempotency, registers durable `reporting:rollup_day` jobs, and makes rollup replacement hard-delete soft-deleted rows before recompute; B9.3 verifies the current reference exposes no delayed automation action params, preserves scheduled-item coverage under P5.12, and completes remaining automation action side effects by queueing `send_email_to_team` plus applying enterprise `add_sla`; Phase 2/3 drift audit now aligns account notifications with Chatwoot `NotificationFinder` list envelopes, `includes` filters, unread counts, primary-actor `read_all`, raw action responses, and `destroy_all` read/all semantics. Next active implementation slice is Phase 6 placeholder audit, followed by further Phase 2/3 drift from smoke/reference evidence. - `go test ./...` passes. - Route dump succeeds with `TOTAL: 833` after adding `GET /api/v1/accounts/:account_id/reports` for the Chatwoot reports index path. - Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`. @@ -87,7 +87,7 @@ This table is the shortest authoritative handoff view. If an older lower section | Priority | Workstream | Current state | Next checkpoint | Commit close rule | | --- | --- | --- | --- | --- | -| 1 | Phase 2/3 drift | Tracked route parity is 0 missing for the current critical set; serializer fixtures remain partial. | Expand route/serializer fixtures when smoke or reference inspection exposes drift. | Regenerate parity artifacts and add endpoint-family fixture tests. | +| 1 | Phase 2/3 drift | Tracked route parity is 0 missing for the current critical set; notification list/action serializers now match Chatwoot's frontend contract; broader serializer fixtures remain partial. | Expand route/serializer fixtures when smoke or reference inspection exposes drift. | Regenerate parity artifacts when routes change and add endpoint-family fixture tests. | | 2 | Phase 6 placeholder audit | Widget/public/webhook critical placeholders are burned down; account/contact/conversation/message/inbox audit remains broad. | Run a fresh placeholder audit and assign every frontend-reachable stub to a tracked owner. | `rg` audit result is recorded and no reused-frontend blocker is ownerless. | | 3 | B12 optional live smoke | API/browser/enterprise smoke commands are checked in; live runs need PostgreSQL, Redis, Meilisearch, Vite, and Chrome. | Run full live smoke when environment is available and map failures to the board. | `docs/parity/frontend_smoke_report.md` records pass/fail and linked owners. | | 4 | B9.3 delayed automation actions | Current reference exposes no delayed automation action params; scheduled-item work is already P5.12; `send_email_to_team` is durable and `add_sla` mutates conversation/applied SLA state. | Keep automation drift closed if future reference/smoke exposes delayed params or unsupported action shapes. | Automation worker/action fixtures verify queued team email replay, retry visibility through worker jobs, and SLA action idempotency. | @@ -139,6 +139,7 @@ This ledger records the committed parity checkpoints that future slices should b | Commit | Scope | Verification summary | Follow-up state | | --- | --- | --- | --- | +| `feat(notifications): align chatwoot notification payloads` | Advances Phase 2/3 drift by aligning the account notifications API with `reference/chatwoot/app/controllers/api/v1/accounts/notifications_controller.rb`, `NotificationFinder`, `index.json.jbuilder`, and the reused frontend `notifications.js` client. Notification index now returns Chatwoot `{ data: { meta, payload } }`, supports `includes[]=read/snoozed`, fixed unread/count metadata, raw notification action responses, primary-actor-scoped `read_all`, raw unread count, and `destroy_all` read/all semantics from query or JSON body. | `go test ./internal/handler/api/v1 -run Notification -count=1`; `go test ./internal/service -run Notification -count=1`; `go test ./internal/handler/api/v1 ./internal/service -count=1`; `go test ./...`; `git diff --check`. | Notification serializer/action drift moves to Review; continue Phase 6 placeholder audit, then further Phase 2/3 drift from smoke/reference evidence. | | `feat(automation): close delayed action parity` | Closes B9.3 for the current reference. `reference/chatwoot` automation models, action service, enterprise action service, listener, and dashboard action components expose no configurable delayed action params, so no separate delayed automation scheduler is required beyond P5.12 scheduled items. The checkpoint completes remaining automation action side effects: `send_email_to_team` now queues durable `automation:team_email_delivery` jobs and replays through the fakeable mailer boundary, while enterprise `add_sla` attaches an account-scoped SLA policy and creates idempotent AppliedSLA targets. The automation serializer now preserves Chatwoot's object-shaped `send_email_to_team` params. | `go test ./internal/automation -run 'ActionService\|AutomationRuleService_MatchAndExecute\|ExecutionLogService' -count=1`; `go test ./internal/handler/api/v1 -run AutomationRule -count=1`; `go test ./internal/automation ./internal/handler/api/v1 ./internal/worker ./internal/app -count=1`; `go test ./...`; `git diff --check`. | B9.3 and Phase 5 move to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, and optional B12 live smoke. | | `feat(reports): add analytics timeseries rollups` | Completes P5.13b for scheduled/cached analytics parity. `GET /api/v1/accounts/:account_id/reports` and v2 `/reports` now route to metric timeseries instead of summary, with support for account/inbox/agent/team/label dimensions, day/hour/week/month/year buckets, conversation/message/reporting-event metrics, and business-hours averages. Analytics summary/dimension/traffic reads call `EnsureRollupsForRange` for lazy freshness; `reporting:rollup_day` jobs provide durable day recompute; rollup replacement uses `Unscoped` delete so soft-deleted rows cannot violate uniqueness on recompute. | `go test ./internal/service -run 'Analytics' -count=1`; `go test ./internal/handler/api/v1 -run 'Analytics\|LiveReport' -count=1`; `go test ./internal/service ./internal/handler/api/v1 ./internal/router ./internal/worker ./internal/app -count=1`; `go run ./cmd/dump_routes > docs/parity/gochat_routes.txt`; `go run ./cmd/route_parity`; `go test ./...`; `git diff --check`; full verification recorded in the P5.13 section. | P5.13 moves to Review; B9.3 is completed by `feat(automation): close delayed action parity`; continue Phase 2/3 drift and Phase 6 placeholder audits. | | `feat(reports): derive analytics aggregates` | Advances P5.13a by replacing frontend-visible analytics placeholder responses with persisted aggregations. Live report conversation metrics now count open/unattended/unassigned/pending conversations with team filtering; grouped live reports return assignee/team grouped counts; bot summary/metrics, conversation summary, inbox-label matrix, first-response distribution, and outgoing-message counts are derived from conversations, messages, labels, agent-bot bindings, and reporting events instead of fixed zero/empty JSON. | `go test ./internal/service -run 'Analytics' -count=1`; `go test ./internal/handler/api/v1 -run 'Analytics\|LiveReport' -count=1`; `go test ./internal/service ./internal/handler/api/v1 ./internal/worker ./internal/app -count=1`; `go test ./...`; `git diff --check`; full verification recorded in the P5.13 section. | P5.13a moves to Review; continue P5.13b scheduled/cached rollup freshness and `/reports` timeseries index parity, then B9.3 delayed automation action check. | @@ -1539,7 +1540,7 @@ Frontend-critical API groups to audit first: | P3.4 | Conversations/messages | List filters, status changes, assignment, labels, private notes, attachments, drafts, typing/read events. | Review | | P3.5 | Contacts/companies | CRUD, merge, labels, notes, custom attributes, import/export, conversations relation. | Doing | | P3.6 | Labels/custom attributes/custom filters | Create/update/list behavior and exact response shapes. | Todo | -| P3.7 | Notifications/reports/help center/campaigns | Frontend-visible payloads and pagination/error envelopes. | Todo | +| P3.7 | Notifications/reports/help center/campaigns | Frontend-visible payloads and pagination/error envelopes. | Doing; notifications and reports now have focused Chatwoot payload fixtures, help center/campaigns remain broader audit scope. | | P3.8 | Widget/public APIs | Widget init, campaigns, config, contact, conversations, messages, direct uploads, public inbox flow, public CSAT. | Doing | | P3.9 | Search payloads | Global search and entity search documents backed by Meilisearch. | Review | @@ -1551,7 +1552,7 @@ Serializer parity work plan: | S2 | Conversations and messages | `reference/chatwoot/app/controllers/api/v1/accounts/conversations*`, serializers/entities | fixture tests for index/show/message create/update | Review | | S3 | Contacts and companies | `reference/chatwoot/app/controllers/api/v1/accounts/contacts*`, `companies*` | fixture tests for list/show/search/merge/relation payloads | Doing | | S4 | Inboxes and channels | `reference/chatwoot/app/controllers/api/v1/accounts/inboxes*`, channel controllers | fixture tests for inbox CRUD, channel settings, widget config | Done | -| S5 | Notifications and settings | `reference/chatwoot/app/controllers/api/v1/accounts/notifications*` | fixture tests for notification list/actions/settings | Todo | +| S5 | Notifications and settings | `reference/chatwoot/app/controllers/api/v1/accounts/notifications*` | fixture tests for notification list/actions/settings | Review; list envelope, includes filters, unread counts, read_all, unread/snooze/update raw responses, and destroy_all read/all are covered. | | S6 | Reports and CSAT | `reference/chatwoot/app/controllers/api/v1/accounts/reports*`, `csat_survey_responses*` | fixture tests for report filters and CSAT metrics/list | Doing | | S7 | Widget/public | `reference/chatwoot/app/controllers/api/v1/widget*`, `public/api/v1*` | widget smoke fixtures and public flow tests | Doing | | S8 | Search | `reference/chatwoot` search controllers plus frontend search client | Meilisearch-backed search response fixtures | Todo | @@ -2048,6 +2049,7 @@ Verification milestone gates: ## Progress Log +- 2026-06-05: Phase 2/3 notification drift checkpoint prepared as `feat(notifications): align chatwoot notification payloads`; audited `reference/chatwoot` notifications controller, `NotificationFinder`, index Jbuilder, and dashboard notifications API/store consumers. Account notifications index now returns Chatwoot `{ data: { meta, payload } }`, filters read/snoozed items with `includes[]=read/snoozed`, reports `count/current_page/unread_count`, serializes frontend-required notification fields, marks all notifications or a primary actor as read, returns raw update/unread/snooze notification payloads, returns unread count as a raw number, and honors `destroy_all` `type=read` from query or JSON body. Focused notification handler/service tests, combined handler/service tests, full `go test ./...`, and `git diff --check` passed; continue Phase 6 placeholder audit next. - 2026-06-05: B9.3 delayed automation action checkpoint prepared as `feat(automation): close delayed action parity`; audited current `reference/chatwoot` automation rule model, listener, action services, enterprise action service, and dashboard action inputs and found no configurable delayed action params. Scheduled root/background work remains covered by P5.12, while this checkpoint closes remaining automation action side effects: `send_email_to_team` now preserves Chatwoot object-shaped params and queues durable `automation:team_email_delivery` jobs through the fakeable mailer boundary, and enterprise `add_sla` attaches account-scoped SLA policies plus idempotent AppliedSLA target rows. Focused automation action/execution tests, AutomationRule handler tests, automation/handler/worker/app package tests, full `go test ./...`, and `git diff --check` passed. B9.3 and Phase 5 move to Review; next slice is Phase 2/3 drift audit. - 2026-06-05: P5.13b scheduled/cached analytics checkpoint prepared as `feat(reports): add analytics timeseries rollups`; `GET /api/v1/accounts/:account_id/reports` and v2 `/reports` now return Chatwoot-style metric timeseries with account/inbox/agent/team/label dimensions, day/hour/week/month/year buckets, and business-hours average support. Analytics reads lazily ensure missing daily rollups, `reporting:rollup_day` durable jobs replay account/date recomputes with idempotency keys, and rollup replacement hard-deletes soft-deleted rows before refresh. Route dump is now `TOTAL: 833`; tracked route parity remains `270 exact, 0 method-compatible, 7 parameter-compatible, 0 missing out of 277 tracked critical routes`. Focused analytics service tests, analytics/live handler tests, service/handler/router/worker/app package tests, route generation/parity, full `go test ./...`, and `git diff --check` passed. P5.13 moves to Review; B9.3 is completed by `feat(automation): close delayed action parity`. - 2026-06-05: P5.13a analytics placeholder burn-down checkpoint prepared as `feat(reports): derive analytics aggregates`; live report conversation metrics and grouped metrics now read persisted conversations, bot summary/metrics read reporting events plus active agent-bot inbox bindings, conversation summary reads conversations/messages/reporting events, inbox-label matrix reads inbox/tag/conversation-label rows, first-response distribution buckets reporting events by channel, and outgoing-message counts group by agent/team/inbox/label. Focused analytics service tests, analytics/live handler tests, service/handler/worker/app package tests, full `go test ./...`, and `git diff --check` passed. Remaining P5.13 follow-up is scheduled/cached rollup freshness and `/reports` timeseries index parity. diff --git a/internal/handler/api/v1/notification_handler.go b/internal/handler/api/v1/notification_handler.go index d52b79e0..3411bde0 100644 --- a/internal/handler/api/v1/notification_handler.go +++ b/internal/handler/api/v1/notification_handler.go @@ -1,11 +1,14 @@ package v1 import ( + "encoding/json" "net/http" + "strings" "time" "github.com/gin-gonic/gin" + "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/service" applogger "github.com/gochat/gochat/pkg/logger" "github.com/gochat/gochat/pkg/pagination" @@ -34,19 +37,34 @@ func (h *NotificationHandler) List(c *gin.Context) { accountID := getAccountID(c) userID := getUserID(c) p := pagination.Parse(c) + if p.PerPage == pagination.DefaultPerPage && c.Query("per_page") == "" { + p.PerPage = 15 + } - notifications, total, err := h.notificationService.ListNotificationsByAccount(c.Request.Context(), userID, accountID, p.Page, p.PerPage) + result, err := h.notificationService.ListNotificationsByAccountWithOptions(c.Request.Context(), userID, accountID, p.Page, p.PerPage, service.NotificationListOptions{ + IncludeRead: notificationIncludes(c, "read"), + IncludeSnoozed: notificationIncludes(c, "snoozed"), + SortOrder: c.DefaultQuery("sort_order", "desc"), + }) if err != nil { applogger.L().Errorf("List notifications: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "Failed to fetch notifications") return } - response.OK(c, gin.H{ - "notifications": notifications, - "page": p.Page, - "per_page": p.PerPage, - "total": total, + payload := make([]gin.H, 0, len(result.Notifications)) + for i := range result.Notifications { + payload = append(payload, serializeNotification(&result.Notifications[i])) + } + c.JSON(http.StatusOK, gin.H{ + "data": gin.H{ + "meta": gin.H{ + "unread_count": result.UnreadCount, + "count": result.Total, + "current_page": p.Page, + }, + "payload": payload, + }, }) } @@ -69,7 +87,7 @@ func (h *NotificationHandler) Get(c *gin.Context) { return } - response.OK(c, notification) + c.JSON(http.StatusOK, serializeNotification(notification)) } // Update marks a notification as read. @@ -95,7 +113,7 @@ func (h *NotificationHandler) Update(c *gin.Context) { return } - response.OK(c, notification) + c.JSON(http.StatusOK, serializeNotification(notification)) } // MarkAllRead marks all unread notifications as read for the current user in account. @@ -104,8 +122,19 @@ func (h *NotificationHandler) Update(c *gin.Context) { func (h *NotificationHandler) MarkAllRead(c *gin.Context) { accountID := getAccountID(c) userID := getUserID(c) + var req struct { + PrimaryActorType string `json:"primary_actor_type"` + PrimaryActorID uint `json:"primary_actor_id"` + } + _ = bindOptionalNotificationJSON(c, &req) - if err := h.notificationService.MarkAllReadByAccount(c.Request.Context(), userID, accountID); err != nil { + var err error + if req.PrimaryActorType != "" && req.PrimaryActorID > 0 { + err = h.notificationService.MarkPrimaryActorReadByAccount(c.Request.Context(), userID, accountID, req.PrimaryActorType, req.PrimaryActorID) + } else { + err = h.notificationService.MarkAllReadByAccount(c.Request.Context(), userID, accountID) + } + if err != nil { applogger.L().Errorf("MarkAllRead notifications: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "Failed to mark notifications as read") return @@ -127,7 +156,7 @@ func (h *NotificationHandler) UnreadCount(c *gin.Context) { return } - response.OK(c, gin.H{"unread_count": count}) + c.JSON(http.StatusOK, count) } // Snooze snoozes a notification until a specified time. @@ -163,7 +192,7 @@ func (h *NotificationHandler) Snooze(c *gin.Context) { return } - response.OK(c, notification) + c.JSON(http.StatusOK, serializeNotification(notification)) } // Unread marks a notification as unread. @@ -185,7 +214,7 @@ func (h *NotificationHandler) Unread(c *gin.Context) { return } - response.OK(c, notification) + c.JSON(http.StatusOK, serializeNotification(notification)) } // Destroy deletes a single notification. @@ -214,11 +243,21 @@ func (h *NotificationHandler) Destroy(c *gin.Context) { func (h *NotificationHandler) DestroyAll(c *gin.Context) { accountID := getAccountID(c) userID := getUserID(c) - // Chatwoot supports type=read filter; GoChat currently deletes all regardless - // TODO: implement type=read filter when async job system is available - _ = c.Query("type") + deleteType := c.Query("type") + var req struct { + Type string `json:"type"` + } + if err := bindOptionalNotificationJSON(c, &req); err == nil && req.Type != "" { + deleteType = req.Type + } - if err := h.notificationService.DeleteAllNotifications(c.Request.Context(), userID, accountID); err != nil { + var err error + if deleteType == "read" { + err = h.notificationService.DeleteReadNotifications(c.Request.Context(), userID, accountID) + } else { + err = h.notificationService.DeleteAllNotifications(c.Request.Context(), userID, accountID) + } + if err != nil { applogger.L().Errorf("DestroyAll notifications: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "Failed to delete notifications") return @@ -226,3 +265,79 @@ func (h *NotificationHandler) DestroyAll(c *gin.Context) { c.Status(http.StatusOK) } + +func notificationIncludes(c *gin.Context, value string) bool { + for _, raw := range append(c.QueryArray("includes[]"), c.QueryArray("includes")...) { + for _, part := range strings.Split(raw, ",") { + if strings.TrimSpace(part) == value { + return true + } + } + } + return false +} + +func serializeNotification(notification *model.Notification) gin.H { + meta := gin.H{} + if len(notification.AdditionalAttributes) > 0 { + _ = json.Unmarshal(notification.AdditionalAttributes, &meta) + } + return gin.H{ + "id": notification.ID, + "notification_type": notification.NotificationType, + "push_message_title": notificationTitle(notification), + "push_message_body": notificationBody(notification), + "primary_actor_type": notification.PrimaryActorType, + "primary_actor_id": notification.PrimaryActorID, + "primary_actor": notificationActor(notification.PrimaryActorType, notification.PrimaryActorID), + "read_at": notification.ReadAt, + "secondary_actor": notificationActor(notification.SecondaryActorType, notification.SecondaryActorID), + "user": gin.H{"id": notification.UserID}, + "created_at": notification.CreatedAt.Unix(), + "last_activity_at": notification.UpdatedAt.Unix(), + "snoozed_until": notification.SnoozedUntil, + "meta": meta, + "additional_attributes": meta, + } +} + +func notificationActor(actorType string, actorID uint) interface{} { + if actorType == "" || actorID == 0 { + return nil + } + return gin.H{"id": actorID, "type": actorType, "meta": gin.H{}} +} + +func notificationTitle(notification *model.Notification) string { + if title := stringFromNotificationAttrs(notification.AdditionalAttributes, "push_message_title", "title"); title != "" { + return title + } + return notification.NotificationType +} + +func notificationBody(notification *model.Notification) string { + return stringFromNotificationAttrs(notification.AdditionalAttributes, "push_message_body", "body", "message") +} + +func stringFromNotificationAttrs(raw json.RawMessage, keys ...string) string { + if len(raw) == 0 { + return "" + } + var attrs map[string]interface{} + if err := json.Unmarshal(raw, &attrs); err != nil { + return "" + } + for _, key := range keys { + if value, ok := attrs[key].(string); ok { + return value + } + } + return "" +} + +func bindOptionalNotificationJSON(c *gin.Context, target interface{}) error { + if c.Request.Body == nil || c.Request.ContentLength == 0 { + return nil + } + return c.ShouldBindJSON(target) +} diff --git a/internal/handler/api/v1/notification_handler_test.go b/internal/handler/api/v1/notification_handler_test.go index 6034467d..4aad2f8e 100644 --- a/internal/handler/api/v1/notification_handler_test.go +++ b/internal/handler/api/v1/notification_handler_test.go @@ -1,6 +1,7 @@ package v1 import ( + "encoding/json" "fmt" "net/http" "net/http/httptest" @@ -59,6 +60,7 @@ func setupNotificationRouter(handler *NotificationHandler) *gin.Engine { // G8 extension routes router.POST("/api/v1/accounts/:account_id/notifications/:notification_id/snooze", handler.Snooze) router.POST("/api/v1/accounts/:account_id/notifications/:notification_id/unread", handler.Unread) + router.POST("/api/v1/accounts/:account_id/notifications/destroy_all", handler.DestroyAll) router.DELETE("/api/v1/accounts/:account_id/notifications/destroy_all", handler.DestroyAll) return router } @@ -91,6 +93,59 @@ func TestNotificationListEmpty(t *testing.T) { sqlDB.Close() } +func TestNotificationHandler_List_ChatwootEnvelopeAndIncludes(t *testing.T) { + db := setupNotificationDB(t) + handler := setupNotificationHandler(t, db) + router := setupNotificationRouter(handler) + + user := &model.User{Name: "Envelope User", Email: "envelope@example.com", Password: "pass", AccountID: 1} + require.NoError(t, db.Create(user).Error) + accountID := uint(1) + readAt := time.Now() + snoozedUntil := time.Now().Add(time.Hour) + unread := &model.Notification{UserID: user.ID, AccountID: &accountID, NotificationType: "message_created", PrimaryActorType: "Conversation", PrimaryActorID: 101, AdditionalAttributes: []byte(`{"push_message_title":"New message","push_message_body":"Hello"}`)} + read := &model.Notification{UserID: user.ID, AccountID: &accountID, NotificationType: "conversation_assignment", PrimaryActorType: "Conversation", PrimaryActorID: 102, ReadAt: &readAt} + snoozed := &model.Notification{UserID: user.ID, AccountID: &accountID, NotificationType: "conversation_mention", PrimaryActorType: "Conversation", PrimaryActorID: 103, SnoozedUntil: &snoozedUntil} + require.NoError(t, db.Create(unread).Error) + require.NoError(t, db.Create(read).Error) + require.NoError(t, db.Create(snoozed).Error) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/api/v1/accounts/1/notifications", nil) + req.Header.Set("X-User-ID", strconv.FormatUint(uint64(user.ID), 10)) + router.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + var body map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + data := body["data"].(map[string]any) + meta := data["meta"].(map[string]any) + payload := data["payload"].([]any) + assert.Equal(t, float64(1), meta["count"]) + assert.Equal(t, float64(1), meta["unread_count"]) + require.Len(t, payload, 1) + item := payload[0].(map[string]any) + assert.Equal(t, "New message", item["push_message_title"]) + assert.Equal(t, "Hello", item["push_message_body"]) + assert.Equal(t, float64(101), item["primary_actor_id"]) + assert.NotContains(t, body, "success") + + w = httptest.NewRecorder() + req, _ = http.NewRequest("GET", "/api/v1/accounts/1/notifications?includes[]=read&includes[]=snoozed", nil) + req.Header.Set("X-User-ID", strconv.FormatUint(uint64(user.ID), 10)) + router.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + data = body["data"].(map[string]any) + meta = data["meta"].(map[string]any) + payload = data["payload"].([]any) + assert.Equal(t, float64(3), meta["count"]) + assert.Equal(t, float64(2), meta["unread_count"]) + assert.Len(t, payload, 3) + + sqlDB, _ := db.DB() + sqlDB.Close() +} + func TestNotificationGet(t *testing.T) { db := setupNotificationDB(t) handler := setupNotificationHandler(t, db) @@ -155,11 +210,11 @@ func TestNotificationCRUDWithDB(t *testing.T) { // Create notification notif := &model.Notification{ - UserID: user.ID, - AccountID: uintPtr(1), - NotificationType: "conversation_assignment", - PrimaryActorType: "conversation", - PrimaryActorID: 5, + UserID: user.ID, + AccountID: uintPtr(1), + NotificationType: "conversation_assignment", + PrimaryActorType: "conversation", + PrimaryActorID: 5, SecondaryActorType: "user", SecondaryActorID: 3, } @@ -201,11 +256,11 @@ func TestNotificationReadAllWithDB(t *testing.T) { // Create multiple unread notifications for i := 0; i < 5; i++ { notif := &model.Notification{ - UserID: user.ID, - AccountID: uintPtr(1), - NotificationType: "message_created", - PrimaryActorType: "message", - PrimaryActorID: uint(i + 1), + UserID: user.ID, + AccountID: uintPtr(1), + NotificationType: "message_created", + PrimaryActorType: "message", + PrimaryActorID: uint(i + 1), } err := db.Create(notif).Error assert.NoError(t, err) @@ -244,11 +299,11 @@ func TestNotificationTypes(t *testing.T) { func TestNotificationModelFields(t *testing.T) { notif := model.Notification{ - UserID: 1, - AccountID: uintPtr(2), - NotificationType: "conversation_assignment", - PrimaryActorType: "conversation", - PrimaryActorID: 5, + UserID: 1, + AccountID: uintPtr(2), + NotificationType: "conversation_assignment", + PrimaryActorType: "conversation", + PrimaryActorID: 5, SecondaryActorType: "user", SecondaryActorID: 3, } @@ -547,4 +602,66 @@ func TestNotificationHandler_DestroyAllWithDB(t *testing.T) { sqlDB, _ := db.DB() sqlDB.Close() -} \ No newline at end of file +} + +func TestNotificationHandler_DestroyAll_ReadOnly(t *testing.T) { + db := setupNotificationDB(t) + handler := setupNotificationHandler(t, db) + router := setupNotificationRouter(handler) + + user := &model.User{Name: "DestroyRead User", Email: "destroy-read@example.com", Password: "pass", AccountID: 1} + require.NoError(t, db.Create(user).Error) + accountID := uint(1) + readAt := time.Now() + readNotification := &model.Notification{UserID: user.ID, AccountID: &accountID, NotificationType: "read", PrimaryActorType: "conversation", PrimaryActorID: 1, ReadAt: &readAt} + unreadNotification := &model.Notification{UserID: user.ID, AccountID: &accountID, NotificationType: "unread", PrimaryActorType: "conversation", PrimaryActorID: 2} + require.NoError(t, db.Create(readNotification).Error) + require.NoError(t, db.Create(unreadNotification).Error) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/api/v1/accounts/1/notifications/destroy_all", strings.NewReader(`{"type":"read"}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-User-ID", strconv.FormatUint(uint64(user.ID), 10)) + router.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + + var count int64 + require.NoError(t, db.Model(&model.Notification{}).Where("id = ?", readNotification.ID).Count(&count).Error) + assert.Equal(t, int64(0), count) + require.NoError(t, db.Model(&model.Notification{}).Where("id = ?", unreadNotification.ID).Count(&count).Error) + assert.Equal(t, int64(1), count) + + sqlDB, _ := db.DB() + sqlDB.Close() +} + +func TestNotificationHandler_MarkAllRead_PrimaryActorOnly(t *testing.T) { + db := setupNotificationDB(t) + handler := setupNotificationHandler(t, db) + router := setupNotificationRouter(handler) + + user := &model.User{Name: "Actor Read User", Email: "actor-read@example.com", Password: "pass", AccountID: 1} + require.NoError(t, db.Create(user).Error) + accountID := uint(1) + matching := &model.Notification{UserID: user.ID, AccountID: &accountID, NotificationType: "message_created", PrimaryActorType: "Conversation", PrimaryActorID: 1} + other := &model.Notification{UserID: user.ID, AccountID: &accountID, NotificationType: "message_created", PrimaryActorType: "Conversation", PrimaryActorID: 2} + require.NoError(t, db.Create(matching).Error) + require.NoError(t, db.Create(other).Error) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/api/v1/accounts/1/notifications/read_all", strings.NewReader(`{"primary_actor_type":"Conversation","primary_actor_id":1}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-User-ID", strconv.FormatUint(uint64(user.ID), 10)) + router.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + + var matchingReloaded model.Notification + require.NoError(t, db.First(&matchingReloaded, matching.ID).Error) + assert.NotNil(t, matchingReloaded.ReadAt) + var otherReloaded model.Notification + require.NoError(t, db.First(&otherReloaded, other.ID).Error) + assert.Nil(t, otherReloaded.ReadAt) + + sqlDB, _ := db.DB() + sqlDB.Close() +} diff --git a/internal/repository/notification_repo.go b/internal/repository/notification_repo.go index b352a451..aa921c0b 100644 --- a/internal/repository/notification_repo.go +++ b/internal/repository/notification_repo.go @@ -15,6 +15,14 @@ type NotificationRepo struct { db *gorm.DB } +// NotificationListFilter mirrors Chatwoot NotificationFinder filters used by +// the dashboard notifications API. +type NotificationListFilter struct { + IncludeRead bool + IncludeSnoozed bool + SortOrder string +} + // NewNotificationRepo creates a new Notification repository. func NewNotificationRepo(db *gorm.DB) *NotificationRepo { return &NotificationRepo{db: db} @@ -64,6 +72,44 @@ func (r *NotificationRepo) ListByUserAndAccount(ctx context.Context, userID uint return notifications, total, nil } +func (r *NotificationRepo) ListByUserAndAccountFiltered(ctx context.Context, userID uint, accountID uint, offset, limit int, filter NotificationListFilter) ([]model.Notification, int64, int64, error) { + var notifications []model.Notification + var total int64 + var unreadCount int64 + + db := r.applyNotificationListFilter(r.db.WithContext(ctx).Model(&model.Notification{}).Where("user_id = ? AND account_id = ?", userID, accountID), filter) + if err := db.Count(&total).Error; err != nil { + return nil, 0, 0, err + } + if err := db.Session(&gorm.Session{}).Where("read_at IS NULL").Count(&unreadCount).Error; err != nil { + return nil, 0, 0, err + } + + sortOrder := "DESC" + if filter.SortOrder == "asc" { + sortOrder = "ASC" + } + if err := db.Session(&gorm.Session{}). + Order("updated_at " + sortOrder). + Offset(offset). + Limit(limit). + Find(¬ifications).Error; err != nil { + return nil, 0, 0, err + } + + return notifications, total, unreadCount, nil +} + +func (r *NotificationRepo) applyNotificationListFilter(db *gorm.DB, filter NotificationListFilter) *gorm.DB { + if !filter.IncludeSnoozed { + db = db.Where("snoozed_until IS NULL") + } + if !filter.IncludeRead { + db = db.Where("read_at IS NULL") + } + return db +} + // Create inserts a new notification. func (r *NotificationRepo) Create(ctx context.Context, notification *model.Notification) error { return r.db.WithContext(ctx).Create(notification).Error @@ -105,6 +151,13 @@ func (r *NotificationRepo) MarkAllReadByUserAndAccount(ctx context.Context, user Update("read_at", now).Error } +func (r *NotificationRepo) MarkPrimaryActorReadByUserAndAccount(ctx context.Context, userID, accountID uint, actorType string, actorID uint) error { + now := time.Now() + return r.db.WithContext(ctx).Model(&model.Notification{}). + Where("user_id = ? AND account_id = ? AND primary_actor_type = ? AND primary_actor_id = ? AND read_at IS NULL", userID, accountID, actorType, actorID). + Update("read_at", now).Error +} + // Delete soft-deletes a notification. func (r *NotificationRepo) Delete(ctx context.Context, id uint) error { return r.db.WithContext(ctx).Delete(&model.Notification{}, id).Error @@ -165,4 +218,11 @@ func (r *NotificationRepo) DeleteAllByUser(ctx context.Context, userID, accountI return r.db.WithContext(ctx). Where("user_id = ? AND account_id = ?", userID, accountID). Delete(&model.Notification{}).Error -} \ No newline at end of file +} + +// DeleteReadByUser soft-deletes only read notifications for a user/account. +func (r *NotificationRepo) DeleteReadByUser(ctx context.Context, userID, accountID uint) error { + return r.db.WithContext(ctx). + Where("user_id = ? AND account_id = ? AND read_at IS NOT NULL", userID, accountID). + Delete(&model.Notification{}).Error +} diff --git a/internal/service/notification_service.go b/internal/service/notification_service.go index 338e345f..dcba35f8 100644 --- a/internal/service/notification_service.go +++ b/internal/service/notification_service.go @@ -10,12 +10,24 @@ import ( "github.com/gochat/gochat/internal/repository" ) +type NotificationListOptions struct { + IncludeRead bool + IncludeSnoozed bool + SortOrder string +} + +type NotificationListResult struct { + Notifications []model.Notification + Total int64 + UnreadCount int64 +} + // NotificationService provides business logic for notifications and notification preferences. // Reference: Chatwoot app/services/notification_service.rb type NotificationService struct { - db *gorm.DB - notifRepo *repository.NotificationRepo - prefRepo *repository.NotificationPreferenceRepo + db *gorm.DB + notifRepo *repository.NotificationRepo + prefRepo *repository.NotificationPreferenceRepo } // NewNotificationService creates a new Notification service with required dependencies. @@ -50,6 +62,19 @@ func (s *NotificationService) ListNotificationsByAccount(ctx context.Context, us return s.notifRepo.ListByUserAndAccount(ctx, userID, accountID, offset, perPage) } +func (s *NotificationService) ListNotificationsByAccountWithOptions(ctx context.Context, userID uint, accountID uint, page, perPage int, opts NotificationListOptions) (*NotificationListResult, error) { + offset := (page - 1) * perPage + items, total, unreadCount, err := s.notifRepo.ListByUserAndAccountFiltered(ctx, userID, accountID, offset, perPage, repository.NotificationListFilter{ + IncludeRead: opts.IncludeRead, + IncludeSnoozed: opts.IncludeSnoozed, + SortOrder: opts.SortOrder, + }) + if err != nil { + return nil, err + } + return &NotificationListResult{Notifications: items, Total: total, UnreadCount: unreadCount}, nil +} + // CreateNotification creates a new notification. func (s *NotificationService) CreateNotification(ctx context.Context, notification *model.Notification) error { return s.notifRepo.Create(ctx, notification) @@ -70,6 +95,10 @@ func (s *NotificationService) MarkAllReadByAccount(ctx context.Context, userID u return s.notifRepo.MarkAllReadByUserAndAccount(ctx, userID, accountID) } +func (s *NotificationService) MarkPrimaryActorReadByAccount(ctx context.Context, userID, accountID uint, actorType string, actorID uint) error { + return s.notifRepo.MarkPrimaryActorReadByUserAndAccount(ctx, userID, accountID, actorType, actorID) +} + // DeleteNotification soft-deletes a notification. func (s *NotificationService) DeleteNotification(ctx context.Context, id uint) error { return s.notifRepo.Delete(ctx, id) @@ -114,3 +143,7 @@ func (s *NotificationService) MarkNotificationUnread(ctx context.Context, id, us func (s *NotificationService) DeleteAllNotifications(ctx context.Context, userID, accountID uint) error { return s.notifRepo.DeleteAllByUser(ctx, userID, accountID) } + +func (s *NotificationService) DeleteReadNotifications(ctx context.Context, userID, accountID uint) error { + return s.notifRepo.DeleteReadByUser(ctx, userID, accountID) +}