feat(attachments): ticket-note attachment upload / download / delete (PMS-483) #358
No reviewers
Labels
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
psa-systems/mokosh-server!358
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/PMS-483-note-attachments"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
PMS-483 ships the upload, download, and delete surface that
ticket_attachments has been missing since migration 005. Both agent
and portal callers can now attach files to a ticket note, and the
download endpoint is symmetric.
Schema (migration 079):
contacts(id) ON DELETE SET NULL - populated on portal-originated
uploads, NULL on agent uploads.
row to point at. The right authorship column per origin is enforced
at the application layer (Uploader enum in attachments.rs).
created_by_contact_id IS NOT NULL for the eventual "your uploads"
portal feed.
Storage backend:
is {dir}/{tenant_id}/{attachment_id} so a hostile file_name can't
escape the per-tenant directory or shadow a sibling tenant's blob.
uploads return 413 via a new AppError::PayloadTooLarge variant.
Routes:
/api/v1/tickets/{id}/notes/{note_id}/attachments[/{attachment_id}]
behind RequireAuth. Tenant-scoped via the (tenant, ticket, note)
triple check.
plus a company-scope check so a contact can only touch attachments
on tickets belonging to their own company. Portal delete additionally
refuses to remove an attachment uploaded by someone else.
Multipart parsing reads the
filefield; original filename + mimetype are sanitised (path separator strip, length cap) before they hit
the DB.
Tests (tests/ticket_note_attachments.rs, requires reqwest
multipartfeature added to dev-deps):
contact uploads, agent downloads, sibling-company contact gets 404.
SPA UI surface lands in a follow-up; the server contract is the
invariant piece and the tests pin it.
#PMS-483
PMS-483 ships the upload, download, and delete surface that ticket_attachments has been missing since migration 005. Both agent and portal callers can now attach files to a ticket note, and the download endpoint is symmetric. Schema (migration 079): - ticket_attachments.created_by_contact_id UUID NULL REFERENCES contacts(id) ON DELETE SET NULL - populated on portal-originated uploads, NULL on agent uploads. - uploaded_by_id relaxed to NULL since portal contacts have no users row to point at. The right authorship column per origin is enforced at the application layer (Uploader enum in attachments.rs). - Partial index on (tenant_id, created_by_contact_id) WHERE created_by_contact_id IS NOT NULL for the eventual "your uploads" portal feed. Storage backend: - Local disk under ATTACHMENT_DIR (default ./attachments). Blob path is {dir}/{tenant_id}/{attachment_id} so a hostile file_name can't escape the per-tenant directory or shadow a sibling tenant's blob. - ATTACHMENT_MAX_BYTES caps the body size (default 25 MiB). Oversize uploads return 413 via a new AppError::PayloadTooLarge variant. Routes: - Agent: POST/GET/DELETE /api/v1/tickets/{id}/notes/{note_id}/attachments[/{attachment_id}] behind RequireAuth. Tenant-scoped via the (tenant, ticket, note) triple check. - Portal: same shape under /api/v1/portal/... behind RequirePortalAuth, plus a company-scope check so a contact can only touch attachments on tickets belonging to their own company. Portal delete additionally refuses to remove an attachment uploaded by someone else. Multipart parsing reads the `file` field; original filename + mime type are sanitised (path separator strip, length cap) before they hit the DB. Tests (tests/ticket_note_attachments.rs, requires reqwest `multipart` feature added to dev-deps): - agent_upload_list_download_delete: round-trip of all four verbs. - oversize_upload_returns_413: 2 KiB body against a 1 KiB cap. - portal_upload_visible_to_agent_blocked_for_sibling: same-company contact uploads, agent downloads, sibling-company contact gets 404. SPA UI surface lands in a follow-up; the server contract is the invariant piece and the tests pin it. #PMS-483CI report after the previous fix: the cross-company test now panics with `same-company portal upload should 2xx; got 401 Unauthorized`. The portal contact logs in cleanly (200 + access_token), but the subsequent upload against `/api/v1/portal/tickets/{id}/notes/{nid}/attachments` gets a 401 from `RequirePortalAuth`. Root cause: `portal_routes` applies `portal_auth_middleware` as a `.layer()` on its own Router. `portal_attachment_routes` was merged into the same portal sub-tree (`api/router.rs::portal_api`) but axum layers are per-Router and `.merge()` does NOT inherit the parent's layers - so requests to the attachment paths reached the handler without `PortalAuthState` ever being inserted into request extensions, and `RequirePortalAuth::from_request_parts` fell through to 401. Fix: - Re-export `PortalAuthMiddleware` from `crate::modules::portal`. - `portal_attachment_routes` now takes a `PortalAuthService` alongside the `AttachmentService`, builds the same middleware inside, and layers it onto its own Router. Mirrors the pattern `portal_routes` uses for its routes. - `api/router.rs` constructs a dedicated `PortalAuthService` clone (`portal_attachment_auth_service`) so the original service can still be moved into `portal_routes` without a borrow conflict; the service is just `(db_pool, jwt_secret)` so cloning it is free. Agent routes are unaffected (they live under the agent tree which applies the legacy `AuthMiddleware` separately). #PMS-483