Skip to content

feat(gallery): permanent delete for archived galleries - #25

Open
manuzzi wants to merge 3 commits into
markusthiel:mainfrom
manuzzi-photo:feat/gallery-delete
Open

manuzzi wants to merge 3 commits into
markusthiel:mainfrom
manuzzi-photo:feat/gallery-delete

Conversation

@manuzzi

@manuzzi manuzzi commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Add "Delete permanently" to the dashboard gallery card menu, wiring up the
    existing (previously unused) api.deleteGallery client and the existing
    DELETE /galleries/:id route.
  • Require the gallery to be archived first, enforced both in the menu and
    server-side (409 gallery_not_archived) — the API is also used outside this
    UI, so the guard lives in the API itself.
  • Add a server-side guard against deleting a gallery with any print orders
    (409 gallery_has_print_orders) — PrintOrderItem rows reference File via
    a Restrict FK, so this also prevents an unfriendly DB constraint error.
  • Add role-based delete authorization (canDeleteGallery in
    gallery-access.ts, deliberately stricter than the general access model):
    Admin → any visible gallery, Owner → only their own, Member → never. Exposed
    to the frontend as a canDelete field on each gallery, so the UI never
    computes permission logic itself.
  • Confirmation requires typing the gallery's exact title via the shared
    usePrompt() primitive.
  • New apiError.* i18n keys for the two new error cases.

Closes #24.

Test plan

  • npm run type-check and npm run check:i18n in apps/frontend
  • npm run test in apps/api (9 new tests covering the permission +
    guard-order logic, 200/200 total passing)
  • Manual: archive + delete a gallery with no print orders as its owner,
    confirm it disappears and an audit log entry / cleanup job appear
  • Manual: wrong typed title → no request sent, inline error shown
  • Manual: as owner, confirm the option is absent on a gallery you don't own
  • Manual: as member, confirm the option never appears
  • Manual: as admin, confirm delete works on any visible gallery
  • Manual: direct API call on a live (non-archived) gallery → 409
  • Manual: archived gallery with a print order → 409, gallery not deleted

Note: npm run lint (next lint) currently fails on this repo's Next.js 16
regardless of branch — pre-existing tooling issue, not introduced here.

Add "Delete permanently" to the dashboard gallery card menu, wiring up
the existing (previously unused) api.deleteGallery client and the
existing DELETE /galleries/:id route.

- Require the gallery to be archived first, enforced both in the menu
  and server-side (409 gallery_not_archived) since the API is also
  used outside this UI.
- Guard against deleting a gallery with any print orders (409
  gallery_has_print_orders) — PrintOrderItem references File via a
  Restrict FK, so this also avoids an unfriendly DB constraint error.
- Add role-based delete authorization (canDeleteGallery in
  gallery-access.ts, deliberately stricter than the general access
  model): admin -> any visible gallery, owner -> only their own,
  member -> never. Exposed to the frontend as a canDelete field on
  each gallery so the UI never computes permission logic itself.
- Confirmation requires typing the gallery's exact title via the
  shared usePrompt() primitive.
- New apiError.* i18n keys for the two new error cases.

Closes markusthiel#24.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 31, 2026 14:42

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The frontend confirmation currently accepts non-exact title matches and the new 403 error code (delete_not_allowed) lacks an i18n translation, both conflicting with the intended UX/error messaging.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds a safe “permanent delete” capability for galleries by exposing the existing hard-delete endpoint through the Studio dashboard, while enforcing stricter server-side guards (authorization, archived-only, and print-order protection) and surfacing delete permission to the UI.

Changes:

  • Frontend: adds “Delete permanently” to archived gallery card menu with typed-title confirmation and api.deleteGallery wiring.
  • API: introduces canDeleteGallery role rule, returns canDelete in gallery list/collection responses, and enforces delete guards in DELETE /galleries/:id (403/409 cases).
  • Tests/i18n/changelog: adds guard-order unit tests, new i18n keys for 409 cases, and documents the feature in the changelog.
File summaries
File Description
CHANGELOG.md Documents the new permanent delete capability and constraints.
apps/frontend/src/lib/i18n/it.ts Adds translated UI + API error strings for delete flow (Italian).
apps/frontend/src/lib/i18n/fi.ts Adds translated UI + API error strings for delete flow (Finnish).
apps/frontend/src/lib/i18n/en.ts Adds translated UI + API error strings for delete flow (English).
apps/frontend/src/lib/i18n/de.ts Adds translated UI + API error strings for delete flow (German).
apps/frontend/src/lib/api.ts Extends Gallery type with backend-computed canDelete.
apps/frontend/src/app/studio/page.tsx Implements “Delete permanently” menu item and typed confirmation flow.
apps/api/src/routes/galleries.ts Adds delete guards (permission, archived, print orders) + canDelete in list responses.
apps/api/src/routes/galleries.delete.test.ts Adds unit tests covering delete permission rules and guard ordering.
apps/api/src/routes/collections.ts Includes canDelete in smart-collection gallery results.
apps/api/src/lib/gallery-access.ts Introduces canDeleteGallery stricter role-based delete authorization.
Review details
  • Files reviewed: 11/11 changed files
  • Comments generated: 5
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +913 to +916
if (typed.trim().toLowerCase() !== g.title.trim().toLowerCase()) {
notify(t("studio.deleteTitleMismatch"));
return;
}
Comment on lines 71 to +74
emailTaken: "That email address is already in use in this studio.",
galleryHasPrintOrders:
"This gallery has print orders and cannot be deleted.",
galleryNotArchived: "Archive the gallery before deleting it permanently.",
Comment on lines 69 to +74
customerMismatch: "Session gehört nicht zu diesem Tenant.",
emailTaken: "Diese E-Mail-Adresse ist im Studio bereits vergeben.",
galleryHasPrintOrders:
"Diese Galerie hat Druckbestellungen und kann nicht gelöscht werden.",
galleryNotArchived:
"Die Galerie muss vor dem endgültigen Löschen archiviert werden.",
Comment on lines 67 to +72
customerMismatch: "Questa sessione non appartiene a questo tenant.",
emailTaken: "Questo indirizzo email è già in uso in questo studio.",
galleryHasPrintOrders:
"Questa galleria ha ordini di stampa e non può essere eliminata.",
galleryNotArchived:
"Archivia la galleria prima di eliminarla definitivamente.",
Comment on lines 71 to +74
emailTaken: "Tämä sähköpostiosoite on jo käytössä tässä studiossa.",
galleryHasPrintOrders:
"Tällä gallerialla on tulostustilauksia, eikä sitä voi poistaa.",
galleryNotArchived: "Arkistoi galleria ennen sen pysyvää poistamista.",
@manuzzi

manuzzi commented Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

Review of the automated Copilot findings

I checked both issues Copilot raised against the actual source rather than taking them at face value:

1. apiError.deleteNotAllowed missing (flagged 4×, once per locale file) — confirmed, real bug.
useErrorText() (error-i18n.ts) only falls back to err.message when no apiError.<code> key matches. Our new 403 delete_not_allowed response sends { error: "delete_not_allowed" } with no message field, so err.message ends up being the raw code itself — if this path is ever hit (e.g. stale canDelete after a role/ownership change between page load and click), the user would see the literal string delete_not_allowed in a toast. My assumption while planning this ("falls back to the generic common.error") was incorrect — there is no such generic fallback for ApiError instances, only for non-ApiError values. Will add apiError.deleteNotAllowed to all 4 locale files in a follow-up commit.

2. Case-insensitive title match — assessed as by design, not a bug, but flagging for a second opinion.
DangerZone.tsx (studio-level deletion — the one existing precedent for typed-confirmation in this codebase) does the exact same trim().toLowerCase() comparison, with an explicit comment: "Studio-Name-Echo ist case-insensitive (UX), Backend macht das nochmal defensiv." This PR's gallery-delete confirmation mirrors that intentionally. The i18n copy itself never claims exact/case-sensitive matching — only my issue/PR description used the word "exact," which was imprecise wording on my part, not a spec. I'd lean toward leaving the comparison as-is for consistency with the existing pattern and just softening "exact" in the issue/PR text, but this is a legitimate style question rather than a clear defect — open to the maintainer's call.

No other issues found on an independent pass of the diff. Not pushing any changes yet — will apply the apiError.deleteNotAllowed fix once confirmed.

Finnish translation review

@canja006 — since you authored the original Finnish translation (feat(i18n): add Finnish UI translation), could you sanity-check the new fi.ts strings this PR adds? Produced without native review, so grammar/phrasing corrections are very welcome:

  • studio.deletePermanently: "Poista pysyvästi"
  • studio.deleteConfirmTitle: "Poista galleria pysyvästi"
  • studio.deleteConfirmMessage: "Tämä poistaa pysyvästi galleria ”{title}” ja kaiken sen sisällön — tiedostot, jakolinkit, kommentit ja arvostelut. Toimintoa ei voi perua.\n\nVahvista kirjoittamalla gallerian nimi."
  • studio.deleteTitleMismatch: "Kirjoittamasi teksti ei vastaa gallerian nimeä. Mitään ei poistettu."
  • apiError.galleryHasPrintOrders: "Tällä gallerialla on tulostustilauksia, eikä sitä voi poistaa."
  • apiError.galleryNotArchived: "Arkistoi galleria ennen sen pysyvää poistamista."

useErrorText() falls back to the raw ApiError.message when no
apiError.<code> key matches, and DELETE /galleries/:id sends
{ error: "delete_not_allowed" } with no message field — so without a
translation, the raw snake_case code would leak into a user-facing
toast on the rare stale-canDelete race (role/ownership changed
between page load and click).

Add apiError.deleteNotAllowed to all 4 locale files, matching the
existing galleryHasPrintOrders/galleryNotArchived keys added for the
same guard chain.

Addresses the review comment on PR markusthiel#25 (flagged by Copilot).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@manuzzi

manuzzi commented Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

Pushed 9232c2d: adds apiError.deleteNotAllowed to all 4 locale files, fixing the raw-error-code leak Copilot flagged. check:i18n (2600/2600 keys across all dictionaries) and type-check pass, full backend suite still 200/200.

Left the case-insensitive title match as-is per the earlier discussion (matches DangerZone.tsx's existing precedent) — happy to revisit if there's disagreement there.

@canja006

Copy link
Copy Markdown
Contributor

Checked all seven — six are natural Finnish as written, nice work. One grammar fix needed in studio.deleteConfirmMessage: "Tämä poistaa pysyvästi galleria ”{title}”" → "Tämä poistaa pysyvästi gallerian ”{title}”" (the object needs the genitive-accusative case). Everything else, including the terminology, matches the existing fi.ts conventions — "tulostustilauksia" is consistent with notifEventPrintOrderDesc.

@manuzzi

manuzzi commented Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

Thank @canja006, I'll update that

deleteConfirmMessage used the nominative "galleria" where the total
object of "poistaa" (delete) requires the genitive-accusative
"gallerian".

Per @canja006's native-speaker review on PR markusthiel#25.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@manuzzi

manuzzi commented Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

Pushed 308af03 — fixed, thanks @canja006: "galleria" → "gallerian" in studio.deleteConfirmMessage. check:i18n still passes (2600/2600).

@markusthiel

Copy link
Copy Markdown
Owner

Thanks for this — the guard work is exactly right, and I like that canDelete is computed server-side so the UI never carries permission logic. The two 409 guards I'd merge as they are.

The role matrix needs adjusting, and I think the naming is to blame rather than anything you did.

In Lumio owner is the studio owner — the top role, not a gallery-level one. galleryAccessWhere() returns an empty fragment for it, so an owner sees every gallery in the studio; admin and member are both scoped to their own galleries plus collaborations. The team UI says the same ("Owner — can additionally assign owner roles"). Plenty of products use admin as the higher of the two, so reading it that way is an easy call to make — it just doesn't match this codebase.

As written, the studio owner would be the one person unable to delete a gallery an employee created, while an admin could delete any gallery shared with them. That collides with why the owner role has gallery-wide reach at all — the comment above galleryAccessWhere() calls it a safety net so galleries don't end up orphaned when an employee leaves. Cleaning up after someone has left is the exact case, and the current rule locks the owner out of it.

For deletion I don't want a distinction between owner and admin: an admin is an admin. Which makes this simpler than the matrix I put in #24, because the visibility check already does the scoping. The lookup with galleryAccessWhere runs first and 404s, so canDeleteGallery only ever sees a gallery the caller can already reach. The rule is then just "not a member":

/**
 * Delete-Berechtigung. Owner und Admin duerfen loeschen, Member nie.
 * Die REICHWEITE ergibt sich aus galleryAccessWhere(), das in der Route
 * vorher greift und sonst 404 liefert — fuer den Owner also jede
 * Galerie des Studios, fuer den Admin seine eigenen plus Freigaben.
 * Deshalb braucht diese Funktion die Galerie selbst nicht.
 */
export function canDeleteGallery(s: SessionContext): boolean {
  return s.user.role === "owner" || s.user.role === "admin";
}

That drops the gallery parameter, so the call in the list route becomes canDeleteGallery(s). Worth noting it ends up equivalent to the predicate requireOwner() already uses (owner or admin) — I'd still keep it as its own named helper with the comment, because the reason it holds is the 404-first ordering, and that's worth stating where someone will read it.

One consequence I want to name rather than leave implicit: an admin will be able to permanently delete a colleague's gallery that was shared with them. I'm fine with that — a collaboration invite inside one studio isn't an arm's-length relationship, and archived-only plus the typed title are already two brakes. But it's a real widening compared to your version, and you've been closer to this code than I have this week. If you think the stricter rule is the right one, make the case and I'll listen.

#24's acceptance criteria carry my original assumption, so those two bullets want updating too — otherwise the issue and the code will disagree once this lands. I'll fix the issue text.

On the case-insensitive title match: agreed, leave it. Matching DangerZone.tsx is worth more than a stricter comparison here, and the user-facing copy never promised exact case. Softening "exact" in the issue text is enough.

manuzzi added a commit to manuzzi-photo/lumio that referenced this pull request Sep 2, 2026
- Custom slug: owner-or-admin instead of owner-only, matching the
  role rule settled in markusthiel#25 (galleryAccessWhere already scopes what
  an admin can reach, so "not a member" is the right check here too).
  Renames the 403 code from gallery_slug_owner_only to
  gallery_slug_forbidden since the old name became inaccurate.
- Catches the P2002 race between the availability check and the
  update (two concurrent claims on the same slug) and maps it to the
  existing gallery_slug_taken response instead of a 500, same pattern
  as the webhook-event dedup in routes/billing.ts.
- GallerySlugEditor shows a second warning line when the gallery is
  public and passwordless, since the slug is the access control in
  that case and a readable one is easier to guess.
- Documents the guessability tradeoff in MULTI_TENANT.md (EN/de/it).

Tenant-scoping the slug namespace (markusthiel#30) is intentionally out of scope
here — it's a prerequisite this PR will rebase onto once it lands.
@markusthiel

Copy link
Copy Markdown
Owner

Picking this back up — I think it just needs the role change and it's done.

You agreed over on #29 that the slug shouldn't be stricter than deletion, and implemented owner-or-admin there in 67ba664. This PR is still on 308af030 from the 31st, so canDeleteGallery here is the version that has it the other way round.

Same shape as what you ended up with on #29: since galleryAccessWhere() already scopes what an admin can reach before this check runs, the rule is just "not a member", and the function doesn't need the gallery argument any more.

export function canDeleteGallery(s: SessionContext): boolean {
  return s.user.role === "owner" || s.user.role === "admin";
}

That plus the call site in the list route, and the two acceptance-criteria bullets in #24 — I said I'd fix those and haven't yet, so I'll do that rather than leave it on you.

Everything else here I'm happy with as it stands.

@manuzzi

manuzzi commented Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

Agreed on the shape — thanks for picking up the role-change and the two #24 acceptance-criteria bullets yourself. Matches exactly what we landed on over in #29 (galleryAccessWhere() already scopes what an admin can reach, so the gallery argument on canDeleteGallery was redundant).

Nothing else from me on this one — ready to merge whenever that commit is in.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Galleries can be archived but never permanently deleted

4 participants