feat(help-center): align portal payloads
This commit is contained in:
@@ -16,12 +16,12 @@ Build GoChat as a Go backend that can directly reuse the frontend from `referenc
|
||||
|
||||
## Current Baseline
|
||||
|
||||
- Current tracking checkpoint: 2026-06-06 after `a02d11e feat(campaigns): derive chatwoot scheduling`.
|
||||
- Latest implementation checkpoint: `a02d11e feat(campaigns): derive chatwoot scheduling`.
|
||||
- Latest documentation-only checkpoint: this checkpoint, prepared as `docs: land help center parity plan`; this document is now the active follow-up plan and supersedes `.hermes/plans/*`.
|
||||
- Worktree status at this implementation checkpoint: Campaign create/update now also mirrors Chatwoot `Campaign#ensure_correct_campaign_attributes` for reused SMS, WhatsApp, and live-chat forms: frontend bodies no longer need `campaign_type`, one-off SMS/Twilio/WhatsApp campaigns derive `campaign_type=one_off`, persist ISO `scheduled_at`, default missing one-off schedules to current UTC, live-chat/web-widget campaigns derive `campaign_type=ongoing` and clear `scheduled_at`, update accepts frontend `inbox_id`, `sender_id`, `scheduled_at`, and omitted descriptions without clearing them, and sender/inbox account scope is validated. Account campaigns already match the reused dashboard `campaigns` store and Chatwoot `CampaignsController`/Jbuilder contract for the core CRUD path: index returns a raw unpaginated array, show/create/update return raw campaign objects with `id` mapped from `display_id`, nested inbox/sender data, parsed JSON `audience/trigger_rules/template_params`, create returns `200 OK`, delete returns empty `200 OK`, PATCH update is registered for `ApiClient.update`, and no-trailing-slash index/create routes are registered for `ApiClient` URLs. Fresh help-center inspection shows portal/category/article routes still use local numeric IDs, envelopes, wrapped mutation bodies, and missing frontend PATCH/bulk endpoints; those gaps are now tracked as P3.7a-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.7a help center portal route/payload parity.
|
||||
- Current tracking checkpoint: 2026-06-06 after this implementation checkpoint, prepared as `feat(help-center): align portal payloads`.
|
||||
- Latest implementation checkpoint: this checkpoint, prepared as `feat(help-center): align portal payloads`.
|
||||
- Latest documentation-only checkpoint: `24a5cb3 docs: land help center parity plan`; this document is now the active follow-up plan and supersedes `.hermes/plans/*`.
|
||||
- Worktree status at this implementation checkpoint: P3.7a help center portal parity now matches the reused dashboard `helpCenter/portals.js` client and Chatwoot `PortalsController`/Jbuilder surface for the portal core path: no-trailing-slash `GET/POST /portals`, `PATCH /portals/:portal_id`, and `PATCH /archive` routes are registered; portal route IDs resolve by slug with legacy numeric fallback and account scope; create/update accept raw frontend bodies plus `{ portal: ... }`; index returns `{ payload, meta: { current_page, portals_count } }`; show/create/update return raw portal payloads with `config`, `meta`, optional `logo`, and SSL settings; delete/logo/archive return empty `200 OK`; `send_instructions` returns `{ message }`; and `ssl_status` returns enterprise `{ status, verification_errors }` or `422` for missing custom domain. Campaign create/update already mirrors Chatwoot `Campaign#ensure_correct_campaign_attributes`; account campaign CRUD already matches the reused dashboard `campaigns` store and Chatwoot Jbuilder contract. Fresh help-center category/article drift remains tracked as P3.7b/P3.7c below. Agent/settings/profile/notification/contact/custom-field/report enterprise drifts listed below remain at their prior checkpoint states. The fresh placeholder audit found remaining `chatwootParityStub` routes only in webhook handler-not-injected fallbacks, not in the reused dashboard account/contact/conversation/message/inbox critical path. P3.6 custom filters/custom attribute definitions and label CRUD are already in Review. B11.1a-B11.3e Captain/Copilot slices remain in Review; B12.1/B12.2/B12.3 smoke harnesses remain in Review; P5 durable job work through P5.13b is in Review. Next active implementation slice is P3.7b help center category route/payload parity.
|
||||
- `go test ./...` passes.
|
||||
- Route dump succeeds with `TOTAL: 846` after adding no-trailing-slash campaign index/create aliases and the frontend-used campaign PATCH update route.
|
||||
- Route dump succeeds with `TOTAL: 850` after adding no-trailing-slash portal index/create aliases, frontend-used portal PATCH update, and Chatwoot PATCH archive route.
|
||||
- Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`.
|
||||
- Tracked frontend-critical route audit covers 277 Chatwoot routes: 270 exact, 0 method-compatible, 7 parameter-compatible, 0 missing. The 7 parameter-compatible routes are Gin-internal parameter-name differences for nested AgentCapacityPolicy users/inbox limits; the external URL shape is equivalent.
|
||||
- `/api/v1/widget` stubs are burned down and public inbox/contact/conversation/message core flows are backed by real handlers.
|
||||
@@ -45,8 +45,8 @@ Next ordered checkpoints:
|
||||
|
||||
| Order | Slice | Required outcome | Primary verification |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | P3.7a help center portals | Make reused dashboard `helpCenter/portals.js` work against slug-based Chatwoot portal routes and raw Jbuilder payloads. | Focused portal handler/service tests, route dump/parity when routes change, and `go test ./...`. |
|
||||
| 2 | P3.7b/P3.7c help center categories/articles | Align category/article raw bodies, slug portal lookup, PATCH routes, list/show serializers, and article bulk actions. | Focused category/article/bulk handler tests plus route dump/parity and `go test ./...`. |
|
||||
| 1 | P3.7b help center categories | Align category raw bodies, slug portal lookup, PATCH route, list/show serializers, delete status, and locale filtering. | Focused category handler/service/repository tests plus route dump/parity and `go test ./...`. |
|
||||
| 2 | P3.7c help center articles | Align article raw bodies, slug portal lookup, PATCH route, list/show serializers, and article bulk actions. | Focused article/bulk handler tests plus route dump/parity and `go test ./...`. |
|
||||
| 3 | Phase 2/3 audit pass | Route/controller/serializer drift found by B12 or new reference inspection is captured as named slices, not free-form TODOs. | Regenerated route parity artifacts and fixture-backed serializer tests. |
|
||||
| 4 | Phase 6 placeholder burn-down | Remaining account/contact/conversation/message/inbox placeholder handlers are either real Chatwoot-compatible flows or explicitly tracked as unsupported reference gaps. | `rg` placeholder audit, route smoke, and endpoint-family tests. |
|
||||
| 5 | B12 optional live smoke | Run the checked smoke harness in a full PostgreSQL/Redis/Meilisearch/Vite/Chrome environment and turn failures into named slices. | `docs/parity/frontend_smoke_report.md` pass/fail entries linked to owners. |
|
||||
@@ -67,7 +67,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 | Start with portal route/payload parity, then categories/articles and article bulk actions. | `Open Checkpoint Contracts`, `Phase 3` | Reused Chatwoot help-center dashboard clients can call portal/category/article APIs without adapters. |
|
||||
| P3.7 help center | Portal route/payload parity is in Review; continue categories/articles and article bulk actions. | `Open Checkpoint Contracts`, `Phase 3` | Reused Chatwoot help-center dashboard clients can call portal/category/article APIs without adapters. |
|
||||
| Phase 2/3 drift | Expand tracked route/serializer fixtures when B12 exposes frontend-critical gaps. | `Phase 2`, `Phase 3`, `docs/parity/` | Route parity remains 0 missing for tracked frontend routes; serializers have reference fixtures. |
|
||||
| Phase 6 placeholders | Re-run placeholder audit and burn down any frontend-reachable stub. | `Phase 6: Core Product Placeholder Burn-down` | Stub list has no reused-frontend critical path without a named owner. |
|
||||
|
||||
@@ -90,7 +90,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, and custom attribute definitions now match the inspected Chatwoot frontend contract. Fresh help-center inspection found slug/raw-payload/PATCH/bulk route drift. | Implement P3.7a help center portal route/payload parity first, then P3.7b categories and 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 payloads now match the inspected Chatwoot frontend contract. Remaining help-center drift is category/article raw-payload/PATCH/bulk route parity. | Implement P3.7b categories, then P3.7c articles/bulk actions. | Regenerate parity artifacts when routes change and add endpoint-family fixture tests. |
|
||||
| 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. |
|
||||
@@ -109,7 +109,7 @@ These rows are the executable development plan from this point forward. A checkp
|
||||
| P5.13b scheduled/cached analytics | `internal/service/analytics_service.go`, `internal/service/analytics_query_helpers.go`, `internal/service/reporting_rollup_worker.go`, `internal/service/reporting_rollup_service.go`, report handlers/services, worker bootstrap | Chatwoot report controllers/services used by dashboard analytics, `Reports::DataSource`, reporting rollup/backfill jobs | Wire scheduled or lazy cached rollup freshness/idempotency and route `GET /reports` to metric timeseries instead of the summary handler. Define freshness rules for expensive rollups. | Review by `feat(reports): add analytics timeseries rollups`; report fixtures prove timeseries values are derived from persisted conversations/messages/reporting events, rollups refresh idempotently, durable `reporting:rollup_day` jobs replay, and hidden placeholder report JSON does not reappear. |
|
||||
| P3.6 label CRUD payload parity | `internal/handler/api/v1/label_handler.go`, `internal/service/tag_service.go`, `internal/model/tag.go`, `internal/repository/tag_repo.go`, migrations, label handler/service tests | `reference/chatwoot/app/controllers/api/v1/accounts/labels_controller.rb`, `app/views/api/v1/accounts/labels/*.json.jbuilder`, `app/models/label.rb`, `db/schema.rb`, dashboard `api/labels.js`, `store/modules/labels.js` | Account `/labels` CRUD now returns Chatwoot `{ payload: [...] }` lists, raw mutation/show payloads, empty `200 OK` delete, `title/description/color/show_on_sidebar` persistence, title lowercasing/uniqueness, account scope, nested `{ label: ... }` request bodies, legacy `name` compatibility, default color, and local association cleanup/rename side effects. | Review by `feat(labels): align chatwoot label payloads`; focused handler/service/repository tests, combined handler/service/router tests, `go test ./cmd/migrate -count=1`, full `go test ./...`, and `git diff --check` passed. |
|
||||
| P3.6 custom filters/custom attribute definitions payload parity | `internal/handler/api/v1/custom_filter_handler.go`, `internal/service/custom_filter_service.go`, `internal/repository/custom_filter_repo.go`, `internal/handler/api/v1/custom_attribute_definition_handler.go`, `internal/service/custom_attribute_definition_service.go`, `internal/service/custom_attribute_value_service.go`, handler/service/repository tests | `reference/chatwoot/app/controllers/api/v1/accounts/custom_filters_controller.rb`, `app/views/api/v1/accounts/custom_filters/*.json.jbuilder`, `app/views/api/v1/models/_custom_filter.json.jbuilder`, dashboard `api/customViews.js`, `store/modules/customViews.js`, `custom_attribute_definitions_controller.rb`, `_custom_attribute_definition.json.jbuilder`, dashboard `api/attributes.js`, `store/modules/attributes.js`, settings attributes screens/constants | `/custom_filters` and `/custom_attribute_definitions` now return raw Chatwoot arrays/objects instead of local envelopes, accept raw reused-frontend bodies plus Rails-style wrappers, use `200 OK` mutation responses, preserve `204` deletes, scope saved views by current user and default `filter_type=conversation`, normalize custom attribute enum integers to Chatwoot string values, and keep custom attribute value validation compatible with normalized `*_attribute` definitions plus legacy rows. | Review by `feat(custom-fields): align chatwoot saved view payloads`; focused handler/service/repository tests, combined handler/service/router tests, full `go test ./...`, and `git diff --check` must pass. |
|
||||
| P3.7a help center portal route/payload parity | `internal/router/router.go`, `internal/handler/api/v1/portal_handler.go`, `internal/service/portal_service.go`, `internal/repository/portal_repo.go`, portal handler/service/repository tests | `reference/chatwoot/app/controllers/api/v1/accounts/portals_controller.rb`, `app/views/api/v1/accounts/portals/*.json.jbuilder`, dashboard `api/helpCenter/portals.js`, help-center portal store/actions | Register frontend no-trailing-slash `GET/POST /portals` and `PATCH /portals/:portal_id`; resolve `:portal_id` as Chatwoot portal slug with legacy numeric fallback; accept raw portal bodies plus `{ portal: ... }`; return raw portal/list Jbuilder payloads instead of local envelopes; make delete empty `200 OK`; return Chatwoot message payloads for `send_instructions`; normalize `ssl_status` and logo-delete responses. | Review by `feat(help-center): align portal payloads`; focused portal handler/service/repository tests, route dump/parity regeneration when routes change, full `go test ./...`, and `git diff --check` must pass. |
|
||||
| P3.7a help center portal route/payload parity | `internal/router/router.go`, `internal/handler/api/v1/portal_handler.go`, `internal/service/portal_service.go`, `internal/repository/portal_repo.go`, portal handler/service/repository tests | `reference/chatwoot/app/controllers/api/v1/accounts/portals_controller.rb`, `app/views/api/v1/accounts/portals/*.json.jbuilder`, dashboard `api/helpCenter/portals.js`, help-center portal store/actions | Frontend no-trailing-slash `GET/POST /portals`, `PATCH /portals/:portal_id`, and PATCH archive routes are registered; `:portal_id` resolves as Chatwoot portal slug with legacy numeric fallback; raw portal bodies plus `{ portal: ... }` wrappers are accepted; portal index/show/create/update return raw Jbuilder-style payloads; delete/logo/archive return empty `200 OK`; `send_instructions` returns `{ message }`; and `ssl_status` returns enterprise `{ status, verification_errors }`/`422` semantics. | Review by `feat(help-center): align portal payloads`; focused portal handler/service/repository tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. |
|
||||
| P3.7b help center category route/payload parity | `internal/router/router.go`, `internal/handler/api/v1/category_handler.go`, `internal/service/category_service.go`, `internal/repository/category_repo.go`, category tests | `reference/chatwoot/app/controllers/api/v1/accounts/categories_controller.rb`, `app/views/api/v1/accounts/categories/*.json.jbuilder`, dashboard `api/helpCenter/categories.js` | Resolve portal by slug for `/portals/:portal_id/categories`; accept raw category bodies plus wrappers; add frontend `PATCH` route; return Chatwoot raw category/list serializers with locale filtering; make delete empty `200 OK`; keep account/portal scope strict. | Review by `feat(help-center): align category payloads`; focused category handler/service/repository tests, route dump/parity regeneration when routes change, full `go test ./...`, and `git diff --check` must pass. |
|
||||
| P3.7c help center article and bulk-action parity | `internal/router/router.go`, `internal/handler/api/v1/article_handler.go`, `internal/service/article_service.go`, `internal/repository/article_repo.go`, article tests | `reference/chatwoot/app/controllers/api/v1/accounts/articles_controller.rb`, `app/controllers/api/v1/accounts/articles/bulk_actions_controller.rb`, `app/views/api/v1/accounts/articles/*.json.jbuilder`, dashboard `api/helpCenter/articles.js` | Resolve portal by slug; accept raw article bodies plus wrappers; add frontend `PATCH /articles/:id`; align list/search/show/create/update/delete serializers and status/category/locale params; register and implement `PATCH /articles/bulk_actions/update_status`, `PATCH /articles/bulk_actions/update_category`, and `DELETE /articles/bulk_actions/delete_articles` with Chatwoot request/response semantics. | Review by `feat(help-center): align article payloads`; focused article and bulk-action tests, route dump/parity regeneration when routes change, full `go test ./...`, and `git diff --check` must pass. |
|
||||
| P6 conversation transcript response parity | `internal/handler/api/v1/conversation_handler.go`, conversation handler tests | `reference/chatwoot/app/controllers/api/v1/accounts/conversations_controller.rb`, dashboard `api/inbox/conversation.js` | Account conversation transcript now follows Chatwoot's controller contract: missing email returns `422 { error: "email param missing" }`, nonblank email schedules through the existing service boundary and returns empty `200 OK`, and invalid-looking but nonblank email values are not rejected by local email format validation. | Review by `feat(conversations): align transcript responses`; focused transcript handler/service tests, combined handler/service/router tests, full `go test ./...`, and `git diff --check` must pass. |
|
||||
@@ -149,6 +149,7 @@ This ledger records the committed parity checkpoints that future slices should b
|
||||
|
||||
| Commit | Scope | Verification summary | Follow-up state |
|
||||
| --- | --- | --- | --- |
|
||||
| `feat(help-center): align portal payloads` | Advances P3.7a help-center parity by matching Chatwoot `PortalsController`, portal Jbuilder views, enterprise SSL status behavior, and reused dashboard `helpCenter/portals.js` plus portal store actions. Portal index/create now expose no-trailing-slash routes; update exposes frontend `PATCH`; archive exposes Chatwoot `PATCH`; route IDs resolve through account-scoped slug lookup with legacy numeric fallback; create/update accept raw frontend portal bodies plus wrappers; index returns `{ payload, meta }`; show/create/update return raw portal objects with `config`, `meta`, logo, and SSL settings; delete/logo/archive return empty `200 OK`; `send_instructions` returns `{ message }`; and SSL status returns `{ status, verification_errors }` or `422` when no custom domain is configured. | `go test ./internal/handler/api/v1 -run PortalHandler -count=1`; `go test ./internal/service ./internal/repository -run Portal -count=1`; `go test ./internal/router -run Router -count=1`; `go run ./cmd/dump_routes > docs/parity/gochat_routes.txt`; `go run ./cmd/route_parity`; full `go test ./...`; `git diff --check`. Route dump is `TOTAL: 850`; tracked route parity remains `270 exact, 7 parameter-compatible, 0 missing`. | P3.7a moves to Review; continue P3.7b help-center categories, then P3.7c articles/bulk actions. |
|
||||
| `docs: land help center parity plan` | Documentation-only handoff checkpoint after `a02d11e feat(campaigns): derive chatwoot scheduling`. Records the latest clean campaign baseline and turns fresh help-center reference/frontend inspection into executable P3.7a-P3.7c contracts for portals, categories, articles, and article bulk actions. | `git diff --check`. No Go code changed. | Start `feat(help-center): align portal payloads`, then continue categories/articles and route parity regeneration. |
|
||||
| `feat(campaigns): derive chatwoot scheduling` | Advances P3.7 campaign behavior parity beyond response envelopes by matching Chatwoot `Campaign#ensure_correct_campaign_attributes`, `campaign_params`, and the reused SMS/WhatsApp/live-chat campaign forms. Create no longer requires frontend-absent `campaign_type`; SMS/Twilio/WhatsApp inboxes derive one-off campaigns, persist ISO `scheduled_at`, and default missing schedules to current UTC; web-widget/live-chat inboxes derive ongoing campaigns and clear schedules; update accepts frontend `inbox_id`, `sender_id`, and `scheduled_at`, preserves omitted descriptions, reloads nested inbox/sender data, and validates inbox/sender account scope. | `go test ./internal/handler/api/v1 -run CampaignHandler -count=1`; `go test ./internal/service ./internal/repository -run Campaign -count=1`; full `go test ./...`; `git diff --check`. No route changes; route dump remains `TOTAL: 846`. | Continue Phase 6 placeholder audit or the next named Phase 2/3 drift from reference/smoke evidence. |
|
||||
| `feat(campaigns): align chatwoot payloads` | Advances P3.7 campaign/settings parity by matching Chatwoot `CampaignsController#index/show/create/update/destroy`, `_campaign.json.jbuilder`, and the reused dashboard `campaigns` API/store. Account campaigns now return raw arrays/objects instead of local success/data/meta envelopes, ignore frontend `page/per_page`, use `display_id` as the external `id`, resolve show/update/delete/start/stop through display ID with legacy primary-key fallback, serialize nested inbox/sender data plus parsed JSON `audience`, `trigger_rules`, and `template_params`, create returns `200 OK`, delete returns empty `200 OK`, and the frontend-used no-trailing-slash index/create plus `PATCH` update routes are registered. | `go test ./internal/handler/api/v1 -run CampaignHandler -count=1`; `go test ./internal/service ./internal/repository -run Campaign -count=1`; `go test ./internal/handler/api/v1 ./internal/service ./internal/repository ./internal/router -run 'Campaign\|Router' -count=1`; `go run ./cmd/dump_routes > docs/parity/gochat_routes.txt`; `go run ./cmd/route_parity`; `go test ./...`; `git diff --check`. Route dump is `TOTAL: 846`; tracked route parity remains `270 exact, 7 parameter-compatible, 0 missing`. | Continue Phase 6 placeholder audit or the next named Phase 2/3 drift from reference/smoke evidence. |
|
||||
@@ -1584,7 +1585,7 @@ Frontend-critical API groups to audit first:
|
||||
| P3.4 | Conversations/messages | List filters, status changes, assignment, labels, private notes, attachments, drafts, typing/read events. | Review |
|
||||
| P3.5 | Contacts/companies | CRUD, merge, labels, notes, custom attributes, import/export, conversations relation, and shared attachments. | Doing |
|
||||
| P3.6 | Labels/custom attributes/custom filters | Create/update/list behavior and exact response shapes. | Doing; label CRUD is in Review with Chatwoot `title/description/color/show_on_sidebar` payloads and raw/list response contracts, while custom attributes/custom filters remain evidence-driven follow-ups. |
|
||||
| P3.7 | Notifications/reports/help center/campaigns | Frontend-visible payloads and pagination/error envelopes. | Doing; notifications, reports, and account campaign CRUD/scheduling now have focused Chatwoot payload fixtures. Help center is split into P3.7a portals, P3.7b categories, and P3.7c articles/bulk actions after direct inspection of Chatwoot controllers/Jbuilders and reused dashboard clients. |
|
||||
| P3.7 | Notifications/reports/help center/campaigns | Frontend-visible payloads and pagination/error envelopes. | Doing; notifications, reports, account campaign CRUD/scheduling, and help-center portal routes/payloads now have focused Chatwoot payload fixtures. Remaining help-center work is P3.7b categories and P3.7c articles/bulk actions. |
|
||||
| P3.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 |
|
||||
|
||||
@@ -2093,6 +2094,7 @@ Verification milestone gates:
|
||||
|
||||
## Progress Log
|
||||
|
||||
- 2026-06-06: P3.7a help-center portal checkpoint prepared as `feat(help-center): align portal payloads`; audited Chatwoot `PortalsController`, enterprise portal `ssl_status`, portal Jbuilder views, reused dashboard `helpCenter/portals.js`, and portal store actions. Account portal APIs now accept raw frontend create/update bodies plus wrappers, resolve portal route IDs by account-scoped slug with numeric fallback, return Chatwoot raw portal/index payloads, expose no-trailing-slash index/create plus frontend `PATCH`, and return empty `200 OK` for delete/logo/archive. `send_instructions` now returns `{ message: "Instructions sent successfully" }`, while SSL status returns `{ status, verification_errors }` or `422` for missing custom domain. Focused portal handler/service/repository tests, router test, route dump/parity, full `go test ./...`, and `git diff --check` passed; continue P3.7b category parity next.
|
||||
- 2026-06-06: Documentation checkpoint prepared as `docs: land help center parity plan`; current baseline now points at `a02d11e feat(campaigns): derive chatwoot scheduling`, and direct help-center inspection is converted into P3.7a-P3.7c executable contracts. Audited Chatwoot `PortalsController`, `CategoriesController`, `ArticlesController`, `Articles::BulkActionsController`, portal/category/article Jbuilder views, and reused dashboard `helpCenter/*.js` clients. Current Go gaps are slug-vs-numeric portal resolution, local response envelopes, wrapped mutation bodies, missing no-trailing-slash/PATCH/frontend bulk routes, and non-Chatwoot delete/send-instruction status bodies. Verification for this docs-only checkpoint: `git diff --check`; next implementation commit is `feat(help-center): align portal payloads`.
|
||||
- 2026-06-06: P3.7 campaigns scheduling checkpoint prepared as `feat(campaigns): derive chatwoot scheduling`; audited Chatwoot `CampaignsController#campaign_params`, `Campaign#ensure_correct_campaign_attributes`, and reused SMS/WhatsApp/live-chat campaign forms. Campaign create now accepts frontend bodies without `campaign_type`, derives one-off campaigns for SMS/Twilio/WhatsApp inboxes, persists ISO `scheduled_at`, defaults missing one-off schedules to current UTC, derives ongoing campaigns for web-widget/live-chat inboxes and clears schedules, while update accepts `inbox_id`, `sender_id`, and `scheduled_at` with account-scope checks and preserves omitted descriptions. Focused campaign handler tests, service/repository campaign tests, full `go test ./...`, and `git diff --check` passed; continue Phase 6 placeholder audit or the next named Phase 2/3 drift.
|
||||
- 2026-06-06: P3.7 campaigns checkpoint prepared as `feat(campaigns): align chatwoot payloads`; audited Chatwoot `CampaignsController`, campaign Jbuilder views, `_campaign.json.jbuilder`, and the reused dashboard campaigns API/store. Account campaigns now return raw Chatwoot arrays/objects, ignore `page/per_page`, use `display_id` as API `id`, resolve route IDs by display ID with legacy primary-key fallback, serialize nested inbox/sender data and parsed campaign JSON fields, accept object/array campaign params, return `200 OK` for create/delete, and register frontend-used no-trailing-slash index/create plus `PATCH` update routes. Focused campaign handler tests, service/repository campaign tests, combined handler/service/repository/router tests, route dump/parity, full `go test ./...`, and `git diff --check` passed; continue Phase 6 placeholder audit or the next named Phase 2/3 drift.
|
||||
|
||||
@@ -298,6 +298,7 @@ GET /api/v1/accounts/:account_id/platform_apps/:platform_app_id
|
||||
GET /api/v1/accounts/:account_id/platform_apps/:platform_app_id/access_tokens
|
||||
GET /api/v1/accounts/:account_id/platform_apps/:platform_app_id/permissibles
|
||||
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/
|
||||
@@ -482,6 +483,8 @@ PATCH /api/v1/accounts/:account_id/integrations/slack/
|
||||
PATCH /api/v1/accounts/:account_id/notification_settings
|
||||
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/teams/:team_id/team_members/
|
||||
PATCH /api/v1/widget/contact
|
||||
PATCH /api/v1/widget/contact/set_user
|
||||
@@ -651,6 +654,7 @@ POST /api/v1/accounts/:account_id/notifications/read_all
|
||||
POST /api/v1/accounts/:account_id/platform_apps/
|
||||
POST /api/v1/accounts/:account_id/platform_apps/:platform_app_id/permissibles
|
||||
POST /api/v1/accounts/:account_id/platform_apps/:platform_app_id/regenerate_access_token
|
||||
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/
|
||||
@@ -844,4 +848,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: 846
|
||||
TOTAL: 850
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/gochat/gochat/internal/service"
|
||||
applogger "github.com/gochat/gochat/pkg/logger"
|
||||
"github.com/gochat/gochat/pkg/pagination"
|
||||
"github.com/gochat/gochat/pkg/response"
|
||||
)
|
||||
|
||||
@@ -24,162 +26,182 @@ func NewPortalHandler(svc *service.PortalService) *PortalHandler {
|
||||
// Create creates a new portal.
|
||||
// POST /api/v1/accounts/:account_id/portals
|
||||
func (h *PortalHandler) Create(c *gin.Context) {
|
||||
accountID, err := strconv.ParseUint(c.Param("account_id"), 10, 64)
|
||||
if err != nil {
|
||||
accountID := getAccountID(c)
|
||||
if accountID == 0 {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
||||
return
|
||||
}
|
||||
|
||||
// Chatwoot: params.require(:portal) → {"portal": {...}}
|
||||
var wrapper struct {
|
||||
Portal service.CreatePortalRequest `json:"portal"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&wrapper); err != nil {
|
||||
var req service.CreatePortalRequest
|
||||
if err := bindChatwootPayload(c, "portal", &req); err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
|
||||
return
|
||||
}
|
||||
req := wrapper.Portal
|
||||
|
||||
portal, err := h.svc.Create(c.Request.Context(), uint(accountID), &req)
|
||||
portal, err := h.svc.Create(c.Request.Context(), accountID, &req)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Create portal: %v", err)
|
||||
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to create portal")
|
||||
return
|
||||
}
|
||||
|
||||
response.Created(c, portal)
|
||||
c.JSON(http.StatusOK, portalPayload(portal, "", 0))
|
||||
}
|
||||
|
||||
// Get retrieves a portal by ID.
|
||||
// GET /api/v1/accounts/:account_id/portals/:portal_id
|
||||
func (h *PortalHandler) Get(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, err := h.svc.GetByID(c.Request.Context(), uint(portalID))
|
||||
portal, err := h.svc.ResolveByAccountAndRouteID(c.Request.Context(), accountID, c.Param("portal_id"))
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Get portal: %v", err)
|
||||
response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "portal not found")
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, portal)
|
||||
c.JSON(http.StatusOK, portalPayload(portal, c.Query("locale"), currentUserID(c)))
|
||||
}
|
||||
|
||||
// Update modifies an existing portal.
|
||||
// PUT /api/v1/accounts/:account_id/portals/:portal_id
|
||||
func (h *PortalHandler) Update(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
|
||||
}
|
||||
|
||||
// Chatwoot: params.require(:portal) → {"portal": {...}}
|
||||
var wrapper struct {
|
||||
Portal service.UpdatePortalRequest `json:"portal"`
|
||||
portal, err := h.svc.ResolveByAccountAndRouteID(c.Request.Context(), accountID, c.Param("portal_id"))
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "portal not found")
|
||||
return
|
||||
}
|
||||
if err := c.ShouldBindJSON(&wrapper); err != nil {
|
||||
|
||||
var req service.PatchPortalRequest
|
||||
if err := bindChatwootPayload(c, "portal", &req); err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
|
||||
return
|
||||
}
|
||||
req := wrapper.Portal
|
||||
|
||||
portal, err := h.svc.Update(c.Request.Context(), uint(portalID), &req)
|
||||
portal, err = h.svc.UpdatePatch(c.Request.Context(), portal, &req)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Update portal: %v", err)
|
||||
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to update portal")
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, portal)
|
||||
c.JSON(http.StatusOK, portalPayload(portal, c.Query("locale"), currentUserID(c)))
|
||||
}
|
||||
|
||||
// Delete soft-deletes a portal.
|
||||
// DELETE /api/v1/accounts/:account_id/portals/:portal_id
|
||||
func (h *PortalHandler) Delete(c *gin.Context) {
|
||||
portalID, err := strconv.ParseUint(c.Param("portal_id"), 10, 64)
|
||||
accountID := getAccountID(c)
|
||||
if accountID == 0 {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
||||
return
|
||||
}
|
||||
portal, err := h.svc.ResolveByAccountAndRouteID(c.Request.Context(), accountID, c.Param("portal_id"))
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid portal_id")
|
||||
response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "portal not found")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.Delete(c.Request.Context(), uint(portalID)); err != nil {
|
||||
if err := h.svc.Delete(c.Request.Context(), portal.ID); err != nil {
|
||||
applogger.L().Errorf("Delete portal: %v", err)
|
||||
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to delete portal")
|
||||
return
|
||||
}
|
||||
|
||||
response.NoContent(c)
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
// List retrieves all portals for an account (paginated).
|
||||
// GET /api/v1/accounts/:account_id/portals
|
||||
func (h *PortalHandler) List(c *gin.Context) {
|
||||
accountID, err := strconv.ParseUint(c.Param("account_id"), 10, 64)
|
||||
if err != nil {
|
||||
accountID := getAccountID(c)
|
||||
if accountID == 0 {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
||||
return
|
||||
}
|
||||
|
||||
pg := pagination.Parse(c)
|
||||
portals, total, err := h.svc.ListByAccountID(c.Request.Context(), uint(accountID), pg.Page, pg.PerPage)
|
||||
portals, _, err := h.svc.ListByAccountIDWithAssociations(c.Request.Context(), accountID, 0, 0)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("List portals: %v", err)
|
||||
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to list portals")
|
||||
return
|
||||
}
|
||||
|
||||
response.OKWithMeta(c, portals, pg.Page, pg.PerPage, total)
|
||||
payload := make([]gin.H, 0, len(portals))
|
||||
for i := range portals {
|
||||
payload = append(payload, portalPayload(&portals[i], "", 0))
|
||||
}
|
||||
currentPage := c.DefaultQuery("page", "1")
|
||||
c.JSON(http.StatusOK, gin.H{"payload": payload, "meta": gin.H{"current_page": currentPage, "portals_count": len(portals)}})
|
||||
}
|
||||
|
||||
// Archive sets archived=true on a portal.
|
||||
// POST /api/v1/accounts/:account_id/portals/:portal_id/archive
|
||||
func (h *PortalHandler) Archive(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, err := h.svc.Archive(c.Request.Context(), uint(portalID))
|
||||
portal, err := h.svc.ResolveByAccountAndRouteID(c.Request.Context(), accountID, c.Param("portal_id"))
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "portal not found")
|
||||
return
|
||||
}
|
||||
_, err = h.svc.Archive(c.Request.Context(), portal.ID)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Archive portal: %v", err)
|
||||
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to archive portal")
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, portal)
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
// RemoveLogo clears the logo_url on a portal.
|
||||
// DELETE /api/v1/accounts/:account_id/portals/:portal_id/logo
|
||||
func (h *PortalHandler) RemoveLogo(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, err := h.svc.RemoveLogo(c.Request.Context(), uint(portalID))
|
||||
portal, err := h.svc.ResolveByAccountAndRouteID(c.Request.Context(), accountID, c.Param("portal_id"))
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "portal not found")
|
||||
return
|
||||
}
|
||||
_, err = h.svc.RemoveLogo(c.Request.Context(), portal.ID)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Remove portal logo: %v", err)
|
||||
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to remove portal logo")
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, portal)
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
// SendInstructions sends CNAME configuration instructions to an email address.
|
||||
// POST /api/v1/accounts/:account_id/portals/:portal_id/send_instructions
|
||||
func (h *PortalHandler) SendInstructions(c *gin.Context) {
|
||||
portalID, err := strconv.ParseUint(c.Param("portal_id"), 10, 64)
|
||||
accountID := getAccountID(c)
|
||||
if accountID == 0 {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
||||
return
|
||||
}
|
||||
portal, err := h.svc.ResolveByAccountAndRouteID(c.Request.Context(), accountID, c.Param("portal_id"))
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid portal_id")
|
||||
response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "portal not found")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -189,30 +211,233 @@ func (h *PortalHandler) SendInstructions(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.SendInstructions(c.Request.Context(), uint(portalID), &req); err != nil {
|
||||
if err := h.svc.SendInstructions(c.Request.Context(), portal.ID, &req); err != nil {
|
||||
applogger.L().Errorf("Send portal instructions: %v", err)
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, err.Error())
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": portalInstructionError(err.Error())})
|
||||
return
|
||||
}
|
||||
|
||||
response.NoContent(c)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Instructions sent successfully"})
|
||||
}
|
||||
|
||||
// SSLStatus returns the SSL certificate status for a portal's custom domain.
|
||||
// GET /api/v1/accounts/:account_id/portals/:portal_id/ssl_status
|
||||
func (h *PortalHandler) SSLStatus(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
|
||||
}
|
||||
|
||||
status, err := h.svc.SSLStatus(c.Request.Context(), uint(portalID))
|
||||
portal, err := h.svc.ResolveByAccountAndRouteID(c.Request.Context(), accountID, c.Param("portal_id"))
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Get SSL status: %v", err)
|
||||
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to get SSL status")
|
||||
response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "portal not found")
|
||||
return
|
||||
}
|
||||
if portal.CustomDomain == "" {
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "Custom domain is not configured"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, portalSSLStatusPayload(portal))
|
||||
}
|
||||
|
||||
response.OK(c, status)
|
||||
}
|
||||
func portalPayload(portal *model.Portal, locale string, currentUserID uint) gin.H {
|
||||
if portal == nil {
|
||||
return gin.H{}
|
||||
}
|
||||
config := portalConfig(portal.PortalConfiguration)
|
||||
defaultLocale := configString(config, "default_locale", portal.Locale)
|
||||
if defaultLocale == "" {
|
||||
defaultLocale = "en"
|
||||
}
|
||||
allowedLocales := configStringSlice(config, "allowed_locales")
|
||||
if len(allowedLocales) == 0 {
|
||||
allowedLocales = []string{defaultLocale}
|
||||
}
|
||||
draftLocales := configStringSet(configStringSlice(config, "draft_locales"))
|
||||
selectedArticles := portal.Articles
|
||||
if locale != "" {
|
||||
selectedArticles = make([]model.Article, 0, len(portal.Articles))
|
||||
for _, article := range portal.Articles {
|
||||
if article.Locale == locale {
|
||||
selectedArticles = append(selectedArticles, article)
|
||||
}
|
||||
}
|
||||
}
|
||||
payload := gin.H{
|
||||
"id": portal.ID,
|
||||
"color": portal.Color,
|
||||
"custom_domain": portal.CustomDomain,
|
||||
"header_text": portal.HeaderText,
|
||||
"homepage_link": portal.HomepageLink,
|
||||
"name": portal.Name,
|
||||
"page_title": portal.PageTitle,
|
||||
"slug": portal.Slug,
|
||||
"archived": portal.Archived,
|
||||
"account_id": portal.AccountID,
|
||||
"config": gin.H{
|
||||
"allowed_locales": portalAllowedLocalePayloads(allowedLocales, draftLocales, portal.Articles, portal.Categories),
|
||||
"default_locale": defaultLocale,
|
||||
"layout": configString(config, "layout", "classic"),
|
||||
"social_profiles": configMap(config, "social_profiles"),
|
||||
},
|
||||
"meta": portalMeta(portal, selectedArticles, defaultLocale, currentUserID),
|
||||
}
|
||||
if portal.ChannelWebWidgetID != nil {
|
||||
payload["inbox"] = gin.H{"id": *portal.ChannelWebWidgetID}
|
||||
}
|
||||
if portal.LogoURL != "" {
|
||||
payload["logo"] = gin.H{"file_url": portal.LogoURL, "portal_id": portal.ID, "account_id": portal.AccountID}
|
||||
}
|
||||
sslSettings := portalSSLStatusPayload(portal)
|
||||
if sslSettings["status"] != nil || sslSettings["verification_errors"] != nil {
|
||||
payload["ssl_settings"] = sslSettings
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func portalMeta(portal *model.Portal, articles []model.Article, defaultLocale string, currentUserID uint) gin.H {
|
||||
meta := gin.H{
|
||||
"all_articles_count": len(articles),
|
||||
"archived_articles_count": articleStatusCount(articles, "archived"),
|
||||
"published_count": articleStatusCount(articles, "published"),
|
||||
"draft_articles_count": articleStatusCount(articles, "draft"),
|
||||
"categories_count": len(portal.Categories),
|
||||
"default_locale": defaultLocale,
|
||||
}
|
||||
if currentUserID != 0 && len(articles) > 0 {
|
||||
mine := 0
|
||||
for _, article := range articles {
|
||||
if article.AuthorID != nil && *article.AuthorID == currentUserID {
|
||||
mine++
|
||||
}
|
||||
}
|
||||
meta["mine_articles_count"] = mine
|
||||
}
|
||||
return meta
|
||||
}
|
||||
|
||||
func articleStatusCount(articles []model.Article, status string) int {
|
||||
count := 0
|
||||
for _, article := range articles {
|
||||
if article.Status == status {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func portalAllowedLocalePayloads(locales []string, draftLocales map[string]bool, articles []model.Article, categories []model.Category) []gin.H {
|
||||
items := make([]gin.H, 0, len(locales))
|
||||
for _, locale := range locales {
|
||||
items = append(items, gin.H{
|
||||
"code": locale,
|
||||
"articles_count": articleLocaleCount(articles, locale),
|
||||
"categories_count": categoryLocaleCount(categories, locale),
|
||||
"draft": draftLocales[locale],
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func articleLocaleCount(articles []model.Article, locale string) int {
|
||||
count := 0
|
||||
for _, article := range articles {
|
||||
if article.Locale == locale {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func categoryLocaleCount(categories []model.Category, locale string) int {
|
||||
count := 0
|
||||
for _, category := range categories {
|
||||
if category.Locale == locale {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func portalConfig(raw json.RawMessage) map[string]any {
|
||||
if len(raw) == 0 {
|
||||
return map[string]any{}
|
||||
}
|
||||
var cfg map[string]any
|
||||
if err := json.Unmarshal(raw, &cfg); err != nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func configString(cfg map[string]any, key, fallback string) string {
|
||||
if value, ok := cfg[key].(string); ok && value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func configStringSlice(cfg map[string]any, key string) []string {
|
||||
items, ok := cfg[key].([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
values := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
if value, ok := item.(string); ok && value != "" {
|
||||
values = append(values, value)
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func configStringSet(values []string) map[string]bool {
|
||||
set := make(map[string]bool, len(values))
|
||||
for _, value := range values {
|
||||
set[value] = true
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
func configMap(cfg map[string]any, key string) map[string]any {
|
||||
if value, ok := cfg[key].(map[string]any); ok {
|
||||
return value
|
||||
}
|
||||
return map[string]any{}
|
||||
}
|
||||
|
||||
func portalSSLStatusPayload(portal *model.Portal) gin.H {
|
||||
settings := portalConfig(portal.SSLSettings)
|
||||
return gin.H{"status": settings["cf_status"], "verification_errors": settings["cf_verification_errors"]}
|
||||
}
|
||||
|
||||
func currentUserID(c *gin.Context) uint {
|
||||
if id, err := strconv.ParseUint(c.GetHeader("X-User-ID"), 10, 32); err == nil {
|
||||
return uint(id)
|
||||
}
|
||||
if userID, exists := c.Get("user_id"); exists {
|
||||
switch v := userID.(type) {
|
||||
case uint:
|
||||
return v
|
||||
case int:
|
||||
return uint(v)
|
||||
case float64:
|
||||
return uint(v)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func portalInstructionError(message string) string {
|
||||
lower := strings.ToLower(message)
|
||||
switch {
|
||||
case strings.Contains(lower, "no custom domain") || strings.Contains(lower, "custom domain"):
|
||||
return "Custom domain is not configured"
|
||||
case strings.Contains(lower, "invalid email"):
|
||||
return "Invalid email format"
|
||||
case strings.Contains(lower, "email is required") || strings.Contains(lower, "email"):
|
||||
return "Email is required"
|
||||
default:
|
||||
return message
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package v1
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -67,7 +68,12 @@ func (s *PortalHandlerTestSuite) 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 payload map[string]any
|
||||
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &payload))
|
||||
assert.Equal(s.T(), "test-portal", payload["name"])
|
||||
assert.Equal(s.T(), "test-slug", payload["slug"])
|
||||
assert.NotContains(s.T(), payload, "success")
|
||||
}
|
||||
|
||||
func (s *PortalHandlerTestSuite) TestGet_Success() {
|
||||
@@ -82,6 +88,41 @@ func (s *PortalHandlerTestSuite) TestGet_Success() {
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
var payload map[string]any
|
||||
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &payload))
|
||||
assert.Equal(s.T(), "get-slug", payload["slug"])
|
||||
assert.Contains(s.T(), payload, "meta")
|
||||
}
|
||||
|
||||
func (s *PortalHandlerTestSuite) TestGet_BySlugReturnsChatwootMeta() {
|
||||
portal := &model.Portal{AccountID: s.account.ID, Name: "slug-portal", Slug: "sluggy", Locale: "en", PortalConfiguration: json.RawMessage(`{"allowed_locales":["en","fr"],"default_locale":"en","draft_locales":["fr"],"layout":"header"}`)}
|
||||
s.Require().NoError(s.db.Create(portal).Error)
|
||||
category := &model.Category{AccountID: s.account.ID, PortalID: portal.ID, Name: "Basics", Slug: "basics", Locale: "en"}
|
||||
s.Require().NoError(s.db.Create(category).Error)
|
||||
authorID := uint(9)
|
||||
s.Require().NoError(s.db.Create(&model.Article{AccountID: s.account.ID, PortalID: portal.ID, CategoryID: &category.ID, AuthorID: &authorID, Title: "Published", Slug: "published", Status: "published", Locale: "en"}).Error)
|
||||
s.Require().NoError(s.db.Create(&model.Article{AccountID: s.account.ID, PortalID: portal.ID, Title: "Draft", Slug: "draft", Status: "draft", Locale: "fr"}).Error)
|
||||
|
||||
r := gin.New()
|
||||
r.GET("/api/v1/accounts/:account_id/portals/:portal_id", s.handler.Get)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/portals/sluggy?locale=en", s.account.ID), nil)
|
||||
req.Header.Set("X-User-ID", "9")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
var payload map[string]any
|
||||
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &payload))
|
||||
assert.Equal(s.T(), "sluggy", payload["slug"])
|
||||
meta := payload["meta"].(map[string]any)
|
||||
assert.EqualValues(s.T(), 1, meta["all_articles_count"])
|
||||
assert.EqualValues(s.T(), 1, meta["published_count"])
|
||||
assert.EqualValues(s.T(), 1, meta["mine_articles_count"])
|
||||
config := payload["config"].(map[string]any)
|
||||
assert.Equal(s.T(), "header", config["layout"])
|
||||
locales := config["allowed_locales"].([]any)
|
||||
assert.Len(s.T(), locales, 2)
|
||||
}
|
||||
|
||||
func (s *PortalHandlerTestSuite) TestUpdate_Success() {
|
||||
@@ -89,15 +130,19 @@ func (s *PortalHandlerTestSuite) TestUpdate_Success() {
|
||||
s.Require().NoError(s.db.Create(portal).Error)
|
||||
|
||||
r := gin.New()
|
||||
r.PUT("/api/v1/accounts/:account_id/portals/:portal_id", s.handler.Update)
|
||||
r.PATCH("/api/v1/accounts/:account_id/portals/:portal_id", s.handler.Update)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
body := `{"name":"updated-portal","slug":"updated-slug"}`
|
||||
req, _ := http.NewRequest("PUT", fmt.Sprintf("/api/v1/accounts/%d/portals/%d", s.account.ID, portal.ID), bytes.NewBufferString(body))
|
||||
body := `{"name":"updated-portal","slug":"updated-slug","custom_domain":""}`
|
||||
req, _ := http.NewRequest("PATCH", fmt.Sprintf("/api/v1/accounts/%d/portals/update-slug", 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 payload map[string]any
|
||||
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &payload))
|
||||
assert.Equal(s.T(), "updated-slug", payload["slug"])
|
||||
assert.Equal(s.T(), "", payload["custom_domain"])
|
||||
}
|
||||
|
||||
func (s *PortalHandlerTestSuite) TestDelete_Success() {
|
||||
@@ -111,7 +156,60 @@ func (s *PortalHandlerTestSuite) TestDelete_Success() {
|
||||
req, _ := http.NewRequest("DELETE", fmt.Sprintf("/api/v1/accounts/%d/portals/%d", s.account.ID, portal.ID), nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(s.T(), http.StatusNoContent, w.Code)
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
func (s *PortalHandlerTestSuite) TestList_ReturnsChatwootPayloadEnvelope() {
|
||||
s.Require().NoError(s.db.Create(&model.Portal{AccountID: s.account.ID, Name: "List One", Slug: "list-one"}).Error)
|
||||
s.Require().NoError(s.db.Create(&model.Portal{AccountID: s.account.ID, Name: "List Two", Slug: "list-two"}).Error)
|
||||
|
||||
r := gin.New()
|
||||
r.GET("/api/v1/accounts/:account_id/portals", s.handler.List)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/portals?page=2", s.account.ID), nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
var payload map[string]any
|
||||
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &payload))
|
||||
assert.NotContains(s.T(), payload, "data")
|
||||
assert.Len(s.T(), payload["payload"], 2)
|
||||
meta := payload["meta"].(map[string]any)
|
||||
assert.Equal(s.T(), "2", meta["current_page"])
|
||||
assert.EqualValues(s.T(), 2, meta["portals_count"])
|
||||
}
|
||||
|
||||
func (s *PortalHandlerTestSuite) TestDelete_BySlugReturnsOK() {
|
||||
portal := &model.Portal{AccountID: s.account.ID, Name: "delete-slug-portal", Slug: "delete-by-slug"}
|
||||
s.Require().NoError(s.db.Create(portal).Error)
|
||||
|
||||
r := gin.New()
|
||||
r.DELETE("/api/v1/accounts/:account_id/portals/:portal_id", s.handler.Delete)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("DELETE", fmt.Sprintf("/api/v1/accounts/%d/portals/delete-by-slug", s.account.ID), nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
func (s *PortalHandlerTestSuite) TestSendInstructions_ChatwootPayload() {
|
||||
portal := &model.Portal{AccountID: s.account.ID, Name: "domain-portal", Slug: "domain-portal", CustomDomain: "help.example.com"}
|
||||
s.Require().NoError(s.db.Create(portal).Error)
|
||||
|
||||
r := gin.New()
|
||||
r.POST("/api/v1/accounts/:account_id/portals/:portal_id/send_instructions", s.handler.SendInstructions)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/portals/domain-portal/send_instructions", s.account.ID), bytes.NewBufferString(`{"email":"agent@example.com"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
var payload map[string]any
|
||||
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &payload))
|
||||
assert.Equal(s.T(), "Instructions sent successfully", payload["message"])
|
||||
}
|
||||
|
||||
func (s *PortalHandlerTestSuite) TestCreate_BadRequest_InvalidAccountID() {
|
||||
@@ -135,5 +233,5 @@ func (s *PortalHandlerTestSuite) TestGet_BadRequest_InvalidID() {
|
||||
req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/portals/abc", s.account.ID), nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
|
||||
}
|
||||
assert.Equal(s.T(), http.StatusNotFound, w.Code)
|
||||
}
|
||||
|
||||
@@ -29,6 +29,17 @@ func (r *PortalRepo) GetByID(ctx context.Context, id uint) (*model.Portal, error
|
||||
return &portal, nil
|
||||
}
|
||||
|
||||
func (r *PortalRepo) GetByAccountAndID(ctx context.Context, accountID, id uint) (*model.Portal, error) {
|
||||
var portal model.Portal
|
||||
if err := r.db.WithContext(ctx).
|
||||
Preload("Categories").Preload("Articles").Preload("Members").
|
||||
Where("account_id = ?", accountID).
|
||||
First(&portal, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &portal, nil
|
||||
}
|
||||
|
||||
func (r *PortalRepo) Update(ctx context.Context, portal *model.Portal) error {
|
||||
return r.db.WithContext(ctx).Save(portal).Error
|
||||
}
|
||||
@@ -42,7 +53,26 @@ func (r *PortalRepo) FindByAccountID(ctx context.Context, accountID uint, offset
|
||||
var count int64
|
||||
db := r.db.WithContext(ctx).Model(&model.Portal{}).Where("account_id = ?", accountID)
|
||||
db.Count(&count)
|
||||
if err := db.Offset(offset).Limit(limit).Find(&portals).Error; err != nil {
|
||||
query := db.Order("id ASC")
|
||||
if limit > 0 {
|
||||
query = query.Offset(offset).Limit(limit)
|
||||
}
|
||||
if err := query.Find(&portals).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return portals, count, nil
|
||||
}
|
||||
|
||||
func (r *PortalRepo) FindByAccountIDWithAssociations(ctx context.Context, accountID uint, offset, limit int) ([]model.Portal, int64, error) {
|
||||
var portals []model.Portal
|
||||
var count int64
|
||||
db := r.db.WithContext(ctx).Model(&model.Portal{}).Where("account_id = ?", accountID)
|
||||
db.Count(&count)
|
||||
query := db.Preload("Categories").Preload("Articles").Order("id ASC")
|
||||
if limit > 0 {
|
||||
query = query.Offset(offset).Limit(limit)
|
||||
}
|
||||
if err := query.Find(&portals).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return portals, count, nil
|
||||
@@ -56,6 +86,17 @@ func (r *PortalRepo) FindBySlug(ctx context.Context, slug string) (*model.Portal
|
||||
return &portal, nil
|
||||
}
|
||||
|
||||
func (r *PortalRepo) FindByAccountAndSlug(ctx context.Context, accountID uint, slug string) (*model.Portal, error) {
|
||||
var portal model.Portal
|
||||
if err := r.db.WithContext(ctx).
|
||||
Preload("Categories").Preload("Articles").Preload("Members").
|
||||
Where("account_id = ? AND slug = ?", accountID, slug).
|
||||
First(&portal).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &portal, nil
|
||||
}
|
||||
|
||||
// Archive sets archived=true on a portal.
|
||||
func (r *PortalRepo) Archive(ctx context.Context, id uint) error {
|
||||
return r.db.WithContext(ctx).Model(&model.Portal{}).Where("id = ?", id).Update("archived", true).Error
|
||||
@@ -99,4 +140,4 @@ func (r *PortalRepo) GetSSLStatus(ctx context.Context, id uint) (*model.PortalSS
|
||||
}
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1383,11 +1383,15 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) {
|
||||
// Feature gate: knowledge_base must be enabled on the account
|
||||
portals := accountScoped.Group("/portals", middleware.FeatureFlagCheck(middleware.FeatureKnowledgeBase))
|
||||
{
|
||||
portals.GET("", h.Portal.List)
|
||||
portals.GET("/", h.Portal.List)
|
||||
portals.POST("", h.Portal.Create)
|
||||
portals.POST("/", h.Portal.Create)
|
||||
portals.GET("/:portal_id", h.Portal.Get)
|
||||
portals.PATCH("/:portal_id", h.Portal.Update)
|
||||
portals.PUT("/:portal_id", h.Portal.Update)
|
||||
portals.DELETE("/:portal_id", h.Portal.Delete)
|
||||
portals.PATCH("/:portal_id/archive", h.Portal.Archive)
|
||||
portals.POST("/:portal_id/archive", h.Portal.Archive)
|
||||
portals.DELETE("/:portal_id/logo", h.Portal.RemoveLogo)
|
||||
portals.POST("/:portal_id/send_instructions", h.Portal.SendInstructions)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/gochat/gochat/internal/repository"
|
||||
@@ -34,6 +35,7 @@ type CreatePortalRequest struct {
|
||||
PortalConfiguration json.RawMessage `json:"portal_configuration"`
|
||||
SSLSettings json.RawMessage `json:"ssl_settings"`
|
||||
HomepageContent string `json:"homepage_content"`
|
||||
Config json.RawMessage `json:"config"`
|
||||
}
|
||||
|
||||
// UpdatePortalRequest is the DTO for updating a portal.
|
||||
@@ -51,6 +53,25 @@ type UpdatePortalRequest struct {
|
||||
PortalConfiguration json.RawMessage `json:"portal_configuration"`
|
||||
SSLSettings json.RawMessage `json:"ssl_settings"`
|
||||
HomepageContent string `json:"homepage_content"`
|
||||
Config json.RawMessage `json:"config"`
|
||||
}
|
||||
|
||||
type PatchPortalRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Slug *string `json:"slug"`
|
||||
Description *string `json:"description"`
|
||||
LogoURL *string `json:"logo_url"`
|
||||
HeaderText *string `json:"header_text"`
|
||||
HomepageLink *string `json:"homepage_link"`
|
||||
PageTitle *string `json:"page_title"`
|
||||
Color *string `json:"color"`
|
||||
Archived *bool `json:"archived"`
|
||||
CustomDomain *string `json:"custom_domain"`
|
||||
Locale *string `json:"locale"`
|
||||
PortalConfiguration *json.RawMessage `json:"portal_configuration"`
|
||||
Config *json.RawMessage `json:"config"`
|
||||
SSLSettings *json.RawMessage `json:"ssl_settings"`
|
||||
HomepageContent *string `json:"homepage_content"`
|
||||
}
|
||||
|
||||
func (s *PortalService) Create(ctx context.Context, accountID uint, req *CreatePortalRequest) (*model.Portal, error) {
|
||||
@@ -70,6 +91,9 @@ func (s *PortalService) Create(ctx context.Context, accountID uint, req *CreateP
|
||||
SSLSettings: req.SSLSettings,
|
||||
HomepageContent: req.HomepageContent,
|
||||
}
|
||||
if len(req.Config) > 0 {
|
||||
portal.PortalConfiguration = req.Config
|
||||
}
|
||||
|
||||
// Defaults
|
||||
if portal.Color == "" {
|
||||
@@ -96,6 +120,23 @@ func (s *PortalService) GetByID(ctx context.Context, id uint) (*model.Portal, er
|
||||
return portal, nil
|
||||
}
|
||||
|
||||
func (s *PortalService) ResolveByAccountAndRouteID(ctx context.Context, accountID uint, routeID string) (*model.Portal, error) {
|
||||
if routeID == "" {
|
||||
return nil, fmt.Errorf("portal not found")
|
||||
}
|
||||
portal, err := s.repo.FindByAccountAndSlug(ctx, accountID, routeID)
|
||||
if err == nil {
|
||||
return portal, nil
|
||||
}
|
||||
if numericID, parseErr := strconv.ParseUint(routeID, 10, 32); parseErr == nil && numericID != 0 {
|
||||
portal, idErr := s.repo.GetByAccountAndID(ctx, accountID, uint(numericID))
|
||||
if idErr == nil {
|
||||
return portal, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("portal not found: %w", err)
|
||||
}
|
||||
|
||||
func (s *PortalService) Update(ctx context.Context, id uint, req *UpdatePortalRequest) (*model.Portal, error) {
|
||||
portal, err := s.repo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
@@ -135,6 +176,9 @@ func (s *PortalService) Update(ctx context.Context, id uint, req *UpdatePortalRe
|
||||
if req.PortalConfiguration != nil {
|
||||
portal.PortalConfiguration = req.PortalConfiguration
|
||||
}
|
||||
if len(req.Config) > 0 {
|
||||
portal.PortalConfiguration = req.Config
|
||||
}
|
||||
if req.SSLSettings != nil {
|
||||
portal.SSLSettings = req.SSLSettings
|
||||
}
|
||||
@@ -148,6 +192,61 @@ func (s *PortalService) Update(ctx context.Context, id uint, req *UpdatePortalRe
|
||||
return portal, nil
|
||||
}
|
||||
|
||||
func (s *PortalService) UpdatePatch(ctx context.Context, portal *model.Portal, req *PatchPortalRequest) (*model.Portal, error) {
|
||||
if portal == nil {
|
||||
return nil, fmt.Errorf("portal not found")
|
||||
}
|
||||
if req.Name != nil {
|
||||
portal.Name = *req.Name
|
||||
}
|
||||
if req.Slug != nil {
|
||||
portal.Slug = *req.Slug
|
||||
}
|
||||
if req.Description != nil {
|
||||
portal.Description = *req.Description
|
||||
}
|
||||
if req.LogoURL != nil {
|
||||
portal.LogoURL = *req.LogoURL
|
||||
}
|
||||
if req.HeaderText != nil {
|
||||
portal.HeaderText = *req.HeaderText
|
||||
}
|
||||
if req.HomepageLink != nil {
|
||||
portal.HomepageLink = *req.HomepageLink
|
||||
}
|
||||
if req.PageTitle != nil {
|
||||
portal.PageTitle = *req.PageTitle
|
||||
}
|
||||
if req.Color != nil {
|
||||
portal.Color = *req.Color
|
||||
}
|
||||
if req.Archived != nil {
|
||||
portal.Archived = *req.Archived
|
||||
}
|
||||
if req.CustomDomain != nil {
|
||||
portal.CustomDomain = *req.CustomDomain
|
||||
}
|
||||
if req.Locale != nil {
|
||||
portal.Locale = *req.Locale
|
||||
}
|
||||
if req.PortalConfiguration != nil {
|
||||
portal.PortalConfiguration = *req.PortalConfiguration
|
||||
}
|
||||
if req.Config != nil {
|
||||
portal.PortalConfiguration = *req.Config
|
||||
}
|
||||
if req.SSLSettings != nil {
|
||||
portal.SSLSettings = *req.SSLSettings
|
||||
}
|
||||
if req.HomepageContent != nil {
|
||||
portal.HomepageContent = *req.HomepageContent
|
||||
}
|
||||
if err := s.repo.Update(ctx, portal); err != nil {
|
||||
return nil, fmt.Errorf("update portal: %w", err)
|
||||
}
|
||||
return s.repo.GetByAccountAndID(ctx, portal.AccountID, portal.ID)
|
||||
}
|
||||
|
||||
func (s *PortalService) Delete(ctx context.Context, id uint) error {
|
||||
// Check existence first — GORM Delete() returns nil even for non-existent IDs
|
||||
if _, err := s.repo.GetByID(ctx, id); err != nil {
|
||||
@@ -160,7 +259,10 @@ func (s *PortalService) Delete(ctx context.Context, id uint) error {
|
||||
}
|
||||
|
||||
func (s *PortalService) ListByAccountID(ctx context.Context, accountID uint, page, perPage int) ([]model.Portal, int64, error) {
|
||||
offset := (page - 1) * perPage
|
||||
offset := 0
|
||||
if page > 0 && perPage > 0 {
|
||||
offset = (page - 1) * perPage
|
||||
}
|
||||
portals, count, err := s.repo.FindByAccountID(ctx, accountID, offset, perPage)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("list portals: %w", err)
|
||||
@@ -168,6 +270,18 @@ func (s *PortalService) ListByAccountID(ctx context.Context, accountID uint, pag
|
||||
return portals, count, nil
|
||||
}
|
||||
|
||||
func (s *PortalService) ListByAccountIDWithAssociations(ctx context.Context, accountID uint, page, perPage int) ([]model.Portal, int64, error) {
|
||||
offset := 0
|
||||
if page > 0 && perPage > 0 {
|
||||
offset = (page - 1) * perPage
|
||||
}
|
||||
portals, count, err := s.repo.FindByAccountIDWithAssociations(ctx, accountID, offset, perPage)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("list portals: %w", err)
|
||||
}
|
||||
return portals, count, nil
|
||||
}
|
||||
|
||||
// Archive sets archived=true on a portal.
|
||||
func (s *PortalService) Archive(ctx context.Context, id uint) (*model.Portal, error) {
|
||||
if err := s.repo.Archive(ctx, id); err != nil {
|
||||
@@ -259,4 +373,4 @@ func (s *PortalService) SSLStatus(ctx context.Context, portalID uint) (*model.Po
|
||||
return nil, fmt.Errorf("get SSL status: %w", err)
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user