diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index 40752f5d..09b68a17 100644 --- a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md +++ b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md @@ -16,12 +16,12 @@ Build GoChat as a Go backend that can directly reuse the frontend from `referenc ## Current Baseline -- Current tracking checkpoint: 2026-06-06 after this implementation checkpoint, prepared as `feat(help-center): align portal payloads`. -- Latest implementation checkpoint: this checkpoint, prepared as `feat(help-center): align portal payloads`. +- Current tracking checkpoint: 2026-06-06 after this implementation checkpoint, prepared as `feat(help-center): align category payloads`. +- Latest implementation checkpoint: this checkpoint, prepared as `feat(help-center): align category payloads`. - Latest documentation-only checkpoint: `24a5cb3 docs: land help center parity plan`; this document is now the active follow-up plan and supersedes `.hermes/plans/*`. -- Worktree status at this implementation checkpoint: P3.7a help center portal parity now matches the reused dashboard `helpCenter/portals.js` client and Chatwoot `PortalsController`/Jbuilder surface for the portal core path: no-trailing-slash `GET/POST /portals`, `PATCH /portals/:portal_id`, and `PATCH /archive` routes are registered; portal route IDs resolve by slug with legacy numeric fallback and account scope; create/update accept raw frontend bodies plus `{ portal: ... }`; index returns `{ payload, meta: { current_page, portals_count } }`; show/create/update return raw portal payloads with `config`, `meta`, optional `logo`, and SSL settings; delete/logo/archive return empty `200 OK`; `send_instructions` returns `{ message }`; and `ssl_status` returns enterprise `{ status, verification_errors }` or `422` for missing custom domain. Campaign create/update already mirrors Chatwoot `Campaign#ensure_correct_campaign_attributes`; account campaign CRUD already matches the reused dashboard `campaigns` store and Chatwoot Jbuilder contract. Fresh help-center category/article drift remains tracked as P3.7b/P3.7c below. Agent/settings/profile/notification/contact/custom-field/report enterprise drifts listed below remain at their prior checkpoint states. The fresh placeholder audit found remaining `chatwootParityStub` routes only in webhook handler-not-injected fallbacks, not in the reused dashboard account/contact/conversation/message/inbox critical path. P3.6 custom filters/custom attribute definitions and label CRUD are already in Review. B11.1a-B11.3e Captain/Copilot slices remain in Review; B12.1/B12.2/B12.3 smoke harnesses remain in Review; P5 durable job work through P5.13b is in Review. Next active implementation slice is P3.7b help center category route/payload parity. +- Worktree status at this implementation checkpoint: P3.7b help center category parity now matches the reused dashboard `helpCenter/categories.js` client and Chatwoot `CategoriesController`/Jbuilder surface for the category core path: no-trailing-slash `GET/POST /portals/:portal_id/categories` and frontend `PATCH /categories/:category_id` routes are registered; portal route IDs resolve by account-scoped slug with numeric fallback through the portal service; create/update accept raw frontend category bodies plus `{ category: ... }`; index returns `{ payload, meta: { current_page, categories_count } }` with locale filtering; show/create/update return `{ payload: category }` with related/parent/root category partials and `meta.articles_count`; delete and reorder return empty `200 OK`; and reorder accepts Chatwoot `positions_hash` while preserving legacy `positions` arrays. P3.7a help-center portal parity is already in Review. Campaign create/update already mirrors Chatwoot `Campaign#ensure_correct_campaign_attributes`; account campaign CRUD already matches the reused dashboard `campaigns` store and Chatwoot Jbuilder contract. Fresh help-center article drift remains tracked as P3.7c below. Agent/settings/profile/notification/contact/custom-field/report enterprise drifts listed below remain at their prior checkpoint states. The fresh placeholder audit found remaining `chatwootParityStub` routes only in webhook handler-not-injected fallbacks, not in the reused dashboard account/contact/conversation/message/inbox critical path. P3.6 custom filters/custom attribute definitions and label CRUD are already in Review. B11.1a-B11.3e Captain/Copilot slices remain in Review; B12.1/B12.2/B12.3 smoke harnesses remain in Review; P5 durable job work through P5.13b is in Review. Next active implementation slice is P3.7c help center article and bulk-action parity. - `go test ./...` passes. -- Route dump succeeds with `TOTAL: 850` after adding no-trailing-slash portal index/create aliases, frontend-used portal PATCH update, and Chatwoot PATCH archive route. +- Route dump succeeds with `TOTAL: 853` after adding no-trailing-slash category index/create aliases and frontend-used category PATCH update route. - Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`. - Tracked frontend-critical route audit covers 277 Chatwoot routes: 270 exact, 0 method-compatible, 7 parameter-compatible, 0 missing. The 7 parameter-compatible routes are Gin-internal parameter-name differences for nested AgentCapacityPolicy users/inbox limits; the external URL shape is equivalent. - `/api/v1/widget` stubs are burned down and public inbox/contact/conversation/message core flows are backed by real handlers. @@ -45,11 +45,10 @@ Next ordered checkpoints: | Order | Slice | Required outcome | Primary verification | | --- | --- | --- | --- | -| 1 | P3.7b help center categories | Align category raw bodies, slug portal lookup, PATCH route, list/show serializers, delete status, and locale filtering. | Focused category handler/service/repository tests plus route dump/parity and `go test ./...`. | -| 2 | P3.7c help center articles | Align article raw bodies, slug portal lookup, PATCH route, list/show serializers, and article bulk actions. | Focused article/bulk handler tests plus route dump/parity and `go test ./...`. | -| 3 | Phase 2/3 audit pass | Route/controller/serializer drift found by B12 or new reference inspection is captured as named slices, not free-form TODOs. | Regenerated route parity artifacts and fixture-backed serializer tests. | -| 4 | Phase 6 placeholder burn-down | Remaining account/contact/conversation/message/inbox placeholder handlers are either real Chatwoot-compatible flows or explicitly tracked as unsupported reference gaps. | `rg` placeholder audit, route smoke, and endpoint-family tests. | -| 5 | B12 optional live smoke | Run the checked smoke harness in a full PostgreSQL/Redis/Meilisearch/Vite/Chrome environment and turn failures into named slices. | `docs/parity/frontend_smoke_report.md` pass/fail entries linked to owners. | +| 1 | P3.7c help center articles | Align article raw bodies, slug portal lookup, PATCH route, list/show serializers, and article bulk actions. | Focused article/bulk handler tests plus route dump/parity and `go test ./...`. | +| 2 | Phase 2/3 audit pass | Route/controller/serializer drift found by B12 or new reference inspection is captured as named slices, not free-form TODOs. | Regenerated route parity artifacts and fixture-backed serializer tests. | +| 3 | Phase 6 placeholder burn-down | Remaining account/contact/conversation/message/inbox placeholder handlers are either real Chatwoot-compatible flows or explicitly tracked as unsupported reference gaps. | `rg` placeholder audit, route smoke, and endpoint-family tests. | +| 4 | B12 optional live smoke | Run the checked smoke harness in a full PostgreSQL/Redis/Meilisearch/Vite/Chrome environment and turn failures into named slices. | `docs/parity/frontend_smoke_report.md` pass/fail entries linked to owners. | ## Handoff Contract @@ -67,7 +66,7 @@ Open work after the current checkpoint: | --- | --- | --- | --- | | Phase 5 jobs | Keep B9.3 in Review and open only evidence-backed drift slices for new async gaps. | `Phase 5: Background Jobs And Integrations` | Automation tests prove remaining action side effects and reference inspection proves no delayed action params remain. | | B12 smoke | Run optional live API/browser/enterprise smoke in a full PostgreSQL/Redis/Meilisearch/Vite environment and convert failures into named slices. | `B12 reused frontend verification breakdown` | `docs/parity/frontend_smoke_report.md` records checked pass/fail results and maps failures to slices. | -| P3.7 help center | Portal route/payload parity is in Review; continue categories/articles and article bulk actions. | `Open Checkpoint Contracts`, `Phase 3` | Reused Chatwoot help-center dashboard clients can call portal/category/article APIs without adapters. | +| P3.7 help center | Portal and category route/payload parity are in Review; continue articles and article bulk actions. | `Open Checkpoint Contracts`, `Phase 3` | Reused Chatwoot help-center dashboard clients can call portal/category/article APIs without adapters. | | Phase 2/3 drift | Expand tracked route/serializer fixtures when B12 exposes frontend-critical gaps. | `Phase 2`, `Phase 3`, `docs/parity/` | Route parity remains 0 missing for tracked frontend routes; serializers have reference fixtures. | | Phase 6 placeholders | Re-run placeholder audit and burn down any frontend-reachable stub. | `Phase 6: Core Product Placeholder Burn-down` | Stub list has no reused-frontend critical path without a named owner. | @@ -90,7 +89,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; notification list/action serializers, user notification-settings raw payloads, campaigns raw payload/display-id routes, Devise password reset/confirmation payloads, CRM shared attachment payloads plus fixed 100-row attachment pagination, account/settings payloads, assignable-agent payloads, agent index full-list behavior, agent create/update/delete defaults/errors/scope, label CRUD payloads, custom filters, custom attribute definitions, and help-center portal payloads now match the inspected Chatwoot frontend contract. Remaining help-center drift is category/article raw-payload/PATCH/bulk route parity. | Implement P3.7b categories, then P3.7c articles/bulk actions. | Regenerate parity artifacts when routes change 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, user notification-settings raw payloads, campaigns raw payload/display-id routes, Devise password reset/confirmation payloads, CRM shared attachment payloads plus fixed 100-row attachment pagination, account/settings payloads, assignable-agent payloads, agent index full-list behavior, agent create/update/delete defaults/errors/scope, label CRUD payloads, custom filters, custom attribute definitions, and help-center portal/category payloads now match the inspected Chatwoot frontend contract. Remaining help-center drift is article raw-payload/PATCH/bulk route parity. | Implement P3.7c articles/bulk actions. | 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; inbox WhatsApp health/register-webhook and sync-template drift are closed; fresh `rg` audit shows only webhook nil-handler fallbacks still call `chatwootParityStub`; dashboard conversation transcript response drift is closed. | Continue targeted account/contact/conversation/message/inbox drift from reference/smoke evidence. | `rg` placeholder audit 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. | @@ -110,7 +109,7 @@ These rows are the executable development plan from this point forward. A checkp | P3.6 label CRUD payload parity | `internal/handler/api/v1/label_handler.go`, `internal/service/tag_service.go`, `internal/model/tag.go`, `internal/repository/tag_repo.go`, migrations, label handler/service tests | `reference/chatwoot/app/controllers/api/v1/accounts/labels_controller.rb`, `app/views/api/v1/accounts/labels/*.json.jbuilder`, `app/models/label.rb`, `db/schema.rb`, dashboard `api/labels.js`, `store/modules/labels.js` | Account `/labels` CRUD now returns Chatwoot `{ payload: [...] }` lists, raw mutation/show payloads, empty `200 OK` delete, `title/description/color/show_on_sidebar` persistence, title lowercasing/uniqueness, account scope, nested `{ label: ... }` request bodies, legacy `name` compatibility, default color, and local association cleanup/rename side effects. | Review by `feat(labels): align chatwoot label payloads`; focused handler/service/repository tests, combined handler/service/router tests, `go test ./cmd/migrate -count=1`, full `go test ./...`, and `git diff --check` passed. | | P3.6 custom filters/custom attribute definitions payload parity | `internal/handler/api/v1/custom_filter_handler.go`, `internal/service/custom_filter_service.go`, `internal/repository/custom_filter_repo.go`, `internal/handler/api/v1/custom_attribute_definition_handler.go`, `internal/service/custom_attribute_definition_service.go`, `internal/service/custom_attribute_value_service.go`, handler/service/repository tests | `reference/chatwoot/app/controllers/api/v1/accounts/custom_filters_controller.rb`, `app/views/api/v1/accounts/custom_filters/*.json.jbuilder`, `app/views/api/v1/models/_custom_filter.json.jbuilder`, dashboard `api/customViews.js`, `store/modules/customViews.js`, `custom_attribute_definitions_controller.rb`, `_custom_attribute_definition.json.jbuilder`, dashboard `api/attributes.js`, `store/modules/attributes.js`, settings attributes screens/constants | `/custom_filters` and `/custom_attribute_definitions` now return raw Chatwoot arrays/objects instead of local envelopes, accept raw reused-frontend bodies plus Rails-style wrappers, use `200 OK` mutation responses, preserve `204` deletes, scope saved views by current user and default `filter_type=conversation`, normalize custom attribute enum integers to Chatwoot string values, and keep custom attribute value validation compatible with normalized `*_attribute` definitions plus legacy rows. | Review by `feat(custom-fields): align chatwoot saved view payloads`; focused handler/service/repository tests, combined handler/service/router tests, full `go test ./...`, and `git diff --check` must pass. | | P3.7a help center portal route/payload parity | `internal/router/router.go`, `internal/handler/api/v1/portal_handler.go`, `internal/service/portal_service.go`, `internal/repository/portal_repo.go`, portal handler/service/repository tests | `reference/chatwoot/app/controllers/api/v1/accounts/portals_controller.rb`, `app/views/api/v1/accounts/portals/*.json.jbuilder`, dashboard `api/helpCenter/portals.js`, help-center portal store/actions | Frontend no-trailing-slash `GET/POST /portals`, `PATCH /portals/:portal_id`, and PATCH archive routes are registered; `:portal_id` resolves as Chatwoot portal slug with legacy numeric fallback; raw portal bodies plus `{ portal: ... }` wrappers are accepted; portal index/show/create/update return raw Jbuilder-style payloads; delete/logo/archive return empty `200 OK`; `send_instructions` returns `{ message }`; and `ssl_status` returns enterprise `{ status, verification_errors }`/`422` semantics. | Review by `feat(help-center): align portal payloads`; focused portal handler/service/repository tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. | -| P3.7b help center category route/payload parity | `internal/router/router.go`, `internal/handler/api/v1/category_handler.go`, `internal/service/category_service.go`, `internal/repository/category_repo.go`, category tests | `reference/chatwoot/app/controllers/api/v1/accounts/categories_controller.rb`, `app/views/api/v1/accounts/categories/*.json.jbuilder`, dashboard `api/helpCenter/categories.js` | Resolve portal by slug for `/portals/:portal_id/categories`; accept raw category bodies plus wrappers; add frontend `PATCH` route; return Chatwoot raw category/list serializers with locale filtering; make delete empty `200 OK`; keep account/portal scope strict. | Review by `feat(help-center): align category payloads`; focused category handler/service/repository tests, route dump/parity regeneration when routes change, full `go test ./...`, and `git diff --check` must pass. | +| P3.7b help center category route/payload parity | `internal/router/router.go`, `internal/handler/api/v1/category_handler.go`, `internal/service/category_service.go`, `internal/repository/category_repo.go`, category tests | `reference/chatwoot/app/controllers/api/v1/accounts/categories_controller.rb`, `app/views/api/v1/accounts/categories/*.json.jbuilder`, dashboard `api/helpCenter/categories.js` | Category APIs now resolve portal slugs account-scoped for `/portals/:portal_id/categories`, accept raw category bodies plus wrappers, expose frontend no-trailing-slash and `PATCH` routes, return Chatwoot `{ payload, meta }` and `{ payload: category }` serializers with locale filtering and article counts, make delete/reorder empty `200 OK`, accept Chatwoot `positions_hash`, and keep category mutations scoped to the resolved portal. | Review by `feat(help-center): align category payloads`; focused category handler/service/repository tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. | | P3.7c help center article and bulk-action parity | `internal/router/router.go`, `internal/handler/api/v1/article_handler.go`, `internal/service/article_service.go`, `internal/repository/article_repo.go`, article tests | `reference/chatwoot/app/controllers/api/v1/accounts/articles_controller.rb`, `app/controllers/api/v1/accounts/articles/bulk_actions_controller.rb`, `app/views/api/v1/accounts/articles/*.json.jbuilder`, dashboard `api/helpCenter/articles.js` | Resolve portal by slug; accept raw article bodies plus wrappers; add frontend `PATCH /articles/:id`; align list/search/show/create/update/delete serializers and status/category/locale params; register and implement `PATCH /articles/bulk_actions/update_status`, `PATCH /articles/bulk_actions/update_category`, and `DELETE /articles/bulk_actions/delete_articles` with Chatwoot request/response semantics. | Review by `feat(help-center): align article payloads`; focused article and bulk-action tests, route dump/parity regeneration when routes change, full `go test ./...`, and `git diff --check` must pass. | | P6 conversation transcript response parity | `internal/handler/api/v1/conversation_handler.go`, conversation handler tests | `reference/chatwoot/app/controllers/api/v1/accounts/conversations_controller.rb`, dashboard `api/inbox/conversation.js` | Account conversation transcript now follows Chatwoot's controller contract: missing email returns `422 { error: "email param missing" }`, nonblank email schedules through the existing service boundary and returns empty `200 OK`, and invalid-looking but nonblank email values are not rejected by local email format validation. | Review by `feat(conversations): align transcript responses`; focused transcript handler/service tests, combined handler/service/router tests, full `go test ./...`, and `git diff --check` must pass. | | P6 conversation custom attributes response parity | `internal/handler/api/v1/conversation_handler.go`, conversation handler tests | `reference/chatwoot/app/controllers/api/v1/accounts/conversations_controller.rb`, `app/views/api/v1/accounts/conversations/custom_attributes.json.jbuilder`, dashboard `api/inbox/conversation.js`, conversation store action | Account conversation custom attribute updates now return Chatwoot `{ custom_attributes: ... }` only, matching the store action that reads `response.data.custom_attributes`, instead of returning the full conversation serializer with unrelated fields. | Review by `feat(conversations): align custom attribute response`; focused handler tests, combined handler/service/router tests, full `go test ./...`, and `git diff --check` must pass. | @@ -149,6 +148,7 @@ This ledger records the committed parity checkpoints that future slices should b | Commit | Scope | Verification summary | Follow-up state | | --- | --- | --- | --- | +| `feat(help-center): align category payloads` | Advances P3.7b help-center parity by matching Chatwoot `CategoriesController`, category Jbuilder views, and reused dashboard `helpCenter/categories.js` plus category store actions. Category index/create now expose no-trailing-slash routes; update exposes frontend `PATCH`; portal IDs resolve through account-scoped slug lookup with numeric fallback; create/update accept raw frontend category bodies plus wrappers; list returns `{ payload, meta }` with locale filtering; show/create/update return `{ payload: category }` with related/parent/root category partials and `meta.articles_count`; delete returns empty `200 OK`; and reorder accepts Chatwoot `positions_hash` while preserving legacy `positions` arrays. | `go test ./internal/handler/api/v1 -run CategoryHandler -count=1`; `go test ./internal/service ./internal/repository -run Category -count=1`; `go test ./internal/router -run Router -count=1`; `go run ./cmd/dump_routes > docs/parity/gochat_routes.txt`; `go run ./cmd/route_parity`; full `go test ./...`; `git diff --check`. Route dump is `TOTAL: 853`; tracked route parity remains `270 exact, 7 parameter-compatible, 0 missing`. | P3.7b moves to Review; continue P3.7c help-center articles and article bulk actions. | | `feat(help-center): align portal payloads` | Advances P3.7a help-center parity by matching Chatwoot `PortalsController`, portal Jbuilder views, enterprise SSL status behavior, and reused dashboard `helpCenter/portals.js` plus portal store actions. Portal index/create now expose no-trailing-slash routes; update exposes frontend `PATCH`; archive exposes Chatwoot `PATCH`; route IDs resolve through account-scoped slug lookup with legacy numeric fallback; create/update accept raw frontend portal bodies plus wrappers; index returns `{ payload, meta }`; show/create/update return raw portal objects with `config`, `meta`, logo, and SSL settings; delete/logo/archive return empty `200 OK`; `send_instructions` returns `{ message }`; and SSL status returns `{ status, verification_errors }` or `422` when no custom domain is configured. | `go test ./internal/handler/api/v1 -run PortalHandler -count=1`; `go test ./internal/service ./internal/repository -run Portal -count=1`; `go test ./internal/router -run Router -count=1`; `go run ./cmd/dump_routes > docs/parity/gochat_routes.txt`; `go run ./cmd/route_parity`; full `go test ./...`; `git diff --check`. Route dump is `TOTAL: 850`; tracked route parity remains `270 exact, 7 parameter-compatible, 0 missing`. | P3.7a moves to Review; continue P3.7b help-center categories, then P3.7c articles/bulk actions. | | `docs: land help center parity plan` | Documentation-only handoff checkpoint after `a02d11e feat(campaigns): derive chatwoot scheduling`. Records the latest clean campaign baseline and turns fresh help-center reference/frontend inspection into executable P3.7a-P3.7c contracts for portals, categories, articles, and article bulk actions. | `git diff --check`. No Go code changed. | Start `feat(help-center): align portal payloads`, then continue categories/articles and route parity regeneration. | | `feat(campaigns): derive chatwoot scheduling` | Advances P3.7 campaign behavior parity beyond response envelopes by matching Chatwoot `Campaign#ensure_correct_campaign_attributes`, `campaign_params`, and the reused SMS/WhatsApp/live-chat campaign forms. Create no longer requires frontend-absent `campaign_type`; SMS/Twilio/WhatsApp inboxes derive one-off campaigns, persist ISO `scheduled_at`, and default missing schedules to current UTC; web-widget/live-chat inboxes derive ongoing campaigns and clear schedules; update accepts frontend `inbox_id`, `sender_id`, and `scheduled_at`, preserves omitted descriptions, reloads nested inbox/sender data, and validates inbox/sender account scope. | `go test ./internal/handler/api/v1 -run CampaignHandler -count=1`; `go test ./internal/service ./internal/repository -run Campaign -count=1`; full `go test ./...`; `git diff --check`. No route changes; route dump remains `TOTAL: 846`. | Continue Phase 6 placeholder audit or the next named Phase 2/3 drift from reference/smoke evidence. | @@ -1585,7 +1585,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, and shared attachments. | Doing | | P3.6 | Labels/custom attributes/custom filters | Create/update/list behavior and exact response shapes. | Doing; label CRUD is in Review with Chatwoot `title/description/color/show_on_sidebar` payloads and raw/list response contracts, while custom attributes/custom filters remain evidence-driven follow-ups. | -| P3.7 | Notifications/reports/help center/campaigns | Frontend-visible payloads and pagination/error envelopes. | Doing; notifications, reports, account campaign CRUD/scheduling, and help-center portal routes/payloads now have focused Chatwoot payload fixtures. Remaining help-center work is P3.7b categories and P3.7c articles/bulk actions. | +| P3.7 | Notifications/reports/help center/campaigns | Frontend-visible payloads and pagination/error envelopes. | Doing; notifications, reports, account campaign CRUD/scheduling, and help-center portal/category routes/payloads now have focused Chatwoot payload fixtures. Remaining help-center work is P3.7c articles/bulk actions. | | 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 | @@ -2094,6 +2094,7 @@ Verification milestone gates: ## Progress Log +- 2026-06-06: P3.7b help-center category checkpoint prepared as `feat(help-center): align category payloads`; audited Chatwoot `CategoriesController`, category Jbuilder views, reused dashboard `helpCenter/categories.js`, and category store actions. Account category APIs now accept raw frontend create/update bodies plus wrappers, resolve portal route IDs by account-scoped slug with numeric fallback, return Chatwoot `{ payload, meta }` list payloads and `{ payload: category }` mutation/show payloads, expose no-trailing-slash index/create plus frontend `PATCH`, return empty `200 OK` for delete/reorder, support locale filtering and `meta.articles_count`, and accept Chatwoot reorder `positions_hash` while keeping legacy `positions` arrays. Focused category handler/service/repository tests, router test, route dump/parity, full `go test ./...`, and `git diff --check` passed; continue P3.7c article/bulk-action parity next. - 2026-06-06: P3.7a help-center portal checkpoint prepared as `feat(help-center): align portal payloads`; audited Chatwoot `PortalsController`, enterprise portal `ssl_status`, portal Jbuilder views, reused dashboard `helpCenter/portals.js`, and portal store actions. Account portal APIs now accept raw frontend create/update bodies plus wrappers, resolve portal route IDs by account-scoped slug with numeric fallback, return Chatwoot raw portal/index payloads, expose no-trailing-slash index/create plus frontend `PATCH`, and return empty `200 OK` for delete/logo/archive. `send_instructions` now returns `{ message: "Instructions sent successfully" }`, while SSL status returns `{ status, verification_errors }` or `422` for missing custom domain. Focused portal handler/service/repository tests, router test, route dump/parity, full `go test ./...`, and `git diff --check` passed; continue P3.7b category parity next. - 2026-06-06: Documentation checkpoint prepared as `docs: land help center parity plan`; current baseline now points at `a02d11e feat(campaigns): derive chatwoot scheduling`, and direct help-center inspection is converted into P3.7a-P3.7c executable contracts. Audited Chatwoot `PortalsController`, `CategoriesController`, `ArticlesController`, `Articles::BulkActionsController`, portal/category/article Jbuilder views, and reused dashboard `helpCenter/*.js` clients. Current Go gaps are slug-vs-numeric portal resolution, local response envelopes, wrapped mutation bodies, missing no-trailing-slash/PATCH/frontend bulk routes, and non-Chatwoot delete/send-instruction status bodies. Verification for this docs-only checkpoint: `git diff --check`; next implementation commit is `feat(help-center): align portal payloads`. - 2026-06-06: P3.7 campaigns scheduling checkpoint prepared as `feat(campaigns): derive chatwoot scheduling`; audited Chatwoot `CampaignsController#campaign_params`, `Campaign#ensure_correct_campaign_attributes`, and reused SMS/WhatsApp/live-chat campaign forms. Campaign create now accepts frontend bodies without `campaign_type`, derives one-off campaigns for SMS/Twilio/WhatsApp inboxes, persists ISO `scheduled_at`, defaults missing one-off schedules to current UTC, derives ongoing campaigns for web-widget/live-chat inboxes and clears schedules, while update accepts `inbox_id`, `sender_id`, and `scheduled_at` with account-scope checks and preserves omitted descriptions. Focused campaign handler tests, service/repository campaign tests, full `go test ./...`, and `git diff --check` passed; continue Phase 6 placeholder audit or the next named Phase 2/3 drift. diff --git a/docs/parity/gochat_routes.txt b/docs/parity/gochat_routes.txt index 8de703bb..2646f576 100644 --- a/docs/parity/gochat_routes.txt +++ b/docs/parity/gochat_routes.txt @@ -306,6 +306,7 @@ GET /api/v1/accounts/:account_id/portals/:portal_id/articles/:article_id GET /api/v1/accounts/:account_id/portals/:portal_id/articles/:article_id/edit GET /api/v1/accounts/:account_id/portals/:portal_id/articles/search GET /api/v1/accounts/:account_id/portals/:portal_id/articles/status_counts +GET /api/v1/accounts/:account_id/portals/:portal_id/categories GET /api/v1/accounts/:account_id/portals/:portal_id/categories/ GET /api/v1/accounts/:account_id/portals/:portal_id/categories/:category_id GET /api/v1/accounts/:account_id/portals/:portal_id/categories/:category_id/articles @@ -485,6 +486,7 @@ PATCH /api/v1/accounts/:account_id/notification_settings/ PATCH /api/v1/accounts/:account_id/notifications/:notification_id PATCH /api/v1/accounts/:account_id/portals/:portal_id PATCH /api/v1/accounts/:account_id/portals/:portal_id/archive +PATCH /api/v1/accounts/:account_id/portals/:portal_id/categories/:category_id PATCH /api/v1/accounts/:account_id/teams/:team_id/team_members/ PATCH /api/v1/widget/contact PATCH /api/v1/widget/contact/set_user @@ -662,6 +664,7 @@ POST /api/v1/accounts/:account_id/portals/:portal_id/articles/bulk_actions POST /api/v1/accounts/:account_id/portals/:portal_id/articles/bulk_delete POST /api/v1/accounts/:account_id/portals/:portal_id/articles/bulk_update_status POST /api/v1/accounts/:account_id/portals/:portal_id/articles/reorder +POST /api/v1/accounts/:account_id/portals/:portal_id/categories POST /api/v1/accounts/:account_id/portals/:portal_id/categories/ POST /api/v1/accounts/:account_id/portals/:portal_id/categories/reorder POST /api/v1/accounts/:account_id/portals/:portal_id/folders/ @@ -848,4 +851,4 @@ PUT /public/api/v1/csat_survey/:id PUT /public/api/v1/inboxes/:inbox_id/contacts/:contact_id PUT /public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/messages/:message_id PUT /widget/direct_uploads/:upload_uuid -TOTAL: 850 +TOTAL: 853 diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index 5b2aea90..1d067e69 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -765,7 +765,7 @@ func Bootstrap(env string) (*App, error) { LiveReport: v1.NewLiveReportHandler(analyticsService), DashboardApp: v1.NewDashboardAppHandler(dashboardAppService), Portal: v1.NewPortalHandler(portalService), - Category: v1.NewCategoryHandler(categoryService), + Category: v1.NewCategoryHandler(categoryService, portalService), Article: v1.NewArticleHandler(articleService), Folder: v1.NewFolderHandler(folderService), PortalMember: v1.NewPortalMemberHandler(portalMemberService), diff --git a/internal/handler/api/v1/category_handler.go b/internal/handler/api/v1/category_handler.go index bd2a4531..0da29e39 100644 --- a/internal/handler/api/v1/category_handler.go +++ b/internal/handler/api/v1/category_handler.go @@ -5,163 +5,290 @@ import ( "strconv" "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" "github.com/gochat/gochat/pkg/response" ) // CategoryHandler handles Category CRUD endpoints. type CategoryHandler struct { - svc *service.CategoryService + svc *service.CategoryService + portalSvc *service.PortalService } -func NewCategoryHandler(svc *service.CategoryService) *CategoryHandler { - return &CategoryHandler{svc: svc} +func NewCategoryHandler(svc *service.CategoryService, portalSvc ...*service.PortalService) *CategoryHandler { + h := &CategoryHandler{svc: svc} + if len(portalSvc) > 0 { + h.portalSvc = portalSvc[0] + } + return h } // Create creates a new category. // POST /api/v1/accounts/:account_id/portals/:portal_id/categories func (h *CategoryHandler) Create(c *gin.Context) { - portalID, err := strconv.ParseUint(c.Param("portal_id"), 10, 64) - if err != nil { - response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid portal_id") - return - } - - accountID, err := strconv.ParseUint(c.Param("account_id"), 10, 64) - if err != nil { + accountID := getAccountID(c) + if accountID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } - - // Chatwoot: params.require(:category) → {"category": {...}} - var wrapper struct { - Category service.CreateCategoryRequest `json:"category"` + portal, ok := h.resolvePortal(c, accountID) + if !ok { + return } - if err := c.ShouldBindJSON(&wrapper); err != nil { + + var req service.CreateCategoryRequest + if err := bindChatwootPayload(c, "category", &req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } - req := wrapper.Category - category, err := h.svc.Create(c.Request.Context(), uint(portalID), uint(accountID), &req) + category, err := h.svc.Create(c.Request.Context(), portal.ID, accountID, &req) if err != nil { applogger.L().Errorf("Create category: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to create category") return } - response.Created(c, category) + c.JSON(http.StatusOK, gin.H{"payload": categoryPayload(category, c.Query("locale"))}) } // Get retrieves a category by ID. // GET /api/v1/accounts/:account_id/portals/:portal_id/categories/:category_id func (h *CategoryHandler) Get(c *gin.Context) { + accountID := getAccountID(c) + if accountID == 0 { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") + return + } + portal, ok := h.resolvePortal(c, accountID) + if !ok { + return + } categoryID, err := strconv.ParseUint(c.Param("category_id"), 10, 64) if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid category_id") return } - category, err := h.svc.GetByID(c.Request.Context(), uint(categoryID)) + category, err := h.svc.GetByPortalAndID(c.Request.Context(), portal.ID, uint(categoryID)) if err != nil { applogger.L().Errorf("Get category: %v", err) response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "category not found") return } - response.OK(c, category) + c.JSON(http.StatusOK, gin.H{"payload": categoryPayload(category, c.Query("locale"))}) } // Update modifies an existing category. // PUT /api/v1/accounts/:account_id/portals/:portal_id/categories/:category_id func (h *CategoryHandler) Update(c *gin.Context) { + accountID := getAccountID(c) + if accountID == 0 { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") + return + } + portal, ok := h.resolvePortal(c, accountID) + if !ok { + return + } categoryID, err := strconv.ParseUint(c.Param("category_id"), 10, 64) if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid category_id") return } - // Chatwoot: params.require(:category) → {"category": {...}} - var wrapper struct { - Category service.UpdateCategoryRequest `json:"category"` - } - if err := c.ShouldBindJSON(&wrapper); err != nil { + var req service.UpdateCategoryRequest + if err := bindChatwootPayload(c, "category", &req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } - req := wrapper.Category - category, err := h.svc.Update(c.Request.Context(), uint(categoryID), &req) + category, err := h.svc.UpdateScoped(c.Request.Context(), portal.ID, uint(categoryID), &req) if err != nil { applogger.L().Errorf("Update category: %v", err) response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, "failed to update category") return } - response.OK(c, category) + c.JSON(http.StatusOK, gin.H{"payload": categoryPayload(category, c.Query("locale"))}) } // Delete removes a category. // DELETE /api/v1/accounts/:account_id/portals/:portal_id/categories/:category_id func (h *CategoryHandler) Delete(c *gin.Context) { + accountID := getAccountID(c) + if accountID == 0 { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") + return + } + portal, ok := h.resolvePortal(c, accountID) + if !ok { + return + } categoryID, err := strconv.ParseUint(c.Param("category_id"), 10, 64) if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid category_id") return } - if err := h.svc.Delete(c.Request.Context(), uint(categoryID)); err != nil { + if err := h.svc.DeleteScoped(c.Request.Context(), portal.ID, uint(categoryID)); err != nil { applogger.L().Errorf("Delete category: %v", err) response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, "failed to delete category") return } - response.NoContent(c) + c.Status(http.StatusOK) } // List returns all categories for a portal. // GET /api/v1/accounts/:account_id/portals/:portal_id/categories func (h *CategoryHandler) List(c *gin.Context) { - portalID, err := strconv.ParseUint(c.Param("portal_id"), 10, 64) - if err != nil { - response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid portal_id") + accountID := getAccountID(c) + if accountID == 0 { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") + return + } + portal, ok := h.resolvePortal(c, accountID) + if !ok { return } locale := c.Query("locale") - pg := pagination.Parse(c) - categories, count, err := h.svc.ListByPortalID(c.Request.Context(), uint(portalID), locale, pg.Page, pg.PerPage) + categories, _, err := h.svc.ListByPortalID(c.Request.Context(), portal.ID, locale, 0, 0) if err != nil { applogger.L().Errorf("List categories: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to list categories") return } - response.OKWithMeta(c, categories, pg.Page, pg.PerPage, count) + payload := make([]gin.H, 0, len(categories)) + for i := range categories { + payload = append(payload, categoryPayload(&categories[i], locale)) + } + c.JSON(http.StatusOK, gin.H{"payload": payload, "meta": gin.H{"current_page": c.DefaultQuery("page", "1"), "categories_count": len(categories)}}) } // Reorder updates the display order of categories. // POST /api/v1/accounts/:account_id/portals/:portal_id/categories/reorder func (h *CategoryHandler) Reorder(c *gin.Context) { - var req reorderRequest - if err := c.ShouldBindJSON(&req); err != nil { + accountID := getAccountID(c) + if accountID == 0 { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") + return + } + portal, ok := h.resolvePortal(c, accountID) + if !ok { + return + } + positions, err := bindCategoryReorder(c) + if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } - positions := make(map[uint]int, len(req.Positions)) - for _, entry := range req.Positions { - positions[entry.ID] = entry.Position - } - - if err := h.svc.Reorder(c.Request.Context(), positions); err != nil { + if err := h.svc.ReorderScoped(c.Request.Context(), portal.ID, positions); err != nil { applogger.L().Errorf("Reorder categories: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to reorder categories") return } - response.NoContent(c) + c.Status(http.StatusOK) +} + +func (h *CategoryHandler) resolvePortal(c *gin.Context, accountID uint) (*model.Portal, bool) { + if h.portalSvc != nil { + portal, err := h.portalSvc.ResolveByAccountAndRouteID(c.Request.Context(), accountID, c.Param("portal_id")) + if err != nil { + response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "portal not found") + return nil, false + } + return portal, true + } + portalID, err := strconv.ParseUint(c.Param("portal_id"), 10, 64) + if err != nil { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid portal_id") + return nil, false + } + return &model.Portal{Base: model.Base{ID: uint(portalID)}, AccountID: accountID}, true +} + +func categoryPayload(category *model.Category, currentLocale string) gin.H { + if category == nil { + return gin.H{} + } + payload := gin.H{ + "id": category.ID, + "name": category.Name, + "slug": category.Slug, + "locale": category.Locale, + "description": category.Description, + "position": category.Position, + "account_id": category.AccountID, + "icon": category.Icon, + "related_categories": relatedCategoryPayloads(category.RelatedCategories), + "meta": gin.H{"articles_count": categoryArticleCount(category.Articles, currentLocale)}, + } + if category.Parent != nil && category.Parent.ID != 0 { + payload["parent_category"] = associatedCategoryPayload(category.Parent) + payload["root_category"] = associatedCategoryPayload(category.Parent) + } + return payload +} + +func relatedCategoryPayloads(related []model.RelatedCategory) []gin.H { + payload := make([]gin.H, 0, len(related)) + for _, item := range related { + if item.RelatedCategory.ID != 0 { + payload = append(payload, associatedCategoryPayload(&item.RelatedCategory)) + } + } + return payload +} + +func associatedCategoryPayload(category *model.Category) gin.H { + return gin.H{ + "id": category.ID, + "name": category.Name, + "slug": category.Slug, + "locale": category.Locale, + "description": category.Description, + "position": category.Position, + "account_id": category.AccountID, + } +} + +func categoryArticleCount(articles []model.Article, locale string) int { + count := 0 + for _, article := range articles { + if locale == "" || article.Locale == locale { + count++ + } + } + return count +} + +func bindCategoryReorder(c *gin.Context) (map[uint]int, error) { + var req struct { + PositionsHash map[string]int `json:"positions_hash"` + Positions []positionEntry `json:"positions"` + } + if err := c.ShouldBindJSON(&req); err != nil { + return nil, err + } + positions := make(map[uint]int, len(req.PositionsHash)+len(req.Positions)) + for id, position := range req.PositionsHash { + parsed, err := strconv.ParseUint(id, 10, 32) + if err != nil { + return nil, err + } + positions[uint(parsed)] = position + } + for _, entry := range req.Positions { + positions[entry.ID] = entry.Position + } + return positions, nil } diff --git a/internal/handler/api/v1/category_handler_test.go b/internal/handler/api/v1/category_handler_test.go index f3cbe405..49f41182 100644 --- a/internal/handler/api/v1/category_handler_test.go +++ b/internal/handler/api/v1/category_handler_test.go @@ -48,8 +48,10 @@ func (s *CategoryHandlerTestSuite) SetupSuite() { &model.AccountUser{}, &model.Portal{}, &model.Category{}, + &model.Article{}, &model.RelatedCategory{}, &model.Folder{}, + &model.PortalMember{}, )) s.db = db @@ -66,8 +68,9 @@ func (s *CategoryHandlerTestSuite) SetupSuite() { // Build real service chain catRepo := repository.NewCategoryRepo(db) relatedRepo := repository.NewRelatedCategoryRepo(db) + portalRepo := repository.NewPortalRepo(db) s.svc = service.NewCategoryService(catRepo, relatedRepo) - s.handler = NewCategoryHandler(s.svc) + s.handler = NewCategoryHandler(s.svc, service.NewPortalService(portalRepo)) s.router = gin.New() s.router.Use(func(c *gin.Context) { @@ -81,6 +84,7 @@ func (s *CategoryHandlerTestSuite) SetupSuite() { { portalGroup.POST("", s.handler.Create) portalGroup.GET("/:category_id", s.handler.Get) + portalGroup.PATCH("/:category_id", s.handler.Update) portalGroup.PUT("/:category_id", s.handler.Update) portalGroup.DELETE("/:category_id", s.handler.Delete) portalGroup.GET("", s.handler.List) @@ -103,11 +107,12 @@ func (s *CategoryHandlerTestSuite) TestCreate_Success() { bytes.NewBufferString(body)) s.router.ServeHTTP(w, req) - s.Equal(http.StatusCreated, w.Code) + s.Equal(http.StatusOK, w.Code) var resp map[string]interface{} s.NoError(json.Unmarshal(w.Body.Bytes(), &resp)) - data := resp["data"].(map[string]interface{}) - s.Equal("Test Category", data["name"]) + payload := resp["payload"].(map[string]interface{}) + s.Equal("Test Category", payload["name"]) + s.NotContains(resp, "data") } func (s *CategoryHandlerTestSuite) TestCreate_InvalidAccountID() { @@ -127,7 +132,7 @@ func (s *CategoryHandlerTestSuite) TestCreate_InvalidPortalID() { "/api/v1/accounts/"+fmt.Sprintf("%d", s.accountID)+"/portals/abc/categories", bytes.NewBufferString(body)) s.router.ServeHTTP(w, req) - s.Equal(http.StatusBadRequest, w.Code) + s.Equal(http.StatusNotFound, w.Code) } func (s *CategoryHandlerTestSuite) TestCreate_InvalidJSON() { @@ -147,14 +152,30 @@ func (s *CategoryHandlerTestSuite) TestCreate_EmptyName() { "/api/v1/accounts/"+fmt.Sprintf("%d", s.accountID)+"/portals/"+fmt.Sprintf("%d", s.portalID)+"/categories", bytes.NewBufferString(body)) s.router.ServeHTTP(w, req) - // ShouldBindJSON binds successfully even with empty name → service creates → 201 - s.Equal(http.StatusCreated, w.Code) + // Chatwoot validation is handled by the model; SQLite unit path accepts the empty name. + s.Equal(http.StatusOK, w.Code) +} + +func (s *CategoryHandlerTestSuite) TestCreate_RawFrontendPayloadAndSlugPortal() { + body := `{"name":"Raw Category","slug":"raw-cat","locale":"en","description":"raw"}` + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, + "/api/v1/accounts/"+fmt.Sprintf("%d", s.accountID)+"/portals/test-portal/categories", + bytes.NewBufferString(body)) + s.router.ServeHTTP(w, req) + s.Equal(http.StatusOK, w.Code) + var resp map[string]interface{} + s.NoError(json.Unmarshal(w.Body.Bytes(), &resp)) + payload := resp["payload"].(map[string]interface{}) + s.Equal("Raw Category", payload["name"]) + s.Equal("raw-cat", payload["slug"]) } // --- Get Tests --- func (s *CategoryHandlerTestSuite) TestGet_Success() { - cat := &model.Category{PortalID: s.portalID, Name: "GetCat", Slug: "get-cat"} + cat := &model.Category{AccountID: s.accountID, PortalID: s.portalID, Name: "GetCat", Slug: "get-cat", Locale: "en"} s.Require().NoError(s.db.Create(cat).Error) + s.Require().NoError(s.db.Create(&model.Article{AccountID: s.accountID, PortalID: s.portalID, CategoryID: &cat.ID, Title: "Article", Slug: "article", Status: "published", Locale: "en"}).Error) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, @@ -162,6 +183,12 @@ func (s *CategoryHandlerTestSuite) TestGet_Success() { nil) s.router.ServeHTTP(w, req) s.Equal(http.StatusOK, w.Code) + var resp map[string]interface{} + s.NoError(json.Unmarshal(w.Body.Bytes(), &resp)) + payload := resp["payload"].(map[string]interface{}) + s.Equal("GetCat", payload["name"]) + meta := payload["meta"].(map[string]interface{}) + s.EqualValues(1, meta["articles_count"]) } func (s *CategoryHandlerTestSuite) TestGet_InvalidCategoryID() { @@ -187,13 +214,17 @@ func (s *CategoryHandlerTestSuite) TestUpdate_Success() { cat := &model.Category{PortalID: s.portalID, Name: "OldName", Slug: "old-slug"} s.Require().NoError(s.db.Create(cat).Error) - body := `{"category":{"name":"NewName","slug":"new-slug"}}` + body := `{"name":"NewName","slug":"new-slug","description":""}` w := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPut, - "/api/v1/accounts/"+fmt.Sprintf("%d", s.accountID)+"/portals/"+fmt.Sprintf("%d", s.portalID)+"/categories/"+fmt.Sprintf("%d", cat.ID), + req := httptest.NewRequest(http.MethodPatch, + "/api/v1/accounts/"+fmt.Sprintf("%d", s.accountID)+"/portals/test-portal/categories/"+fmt.Sprintf("%d", cat.ID), bytes.NewBufferString(body)) s.router.ServeHTTP(w, req) s.Equal(http.StatusOK, w.Code) + var resp map[string]interface{} + s.NoError(json.Unmarshal(w.Body.Bytes(), &resp)) + payload := resp["payload"].(map[string]interface{}) + s.Equal("new-slug", payload["slug"]) } func (s *CategoryHandlerTestSuite) TestUpdate_InvalidCategoryID() { @@ -227,7 +258,7 @@ func (s *CategoryHandlerTestSuite) TestDelete_Success() { "/api/v1/accounts/"+fmt.Sprintf("%d", s.accountID)+"/portals/"+fmt.Sprintf("%d", s.portalID)+"/categories/"+fmt.Sprintf("%d", cat.ID), nil) s.router.ServeHTTP(w, req) - s.Equal(http.StatusNoContent, w.Code) + s.Equal(http.StatusOK, w.Code) } func (s *CategoryHandlerTestSuite) TestDelete_InvalidCategoryID() { @@ -253,17 +284,24 @@ func (s *CategoryHandlerTestSuite) TestDelete_NotFound() { // --- List Tests --- func (s *CategoryHandlerTestSuite) TestList_Success() { - cat1 := &model.Category{PortalID: s.portalID, Name: "Cat1", Slug: "cat1"} - cat2 := &model.Category{PortalID: s.portalID, Name: "Cat2", Slug: "cat2"} + cat1 := &model.Category{AccountID: s.accountID, PortalID: s.portalID, Name: "Cat1", Slug: "cat1", Locale: "en", Position: 2} + cat2 := &model.Category{AccountID: s.accountID, PortalID: s.portalID, Name: "Cat2", Slug: "cat2", Locale: "fr", Position: 1} s.Require().NoError(s.db.Create(cat1).Error) s.Require().NoError(s.db.Create(cat2).Error) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, - "/api/v1/accounts/"+fmt.Sprintf("%d", s.accountID)+"/portals/"+fmt.Sprintf("%d", s.portalID)+"/categories", + "/api/v1/accounts/"+fmt.Sprintf("%d", s.accountID)+"/portals/test-portal/categories?locale=en&page=2", nil) s.router.ServeHTTP(w, req) s.Equal(http.StatusOK, w.Code) + var resp map[string]interface{} + s.NoError(json.Unmarshal(w.Body.Bytes(), &resp)) + s.Len(resp["payload"], 1) + meta := resp["meta"].(map[string]interface{}) + s.Equal("2", meta["current_page"]) + s.EqualValues(1, meta["categories_count"]) + s.NotContains(resp, "data") } func (s *CategoryHandlerTestSuite) TestList_InvalidPortalID() { @@ -272,7 +310,7 @@ func (s *CategoryHandlerTestSuite) TestList_InvalidPortalID() { "/api/v1/accounts/"+fmt.Sprintf("%d", s.accountID)+"/portals/abc/categories", nil) s.router.ServeHTTP(w, req) - s.Equal(http.StatusBadRequest, w.Code) + s.Equal(http.StatusNotFound, w.Code) } func (s *CategoryHandlerTestSuite) TestList_Empty() { @@ -291,14 +329,13 @@ func (s *CategoryHandlerTestSuite) TestReorder_Success() { s.Require().NoError(s.db.Create(cat1).Error) s.Require().NoError(s.db.Create(cat2).Error) - body := fmt.Sprintf(`{"positions":[{"id":%d,"position":2},{"id":%d,"position":1}]}`, cat1.ID, cat2.ID) + body := fmt.Sprintf(`{"positions_hash":{"%d":2,"%d":1}}`, cat1.ID, cat2.ID) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/v1/accounts/"+fmt.Sprintf("%d", s.accountID)+"/portals/"+fmt.Sprintf("%d", s.portalID)+"/categories/reorder", bytes.NewBufferString(body)) s.router.ServeHTTP(w, req) - // Reorder success → NoContent (204) - s.Equal(http.StatusNoContent, w.Code) + s.Equal(http.StatusOK, w.Code) } func (s *CategoryHandlerTestSuite) TestReorder_InvalidJSON() { @@ -308,4 +345,4 @@ func (s *CategoryHandlerTestSuite) TestReorder_InvalidJSON() { bytes.NewBufferString("{invalid}")) s.router.ServeHTTP(w, req) s.Equal(http.StatusBadRequest, w.Code) -} \ No newline at end of file +} diff --git a/internal/repository/category_repo.go b/internal/repository/category_repo.go index 7e6ccf9f..1bab915e 100644 --- a/internal/repository/category_repo.go +++ b/internal/repository/category_repo.go @@ -22,7 +22,18 @@ func (r *CategoryRepo) Create(ctx context.Context, category *model.Category) err func (r *CategoryRepo) GetByID(ctx context.Context, id uint) (*model.Category, error) { var category model.Category - if err := r.db.WithContext(ctx).Preload("Parent").Preload("RelatedCategories").Preload("Folders").First(&category, id).Error; err != nil { + if err := r.db.WithContext(ctx).Preload("Parent").Preload("RelatedCategories.RelatedCategory").Preload("Folders").Preload("Articles").First(&category, id).Error; err != nil { + return nil, err + } + return &category, nil +} + +func (r *CategoryRepo) GetByPortalAndID(ctx context.Context, portalID, id uint) (*model.Category, error) { + var category model.Category + if err := r.db.WithContext(ctx). + Preload("Parent").Preload("RelatedCategories.RelatedCategory").Preload("Folders").Preload("Articles"). + Where("portal_id = ?", portalID). + First(&category, id).Error; err != nil { return nil, err } return &category, nil @@ -42,7 +53,11 @@ func (r *CategoryRepo) FindByPortalID(ctx context.Context, portalID uint, offset var count int64 db := r.db.WithContext(ctx).Model(&model.Category{}).Where("portal_id = ?", portalID) db.Count(&count) - if err := db.Offset(offset).Limit(limit).Order("position ASC").Find(&categories).Error; err != nil { + query := db.Preload("Parent").Preload("RelatedCategories.RelatedCategory").Preload("Articles").Order("position ASC") + if limit > 0 { + query = query.Offset(offset).Limit(limit) + } + if err := query.Find(&categories).Error; err != nil { return nil, 0, err } return categories, count, nil @@ -54,7 +69,11 @@ func (r *CategoryRepo) FindByPortalIDAndLocale(ctx context.Context, portalID uin var count int64 db := r.db.WithContext(ctx).Model(&model.Category{}).Where("portal_id = ? AND locale = ?", portalID, locale) db.Count(&count) - if err := db.Offset(offset).Limit(limit).Order("position ASC").Find(&categories).Error; err != nil { + query := db.Preload("Parent").Preload("RelatedCategories.RelatedCategory").Preload("Articles").Order("position ASC") + if limit > 0 { + query = query.Offset(offset).Limit(limit) + } + if err := query.Find(&categories).Error; err != nil { return nil, 0, err } return categories, count, nil @@ -83,6 +102,15 @@ func (r *CategoryRepo) UpdatePositions(ctx context.Context, positions map[uint]i return nil } +func (r *CategoryRepo) UpdatePositionsForPortal(ctx context.Context, portalID uint, positions map[uint]int) error { + for id, pos := range positions { + if err := r.db.WithContext(ctx).Model(&model.Category{}).Where("id = ? AND portal_id = ?", id, portalID).Update("position", pos).Error; err != nil { + return err + } + } + return nil +} + // FindBySlugAndPortalIDAndLocale returns a category by slug, portal, and locale. func (r *CategoryRepo) FindBySlugAndPortalIDAndLocale(ctx context.Context, slug string, portalID uint, locale string) (*model.Category, error) { var category model.Category @@ -90,4 +118,4 @@ func (r *CategoryRepo) FindBySlugAndPortalIDAndLocale(ctx context.Context, slug return nil, err } return &category, nil -} \ No newline at end of file +} diff --git a/internal/repository/category_repo_test.go b/internal/repository/category_repo_test.go index 440856db..04bb5650 100644 --- a/internal/repository/category_repo_test.go +++ b/internal/repository/category_repo_test.go @@ -23,7 +23,7 @@ func newTestCategory(portalID uint, name string) *model.Category { // ========== Create ========== func TestCategoryRepo_Create(t *testing.T) { - db := setupTestDB(t, &model.Category{}, &model.Folder{}, &model.RelatedCategory{}, &model.Portal{}, &model.Account{}) + db := setupTestDB(t, &model.Category{}, &model.Folder{}, &model.RelatedCategory{}, &model.Portal{}, &model.Account{}, &model.Article{}) repo := NewCategoryRepo(db) cat := newTestCategory(1, "TestCategory") @@ -36,7 +36,7 @@ func TestCategoryRepo_Create(t *testing.T) { // ========== GetByID ========== func TestCategoryRepo_GetByID(t *testing.T) { - db := setupTestDB(t, &model.Category{}, &model.Folder{}, &model.RelatedCategory{}, &model.Portal{}, &model.Account{}) + db := setupTestDB(t, &model.Category{}, &model.Folder{}, &model.RelatedCategory{}, &model.Portal{}, &model.Account{}, &model.Article{}) repo := NewCategoryRepo(db) cat := newTestCategory(1, "FindByIDCat") @@ -49,7 +49,7 @@ func TestCategoryRepo_GetByID(t *testing.T) { } func TestCategoryRepo_GetByID_NotFound(t *testing.T) { - db := setupTestDB(t, &model.Category{}, &model.Folder{}, &model.RelatedCategory{}, &model.Portal{}, &model.Account{}) + db := setupTestDB(t, &model.Category{}, &model.Folder{}, &model.RelatedCategory{}, &model.Portal{}, &model.Account{}, &model.Article{}) repo := NewCategoryRepo(db) found, err := repo.GetByID(context.Background(), 9999) @@ -60,7 +60,7 @@ func TestCategoryRepo_GetByID_NotFound(t *testing.T) { // ========== Update ========== func TestCategoryRepo_Update(t *testing.T) { - db := setupTestDB(t, &model.Category{}, &model.Folder{}, &model.RelatedCategory{}, &model.Portal{}, &model.Account{}) + db := setupTestDB(t, &model.Category{}, &model.Folder{}, &model.RelatedCategory{}, &model.Portal{}, &model.Account{}, &model.Article{}) repo := NewCategoryRepo(db) cat := newTestCategory(1, "BeforeUpdate") @@ -81,7 +81,7 @@ func TestCategoryRepo_Update(t *testing.T) { // ========== Delete ========== func TestCategoryRepo_Delete(t *testing.T) { - db := setupTestDB(t, &model.Category{}, &model.Folder{}, &model.RelatedCategory{}, &model.Portal{}, &model.Account{}) + db := setupTestDB(t, &model.Category{}, &model.Folder{}, &model.RelatedCategory{}, &model.Portal{}, &model.Account{}, &model.Article{}) repo := NewCategoryRepo(db) cat := newTestCategory(1, "DeleteCat") @@ -99,7 +99,7 @@ func TestCategoryRepo_Delete(t *testing.T) { // ========== FindByPortalID ========== func TestCategoryRepo_FindByPortalID(t *testing.T) { - db := setupTestDB(t, &model.Category{}, &model.Folder{}, &model.RelatedCategory{}, &model.Portal{}, &model.Account{}) + db := setupTestDB(t, &model.Category{}, &model.Folder{}, &model.RelatedCategory{}, &model.Portal{}, &model.Account{}, &model.Article{}) repo := NewCategoryRepo(db) portalID := uint(10) @@ -119,7 +119,7 @@ func TestCategoryRepo_FindByPortalID(t *testing.T) { // ========== FindByPortalIDAndParentID ========== func TestCategoryRepo_FindByPortalIDAndParentID(t *testing.T) { - db := setupTestDB(t, &model.Category{}, &model.Folder{}, &model.RelatedCategory{}, &model.Portal{}, &model.Account{}) + db := setupTestDB(t, &model.Category{}, &model.Folder{}, &model.RelatedCategory{}, &model.Portal{}, &model.Account{}, &model.Article{}) repo := NewCategoryRepo(db) portalID := uint(10) @@ -145,4 +145,4 @@ func TestCategoryRepo_FindByPortalIDAndParentID(t *testing.T) { for _, c := range categories { assert.Equal(t, parentID, *c.ParentID) } -} \ No newline at end of file +} diff --git a/internal/router/router.go b/internal/router/router.go index 07f27898..5c64d7fc 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -1400,9 +1400,12 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) { // Categories nested under portal categories := portals.Group("/:portal_id/categories") { + categories.GET("", h.Category.List) categories.GET("/", h.Category.List) + categories.POST("", h.Category.Create) categories.POST("/", h.Category.Create) categories.GET("/:category_id", h.Category.Get) + categories.PATCH("/:category_id", h.Category.Update) categories.PUT("/:category_id", h.Category.Update) categories.DELETE("/:category_id", h.Category.Delete) categories.POST("/reorder", h.Category.Reorder) diff --git a/internal/service/category_service.go b/internal/service/category_service.go index 969285fe..7e6ac065 100644 --- a/internal/service/category_service.go +++ b/internal/service/category_service.go @@ -12,8 +12,8 @@ import ( // CategoryService implements business logic for Category CRUD. type CategoryService struct { - repo *repository.CategoryRepo - relatedRepo *repository.RelatedCategoryRepo + repo *repository.CategoryRepo + relatedRepo *repository.RelatedCategoryRepo } func NewCategoryService(repo *repository.CategoryRepo, relatedRepo *repository.RelatedCategoryRepo) *CategoryService { @@ -22,44 +22,47 @@ func NewCategoryService(repo *repository.CategoryRepo, relatedRepo *repository.R // CreateCategoryRequest is the DTO for creating a category. type CreateCategoryRequest struct { - Name string `json:"name" validate:"required,min=2"` - Slug string `json:"slug" validate:"required"` - Description string `json:"description"` - Icon string `json:"icon"` - Position int `json:"position"` - Locale string `json:"locale"` - ParentID *uint `json:"parent_id"` - AssociatedCategoryID *uint `json:"associated_category_id"` - RelatedCategoryIDs []uint `json:"related_category_ids"` - CustomAttributes json.RawMessage `json:"custom_attributes"` + Name string `json:"name" validate:"required,min=2"` + Slug string `json:"slug" validate:"required"` + Description string `json:"description"` + Icon string `json:"icon"` + Position int `json:"position"` + Locale string `json:"locale"` + ParentID *uint `json:"parent_id"` + ParentCategoryID *uint `json:"parent_category_id"` + AssociatedCategoryID *uint `json:"associated_category_id"` + RelatedCategoryIDs []uint `json:"related_category_ids"` + CustomAttributes json.RawMessage `json:"custom_attributes"` } // UpdateCategoryRequest is the DTO for updating a category. type UpdateCategoryRequest struct { - Name string `json:"name"` - Description string `json:"description"` - Icon string `json:"icon"` - Position *int `json:"position"` - Locale string `json:"locale"` - ParentID *uint `json:"parent_id"` - AssociatedCategoryID *uint `json:"associated_category_id"` - RelatedCategoryIDs []uint `json:"related_category_ids"` - CustomAttributes json.RawMessage `json:"custom_attributes"` + Name *string `json:"name"` + Slug *string `json:"slug"` + Description *string `json:"description"` + Icon *string `json:"icon"` + Position *int `json:"position"` + Locale *string `json:"locale"` + ParentID *uint `json:"parent_id"` + ParentCategoryID *uint `json:"parent_category_id"` + AssociatedCategoryID *uint `json:"associated_category_id"` + RelatedCategoryIDs []uint `json:"related_category_ids"` + CustomAttributes *json.RawMessage `json:"custom_attributes"` } func (s *CategoryService) Create(ctx context.Context, portalID uint, accountID uint, req *CreateCategoryRequest) (*model.Category, error) { category := &model.Category{ - AccountID: accountID, - PortalID: portalID, - Name: req.Name, - Slug: req.Slug, - Description: req.Description, - Icon: req.Icon, - Position: req.Position, - Locale: req.Locale, - ParentID: req.ParentID, + AccountID: accountID, + PortalID: portalID, + Name: req.Name, + Slug: req.Slug, + Description: req.Description, + Icon: req.Icon, + Position: req.Position, + Locale: req.Locale, + ParentID: firstCategoryParentID(req.ParentID, req.ParentCategoryID), AssociatedCategoryID: req.AssociatedCategoryID, - CustomAttributes: req.CustomAttributes, + CustomAttributes: req.CustomAttributes, } if err := s.repo.Create(ctx, category); err != nil { @@ -73,7 +76,7 @@ func (s *CategoryService) Create(ctx context.Context, portalID uint, accountID u } } - return category, nil + return s.repo.GetByPortalAndID(ctx, portalID, category.ID) } func (s *CategoryService) GetByID(ctx context.Context, id uint) (*model.Category, error) { @@ -84,35 +87,46 @@ func (s *CategoryService) GetByID(ctx context.Context, id uint) (*model.Category return category, nil } +func (s *CategoryService) GetByPortalAndID(ctx context.Context, portalID, id uint) (*model.Category, error) { + category, err := s.repo.GetByPortalAndID(ctx, portalID, id) + if err != nil { + return nil, fmt.Errorf("get category: %w", err) + } + return category, nil +} + func (s *CategoryService) Update(ctx context.Context, id uint, req *UpdateCategoryRequest) (*model.Category, error) { category, err := s.repo.GetByID(ctx, id) if err != nil { return nil, fmt.Errorf("find category: %w", err) } - if req.Name != "" { - category.Name = req.Name + if req.Name != nil { + category.Name = *req.Name } - if req.Description != "" { - category.Description = req.Description + if req.Slug != nil { + category.Slug = *req.Slug } - if req.Icon != "" { - category.Icon = req.Icon + if req.Description != nil { + category.Description = *req.Description + } + if req.Icon != nil { + category.Icon = *req.Icon } if req.Position != nil { category.Position = *req.Position } - if req.Locale != "" { - category.Locale = req.Locale + if req.Locale != nil { + category.Locale = *req.Locale } - if req.ParentID != nil { - category.ParentID = req.ParentID + if parentID := firstCategoryParentID(req.ParentID, req.ParentCategoryID); parentID != nil { + category.ParentID = parentID } if req.AssociatedCategoryID != nil { category.AssociatedCategoryID = req.AssociatedCategoryID } if req.CustomAttributes != nil { - category.CustomAttributes = req.CustomAttributes + category.CustomAttributes = *req.CustomAttributes } if err := s.repo.Update(ctx, category); err != nil { @@ -126,7 +140,57 @@ func (s *CategoryService) Update(ctx context.Context, id uint, req *UpdateCatego } } - return category, nil + return s.repo.GetByPortalAndID(ctx, category.PortalID, category.ID) +} + +func (s *CategoryService) UpdateScoped(ctx context.Context, portalID, id uint, req *UpdateCategoryRequest) (*model.Category, error) { + category, err := s.repo.GetByPortalAndID(ctx, portalID, id) + if err != nil { + return nil, fmt.Errorf("find category: %w", err) + } + return s.UpdateExisting(ctx, category, req) +} + +func (s *CategoryService) UpdateExisting(ctx context.Context, category *model.Category, req *UpdateCategoryRequest) (*model.Category, error) { + if category == nil { + return nil, fmt.Errorf("find category: record not found") + } + if req.Name != nil { + category.Name = *req.Name + } + if req.Slug != nil { + category.Slug = *req.Slug + } + if req.Description != nil { + category.Description = *req.Description + } + if req.Icon != nil { + category.Icon = *req.Icon + } + if req.Position != nil { + category.Position = *req.Position + } + if req.Locale != nil { + category.Locale = *req.Locale + } + if parentID := firstCategoryParentID(req.ParentID, req.ParentCategoryID); parentID != nil { + category.ParentID = parentID + } + if req.AssociatedCategoryID != nil { + category.AssociatedCategoryID = req.AssociatedCategoryID + } + if req.CustomAttributes != nil { + category.CustomAttributes = *req.CustomAttributes + } + if err := s.repo.Update(ctx, category); err != nil { + return nil, fmt.Errorf("update category: %w", err) + } + if req.RelatedCategoryIDs != nil { + if err := s.relatedRepo.ReplaceAll(ctx, category.ID, req.RelatedCategoryIDs); err != nil { + applogger.L().Warnf("Failed to update related categories for category %d: %v", category.ID, err) + } + } + return s.repo.GetByPortalAndID(ctx, category.PortalID, category.ID) } func (s *CategoryService) Delete(ctx context.Context, id uint) error { @@ -146,19 +210,49 @@ func (s *CategoryService) Delete(ctx context.Context, id uint) error { return nil } +func (s *CategoryService) DeleteScoped(ctx context.Context, portalID, id uint) error { + if _, err := s.repo.GetByPortalAndID(ctx, portalID, id); err != nil { + return fmt.Errorf("find category for delete: %w", err) + } + if err := s.relatedRepo.DeleteByCategoryID(ctx, id); err != nil { + applogger.L().Warnf("Failed to delete related categories for category %d: %v", id, err) + } + if err := s.repo.Delete(ctx, id); err != nil { + return fmt.Errorf("delete category: %w", err) + } + return nil +} + // ListByPortalID returns categories for a portal with pagination. func (s *CategoryService) ListByPortalID(ctx context.Context, portalID uint, locale string, page, perPage int) ([]model.Category, int64, error) { - offset := (page - 1) * perPage + offset := 0 + if page > 0 && perPage > 0 { + offset = (page - 1) * perPage + } if locale != "" { return s.repo.FindByPortalIDAndLocale(ctx, portalID, locale, offset, perPage) } return s.repo.FindByPortalID(ctx, portalID, offset, perPage) } +func firstCategoryParentID(parentID, parentCategoryID *uint) *uint { + if parentCategoryID != nil { + return parentCategoryID + } + return parentID +} + // Reorder batch-updates category positions. func (s *CategoryService) Reorder(ctx context.Context, positions map[uint]int) error { if err := s.repo.UpdatePositions(ctx, positions); err != nil { return fmt.Errorf("reorder categories: %w", err) } return nil -} \ No newline at end of file +} + +func (s *CategoryService) ReorderScoped(ctx context.Context, portalID uint, positions map[uint]int) error { + if err := s.repo.UpdatePositionsForPortal(ctx, portalID, positions); err != nil { + return fmt.Errorf("reorder categories: %w", err) + } + return nil +} diff --git a/internal/service/category_service_test.go b/internal/service/category_service_test.go index 8105e68c..7fe3c5de 100644 --- a/internal/service/category_service_test.go +++ b/internal/service/category_service_test.go @@ -95,12 +95,16 @@ func TestCategoryService_Update(t *testing.T) { require.NoError(t, err) newPos := 5 + newName := "User Guides" + newDescription := "Updated description" + newIcon := "📖" + newLocale := "zh" updateReq := &UpdateCategoryRequest{ - Name: "User Guides", - Description: "Updated description", - Icon: "📖", + Name: &newName, + Description: &newDescription, + Icon: &newIcon, Position: &newPos, - Locale: "zh", + Locale: &newLocale, } updated, err := svc.Update(context.Background(), created.ID, updateReq) @@ -116,8 +120,9 @@ func TestCategoryService_Update_NotFound(t *testing.T) { svc, _, _ := setupCategoryService(t) newPos := 1 + newName := "Nonexistent" updateReq := &UpdateCategoryRequest{ - Name: "Nonexistent", + Name: &newName, Position: &newPos, } @@ -248,10 +253,10 @@ func TestCategoryService_ListByPortalID(t *testing.T) { names := []string{"First", "Second", "Third"} for i, name := range names { _, err := svc.Create(context.Background(), portalID, accountID, &CreateCategoryRequest{ - Name: name, - Slug: name, + Name: name, + Slug: name, Position: i + 1, - Locale: "en", + Locale: "en", }) require.NoError(t, err) } @@ -307,4 +312,4 @@ func TestCategoryService_ListByPortalID_WithLocale(t *testing.T) { for _, cat := range categories { assert.Equal(t, "en", cat.Locale) } -} \ No newline at end of file +}