feat(notes): expose created_by_contact_id on note DTO + contact-notes feed (PMS-468) #350

Merged
YousifShkara merged 1 commit from feat/PMS-468-portal-notes-attachments into main 2026-06-24 08:40:24 +02:00
Owner

PMS-449 phase 1 stamped ticket_notes.created_by_contact_id on portal-originated comments but did NOT surface the column on the wire shape. The SPA distinguished Customer vs Agent comments by sniffing created_by_name against the contacts table - lossy enough that a deleted contact mis-rendered as Agent and a contact whose display name matched a user's first+last name collided. This PR exposes the column directly and adds the agent-facing "all comments from this contact" feed that the new partial index on (tenant_id, created_by_contact_id) was sized for.

What landed:

  • mokosh_types::tickets::TicketNote and TicketNoteResponse gain an Option<Uuid> created_by_contact_id field. On TicketNoteResponse it's #[serde(skip_serializing_if = "Option::is_none")] so existing agent-originated payloads stay byte-identical and only portal-originated rows carry the new key.

  • The TicketNoteRow FromRow target picks up the column; the three SELECT call sites in tickets::service (get_note, get_ticket_notes, list_portal_ticket_notes) hydrate it. The four consumer call sites that build TicketNoteResponse (tickets::routes::get_ticket_notes / add_note, portal::routes::list_ticket_notes / create_ticket_note) thread it through.

  • New TicketService::list_notes_by_contact(tenant_id, contact_id, pagination) queries the (tenant_id, created_by_contact_id) WHERE created_by_contact_id IS NOT NULL partial index added in migration 069. Returns note_type='public' rows only - internal / resolution / time_entry notes are agent-side abstractions and don't belong on a customer-comment feed.

  • New endpoint GET /api/v1/contacts/{contact_id}/notes. Mounted via a sibling router contact_notes_routes(ticket_service) so the path matches the SPA's contact-detail URL while the handler stays in the tickets module that owns the notes surface. Auth = RequireAuth (any authenticated user); tenant-scoped through user.tenant() in the service call so a cross-tenant UUID guess returns an empty page, not the other tenant's rows.

  • Follow-up filed as PMS-483 for the deferred attachment-upload slice of PMS-468; agent and portal upload surfaces have to land together because no agent-side attachment endpoints exist yet either.

Integration tests at tests/contact_notes_feed.rs:

  • note_dto_carries_contact_attribution: a portal-originated note round-trips through the agent list endpoint with created_by_contact_id populated; an agent-originated note on the same ticket leaves the field absent from the serialised JSON.

  • contact_notes_feed_lists_all_public_from_contact: two portal notes from the same contact across two different tickets land in the feed; an agent note on one of those tickets stays out.

  • contact_notes_feed_is_tenant_scoped: a tenant-A admin querying a tenant-B contact's notes feed gets an empty page, never the cross-tenant rows.

#PMS-468

PMS-449 phase 1 stamped `ticket_notes.created_by_contact_id` on portal-originated comments but did NOT surface the column on the wire shape. The SPA distinguished Customer vs Agent comments by sniffing `created_by_name` against the contacts table - lossy enough that a deleted contact mis-rendered as Agent and a contact whose display name matched a user's first+last name collided. This PR exposes the column directly and adds the agent-facing "all comments from this contact" feed that the new partial index on `(tenant_id, created_by_contact_id)` was sized for. What landed: * `mokosh_types::tickets::TicketNote` and `TicketNoteResponse` gain an `Option<Uuid> created_by_contact_id` field. On `TicketNoteResponse` it's `#[serde(skip_serializing_if = "Option::is_none")]` so existing agent-originated payloads stay byte-identical and only portal-originated rows carry the new key. * The `TicketNoteRow` FromRow target picks up the column; the three SELECT call sites in `tickets::service` (`get_note`, `get_ticket_notes`, `list_portal_ticket_notes`) hydrate it. The four consumer call sites that build `TicketNoteResponse` (`tickets::routes::get_ticket_notes / add_note`, `portal::routes::list_ticket_notes / create_ticket_note`) thread it through. * New `TicketService::list_notes_by_contact(tenant_id, contact_id, pagination)` queries the `(tenant_id, created_by_contact_id) WHERE created_by_contact_id IS NOT NULL` partial index added in migration 069. Returns `note_type='public'` rows only - internal / resolution / time_entry notes are agent-side abstractions and don't belong on a customer-comment feed. * New endpoint `GET /api/v1/contacts/{contact_id}/notes`. Mounted via a sibling router `contact_notes_routes(ticket_service)` so the path matches the SPA's contact-detail URL while the handler stays in the tickets module that owns the notes surface. Auth = `RequireAuth` (any authenticated user); tenant-scoped through `user.tenant()` in the service call so a cross-tenant UUID guess returns an empty page, not the other tenant's rows. * Follow-up filed as PMS-483 for the deferred attachment-upload slice of PMS-468; agent and portal upload surfaces have to land together because no agent-side attachment endpoints exist yet either. Integration tests at `tests/contact_notes_feed.rs`: * `note_dto_carries_contact_attribution`: a portal-originated note round-trips through the agent list endpoint with `created_by_contact_id` populated; an agent-originated note on the same ticket leaves the field absent from the serialised JSON. * `contact_notes_feed_lists_all_public_from_contact`: two portal notes from the same contact across two different tickets land in the feed; an agent note on one of those tickets stays out. * `contact_notes_feed_is_tenant_scoped`: a tenant-A admin querying a tenant-B contact's notes feed gets an empty page, never the cross-tenant rows. #PMS-468
feat(notes): expose created_by_contact_id on note DTO + contact-notes feed (PMS-468)
All checks were successful
E2E / Playwright against staging (pull_request) Successful in 51s
Check / fmt + clippy + build + tests (pull_request) Successful in 2m41s
Integration / integration tests (pull_request) Successful in 11m1s
Create release / Create release from merged PR (pull_request) Successful in 3s
9a48f58c97
PMS-449 phase 1 stamped `ticket_notes.created_by_contact_id` on portal-originated comments but did NOT surface the column on the wire shape. The SPA distinguished Customer vs Agent comments by sniffing `created_by_name` against the contacts table - lossy enough that a deleted contact mis-rendered as Agent and a contact whose display name matched a user's first+last name collided. This PR exposes the column directly and adds the agent-facing "all comments from this contact" feed that the new partial index on `(tenant_id, created_by_contact_id)` was sized for.

What landed:

* `mokosh_types:🎟️:TicketNote` and `TicketNoteResponse` gain an `Option<Uuid> created_by_contact_id` field. On `TicketNoteResponse` it's `#[serde(skip_serializing_if = "Option::is_none")]` so existing agent-originated payloads stay byte-identical and only portal-originated rows carry the new key.

* The `TicketNoteRow` FromRow target picks up the column; the three SELECT call sites in `tickets::service` (`get_note`, `get_ticket_notes`, `list_portal_ticket_notes`) hydrate it. The four consumer call sites that build `TicketNoteResponse` (`tickets::routes::get_ticket_notes / add_note`, `portal::routes::list_ticket_notes / create_ticket_note`) thread it through.

* New `TicketService::list_notes_by_contact(tenant_id, contact_id, pagination)` queries the `(tenant_id, created_by_contact_id) WHERE created_by_contact_id IS NOT NULL` partial index added in migration 069. Returns `note_type='public'` rows only - internal / resolution / time_entry notes are agent-side abstractions and don't belong on a customer-comment feed.

* New endpoint `GET /api/v1/contacts/{contact_id}/notes`. Mounted via a sibling router `contact_notes_routes(ticket_service)` so the path matches the SPA's contact-detail URL while the handler stays in the tickets module that owns the notes surface. Auth = `RequireAuth` (any authenticated user); tenant-scoped through `user.tenant()` in the service call so a cross-tenant UUID guess returns an empty page, not the other tenant's rows.

* Follow-up filed as PMS-483 for the deferred attachment-upload slice of PMS-468; agent and portal upload surfaces have to land together because no agent-side attachment endpoints exist yet either.

Integration tests at `tests/contact_notes_feed.rs`:

* `note_dto_carries_contact_attribution`: a portal-originated note round-trips through the agent list endpoint with `created_by_contact_id` populated; an agent-originated note on the same ticket leaves the field absent from the serialised JSON.

* `contact_notes_feed_lists_all_public_from_contact`: two portal notes from the same contact across two different tickets land in the feed; an agent note on one of those tickets stays out.

* `contact_notes_feed_is_tenant_scoped`: a tenant-A admin querying a tenant-B contact's notes feed gets an empty page, never the cross-tenant rows.

#PMS-468
YousifShkara deleted branch feat/PMS-468-portal-notes-attachments 2026-06-24 08:40:24 +02:00
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!350
No description provided.