Store an organisation logo and contact, and use them on client surfaces (MAPPS-429) #509

Merged
longjacksonle merged 2 commits from feat/MAPPS-429-org-contact-and-logo into main 2026-08-10 18:51:43 +02:00

MAPPS-429, server half. The SPA half (onboarding and settings collecting these, the form page rendering the logo) follows and needs this.

Re-scoped after David asked for ORGANISATION metadata rather than user identity, which is what unblocked the ticket: PMS-512 gave user names to bunyip and left mokosh unable to write them, and none of that applies to tenant-level fields.

Most of the storage already existed

TenantBranding has declared logo_url, support_phone and friends since migration 002, UpdateTenantRequest already accepts branding, and TenantResponse already returns it. What it lacked was a way to put an image behind logo_url, and a field for who a client should ask for.

  • support_contact_name, distinct from billing_contact_name (who an invoice goes to, routinely a different person).
  • logo_mime, so the serving route answers with the right content type rather than sniffing bytes or encoding the format into a filename.

Both are fields on the existing JSONB column, so no migration for either.

Storage follows PMS-483's ticket attachments: local disk under the shared upload root, in a tenant-logos/ subdirectory so a deployment still mounts one volume, one file per tenant, overwritten in place. A logo has no history worth keeping and versioning it only invites a cache-busting problem.

The read side is public because it has to be. This image has to render in a client's browser on /request-forms/{token} and in a client's MAIL CLIENT. Neither has a session and a mail client will never authenticate, so an authenticated route could not serve it at all. A company logo is the least private asset an MSP owns, and the route is keyed by a v4 tenant uuid.

SVG is refused, along with everything outside PNG, JPEG, WebP and GIF: it is a script-capable document and this route serves bytes from the API origin to anonymous callers.

Writing is admin-gated like the rename. Upload writes the file before the branding pointer and delete clears the pointer before the file, so neither order leaves branding pointing at something that is not there.

Reading it back

Collecting metadata is only worth doing if something consumes it, so:

  • The contact line falls back to the organisation's contact when a form defines none, in the email and on the form page. PMS-748 otherwise wrote "Contact {MSP}, who sent it to you", which tells a client nothing actionable. A per-form line is for the request type that routes somewhere unusual; every other form inherits the number the MSP set once.
  • The email gains the logo, as {{logo_html}} composed server-side into a complete element or an empty string. Same shape PMS-748 used for the abuse notice, for the same two reasons: no conditionals in the renderer, and an absent key ships literal braces to the client (MAPPS-425).

New config: PUBLIC_API_BASE_URL

A mail client cannot resolve a relative src, and it is the only caller that cannot (the SPA joins the same path with the API base it already resolved). Deliberately not derived from BASE_URL or SPA_BASE_URL: on staging and production those are the apex and the SPA, while the logo is served by the API on a third host. Unset omits the logo rather than emitting a broken image.

This needs a ~/docker PR to take effect on staging and production, same as ABUSE_CONTACT_EMAIL did. Opening that separately.

Migration 103 adds the placeholder to the seeded template, matching the PMS-748 body verbatim, so a tenant that customised its template keeps its copy and one still holding the original 101 wording is left alone.

Testing

cargo fmt --all --check, cargo clippy --all-targets (clean), both migration guards, and the tenants suite (19 tests). New coverage:

  • End-to-end logo round trip: admin uploads real PNG bytes, an unauthenticated client fetches them back with the right content type, delete clears the pointer and the public route stops answering. End-to-end because the whole point is the hop from an authenticated write to an anonymous read.
  • A technician is refused the upload.
  • application/pdf and image/svg+xml are refused.
  • Unit tests on the contact-line fallback in all four name/phone combinations, and on the emailed logo being absolute or absent.
MAPPS-429, server half. The SPA half (onboarding and settings collecting these, the form page rendering the logo) follows and needs this. Re-scoped after David asked for ORGANISATION metadata rather than user identity, which is what unblocked the ticket: PMS-512 gave user names to bunyip and left mokosh unable to write them, and none of that applies to tenant-level fields. ## Most of the storage already existed `TenantBranding` has declared `logo_url`, `support_phone` and friends since migration 002, `UpdateTenantRequest` already accepts `branding`, and `TenantResponse` already returns it. What it lacked was a way to put an image behind `logo_url`, and a field for who a client should ask for. - `support_contact_name`, distinct from `billing_contact_name` (who an invoice goes to, routinely a different person). - `logo_mime`, so the serving route answers with the right content type rather than sniffing bytes or encoding the format into a filename. Both are fields on the existing JSONB column, so **no migration** for either. ## The logo Storage follows PMS-483's ticket attachments: local disk under the shared upload root, in a `tenant-logos/` subdirectory so a deployment still mounts one volume, one file per tenant, overwritten in place. A logo has no history worth keeping and versioning it only invites a cache-busting problem. **The read side is public because it has to be.** This image has to render in a client's browser on `/request-forms/{token}` and in a client's MAIL CLIENT. Neither has a session and a mail client will never authenticate, so an authenticated route could not serve it at all. A company logo is the least private asset an MSP owns, and the route is keyed by a v4 tenant uuid. SVG is refused, along with everything outside PNG, JPEG, WebP and GIF: it is a script-capable document and this route serves bytes from the API origin to anonymous callers. Writing is admin-gated like the rename. Upload writes the file before the branding pointer and delete clears the pointer before the file, so neither order leaves branding pointing at something that is not there. ## Reading it back Collecting metadata is only worth doing if something consumes it, so: - The **contact line** falls back to the organisation's contact when a form defines none, in the email and on the form page. PMS-748 otherwise wrote "Contact {MSP}, who sent it to you", which tells a client nothing actionable. A per-form line is for the request type that routes somewhere unusual; every other form inherits the number the MSP set once. - The **email** gains the logo, as `{{logo_html}}` composed server-side into a complete element or an empty string. Same shape PMS-748 used for the abuse notice, for the same two reasons: no conditionals in the renderer, and an absent key ships literal braces to the client (MAPPS-425). ## New config: PUBLIC_API_BASE_URL A mail client cannot resolve a relative `src`, and it is the only caller that cannot (the SPA joins the same path with the API base it already resolved). Deliberately not derived from `BASE_URL` or `SPA_BASE_URL`: on staging and production those are the apex and the SPA, while the logo is served by the API on a third host. Unset omits the logo rather than emitting a broken image. **This needs a `~/docker` PR to take effect on staging and production**, same as `ABUSE_CONTACT_EMAIL` did. Opening that separately. Migration 103 adds the placeholder to the seeded template, matching the PMS-748 body verbatim, so a tenant that customised its template keeps its copy and one still holding the original 101 wording is left alone. ## Testing `cargo fmt --all --check`, `cargo clippy --all-targets` (clean), both migration guards, and the tenants suite (19 tests). New coverage: - End-to-end logo round trip: admin uploads real PNG bytes, an unauthenticated client fetches them back with the right content type, delete clears the pointer and the public route stops answering. End-to-end because the whole point is the hop from an authenticated write to an anonymous read. - A technician is refused the upload. - `application/pdf` and `image/svg+xml` are refused. - Unit tests on the contact-line fallback in all four name/phone combinations, and on the emailed logo being absolute or absent.
MAPPS-429. `TenantBranding` already declared `logo_url` and `support_phone` and has since migration 002; what it never had was a way to put an image behind the first one, or a field for who a client should ask for. It gains `support_contact_name` (distinct from `billing_contact_name`, which is who an invoice goes to, routinely a different person) and `logo_mime`, so the serving route answers with the right content type instead of sniffing bytes. Both live in the existing JSONB column, so there is no migration.

Storage follows PMS-483's ticket attachments: a file on local disk under the shared upload root, in a `tenant-logos/` subdirectory so one mounted volume still covers both. One file per tenant, overwritten in place, since a logo has no history worth keeping.

The read side is public because it has to be. The two places this image appears are a client's browser on the request-form page and a client's MAIL CLIENT rendering the email that links there; neither has a session and a mail client will never authenticate. A company logo is the least private asset an MSP owns, and the route is keyed by a v4 tenant uuid.

SVG is refused along with everything outside PNG, JPEG, WebP and GIF. It is a script-capable document, and this route serves bytes from the API origin to anonymous callers.

Writing is admin-gated like the rename: the logo is what every client sees on the forms and email this tenant sends, so it is tenant-wide configuration. Upload writes the file before the branding pointer and delete clears the pointer before the file, so neither order can leave branding pointing at something that is not there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E48b8YHWYwmaKjFBy5esXp
feat(forms): use the organisation contact and logo on the client's surfaces
All checks were successful
E2E / Playwright against staging (pull_request) Successful in 1m28s
Check / fmt + clippy + build + tests (pull_request) Successful in 1m59s
Integration / integration tests (pull_request) Successful in 5m9s
Create release / Gate (release-branch merges only) (pull_request) Successful in 1s
Create release / Create release from merged PR (pull_request) Has been skipped
67c2d2eb41
MAPPS-429. Collecting organisation metadata is only worth doing if something reads it, so this is the reading half.

The request-form contact line falls back to the organisation's contact when a form defines none. PMS-748 gave each definition an optional `contact_info` and otherwise wrote "Contact {MSP}, who sent it to you", which tells a client nothing actionable. A per-form line is for the request type that routes somewhere unusual; every other form should inherit the service-desk number the MSP set once. Same fallback on the client-facing form page.

The email gains the logo. `{{logo_html}}` is composed server-side into a complete `<p><img></p>` or an empty string, the same shape PMS-748 used for the abuse notice and for the same two reasons: `render_template` has no conditionals, so an element that must sometimes disappear cannot live in the template, and a key that is sometimes absent ships literal braces to the client (MAPPS-425).

That needs an absolute URL, which is what `PUBLIC_API_BASE_URL` is for. A mail client cannot resolve a relative `src`, and this is the only caller that cannot: the SPA joins the same path with the API base it already resolved. It is deliberately not derived from `BASE_URL` or `SPA_BASE_URL`, which are the apex and the SPA on every deployed environment, while the logo is served by the API on a third host. Unset omits the logo rather than emitting a broken image.

Migration 103 adds the placeholder to the seeded template, matching the PMS-748 body verbatim so a customised template keeps its copy, and so does a tenant still holding the original 101 wording.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E48b8YHWYwmaKjFBy5esXp
longjacksonle deleted branch feat/MAPPS-429-org-contact-and-logo 2026-08-10 18:51:43 +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!509
No description provided.