From ab9a93b2bea9b925f39b6e1bbabfb5b42fce24a6 Mon Sep 17 00:00:00 2001 From: Rogee Date: Sat, 6 Jun 2026 04:22:04 +0800 Subject: [PATCH] feat(help-center): align article payloads --- docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md | 25 +- docs/parity/gochat_routes.txt | 9 +- internal/app/bootstrap.go | 2 +- internal/handler/api/v1/article_handler.go | 449 +++++++++++++----- .../handler/api/v1/article_handler_test.go | 160 ++++++- internal/model/article.go | 13 +- internal/repository/article_repo.go | 147 ++++-- internal/router/router.go | 7 + internal/service/article_service.go | 254 ++++++++-- internal/service/search_indexer_hooks_test.go | 3 +- 10 files changed, 857 insertions(+), 212 deletions(-) diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index f5d234b4..ed65e74d 100644 --- a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md +++ b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md @@ -37,12 +37,12 @@ Hermes plan landing map: ## Current Baseline -- Current tracking checkpoint: 2026-06-06 after this documentation checkpoint, prepared as `docs: lock parity execution tracker`. -- Latest implementation checkpoint: this checkpoint, prepared as `feat(help-center): align category payloads`. +- Current tracking checkpoint: 2026-06-06 after this implementation checkpoint, prepared as `feat(help-center): align article payloads`. +- Latest implementation checkpoint: this checkpoint, prepared as `feat(help-center): align article payloads`. - Latest documentation-only checkpoint: this checkpoint, prepared as `docs: lock parity execution tracker`; this document is now the active follow-up plan and supersedes `.hermes/plans/*`. -- 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. +- Worktree status at this implementation checkpoint: P3.7c help center article parity now matches the reused dashboard `helpCenter/articles.js` client and Chatwoot `ArticlesController`/`Articles::BulkActionsController`/Jbuilder surface for the dashboard article path: no-trailing-slash `GET/POST /portals/:portal_id/articles`, frontend `PATCH /articles/:article_id`, `PATCH /articles/bulk_actions/update_status`, `PATCH /articles/bulk_actions/update_category`, `DELETE /articles/bulk_actions/delete_articles`, and `POST /articles/bulk_actions/translate` are registered; portal route IDs resolve by account-scoped slug with numeric fallback; create/update accept raw frontend article bodies plus `{ article: ... }`; create/show/edit/update/list/search return Chatwoot `{ payload }` or `{ payload, meta }` article serializers with category, author, views, Unix `updated_at`, `meta`, and associated article payloads; delete/reorder/bulk updates return empty `200 OK`; reorder accepts Chatwoot `positions_hash`; bulk action errors return `422 { error }`; and scoped repository/service methods keep mutations inside the resolved portal. P3.7a portal and P3.7b category parity are 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. 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 Phase 2/3 drift audit or Phase 6 placeholder burn-down from fresh reference/smoke evidence. - `go test ./...` passes. -- Route dump succeeds with `TOTAL: 853` after adding no-trailing-slash category index/create aliases and frontend-used category PATCH update route. +- Route dump succeeds with `TOTAL: 860` after adding no-trailing-slash article index/create aliases, frontend-used article PATCH update route, and frontend article bulk-action routes. - 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. @@ -66,10 +66,9 @@ Next ordered checkpoints: | Order | Slice | Required outcome | Primary verification | | --- | --- | --- | --- | -| 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. | +| 1 | 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. | +| 2 | 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. | +| 3 | 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 @@ -87,7 +86,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 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. | +| P3.7 help center | Portal, category, and article route/payload parity are in Review for the inspected dashboard client paths. | `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. | @@ -110,7 +109,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/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. | +| 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/article payloads now match the inspected Chatwoot frontend contract. | Run the next evidence-backed route/controller/serializer drift audit. | 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. | @@ -131,7 +130,7 @@ These rows are the executable development plan from this point forward. A checkp | 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` | 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. | +| 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` | Article APIs now resolve portal slugs account-scoped with numeric fallback, accept raw article bodies plus wrappers, expose no-trailing-slash index/create plus frontend `PATCH`, return Chatwoot `{ payload, meta }` list/search payloads and `{ payload: article }` show/create/update/edit payloads, serialize category/author/views/Unix updated-at/meta/associated articles, make delete/reorder/bulk actions empty `200 OK`, accept Chatwoot `positions_hash`, scope mutations to the resolved portal, and implement frontend bulk update-status/update-category/delete routes plus `translate` `501`. | Review by `feat(help-center): align article payloads`; focused article handler tests, service/repository article tests, router test, route dump/parity regeneration, escalated full `go test ./...`, and `git diff --check` passed. | | 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. | | Phase 2/3 drift audit | `cmd/route_parity`, `docs/parity/*`, serializer tests | `reference/chatwoot/config/routes.rb`, controller Jbuilder views, reused frontend API clients | Convert any smoke/reference mismatch into a named route, controller, or serializer slice. Static route extraction remains acceptable until Ruby/Bundler is available. | Regenerated route parity shows 0 missing tracked frontend routes; new serializer fixtures cover the drift. | @@ -169,6 +168,7 @@ This ledger records the committed parity checkpoints that future slices should b | Commit | Scope | Verification summary | Follow-up state | | --- | --- | --- | --- | +| `feat(help-center): align article payloads` | Advances P3.7c help-center parity by matching Chatwoot `ArticlesController`, `Articles::BulkActionsController`, article Jbuilder views, and reused dashboard `helpCenter/articles.js`. Article index/create now expose no-trailing-slash routes; update exposes frontend `PATCH`; frontend bulk update-status/update-category/delete and translate routes are registered; portal IDs resolve through account-scoped slug lookup with numeric fallback; create/update accept raw frontend article bodies plus wrappers; list/search return `{ payload, meta }`; show/create/update/edit return `{ payload: article }` with category, author, views, Unix `updated_at`, `meta`, and associated article fields; delete/reorder/bulk actions return empty `200 OK`; reorder accepts Chatwoot `positions_hash`; and bulk action errors return `422 { error }`. | `go test ./internal/handler/api/v1 -run ArticleHandler -count=1`; `go test ./internal/service ./internal/repository -run Article -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`; sandboxed full `go test ./...` failed only on local socket restrictions; escalated full `go test ./...` passed; `git diff --check`. Route dump is `TOTAL: 860`; tracked route parity remains `270 exact, 7 parameter-compatible, 0 missing`. | P3.7c moves to Review; continue Phase 2/3 drift audit or Phase 6 placeholder burn-down from fresh reference/smoke evidence. | | `docs: lock parity execution tracker` | Documentation-only checkpoint that makes this file the single active tracker after user confirmation. It locks direct Chatwoot frontend reuse, local `reference/chatwoot` as the only baseline, `go test ./...` first, Meilisearch as mandatory final search, and enterprise scope excluding only SSO/SAML/LDAP/OIDC. It also maps the remaining Hermes search and automation/macro/CSAT notes into the existing Phase 1/B6, B8/B9, Phase 5, B7-B12, and active P3.7 help-center rows. | `git diff --check`. No Go code changed. | Continue P3.7c help-center articles and article bulk actions; future slices update this tracker in the same commit. | | `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. | @@ -1607,7 +1607,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/category routes/payloads now have focused Chatwoot payload fixtures. Remaining help-center work is 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/article routes/payloads now have focused Chatwoot payload fixtures. Continue only with fresh reference/smoke drift. | | 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 | @@ -2116,6 +2116,7 @@ Verification milestone gates: ## Progress Log +- 2026-06-06: P3.7c help-center article checkpoint prepared as `feat(help-center): align article payloads`; audited Chatwoot `ArticlesController`, `Articles::BulkActionsController`, article Jbuilder views, and reused dashboard `helpCenter/articles.js`. Account article 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/search payloads and `{ payload: article }` mutation/show/edit payloads, expose no-trailing-slash index/create plus frontend `PATCH`, return empty `200 OK` for delete/reorder/bulk actions, support status/category/locale/query/author filters and dashboard article counts, accept Chatwoot reorder `positions_hash`, and implement frontend bulk update-status/update-category/delete routes with `422 { error }` failures. Focused article handler tests, service/repository article tests, router test, route dump/parity, escalated full `go test ./...`, and `git diff --check` passed; continue Phase 2/3 drift audit or Phase 6 placeholder burn-down from fresh evidence. - 2026-06-06: Documentation checkpoint prepared as `docs: lock parity execution tracker`; user decisions are now locked into the tracker: reuse Chatwoot frontend directly, treat local `reference/chatwoot` as the only baseline, keep `go test ./...` green before deeper parity, require Meilisearch as final search, exclude only SSO/SAML/LDAP/OIDC, and keep the remaining enterprise families in scope. The older Hermes search and automation/macro/CSAT plans are mapped to active tracker sections so future work can be followed from this document alone. Verification for this docs-only checkpoint: `git diff --check`; next implementation slice remains P3.7c help-center articles and bulk actions. - 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. diff --git a/docs/parity/gochat_routes.txt b/docs/parity/gochat_routes.txt index 2646f576..2418e347 100644 --- a/docs/parity/gochat_routes.txt +++ b/docs/parity/gochat_routes.txt @@ -82,6 +82,7 @@ DELETE /api/v1/accounts/:account_id/platform_apps/:platform_app_id DELETE /api/v1/accounts/:account_id/platform_apps/:platform_app_id/permissibles/:permissible_id DELETE /api/v1/accounts/:account_id/portals/:portal_id DELETE /api/v1/accounts/:account_id/portals/:portal_id/articles/:article_id +DELETE /api/v1/accounts/:account_id/portals/:portal_id/articles/bulk_actions/delete_articles DELETE /api/v1/accounts/:account_id/portals/:portal_id/categories/:category_id DELETE /api/v1/accounts/:account_id/portals/:portal_id/folders/:folder_id DELETE /api/v1/accounts/:account_id/portals/:portal_id/logo @@ -301,6 +302,7 @@ GET /api/v1/accounts/:account_id/platform_apps/search GET /api/v1/accounts/:account_id/portals GET /api/v1/accounts/:account_id/portals/ GET /api/v1/accounts/:account_id/portals/:portal_id +GET /api/v1/accounts/:account_id/portals/:portal_id/articles GET /api/v1/accounts/:account_id/portals/:portal_id/articles/ 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 @@ -486,6 +488,9 @@ 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/articles/:article_id +PATCH /api/v1/accounts/:account_id/portals/:portal_id/articles/bulk_actions/update_category +PATCH /api/v1/accounts/:account_id/portals/:portal_id/articles/bulk_actions/update_status 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 @@ -659,8 +664,10 @@ POST /api/v1/accounts/:account_id/platform_apps/:platform_app_id/regenerate_acce POST /api/v1/accounts/:account_id/portals POST /api/v1/accounts/:account_id/portals/ POST /api/v1/accounts/:account_id/portals/:portal_id/archive +POST /api/v1/accounts/:account_id/portals/:portal_id/articles POST /api/v1/accounts/:account_id/portals/:portal_id/articles/ POST /api/v1/accounts/:account_id/portals/:portal_id/articles/bulk_actions +POST /api/v1/accounts/:account_id/portals/:portal_id/articles/bulk_actions/translate 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 @@ -851,4 +858,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: 853 +TOTAL: 860 diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index 1d067e69..61ed5869 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -766,7 +766,7 @@ func Bootstrap(env string) (*App, error) { DashboardApp: v1.NewDashboardAppHandler(dashboardAppService), Portal: v1.NewPortalHandler(portalService), Category: v1.NewCategoryHandler(categoryService, portalService), - Article: v1.NewArticleHandler(articleService), + Article: v1.NewArticleHandler(articleService, portalService), Folder: v1.NewFolderHandler(folderService), PortalMember: v1.NewPortalMemberHandler(portalMemberService), AutomationRule: v1.NewAutomationRuleHandler(automationRuleService).WithAuditService(auditService), diff --git a/internal/handler/api/v1/article_handler.go b/internal/handler/api/v1/article_handler.go index b735bfd3..acbfd153 100644 --- a/internal/handler/api/v1/article_handler.go +++ b/internal/handler/api/v1/article_handler.go @@ -1,11 +1,14 @@ package v1 import ( + "encoding/json" + "errors" "net/http" "strconv" "github.com/gin-gonic/gin" + "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" "github.com/gochat/gochat/internal/service" applogger "github.com/gochat/gochat/pkg/logger" @@ -15,67 +18,62 @@ import ( // ArticleHandler handles Article CRUD and Knowledge Base endpoints. type ArticleHandler struct { - svc *service.ArticleService + svc *service.ArticleService + portalSvc *service.PortalService } // NewArticleHandler creates a new ArticleHandler. -func NewArticleHandler(svc *service.ArticleService) *ArticleHandler { - return &ArticleHandler{svc: svc} +func NewArticleHandler(svc *service.ArticleService, portalSvc ...*service.PortalService) *ArticleHandler { + h := &ArticleHandler{svc: svc} + if len(portalSvc) > 0 { + h.portalSvc = portalSvc[0] + } + return h } // Create creates a new article. // POST /portals/:portal_id/articles func (h *ArticleHandler) 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") + accountID := getAccountID(c) + if accountID == 0 { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } - accountID, err := parseUintParam(c, "account_id") - if err != nil { - response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account id") + portal, ok := h.resolvePortal(c, accountID) + if !ok { return } - // Chatwoot: params.require(:article) → {"article": {...}} - var wrapper struct { - Article service.CreateArticleRequest `json:"article"` - } - if err := c.ShouldBindJSON(&wrapper); err != nil { + var req service.CreateArticleRequest + if err := bindChatwootPayload(c, "article", &req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } - req := wrapper.Article - // Extract authorID from auth context header (X-User-ID). - // When auth middleware is wired, this will come from c.Get("user_id"). - authorIDStr := c.GetHeader("X-User-ID") - var authorID uint - authorID64, parseErr := strconv.ParseUint(authorIDStr, 10, 64) - if parseErr == nil && authorID64 != 0 { - authorID = uint(authorID64) - } - - article, err := h.svc.CreateWithAccount(c.Request.Context(), accountID, uint(portalID), authorID, &req) + article, err := h.svc.CreateWithAccount(c.Request.Context(), accountID, portal.ID, currentUserID(c), &req) if err != nil { applogger.L().Errorf("Create article: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to create article") return } - response.Created(c, article) + c.JSON(http.StatusOK, gin.H{"payload": articlePayload(article)}) } // Get retrieves an article by ID and increments its view count. // GET /portals/:portal_id/articles/:id func (h *ArticleHandler) Get(c *gin.Context) { - id, err := strconv.ParseUint(c.Param("id"), 10, 64) + portal, ok := h.resolvePortalForRequest(c) + if !ok { + return + } + id, err := articleIDParam(c) if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid article id") return } - article, err := h.svc.GetByID(c.Request.Context(), uint(id)) + article, err := h.svc.GetByPortalAndID(c.Request.Context(), portal.ID, id) if err != nil { applogger.L().Errorf("Get article: %v", err) response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "article not found") @@ -83,111 +81,121 @@ func (h *ArticleHandler) Get(c *gin.Context) { } // Increment views on each read (non-blocking; log error only) - if err := h.svc.IncrementViews(c.Request.Context(), uint(id)); err != nil { + if err := h.svc.IncrementViews(c.Request.Context(), id); err != nil { applogger.L().Warnf("IncrementViews article %d: %v", id, err) + } else { + article.Views++ } - response.OK(c, article) + c.JSON(http.StatusOK, gin.H{"payload": articlePayload(article)}) } // Edit retrieves an article in edit context (full content without incrementing views). // GET /portals/:portal_id/articles/:id/edit func (h *ArticleHandler) Edit(c *gin.Context) { - id, err := strconv.ParseUint(c.Param("id"), 10, 64) + portal, ok := h.resolvePortalForRequest(c) + if !ok { + return + } + id, err := articleIDParam(c) if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid article id") return } - article, err := h.svc.GetByID(c.Request.Context(), uint(id)) + article, err := h.svc.GetByPortalAndID(c.Request.Context(), portal.ID, id) if err != nil { applogger.L().Errorf("Edit article: %v", err) response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "article not found") return } - response.OK(c, article) + c.JSON(http.StatusOK, gin.H{"payload": articlePayload(article)}) } // Update modifies an existing article. // PUT /portals/:portal_id/articles/:id func (h *ArticleHandler) Update(c *gin.Context) { - id, err := strconv.ParseUint(c.Param("id"), 10, 64) + portal, ok := h.resolvePortalForRequest(c) + if !ok { + return + } + id, err := articleIDParam(c) if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid article id") return } - // Chatwoot: params.require(:article) → {"article": {...}} - var wrapper struct { - Article service.UpdateArticleRequest `json:"article"` - } - if err := c.ShouldBindJSON(&wrapper); err != nil { + var req service.UpdateArticleRequest + if err := bindChatwootPayload(c, "article", &req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } - req := wrapper.Article - article, err := h.svc.Update(c.Request.Context(), uint(id), &req) + article, err := h.svc.UpdateScoped(c.Request.Context(), portal.ID, id, &req) if err != nil { applogger.L().Errorf("Update article: %v", err) - response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to update article") + response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, "failed to update article") return } - response.OK(c, article) + c.JSON(http.StatusOK, gin.H{"payload": articlePayload(article)}) } // Delete removes an article. // DELETE /portals/:portal_id/articles/:id func (h *ArticleHandler) Delete(c *gin.Context) { - id, err := strconv.ParseUint(c.Param("id"), 10, 64) + portal, ok := h.resolvePortalForRequest(c) + if !ok { + return + } + id, err := articleIDParam(c) if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid article id") return } - if err := h.svc.Delete(c.Request.Context(), uint(id)); err != nil { + if err := h.svc.DeleteScoped(c.Request.Context(), portal.ID, id); err != nil { applogger.L().Errorf("Delete article: %v", err) - response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to delete article") + response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, "failed to delete article") return } - response.NoContent(c) + c.Status(http.StatusOK) } // List returns all articles for a portal (paginated). // GET /portals/:portal_id/articles func (h *ArticleHandler) 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 } p := pagination.Parse(c) - - // If status query param is present, delegate to ListByStatus - status := c.Query("status") - if status != "" { - articles, count, svcErr := h.svc.ListByStatus(c.Request.Context(), uint(portalID), status, p.Page, p.PerPage) - if svcErr != nil { - applogger.L().Errorf("List articles by status: %v", svcErr) - response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to list articles") - return - } - response.OKWithMeta(c, articles, p.Page, p.PerPage, count) + params, ok := h.articleSearchParams(c, portal.ID, p.Offset, p.PerPage) + if !ok { return } - - articles, count, svcErr := h.svc.ListByPortalID(c.Request.Context(), uint(portalID), p.Page, p.PerPage) + articles, _, svcErr := h.svc.Search(c.Request.Context(), params) if svcErr != nil { applogger.L().Errorf("List articles: %v", svcErr) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to list articles") return } + meta, svcErr := h.svc.ListMeta(c.Request.Context(), params, currentUserID(c)) + if svcErr != nil { + applogger.L().Errorf("Article list meta: %v", svcErr) + response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to list articles") + return + } - response.OKWithMeta(c, articles, p.Page, p.PerPage, count) + c.JSON(http.StatusOK, gin.H{"payload": articlePayloads(articles), "meta": articleListMetaPayload(meta, p.Page)}) } // ListByCategory returns articles for a specific category (paginated). @@ -214,35 +222,20 @@ func (h *ArticleHandler) ListByCategory(c *gin.Context) { // Search returns filtered articles based on query parameters. // GET /portals/:portal_id/articles/search func (h *ArticleHandler) Search(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 } p := pagination.Parse(c) - - params := repository.ArticleSearchParams{ - PortalID: uint(portalID), - Query: c.Query("query"), - CategorySlug: c.Query("category_slug"), - Locale: c.Query("locale"), - Status: c.Query("status"), - SortBy: c.Query("sort_by"), - Offset: p.Offset, - Limit: p.PerPage, - } - - // Parse optional author_id - authorIDStr := c.Query("author_id") - if authorIDStr != "" { - aid, parseErr := strconv.ParseUint(authorIDStr, 10, 64) - if parseErr != nil { - response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid author_id") - return - } - aidUint := uint(aid) - params.AuthorID = &aidUint + params, ok := h.articleSearchParams(c, portal.ID, p.Offset, p.PerPage) + if !ok { + return } articles, count, svcErr := h.svc.Search(c.Request.Context(), params) @@ -252,7 +245,14 @@ func (h *ArticleHandler) Search(c *gin.Context) { return } - response.OKWithMeta(c, articles, p.Page, p.PerPage, count) + meta, svcErr := h.svc.ListMeta(c.Request.Context(), params, currentUserID(c)) + if svcErr != nil { + applogger.L().Errorf("Article search meta: %v", svcErr) + response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to search articles") + return + } + meta.ArticlesCount = count + c.JSON(http.StatusOK, gin.H{"payload": articlePayloads(articles), "meta": articleListMetaPayload(meta, p.Page)}) } // StatusCounts returns article counts grouped by status. @@ -288,25 +288,23 @@ type positionEntry struct { // Reorder batch-updates article positions. // POST /portals/:portal_id/articles/reorder func (h *ArticleHandler) Reorder(c *gin.Context) { - var req reorderRequest - if err := c.ShouldBindJSON(&req); err != nil { + portal, ok := h.resolvePortalForRequest(c) + if !ok { + return + } + positions, err := bindArticleReorder(c) + if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } - // Convert slice to map[uint]int as expected by service - 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 articles: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to reorder articles") return } - response.OK(c, gin.H{"reordered": true}) + c.Status(http.StatusOK) } // bulkUpdateStatusRequest is the JSON payload for BulkUpdateStatus. @@ -318,29 +316,46 @@ type bulkUpdateStatusRequest struct { // BulkUpdateStatus updates the status of multiple articles at once. // POST /portals/:portal_id/articles/bulk_update_status func (h *ArticleHandler) BulkUpdateStatus(c *gin.Context) { + portal, ok := h.resolvePortalForRequest(c) + if !ok { + return + } var req bulkUpdateStatusRequest if err := c.ShouldBindJSON(&req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } - if len(req.IDs) == 0 { - response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "ids must not be empty") - return - } - - if req.Status == "" { - response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "status must not be empty") - return - } - - if err := h.svc.BulkUpdateStatus(c.Request.Context(), req.IDs, req.Status); err != nil { + if err := h.svc.BulkUpdateStatusScoped(c.Request.Context(), portal.ID, req.IDs, req.Status); err != nil { applogger.L().Errorf("BulkUpdateStatus articles: %v", err) - response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to bulk update status") + renderArticleBulkError(c, err) return } - response.OK(c, gin.H{"updated": true}) + c.Status(http.StatusOK) +} + +type bulkUpdateCategoryRequest struct { + IDs []uint `json:"ids"` + CategoryID uint `json:"category_id"` +} + +func (h *ArticleHandler) BulkUpdateCategory(c *gin.Context) { + portal, ok := h.resolvePortalForRequest(c) + if !ok { + return + } + var req bulkUpdateCategoryRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) + return + } + if err := h.svc.BulkUpdateCategoryScoped(c.Request.Context(), portal.ID, req.IDs, req.CategoryID); err != nil { + applogger.L().Errorf("BulkUpdateCategory articles: %v", err) + renderArticleBulkError(c, err) + return + } + c.Status(http.StatusOK) } // bulkDeleteRequest is the JSON payload for BulkDelete. @@ -351,24 +366,27 @@ type bulkDeleteRequest struct { // BulkDelete deletes multiple articles at once. // POST /portals/:portal_id/articles/bulk_delete func (h *ArticleHandler) BulkDelete(c *gin.Context) { + portal, ok := h.resolvePortalForRequest(c) + if !ok { + return + } var req bulkDeleteRequest if err := c.ShouldBindJSON(&req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } - if len(req.IDs) == 0 { - response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "ids must not be empty") - return - } - - if err := h.svc.BulkDelete(c.Request.Context(), req.IDs); err != nil { + if err := h.svc.BulkDeleteScoped(c.Request.Context(), portal.ID, req.IDs); err != nil { applogger.L().Errorf("BulkDelete articles: %v", err) - response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to bulk delete articles") + renderArticleBulkError(c, err) return } - response.OK(c, gin.H{"deleted": true}) + c.Status(http.StatusOK) +} + +func (h *ArticleHandler) BulkTranslate(c *gin.Context) { + c.Status(http.StatusNotImplemented) } // BulkActions performs a bulk operation (publish/archive/delete) on multiple articles. @@ -393,3 +411,192 @@ func (h *ArticleHandler) BulkActions(c *gin.Context) { response.OK(c, gin.H{"action": req.Action, "ids": req.IDs}) } + +func (h *ArticleHandler) resolvePortalForRequest(c *gin.Context) (*model.Portal, bool) { + accountID := getAccountID(c) + if accountID == 0 { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") + return nil, false + } + return h.resolvePortal(c, accountID) +} + +func (h *ArticleHandler) 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 (h *ArticleHandler) articleSearchParams(c *gin.Context, portalID uint, offset, limit int) (repository.ArticleSearchParams, bool) { + params := repository.ArticleSearchParams{ + PortalID: portalID, + Query: c.Query("query"), + CategorySlug: c.Query("category_slug"), + Locale: c.Query("locale"), + Status: c.Query("status"), + SortBy: c.Query("sort_by"), + Offset: offset, + Limit: limit, + } + if params.SortBy == "" { + params.SortBy = c.Query("sort") + } + if authorIDStr := c.Query("author_id"); authorIDStr != "" { + aid, parseErr := strconv.ParseUint(authorIDStr, 10, 64) + if parseErr != nil { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid author_id") + return params, false + } + aidUint := uint(aid) + params.AuthorID = &aidUint + } + return params, true +} + +func articleIDParam(c *gin.Context) (uint, error) { + if id, err := parseUintAnyParam(c, "article_id", "id"); err == nil && id != 0 { + return id, nil + } else if err != nil { + return 0, err + } + return 0, strconv.ErrSyntax +} + +func bindArticleReorder(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 +} + +func articlePayloads(articles []model.Article) []gin.H { + payload := make([]gin.H, 0, len(articles)) + for i := range articles { + payload = append(payload, articlePayload(&articles[i])) + } + return payload +} + +func articlePayload(article *model.Article) gin.H { + if article == nil { + return gin.H{} + } + payload := gin.H{ + "id": article.ID, + "slug": article.Slug, + "title": article.Title, + "content": article.Content, + "description": article.Description, + "status": article.Status, + "position": article.Position, + "account_id": article.AccountID, + "updated_at": article.UpdatedAt.Unix(), + "meta": articleMetaPayload(article.Meta), + "category": articleCategoryPayload(article), + "views": article.Views, + "associated_articles": associatedArticlePayloads(article.AssociatedArticles), + } + if article.Author != nil && article.Author.ID != 0 { + payload["author"] = serializeAgentUser(article.Author, article.AccountID, article.Author.Role, "", false, 0) + } + return payload +} + +func articleCategoryPayload(article *model.Article) gin.H { + payload := gin.H{"id": article.CategoryID, "name": nil, "slug": nil, "locale": nil} + if article.Category != nil { + payload["name"] = article.Category.Name + payload["slug"] = article.Category.Slug + payload["locale"] = article.Category.Locale + } + return payload +} + +func associatedArticlePayloads(articles []model.Article) []gin.H { + payload := make([]gin.H, 0, len(articles)) + for i := range articles { + article := &articles[i] + item := gin.H{ + "id": article.ID, + "category_id": article.CategoryID, + "title": article.Title, + "content": article.Content, + "description": article.Description, + "status": article.Status, + "account_id": article.AccountID, + "views": article.Views, + } + if article.Portal.ID != 0 { + item["portal"] = portalPayload(&article.Portal, article.Locale, 0) + } + if article.Author != nil && article.Author.ID != 0 { + item["author"] = serializeAgentUser(article.Author, article.AccountID, article.Author.Role, "", false, 0) + } + payload = append(payload, item) + } + return payload +} + +func articleListMetaPayload(meta *service.ArticleListMeta, currentPage int) gin.H { + return gin.H{ + "all_articles_count": meta.AllArticlesCount, + "archived_articles_count": meta.ArchivedArticlesCount, + "articles_count": meta.ArticlesCount, + "current_page": currentPage, + "draft_articles_count": meta.DraftArticlesCount, + "mine_articles_count": meta.MineArticlesCount, + "published_count": meta.PublishedCount, + } +} + +func articleMetaPayload(raw json.RawMessage) map[string]any { + if len(raw) == 0 { + return map[string]any{} + } + var obj map[string]any + if err := json.Unmarshal(raw, &obj); err != nil || obj == nil { + return map[string]any{} + } + return obj +} + +func renderArticleBulkError(c *gin.Context, err error) { + message := "failed to update articles" + switch { + case errors.Is(err, service.ErrArticleBulkNoArticles): + message = "No articles found" + case errors.Is(err, service.ErrArticleBulkInvalidStatus): + message = "Invalid status" + case errors.Is(err, service.ErrArticleBulkCategoryNotFound): + message = "Category not found" + default: + message = err.Error() + } + c.JSON(http.StatusUnprocessableEntity, gin.H{"error": message}) +} diff --git a/internal/handler/api/v1/article_handler_test.go b/internal/handler/api/v1/article_handler_test.go index 379ab55e..e6b55715 100644 --- a/internal/handler/api/v1/article_handler_test.go +++ b/internal/handler/api/v1/article_handler_test.go @@ -37,12 +37,14 @@ func (s *ArticleHandlerTestSuite) SetupSuite() { s.Require().NoError(db.AutoMigrate( &model.Account{}, &model.User{}, &model.Portal{}, &model.Category{}, &model.Folder{}, &model.Article{}, + &model.PortalMember{}, )) s.db = db repo := repository.NewArticleRepo(db) svc := service.NewArticleService(repo) - s.handler = NewArticleHandler(svc) + portalRepo := repository.NewPortalRepo(db) + s.handler = NewArticleHandler(svc, service.NewPortalService(portalRepo)) s.account = &model.Account{Name: "test-article-account"} s.Require().NoError(db.Create(s.account).Error) @@ -82,7 +84,7 @@ func (s *ArticleHandlerTestSuite) TestCreate_BadRequest_InvalidPortalID() { req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) - assert.Equal(s.T(), http.StatusBadRequest, w.Code) + assert.Equal(s.T(), http.StatusNotFound, w.Code) } func (s *ArticleHandlerTestSuite) TestCreate_BadRequest_EmptyBody() { @@ -142,8 +144,6 @@ func (s *ArticleHandlerTestSuite) TestDelete_BadRequest_InvalidID() { assert.Equal(s.T(), http.StatusBadRequest, w.Code) } - - func (s *ArticleHandlerTestSuite) TestList_BadRequest_InvalidPortalID() { r := gin.New() r.GET("/api/v1/accounts/:account_id/portals/:portal_id/articles", s.handler.List) @@ -152,9 +152,7 @@ func (s *ArticleHandlerTestSuite) TestList_BadRequest_InvalidPortalID() { req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/portals/abc/articles", s.account.ID), nil) r.ServeHTTP(w, req) - // List doesn't validate account_id, so invalid portal_id may pass or return empty - // We just check it doesn't crash - assert.True(s.T(), w.Code == http.StatusOK || w.Code == http.StatusBadRequest) + assert.Equal(s.T(), http.StatusNotFound, w.Code) } func (s *ArticleHandlerTestSuite) TestListByCategory_BadRequest_InvalidCategoryID() { @@ -176,7 +174,7 @@ func (s *ArticleHandlerTestSuite) TestSearch_BadRequest_InvalidPortalID() { req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/portals/abc/articles/search?q=test", s.account.ID), nil) r.ServeHTTP(w, req) - assert.Equal(s.T(), http.StatusBadRequest, w.Code) + assert.Equal(s.T(), http.StatusNotFound, w.Code) } func (s *ArticleHandlerTestSuite) TestStatusCounts_BadRequest_InvalidPortalID() { @@ -242,7 +240,12 @@ func (s *ArticleHandlerTestSuite) TestCreate_Success() { req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) - assert.Equal(s.T(), http.StatusCreated, w.Code) + assert.Equal(s.T(), http.StatusOK, w.Code) + var resp map[string]interface{} + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) + payload := resp["payload"].(map[string]interface{}) + assert.Equal(s.T(), "test-article", payload["title"]) + assert.Equal(s.T(), "draft", payload["status"]) } func (s *ArticleHandlerTestSuite) TestList_Success() { @@ -264,4 +267,141 @@ func (s *ArticleHandlerTestSuite) TestList_Success() { r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) -} \ No newline at end of file + var resp map[string]interface{} + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Contains(s.T(), resp, "payload") + meta := resp["meta"].(map[string]interface{}) + assert.Contains(s.T(), meta, "all_articles_count") +} + +func (s *ArticleHandlerTestSuite) TestCreate_RawFrontendPayloadAndSlugPortal() { + r := gin.New() + r.POST("/api/v1/accounts/:account_id/portals/:portal_id/articles", s.handler.Create) + + body := map[string]interface{}{ + "title": "Raw Article", + "content": "raw content", + "author_id": uint(7), + "category_id": nil, + "locale": "en", + } + b, _ := json.Marshal(body) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/portals/test-portal/articles", s.account.ID), bytes.NewBuffer(b)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusOK, w.Code) + var resp map[string]interface{} + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) + payload := resp["payload"].(map[string]interface{}) + assert.Equal(s.T(), "Raw Article", payload["title"]) + assert.NotEmpty(s.T(), payload["slug"]) +} + +func (s *ArticleHandlerTestSuite) TestPatch_RawPayloadClearsDescription() { + article := &model.Article{AccountID: s.account.ID, PortalID: s.portal.ID, Title: "patch-article", Slug: "patch-article", Description: "old", Status: "draft"} + s.Require().NoError(s.db.Create(article).Error) + + r := gin.New() + r.PATCH("/api/v1/accounts/:account_id/portals/:portal_id/articles/:article_id", s.handler.Update) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("PATCH", fmt.Sprintf("/api/v1/accounts/%d/portals/test-portal/articles/%d", s.account.ID, article.ID), bytes.NewBufferString(`{"description":""}`)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusOK, w.Code) + var resp map[string]interface{} + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) + payload := resp["payload"].(map[string]interface{}) + assert.Equal(s.T(), "", payload["description"]) +} + +func (s *ArticleHandlerTestSuite) TestDelete_ReturnsEmptyOK() { + article := &model.Article{AccountID: s.account.ID, PortalID: s.portal.ID, Title: "delete-article", Slug: "delete-article", Status: "draft"} + s.Require().NoError(s.db.Create(article).Error) + + r := gin.New() + r.DELETE("/api/v1/accounts/:account_id/portals/:portal_id/articles/:article_id", s.handler.Delete) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("DELETE", fmt.Sprintf("/api/v1/accounts/%d/portals/test-portal/articles/%d", s.account.ID, article.ID), nil) + r.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusOK, w.Code) +} + +func (s *ArticleHandlerTestSuite) TestReorder_PositionsHashScoped() { + article := &model.Article{AccountID: s.account.ID, PortalID: s.portal.ID, Title: "reorder-article", Slug: "reorder-article", Status: "draft", Position: 1} + s.Require().NoError(s.db.Create(article).Error) + + r := gin.New() + r.POST("/api/v1/accounts/:account_id/portals/:portal_id/articles/reorder", s.handler.Reorder) + + w := httptest.NewRecorder() + body := fmt.Sprintf(`{"positions_hash":{"%d":30}}`, article.ID) + req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/portals/test-portal/articles/reorder", s.account.ID), bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusOK, w.Code) + var updated model.Article + s.Require().NoError(s.db.First(&updated, article.ID).Error) + assert.Equal(s.T(), 30, updated.Position) +} + +func (s *ArticleHandlerTestSuite) TestBulkActions_FrontendRoutes() { + category := &model.Category{AccountID: s.account.ID, PortalID: s.portal.ID, Name: "BulkCat", Slug: "bulk-cat", Locale: "en"} + s.Require().NoError(s.db.Create(category).Error) + a1 := &model.Article{AccountID: s.account.ID, PortalID: s.portal.ID, Title: "bulk-one", Slug: "bulk-one", Status: "draft"} + a2 := &model.Article{AccountID: s.account.ID, PortalID: s.portal.ID, Title: "bulk-two", Slug: "bulk-two", Status: "draft"} + s.Require().NoError(s.db.Create(a1).Error) + s.Require().NoError(s.db.Create(a2).Error) + + r := gin.New() + r.PATCH("/api/v1/accounts/:account_id/portals/:portal_id/articles/bulk_actions/update_status", s.handler.BulkUpdateStatus) + r.PATCH("/api/v1/accounts/:account_id/portals/:portal_id/articles/bulk_actions/update_category", s.handler.BulkUpdateCategory) + r.DELETE("/api/v1/accounts/:account_id/portals/:portal_id/articles/bulk_actions/delete_articles", s.handler.BulkDelete) + + statusBody := fmt.Sprintf(`{"ids":[%d,%d],"status":"published"}`, a1.ID, a2.ID) + w := httptest.NewRecorder() + req, _ := http.NewRequest("PATCH", fmt.Sprintf("/api/v1/accounts/%d/portals/test-portal/articles/bulk_actions/update_status", s.account.ID), bytes.NewBufferString(statusBody)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + assert.Equal(s.T(), http.StatusOK, w.Code) + + categoryBody := fmt.Sprintf(`{"ids":[%d,%d],"category_id":%d}`, a1.ID, a2.ID, category.ID) + w = httptest.NewRecorder() + req, _ = http.NewRequest("PATCH", fmt.Sprintf("/api/v1/accounts/%d/portals/test-portal/articles/bulk_actions/update_category", s.account.ID), bytes.NewBufferString(categoryBody)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + assert.Equal(s.T(), http.StatusOK, w.Code) + + deleteBody := fmt.Sprintf(`{"ids":[%d,%d]}`, a1.ID, a2.ID) + w = httptest.NewRecorder() + req, _ = http.NewRequest("DELETE", fmt.Sprintf("/api/v1/accounts/%d/portals/test-portal/articles/bulk_actions/delete_articles", s.account.ID), bytes.NewBufferString(deleteBody)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + assert.Equal(s.T(), http.StatusOK, w.Code) +} + +func (s *ArticleHandlerTestSuite) TestBulkUpdateStatus_InvalidStatusReturnsChatwootError() { + article := &model.Article{AccountID: s.account.ID, PortalID: s.portal.ID, Title: "bulk-invalid", Slug: "bulk-invalid", Status: "draft"} + s.Require().NoError(s.db.Create(article).Error) + + r := gin.New() + r.PATCH("/api/v1/accounts/:account_id/portals/:portal_id/articles/bulk_actions/update_status", s.handler.BulkUpdateStatus) + + w := httptest.NewRecorder() + body := fmt.Sprintf(`{"ids":[%d],"status":"missing"}`, article.ID) + req, _ := http.NewRequest("PATCH", fmt.Sprintf("/api/v1/accounts/%d/portals/test-portal/articles/bulk_actions/update_status", s.account.ID), bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusUnprocessableEntity, w.Code) + var resp map[string]interface{} + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Contains(s.T(), resp, "error") +} diff --git a/internal/model/article.go b/internal/model/article.go index ddcd4e21..85ac5dd9 100644 --- a/internal/model/article.go +++ b/internal/model/article.go @@ -25,11 +25,12 @@ type Article struct { Meta json.RawMessage `gorm:"type:jsonb;serializer:json" json:"meta"` CustomAttributes json.RawMessage `gorm:"type:jsonb;serializer:json" json:"custom_attributes"` - Portal Portal `gorm:"foreignKey:PortalID" json:"portal,omitempty"` - Category *Category `gorm:"foreignKey:CategoryID" json:"category,omitempty"` - Folder *Folder `gorm:"foreignKey:FolderID" json:"folder,omitempty"` - Author *User `gorm:"foreignKey:AuthorID" json:"author,omitempty"` - AssociatedArticle *Article `gorm:"foreignKey:AssociatedArticleID" json:"associated_article,omitempty"` + Portal Portal `gorm:"foreignKey:PortalID" json:"portal,omitempty"` + Category *Category `gorm:"foreignKey:CategoryID" json:"category,omitempty"` + Folder *Folder `gorm:"foreignKey:FolderID" json:"folder,omitempty"` + Author *User `gorm:"foreignKey:AuthorID" json:"author,omitempty"` + AssociatedArticle *Article `gorm:"foreignKey:AssociatedArticleID" json:"associated_article,omitempty"` + AssociatedArticles []Article `gorm:"foreignKey:AssociatedArticleID" json:"associated_articles,omitempty"` } -func (Article) TableName() string { return "articles" } \ No newline at end of file +func (Article) TableName() string { return "articles" } diff --git a/internal/repository/article_repo.go b/internal/repository/article_repo.go index ee73d1dd..c409fdad 100644 --- a/internal/repository/article_repo.go +++ b/internal/repository/article_repo.go @@ -2,6 +2,7 @@ package repository import ( "context" + "strings" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/search" @@ -23,7 +24,15 @@ func (r *ArticleRepo) Create(ctx context.Context, article *model.Article) error func (r *ArticleRepo) GetByID(ctx context.Context, id uint) (*model.Article, error) { var article model.Article - if err := r.db.WithContext(ctx).Preload("Category").Preload("Folder").Preload("Author").First(&article, id).Error; err != nil { + if err := r.articlePreloads(r.db.WithContext(ctx)).First(&article, id).Error; err != nil { + return nil, err + } + return &article, nil +} + +func (r *ArticleRepo) GetByPortalAndID(ctx context.Context, portalID, id uint) (*model.Article, error) { + var article model.Article + if err := r.articlePreloads(r.db.WithContext(ctx)).Where("portal_id = ?", portalID).First(&article, id).Error; err != nil { return nil, err } return &article, nil @@ -43,7 +52,11 @@ func (r *ArticleRepo) FindByPortalID(ctx context.Context, portalID uint, offset, var count int64 db := r.db.WithContext(ctx).Model(&model.Article{}).Where("portal_id = ?", portalID) db.Count(&count) - if err := db.Offset(offset).Limit(limit).Order("position ASC").Find(&articles).Error; err != nil { + query := r.articlePreloads(db).Offset(offset).Limit(limit).Order("position ASC") + if limit <= 0 { + query = r.articlePreloads(db).Order("position ASC") + } + if err := query.Find(&articles).Error; err != nil { return nil, 0, err } return articles, count, nil @@ -55,7 +68,7 @@ func (r *ArticleRepo) FindByCategoryID(ctx context.Context, categoryID uint, off var count int64 db := r.db.WithContext(ctx).Model(&model.Article{}).Where("category_id = ?", categoryID) db.Count(&count) - if err := db.Offset(offset).Limit(limit).Order("position ASC").Find(&articles).Error; err != nil { + if err := r.articlePreloads(db).Offset(offset).Limit(limit).Order("position ASC").Find(&articles).Error; err != nil { return nil, 0, err } return articles, count, nil @@ -67,7 +80,7 @@ func (r *ArticleRepo) FindByFolderID(ctx context.Context, folderID uint, offset, var count int64 db := r.db.WithContext(ctx).Model(&model.Article{}).Where("folder_id = ?", folderID) db.Count(&count) - if err := db.Offset(offset).Limit(limit).Order("position ASC").Find(&articles).Error; err != nil { + if err := r.articlePreloads(db).Offset(offset).Limit(limit).Order("position ASC").Find(&articles).Error; err != nil { return nil, 0, err } return articles, count, nil @@ -79,7 +92,7 @@ func (r *ArticleRepo) FindByStatus(ctx context.Context, portalID uint, status mo var count int64 db := r.db.WithContext(ctx).Model(&model.Article{}).Where("portal_id = ? AND status = ?", portalID, status) db.Count(&count) - if err := db.Offset(offset).Limit(limit).Order("updated_at DESC").Find(&articles).Error; err != nil { + if err := r.articlePreloads(db).Offset(offset).Limit(limit).Order("updated_at DESC").Find(&articles).Error; err != nil { return nil, 0, err } return articles, count, nil @@ -114,7 +127,36 @@ func (r *ArticleRepo) Search(ctx context.Context, params ArticleSearchParams) ([ var articles []model.Article var count int64 - db := r.db.WithContext(ctx).Model(&model.Article{}).Where("portal_id = ?", params.PortalID) + db := r.applyArticleSearchParams(r.db.WithContext(ctx).Model(&model.Article{}).Where("articles.portal_id = ?", params.PortalID), params) + + // Count before pagination + db.Count(&count) + + // Sorting + switch params.SortBy { + case "newest": + db = db.Order("articles.updated_at DESC") + case "popular": + db = db.Order("articles.views DESC") + default: + if params.CategorySlug != "" || params.Status != "" { + db = db.Order("articles.position ASC") + } else { + db = db.Order("articles.updated_at DESC") + } + } + + query := r.articlePreloads(db) + if params.Limit > 0 { + query = query.Offset(params.Offset).Limit(params.Limit) + } + if err := query.Find(&articles).Error; err != nil { + return nil, 0, err + } + return articles, count, nil +} + +func (r *ArticleRepo) applyArticleSearchParams(db *gorm.DB, params ArticleSearchParams) *gorm.DB { // Filter by category slug — join categories table if params.CategorySlug != "" { @@ -146,33 +188,24 @@ func (r *ArticleRepo) Search(ctx context.Context, params ArticleSearchParams) ([ } else { // ILIKE substring match (default) searchPattern := "%" + params.Query + "%" - db = db.Where("articles.title ILIKE ? OR articles.description ILIKE ? OR articles.content ILIKE ?", + operator := "ILIKE" + if strings.EqualFold(db.Dialector.Name(), "sqlite") { + operator = "LIKE" + } + db = db.Where("articles.title "+operator+" ? OR articles.description "+operator+" ? OR articles.content "+operator+" ?", searchPattern, searchPattern, searchPattern) } } + return db +} - // Count before pagination - db.Count(&count) - - // Sorting - switch params.SortBy { - case "newest": - db = db.Order("articles.updated_at DESC") - case "popular": - db = db.Order("articles.views DESC") - default: - // When category is present, sort by position; otherwise by updated_at - if params.CategorySlug != "" || params.Status != "" { - db = db.Order("articles.position ASC") - } else { - db = db.Order("articles.updated_at DESC") - } +func (r *ArticleRepo) CountSearch(ctx context.Context, params ArticleSearchParams) (int64, error) { + var count int64 + db := r.applyArticleSearchParams(r.db.WithContext(ctx).Model(&model.Article{}).Where("articles.portal_id = ?", params.PortalID), params) + if err := db.Count(&count).Error; err != nil { + return 0, err } - - if err := db.Offset(params.Offset).Limit(params.Limit).Find(&articles).Error; err != nil { - return nil, 0, err - } - return articles, count, nil + return count, nil } // StatusCounts returns the count of articles by status for a portal. @@ -195,11 +228,11 @@ func (r *ArticleRepo) StatusCounts(ctx context.Context, portalID uint) (map[stri } counts := map[string]int64{ - "all_count": 0, - "mine_count": 0, + "all_count": 0, + "mine_count": 0, "published_count": 0, - "draft_count": 0, - "archived_count": 0, + "draft_count": 0, + "archived_count": 0, } var total int64 for _, r := range results { @@ -222,6 +255,15 @@ func (r *ArticleRepo) UpdatePositions(ctx context.Context, positions map[uint]in return nil } +func (r *ArticleRepo) UpdatePositionsForPortal(ctx context.Context, portalID uint, positions map[uint]int) error { + for id, pos := range positions { + if err := r.db.WithContext(ctx).Model(&model.Article{}).Where("id = ? AND portal_id = ?", id, portalID).Update("position", pos).Error; err != nil { + return err + } + } + return nil +} + // BulkUpdateStatus updates status for multiple articles. func (r *ArticleRepo) BulkUpdateStatus(ctx context.Context, ids []uint, status string) error { return r.db.WithContext(ctx).Model(&model.Article{}). @@ -229,14 +271,53 @@ func (r *ArticleRepo) BulkUpdateStatus(ctx context.Context, ids []uint, status s Update("status", status).Error } +func (r *ArticleRepo) BulkUpdateStatusForPortal(ctx context.Context, portalID uint, ids []uint, status string) error { + return r.db.WithContext(ctx).Model(&model.Article{}). + Where("portal_id = ? AND id IN ?", portalID, ids). + Update("status", status).Error +} + +func (r *ArticleRepo) BulkUpdateCategoryForPortal(ctx context.Context, portalID uint, ids []uint, categoryID uint) error { + return r.db.WithContext(ctx).Model(&model.Article{}). + Where("portal_id = ? AND id IN ?", portalID, ids). + Update("category_id", categoryID).Error +} + // BulkDelete deletes multiple articles. func (r *ArticleRepo) BulkDelete(ctx context.Context, ids []uint) error { return r.db.WithContext(ctx).Where("id IN ?", ids).Delete(&model.Article{}).Error } +func (r *ArticleRepo) BulkDeleteForPortal(ctx context.Context, portalID uint, ids []uint) error { + return r.db.WithContext(ctx).Where("portal_id = ? AND id IN ?", portalID, ids).Delete(&model.Article{}).Error +} + +func (r *ArticleRepo) CountByPortalAndIDs(ctx context.Context, portalID uint, ids []uint) (int64, error) { + var count int64 + if len(ids) == 0 { + return 0, nil + } + if err := r.db.WithContext(ctx).Model(&model.Article{}).Where("portal_id = ? AND id IN ?", portalID, ids).Count(&count).Error; err != nil { + return 0, err + } + return count, nil +} + +func (r *ArticleRepo) CategoryExistsForPortal(ctx context.Context, portalID uint, categoryID uint) (bool, error) { + var count int64 + if err := r.db.WithContext(ctx).Model(&model.Category{}).Where("portal_id = ? AND id = ?", portalID, categoryID).Count(&count).Error; err != nil { + return false, err + } + return count > 0, nil +} + // IncrementViews increments the view count for an article by 1. func (r *ArticleRepo) IncrementViews(ctx context.Context, id uint) error { return r.db.WithContext(ctx).Model(&model.Article{}). Where("id = ?", id). UpdateColumn("views", gorm.Expr("views + 1")).Error -} \ No newline at end of file +} + +func (r *ArticleRepo) articlePreloads(db *gorm.DB) *gorm.DB { + return db.Preload("Category").Preload("Folder").Preload("Author").Preload("Portal").Preload("AssociatedArticles.Category").Preload("AssociatedArticles.Author").Preload("AssociatedArticles.Portal") +} diff --git a/internal/router/router.go b/internal/router/router.go index 5c64d7fc..dcf9e13f 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -1414,16 +1414,23 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) { // Articles nested under portal articles := portals.Group("/:portal_id/articles") { + articles.GET("", h.Article.List) articles.GET("/", h.Article.List) + articles.POST("", h.Article.Create) articles.POST("/", h.Article.Create) articles.GET("/search", h.Article.Search) articles.GET("/status_counts", h.Article.StatusCounts) articles.POST("/reorder", h.Article.Reorder) + articles.PATCH("/bulk_actions/update_status", h.Article.BulkUpdateStatus) + articles.PATCH("/bulk_actions/update_category", h.Article.BulkUpdateCategory) + articles.DELETE("/bulk_actions/delete_articles", h.Article.BulkDelete) + articles.POST("/bulk_actions/translate", h.Article.BulkTranslate) articles.POST("/bulk_update_status", h.Article.BulkUpdateStatus) articles.POST("/bulk_delete", h.Article.BulkDelete) articles.POST("/bulk_actions", h.Article.BulkActions) articles.GET("/:article_id", h.Article.Get) articles.GET("/:article_id/edit", h.Article.Edit) + articles.PATCH("/:article_id", h.Article.Update) articles.PUT("/:article_id", h.Article.Update) articles.DELETE("/:article_id", h.Article.Delete) } diff --git a/internal/service/article_service.go b/internal/service/article_service.go index a4f23c7d..6e23804f 100644 --- a/internal/service/article_service.go +++ b/internal/service/article_service.go @@ -4,6 +4,9 @@ import ( "context" "encoding/json" "fmt" + "regexp" + "strings" + "time" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" @@ -39,12 +42,13 @@ func (s *ArticleService) deleteArticleIndex(ctx context.Context, accountID uint, // CreateArticleRequest is the DTO for creating an article. type CreateArticleRequest struct { Title string `json:"title" validate:"required,min=2"` - Slug string `json:"slug" validate:"required"` + Slug string `json:"slug"` Description string `json:"description"` Content string `json:"content"` Status model.ArticleStatus `json:"status"` FolderID *uint `json:"folder_id"` CategoryID *uint `json:"category_id"` + AuthorID *uint `json:"author_id"` Position int `json:"position"` Locale string `json:"locale"` AssociatedArticleID *uint `json:"associated_article_id"` @@ -54,17 +58,19 @@ type CreateArticleRequest struct { // UpdateArticleRequest is the DTO for updating an article. type UpdateArticleRequest struct { - Title string `json:"title"` - Description string `json:"description"` - Content string `json:"content"` - Status model.ArticleStatus `json:"status"` - FolderID *uint `json:"folder_id"` - CategoryID *uint `json:"category_id"` - Position *int `json:"position"` - Locale string `json:"locale"` - AssociatedArticleID *uint `json:"associated_article_id"` - Meta json.RawMessage `json:"meta"` - CustomAttributes json.RawMessage `json:"custom_attributes"` + Title *string `json:"title"` + Slug *string `json:"slug"` + Description *string `json:"description"` + Content *string `json:"content"` + Status *model.ArticleStatus `json:"status"` + FolderID *uint `json:"folder_id"` + CategoryID *uint `json:"category_id"` + AuthorID *uint `json:"author_id"` + Position *int `json:"position"` + Locale *string `json:"locale"` + AssociatedArticleID *uint `json:"associated_article_id"` + Meta *json.RawMessage `json:"meta"` + CustomAttributes *json.RawMessage `json:"custom_attributes"` } func (s *ArticleService) Create(ctx context.Context, portalID uint, authorID uint, req *CreateArticleRequest) (*model.Article, error) { @@ -77,13 +83,20 @@ func (s *ArticleService) CreateWithAccount(ctx context.Context, accountID uint, if status == "" { status = string(model.ArticleStatusDraft) } + if req.AuthorID != nil { + authorID = *req.AuthorID + } + slug := req.Slug + if slug == "" { + slug = articleSlug(req.Title) + } article := &model.Article{ AccountID: accountID, PortalID: portalID, AuthorID: &authorID, Title: req.Title, - Slug: req.Slug, + Slug: slug, Description: req.Description, Content: req.Content, Status: status, @@ -106,7 +119,7 @@ func (s *ArticleService) CreateWithAccount(ctx context.Context, accountID uint, } s.indexArticle(ctx, article) - return article, nil + return s.repo.GetByPortalAndID(ctx, portalID, article.ID) } func (s *ArticleService) GetByID(ctx context.Context, id uint) (*model.Article, error) { @@ -117,24 +130,49 @@ func (s *ArticleService) GetByID(ctx context.Context, id uint) (*model.Article, return article, nil } +func (s *ArticleService) GetByPortalAndID(ctx context.Context, portalID, id uint) (*model.Article, error) { + article, err := s.repo.GetByPortalAndID(ctx, portalID, id) + if err != nil { + return nil, fmt.Errorf("get article: %w", err) + } + return article, nil +} + func (s *ArticleService) Update(ctx context.Context, id uint, req *UpdateArticleRequest) (*model.Article, error) { article, err := s.repo.GetByID(ctx, id) if err != nil { return nil, fmt.Errorf("find article: %w", err) } + return s.UpdateExisting(ctx, article, req) +} +func (s *ArticleService) UpdateScoped(ctx context.Context, portalID, id uint, req *UpdateArticleRequest) (*model.Article, error) { + article, err := s.repo.GetByPortalAndID(ctx, portalID, id) + if err != nil { + return nil, fmt.Errorf("find article: %w", err) + } + return s.UpdateExisting(ctx, article, req) +} + +func (s *ArticleService) UpdateExisting(ctx context.Context, article *model.Article, req *UpdateArticleRequest) (*model.Article, error) { + if article == nil { + return nil, fmt.Errorf("find article: record not found") + } // Update fields — only non-zero/non-nil values - if req.Title != "" { - article.Title = req.Title + if req.Title != nil { + article.Title = *req.Title } - if req.Description != "" { - article.Description = req.Description + if req.Slug != nil { + article.Slug = *req.Slug } - if req.Content != "" { - article.Content = req.Content + if req.Description != nil { + article.Description = *req.Description } - if req.Status != "" { - article.Status = string(req.Status) + if req.Content != nil { + article.Content = *req.Content + } + if req.Status != nil { + article.Status = string(*req.Status) } if req.FolderID != nil { article.FolderID = req.FolderID @@ -142,27 +180,30 @@ func (s *ArticleService) Update(ctx context.Context, id uint, req *UpdateArticle if req.CategoryID != nil { article.CategoryID = req.CategoryID } + if req.AuthorID != nil { + article.AuthorID = req.AuthorID + } if req.Position != nil { article.Position = *req.Position } - if req.Locale != "" { - article.Locale = req.Locale + if req.Locale != nil { + article.Locale = *req.Locale } if req.AssociatedArticleID != nil { article.AssociatedArticleID = req.AssociatedArticleID } if req.Meta != nil { - article.Meta = req.Meta + article.Meta = *req.Meta } if req.CustomAttributes != nil { - article.CustomAttributes = req.CustomAttributes + article.CustomAttributes = *req.CustomAttributes } if err := s.repo.Update(ctx, article); err != nil { return nil, fmt.Errorf("update article: %w", err) } s.indexArticle(ctx, article) - return article, nil + return s.repo.GetByPortalAndID(ctx, article.PortalID, article.ID) } func (s *ArticleService) Delete(ctx context.Context, id uint) error { @@ -178,6 +219,18 @@ func (s *ArticleService) Delete(ctx context.Context, id uint) error { return nil } +func (s *ArticleService) DeleteScoped(ctx context.Context, portalID, id uint) error { + article, err := s.repo.GetByPortalAndID(ctx, portalID, id) + if err != nil { + return fmt.Errorf("find article for delete: %w", err) + } + if err := s.repo.Delete(ctx, id); err != nil { + return fmt.Errorf("delete article: %w", err) + } + s.deleteArticleIndex(ctx, article.AccountID, id) + return nil +} + // ListByPortalID returns articles for a portal with pagination. func (s *ArticleService) ListByPortalID(ctx context.Context, portalID uint, page, perPage int) ([]model.Article, int64, error) { offset := (page - 1) * perPage @@ -227,6 +280,66 @@ func (s *ArticleService) StatusCounts(ctx context.Context, portalID uint) (map[s return counts, nil } +type ArticleListMeta struct { + AllArticlesCount int64 + ArchivedArticlesCount int64 + ArticlesCount int64 + DraftArticlesCount int64 + MineArticlesCount int64 + PublishedCount int64 +} + +func (s *ArticleService) ListMeta(ctx context.Context, params repository.ArticleSearchParams, currentUserID uint) (*ArticleListMeta, error) { + allCount, err := s.repo.CountSearch(ctx, repository.ArticleSearchParams{PortalID: params.PortalID}) + if err != nil { + return nil, fmt.Errorf("count all articles: %w", err) + } + baseParams := params + baseParams.Status = "" + baseParams.AuthorID = nil + articlesCount, err := s.repo.CountSearch(ctx, baseParams) + if err != nil { + return nil, fmt.Errorf("count articles: %w", err) + } + mineParams := baseParams + if currentUserID != 0 { + mineParams.AuthorID = ¤tUserID + } + mineCount := int64(0) + if currentUserID != 0 { + mineCount, err = s.repo.CountSearch(ctx, mineParams) + if err != nil { + return nil, fmt.Errorf("count mine articles: %w", err) + } + } + publishedParams := baseParams + publishedParams.Status = string(model.ArticleStatusPublished) + publishedCount, err := s.repo.CountSearch(ctx, publishedParams) + if err != nil { + return nil, fmt.Errorf("count published articles: %w", err) + } + draftParams := baseParams + draftParams.Status = string(model.ArticleStatusDraft) + draftCount, err := s.repo.CountSearch(ctx, draftParams) + if err != nil { + return nil, fmt.Errorf("count draft articles: %w", err) + } + archivedParams := baseParams + archivedParams.Status = string(model.ArticleStatusArchived) + archivedCount, err := s.repo.CountSearch(ctx, archivedParams) + if err != nil { + return nil, fmt.Errorf("count archived articles: %w", err) + } + return &ArticleListMeta{ + AllArticlesCount: allCount, + ArchivedArticlesCount: archivedCount, + ArticlesCount: articlesCount, + DraftArticlesCount: draftCount, + MineArticlesCount: mineCount, + PublishedCount: publishedCount, + }, nil +} + // Reorder batch-updates article positions. func (s *ArticleService) Reorder(ctx context.Context, positions map[uint]int) error { if err := s.repo.UpdatePositions(ctx, positions); err != nil { @@ -235,6 +348,13 @@ func (s *ArticleService) Reorder(ctx context.Context, positions map[uint]int) er return nil } +func (s *ArticleService) 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 articles: %w", err) + } + return nil +} + // BulkUpdateStatus updates status for multiple articles. func (s *ArticleService) BulkUpdateStatus(ctx context.Context, ids []uint, status string) error { // Validate status @@ -258,6 +378,44 @@ func (s *ArticleService) BulkUpdateStatus(ctx context.Context, ids []uint, statu return nil } +func (s *ArticleService) BulkUpdateStatusScoped(ctx context.Context, portalID uint, ids []uint, status string) error { + count, err := s.repo.CountByPortalAndIDs(ctx, portalID, ids) + if err != nil { + return fmt.Errorf("count articles: %w", err) + } + if count == 0 { + return ErrArticleBulkNoArticles + } + if err := validateArticleBulkStatus(status); err != nil { + return err + } + if err := s.repo.BulkUpdateStatusForPortal(ctx, portalID, ids, status); err != nil { + return fmt.Errorf("bulk update status: %w", err) + } + return nil +} + +func (s *ArticleService) BulkUpdateCategoryScoped(ctx context.Context, portalID uint, ids []uint, categoryID uint) error { + count, err := s.repo.CountByPortalAndIDs(ctx, portalID, ids) + if err != nil { + return fmt.Errorf("count articles: %w", err) + } + if count == 0 { + return ErrArticleBulkNoArticles + } + exists, err := s.repo.CategoryExistsForPortal(ctx, portalID, categoryID) + if err != nil { + return fmt.Errorf("find category: %w", err) + } + if !exists { + return ErrArticleBulkCategoryNotFound + } + if err := s.repo.BulkUpdateCategoryForPortal(ctx, portalID, ids, categoryID); err != nil { + return fmt.Errorf("bulk update category: %w", err) + } + return nil +} + // BulkDelete deletes multiple articles. func (s *ArticleService) BulkDelete(ctx context.Context, ids []uint) error { articles := make([]*model.Article, 0, len(ids)) @@ -276,6 +434,20 @@ func (s *ArticleService) BulkDelete(ctx context.Context, ids []uint) error { return nil } +func (s *ArticleService) BulkDeleteScoped(ctx context.Context, portalID uint, ids []uint) error { + count, err := s.repo.CountByPortalAndIDs(ctx, portalID, ids) + if err != nil { + return fmt.Errorf("count articles: %w", err) + } + if count == 0 { + return ErrArticleBulkNoArticles + } + if err := s.repo.BulkDeleteForPortal(ctx, portalID, ids); err != nil { + return fmt.Errorf("bulk delete: %w", err) + } + return nil +} + // IncrementViews increments the view count for an article by 1. func (s *ArticleService) IncrementViews(ctx context.Context, id uint) error { if err := s.repo.IncrementViews(ctx, id); err != nil { @@ -305,3 +477,31 @@ func (s *ArticleService) BulkActions(ctx context.Context, req *BulkActionsReques return fmt.Errorf("unsupported action: %s — must be publish, archive, or delete", req.Action) } } + +var ( + ErrArticleBulkNoArticles = fmt.Errorf("no articles found") + ErrArticleBulkInvalidStatus = fmt.Errorf("invalid status") + ErrArticleBulkCategoryNotFound = fmt.Errorf("category not found") +) + +func validateArticleBulkStatus(status string) error { + validStatuses := map[model.ArticleStatus]bool{ + model.ArticleStatusDraft: true, + model.ArticleStatusPublished: true, + model.ArticleStatusArchived: true, + } + if !validStatuses[model.ArticleStatus(status)] { + return ErrArticleBulkInvalidStatus + } + return nil +} + +func articleSlug(title string) string { + slug := strings.ToLower(strings.TrimSpace(title)) + slug = regexp.MustCompile(`[^a-z0-9]+`).ReplaceAllString(slug, "-") + slug = strings.Trim(slug, "-") + if slug == "" { + slug = "article" + } + return fmt.Sprintf("%d-%s", time.Now().UTC().Unix(), slug) +} diff --git a/internal/service/search_indexer_hooks_test.go b/internal/service/search_indexer_hooks_test.go index 21dbc3f8..49bdc96a 100644 --- a/internal/service/search_indexer_hooks_test.go +++ b/internal/service/search_indexer_hooks_test.go @@ -147,7 +147,8 @@ func TestArticleService_SearchIndexHooks(t *testing.T) { article, err := svc.CreateWithAccount(context.Background(), 7, 3, 1, &CreateArticleRequest{Title: "Install", Slug: "install"}) require.NoError(t, err) - _, err = svc.Update(context.Background(), article.ID, &UpdateArticleRequest{Title: "Install GoChat"}) + newTitle := "Install GoChat" + _, err = svc.Update(context.Background(), article.ID, &UpdateArticleRequest{Title: &newTitle}) require.NoError(t, err) require.NoError(t, svc.Delete(context.Background(), article.ID))