feat(portal): finish PMS-25 story (contact session, ticket endpoints, billing/kb placeholders) #24

Closed
David wants to merge 7 commits from feat/portal-story-pms-25 into feat/tickets-story-pms-11
Owner

Implements YouTrack story PMS-25 (customer portal API). Seven commits, one per sub-task.

Stacked on top of #21 (PMS-11 tickets story), which itself stacks on #20 (PMS-4 auth story). Targets feat/tickets-story-pms-11 so the portal can reuse the JOINed get_ticket_response from PMS-11 and the host-crate Mailer from PMS-4. Retarget to main as the lower PRs merge.

Sub-tasks

  • PMS-26 (F6) portal contact-scoped session: new mokosh-server::modules::portal module with PortalAuthService, HS256 JWT tagged typ = "portal_access" so portal and agent tokens are not cross-replayable, portal_auth_middleware, and a RequirePortalAuth extractor. Login takes (tenant_slug, email, password). Schema reuses contacts.{is_portal_user, portal_password_hash, portal_last_login_at}; no migration.
  • PMS-27 POST /api/v1/portal/tickets: scoped to the contact's company; source = Portal; new TicketService::create_portal_ticket picks an admin/manager user in the tenant as created_by_id (NOT NULL FK).
  • PMS-28 GET /api/v1/portal/tickets: paginated list of all tickets at the contact's company. Company-scope matches the typical helpdesk model.
  • PMS-29 GET /api/v1/portal/tickets/:id: cross-company returns 404 (not 403) so we don't leak the existence of sibling-company tickets.
  • PMS-30 / PMS-31 GET /api/v1/portal/invoices[/:id]: 401-vs-empty-200 / 401-vs-404 placeholders pending the billing module (PMS-33 story).
  • PMS-32 GET /api/v1/portal/kb: empty-page placeholder pending the KB module (PMS-79 story).

Behaviour-visible changes

  • /api/v1/portal/* no longer 501s. Login + ticket endpoints do real work; invoice / kb endpoints return contract-stable empty results.
  • New JWT typ value: "portal_access". Portal middleware rejects everything else.
  • Portal-originated tickets show up in tickets with source = portal and a real admin's id in created_by_id (no nil-UUID FK hack).

Test plan

  • cargo check --bin mokosh-server clean (verified locally).
  • Smoke: POST /api/v1/portal/auth/login with a known (tenant_slug, email, password) returns an access token; reusing it on /api/v1/portal/auth/me returns the contact.
  • Smoke: POST /api/v1/portal/tickets creates a ticket visible at /api/v1/portal/tickets and /api/v1/portal/tickets/:id.
  • Smoke: /api/v1/portal/invoices returns {"data": [], "total": 0, ...}; /api/v1/portal/invoices/some-uuid returns 404; same for /api/v1/portal/kb.

Closes #PMS-25

Implements YouTrack story PMS-25 (customer portal API). Seven commits, one per sub-task. **Stacked on top of #21 (PMS-11 tickets story), which itself stacks on #20 (PMS-4 auth story).** Targets `feat/tickets-story-pms-11` so the portal can reuse the JOINed `get_ticket_response` from PMS-11 and the host-crate `Mailer` from PMS-4. Retarget to `main` as the lower PRs merge. ## Sub-tasks - PMS-26 (F6) portal contact-scoped session: new `mokosh-server::modules::portal` module with `PortalAuthService`, HS256 JWT tagged `typ = "portal_access"` so portal and agent tokens are not cross-replayable, `portal_auth_middleware`, and a `RequirePortalAuth` extractor. Login takes `(tenant_slug, email, password)`. Schema reuses `contacts.{is_portal_user, portal_password_hash, portal_last_login_at}`; no migration. - PMS-27 `POST /api/v1/portal/tickets`: scoped to the contact's company; `source = Portal`; new `TicketService::create_portal_ticket` picks an admin/manager user in the tenant as `created_by_id` (NOT NULL FK). - PMS-28 `GET /api/v1/portal/tickets`: paginated list of all tickets at the contact's company. Company-scope matches the typical helpdesk model. - PMS-29 `GET /api/v1/portal/tickets/:id`: cross-company returns 404 (not 403) so we don't leak the existence of sibling-company tickets. - PMS-30 / PMS-31 `GET /api/v1/portal/invoices[/:id]`: 401-vs-empty-200 / 401-vs-404 placeholders pending the billing module (PMS-33 story). - PMS-32 `GET /api/v1/portal/kb`: empty-page placeholder pending the KB module (PMS-79 story). ## Behaviour-visible changes - `/api/v1/portal/*` no longer 501s. Login + ticket endpoints do real work; invoice / kb endpoints return contract-stable empty results. - New JWT `typ` value: `"portal_access"`. Portal middleware rejects everything else. - Portal-originated tickets show up in `tickets` with `source = portal` and a real admin's id in `created_by_id` (no nil-UUID FK hack). ## Test plan - [ ] `cargo check --bin mokosh-server` clean (verified locally). - [ ] Smoke: `POST /api/v1/portal/auth/login` with a known `(tenant_slug, email, password)` returns an access token; reusing it on `/api/v1/portal/auth/me` returns the contact. - [ ] Smoke: `POST /api/v1/portal/tickets` creates a ticket visible at `/api/v1/portal/tickets` and `/api/v1/portal/tickets/:id`. - [ ] Smoke: `/api/v1/portal/invoices` returns `{"data": [], "total": 0, ...}`; `/api/v1/portal/invoices/some-uuid` returns 404; same for `/api/v1/portal/kb`. Closes #PMS-25
Lays the foundation for the `/api/v1/portal/*` surface: a `PortalAuthService` that verifies `(tenant_slug, email, password)` against `contacts` (gated on `is_portal_user = TRUE`), issues an HS256 JWT tagged `typ = "portal_access"`, and a `portal_auth_middleware` + `RequirePortalAuth` extractor that mirror the agent-side `AuthMiddleware` / `RequireAuth` shape so portal handlers feel familiar.

Schema reuse: `contacts.portal_password_hash`, `is_portal_user`, `portal_last_login_at` already existed; no migration. The `tenant_slug` is in the request body because `contacts.email` is unique only within a tenant — deployments hosting at e.g. `portal.acme.example.com` should pull the slug from the subdomain client-side.

The `typ` field makes portal tokens unreplayable against the agent surface and vice versa. `/api/v1/portal/auth/login` is public; `/api/v1/portal/auth/me` requires the extractor. The rest of the portal endpoints (tickets, invoices, KB) land in subsequent commits.

#PMS-26 State Done
The handler reads `contact.tenant_id` / `contact.company_id` / `contact.id` from the `RequirePortalAuth` extractor, so the contact cannot lie about which company they belong to. The body is the narrow `CreatePortalTicketRequest` (title, description, priority, type) — assignment, scheduling, SLA / contract pick, billing flags stay agent-only.

New `TicketService::create_portal_ticket` picks an admin/manager user from the tenant as `created_by_id` (the FK is NOT NULL and contacts aren't in `users`), forces `source = Portal`, and round-trips through `get_ticket_response` so the wire shape matches the agent-side ticket DTO. Tenants with no admin/manager surface a clean `Configuration` error instead of an FK violation.

#PMS-27 State Done
Reads `contact.tenant_id` + `contact.company_id` from the extractor and pages through `TicketService::list_portal_tickets`, which under the hood builds a `TicketFilter { company_id: Some(...), .. }` and shares the same JOINed SELECT as the agent-side list. Pagination piggybacks on `PaginationParams`.

Company-wide visibility (not just `contact_id = self`) matches the typical helpdesk model where employees of company X can follow each other's tickets without admin glue.

#PMS-28 State Done
Reads ticket via `get_portal_ticket(tenant, company, id)`, which fetches the fully-joined `TicketResponse` and returns 404 (not 403) when the ticket exists in another company. The 404-vs-403 distinction matters: 403 leaks the fact that a ticket id exists in a sibling tenant/company, 404 does not.

#PMS-29 State Done
Mounts the endpoint under `RequirePortalAuth` so callers get the correct 401-vs-empty-200 distinction today, and returns an empty paginated page so the portal frontend can render its "no invoices yet" state without branching on 501. The real read against billing tables lands when the billing module ships (story PMS-33).

#PMS-30 State Done
Same shape as list_invoices: `RequirePortalAuth` keeps the 401-vs-404 distinction honest, and 404 is the universal answer until the billing module ships.

#PMS-31 State Done
Returns an empty paginated page under `RequirePortalAuth` until the knowledge_base module (story PMS-79) lands. The real read will filter on `knowledge_base.articles WHERE portal_visible = TRUE`. Empty-page now means the portal frontend can render its "no articles" state without branching on 501.

#PMS-32 State Done
vas2000-work closed this pull request 2026-05-21 02:42:54 +02:00

Pull request closed

Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/mokosh-server!24
No description provided.