Admin: full edit flow for existing cards (text + media + all fields) #31

Closed
opened 2026-06-23 12:08:38 +00:00 by mAi · 1 comment
Collaborator

Goal

The admin (/admin) can today only list and delete cards, and manage the gallery image pool. There is no way to edit an existing card. m wants to edit a card after it was composed — change the text, swap/add/remove images, and adjust every other field — without recreating it via /compose.

Real example that motivated this: the birthday card at /to/ina needed its image swapped (done manually in DB+storage by otto-head as a stopgap). That should be a self-service admin action.

What to build

A per-card edit flow in the admin, gated by the same admin session as the overview/delete:

  • GET /admin/cards/{token}/edit — pre-filled edit form for the card.
  • POST /admin/cards/{token}/edit — validate + persist changes.
  • Add a “Bearbeiten” link/button per row in the admin overview (web/templates/admin.html), next to the existing Delete.

Editable scope (full edit)

Every field a card carries, mirroring /compose:

  • Text: message, sender_name, recipient_name.
  • Presentation: layout (single/gallery/flip), theme, occasion (+ occasion_label when occasion = custom).
  • Audience: single / broadcast.
  • Password: set a new password, change it, or remove it. Never render the existing hash. Pattern: blank field = keep current; an explicit “remove password” control clears it. Re-hash with bcrypt on set (reuse internal/postcard/password.go).
  • Limits / expiry: valid_until, max_visits.
  • Slug: editable. On change, keep Shlink in sync (see below). Clearing the slug removes the Shlink mapping.
  • Media (images and videos):
    • Add — upload new file(s) OR pick from the gallery (reuse the compose gallery-pick path that copies the catalogue object to a fresh per-card key, see #28).
    • Replace an existing item.
    • Remove an item.
    • Reorder items (the position column) — matters for gallery/flip layouts.

Reuse, don't duplicate

handleComposeSubmit (internal/server/handlers.go) already does almost all of this for creation: field parsing, validation, file upload via the Store, gallery-pick copy, layout/theme/occasion normalization, password hashing, slug + Shlink sync, valid_until/max_visits forwarding. Factor the shared logic out (field parsing + media handling + Shlink reconcile) so compose and edit call the same helpers. Do not copy-paste the compose handler. The whole point is no divergence between the two paths.

Repo layer

There is currently no Repo.Update (only Create, GetByToken, Delete, …). Add one:

  • Repo.Update(ctx, *Postcard)UPDATE postcards.postcards SET … for the scalar fields, plus reconcile postcard_images / postcard_videos to the desired final set (rows + positions) in a single transaction.
  • It must return (or otherwise expose) the storage keys that are no longer referenced after the reconcile, so the handler can Store.Delete them. No orphaned storage objects on replace/remove — that is an explicit acceptance criterion (the manual ina swap already left one orphan: -yXwfQmu5x-_wWqhmFuJfg/qezJ2WQVrPu09neO0w4j5Q.jpg — clean it up as part of this work or note it).
  • The card's token and id are immutable — never editable.

On slug / valid_until / max_visits change, reconcile the Shlink short URL exactly as compose does (internal/shlink): create on new slug, update on changed expiry/cap, delete on slug removal. A card without a slug has no Shlink row.

Tests (the repo is test-first — match it)

  • Repo.Update: scalar field update; image/video reconcile (add/replace/remove/reorder); correct set of orphaned keys returned; transaction rolls back cleanly on error.
  • Edit handler render: form is pre-filled with the card's current values; password field never leaks the hash.
  • Edit handler submit: text-only change; image add (upload + gallery pick); image replace; image remove (orphan deleted from store); reorder; password set / change / clear; slug change → Shlink updated; slug clear → Shlink removed; validation errors re-render the form with messages.
  • Admin auth: edit routes require the admin session (same guard as delete), 404/redirect when admin is disabled.
  • go build ./... && go vet ./... && go test ./... all green.

DoD

  • From /admin, any card can be fully edited: text, all presentation fields, password, limits, slug, and media (add/replace/remove/reorder for images and videos).
  • Replacing/removing media deletes the old storage object — zero orphans.
  • Shlink stays consistent on slug/expiry/cap changes.
  • Compose and edit share the field/media/Shlink logic (no duplicated handler body).
  • build/vet/test green; e2e verified by editing a real card on the live site (text + an image swap), then reverting test changes.
  • Commit references this issue. otto-head merges the mai/* branch and deploys to sendmy.cards (do not self-merge to main); comment the commit link on this issue and set the done label (do not close — m closes).

Out of scope

  • Changing a card's token (immutable by design).
  • Public/recipient-facing editing — admin-only.
  • Edit history / versioning / undo.
  • Any redesign of the compose UX beyond extracting shared helpers.

Pointers

  • Routes: internal/server/server.go (admin block).
  • Compose logic to factor out: handleComposeSubmit in internal/server/handlers.go.
  • Admin handlers/templates: internal/server/handlers_admin.go, web/templates/admin*.html.
  • Repo + model: internal/postcard/postcard.go (add Update); gallery copy path: internal/postcard/gallery.go.
  • Store: internal/store (Save/Read/PublicURL/Delete).
  • Shlink: internal/shlink.
  • Local checkout: ~/dev/web/postcards. App serves sendmy.cards (and post.msbls.de).
## Goal The admin (`/admin`) can today only **list** and **delete** cards, and manage the **gallery** image pool. There is no way to edit an existing card. m wants to edit a card after it was composed — change the text, swap/add/remove images, and adjust every other field — without recreating it via `/compose`. Real example that motivated this: the birthday card at `/to/ina` needed its image swapped (done manually in DB+storage by otto-head as a stopgap). That should be a self-service admin action. ## What to build A per-card **edit flow** in the admin, gated by the same admin session as the overview/delete: - `GET /admin/cards/{token}/edit` — pre-filled edit form for the card. - `POST /admin/cards/{token}/edit` — validate + persist changes. - Add a **“Bearbeiten”** link/button per row in the admin overview (`web/templates/admin.html`), next to the existing Delete. ### Editable scope (full edit) Every field a card carries, mirroring `/compose`: - **Text**: `message`, `sender_name`, `recipient_name`. - **Presentation**: `layout` (single/gallery/flip), `theme`, `occasion` (+ `occasion_label` when occasion = custom). - **Audience**: single / broadcast. - **Password**: set a new password, change it, or remove it. Never render the existing hash. Pattern: blank field = keep current; an explicit “remove password” control clears it. Re-hash with bcrypt on set (reuse `internal/postcard/password.go`). - **Limits / expiry**: `valid_until`, `max_visits`. - **Slug**: editable. On change, keep Shlink in sync (see below). Clearing the slug removes the Shlink mapping. - **Media (images and videos)**: - **Add** — upload new file(s) OR pick from the gallery (reuse the compose gallery-pick path that copies the catalogue object to a fresh per-card key, see #28). - **Replace** an existing item. - **Remove** an item. - **Reorder** items (the `position` column) — matters for gallery/flip layouts. ### Reuse, don't duplicate `handleComposeSubmit` (`internal/server/handlers.go`) already does almost all of this for creation: field parsing, validation, file upload via the `Store`, gallery-pick copy, layout/theme/occasion normalization, password hashing, slug + Shlink sync, valid_until/max_visits forwarding. **Factor the shared logic out** (field parsing + media handling + Shlink reconcile) so compose and edit call the same helpers. Do not copy-paste the compose handler. The whole point is no divergence between the two paths. ### Repo layer There is currently **no `Repo.Update`** (only `Create`, `GetByToken`, `Delete`, …). Add one: - `Repo.Update(ctx, *Postcard)` — `UPDATE postcards.postcards SET …` for the scalar fields, **plus** reconcile `postcard_images` / `postcard_videos` to the desired final set (rows + positions) **in a single transaction**. - It must return (or otherwise expose) the storage keys that are **no longer referenced** after the reconcile, so the handler can `Store.Delete` them. **No orphaned storage objects** on replace/remove — that is an explicit acceptance criterion (the manual ina swap already left one orphan: `-yXwfQmu5x-_wWqhmFuJfg/qezJ2WQVrPu09neO0w4j5Q.jpg` — clean it up as part of this work or note it). - The card's `token` and `id` are immutable — never editable. ### Shlink sync On slug / valid_until / max_visits change, reconcile the Shlink short URL exactly as compose does (`internal/shlink`): create on new slug, update on changed expiry/cap, delete on slug removal. A card without a slug has no Shlink row. ## Tests (the repo is test-first — match it) - `Repo.Update`: scalar field update; image/video reconcile (add/replace/remove/reorder); correct set of orphaned keys returned; transaction rolls back cleanly on error. - Edit handler render: form is pre-filled with the card's current values; password field never leaks the hash. - Edit handler submit: text-only change; image add (upload + gallery pick); image replace; image remove (orphan deleted from store); reorder; password set / change / clear; slug change → Shlink updated; slug clear → Shlink removed; validation errors re-render the form with messages. - Admin auth: edit routes require the admin session (same guard as delete), 404/redirect when admin is disabled. - `go build ./... && go vet ./... && go test ./...` all green. ## DoD - From `/admin`, any card can be fully edited: text, all presentation fields, password, limits, slug, and media (add/replace/remove/reorder for images **and** videos). - Replacing/removing media deletes the old storage object — zero orphans. - Shlink stays consistent on slug/expiry/cap changes. - Compose and edit share the field/media/Shlink logic (no duplicated handler body). - build/vet/test green; e2e verified by editing a real card on the live site (text + an image swap), then reverting test changes. - Commit references this issue. **otto-head merges the `mai/*` branch and deploys to sendmy.cards** (do not self-merge to main); comment the commit link on this issue and set the `done` label (do not close — m closes). ## Out of scope - Changing a card's `token` (immutable by design). - Public/recipient-facing editing — admin-only. - Edit history / versioning / undo. - Any redesign of the compose UX beyond extracting shared helpers. ## Pointers - Routes: `internal/server/server.go` (admin block). - Compose logic to factor out: `handleComposeSubmit` in `internal/server/handlers.go`. - Admin handlers/templates: `internal/server/handlers_admin.go`, `web/templates/admin*.html`. - Repo + model: `internal/postcard/postcard.go` (add `Update`); gallery copy path: `internal/postcard/gallery.go`. - Store: `internal/store` (`Save`/`Read`/`PublicURL`/`Delete`). - Shlink: `internal/shlink`. - Local checkout: `~/dev/web/postcards`. App serves sendmy.cards (and post.msbls.de).
mAi self-assigned this 2026-06-23 12:08:38 +00:00
Author
Collaborator

#31 implemented — admin full edit flow (text + media + all fields)

Branch mai/hermes/issue-31-admin-full-edit, commit 61e0704. Not merged/deployed — otto-head merges the mai/* branch and deploys to sendmy.cards.

What shipped

Routes + UIGET/POST /admin/cards/{token}/edit behind the same admin session as the overview/delete, plus a Bearbeiten link per row in admin.html (next to Öffnen/Löschen) and an ✓ Karte aktualisiert. flash on success. New template web/templates/admin_edit.html + edit-slot CSS.

Full editable scope (mirrors compose): message/sender_name/recipient_name; layout/theme/occasion (+occasion_label); audience; password (blank = keep, new value = re-hash with bcrypt, explicit „Passwortschutz entfernen“ = clear — the hash is never rendered); valid_until/max_visits; slug (with the to/from namespace, gated on Shlink); and media for images AND videos — add (own upload OR gallery pick), replace, remove, reorder (per-item position).

Reuse, not duplication — factored the shared scalar parsing/validation out of handleComposeSubmit into parseCardFields / validateSlugFormat / validateLifecycle, and reused the existing saveImages/saveVideos/saveGalleryPicks helpers. Compose and edit now share one form contract and can't drift.

Repo layer — new Repo.Update(ctx, *Postcard): UPDATE of the scalar fields plus reconcile of postcard_images/postcard_videos to the desired final set (rows + positions) in a single transaction; token/id immutable, visit_count untouched. It returns the storage keys no longer referenced (pure orphanedMediaKeys) so the handler Store.Deletes them — zero orphaned objects on replace/remove. The edit handler also rolls back freshly-saved objects + a freshly-created slug if the commit fails.

Shlink sync — new/changed slug is created up front (a taken slug blocks the edit before any DB write, nothing to undo); after a successful commit the old slug is deleted (slug change/clear) or its expiry/cap is PATCHed to match the card (shlink.UpdateShortURL, new — JSON null clears a limit).

Tests (all green: go build ./... && go vet ./... && go test ./...)

  • orphanedMediaKeys: add / replace / remove / reorder → correct orphan set, incl. images+videos and dedup.
  • shlink.UpdateShortURL: PATCH path/domain/body, null-clears limits, 404 = no-op.
  • Edit-form render: pre-filled across every field; never leaks the password hash; slug field gated on Shlink.
  • Admin auth: edit routes disabled → 404, no session → 401.
  • resolveEditMedia: keep/reorder/replace/remove + new upload, final positions 0..n, new objects saved.

E2E (throwaway docker postgres:16-alpine + local storage, the established pattern)

Composed a 2-image card → edited it: replaced img0, removed img1, added a new image, changed message/layout/theme, set max_visits + a password. Verified: scalars persisted; final image set correct with reassigned positions; both orphaned objects deleted from storage (zero orphans); card became password-gated. Second edit: removed password + reordered (swap) + text-only → no-password card renders, order swapped, no new objects churned. Negative paths: empty card (all media removed, blank message) → 400 + re-render, card untouched; unknown token → 404.

Live-site e2e (sendmy.cards) happens post-deploy by otto-head, as with prior issues. Shlink slug round-trip against the shared instance was left to post-deploy to avoid polluting prod Shlink; the client + reconcile branching are unit-tested.

Orphan cleanup (explicit acceptance criterion)

The pre-existing production orphan from the manual ina swap — -yXwfQmu5x-_wWqhmFuJfg/qezJ2WQVrPu09neO0w4j5Q.jpg — was verified unreferenced in the prod DB (the card now points to oQRJa3kcDD2grYk-9xQPMW.jpg) and deleted from the postcards bucket (200 → deleted → 404).

Note (pre-existing tech debt, not touched)

internal/server/media_key_test.go is still not gofmt-clean (flagged back in #28). All files in this diff are gofmt-clean; left that one out to keep this commit scoped.

Commit: 61e07049df

## #31 implemented — admin full edit flow (text + media + all fields) Branch `mai/hermes/issue-31-admin-full-edit`, commit [`61e0704`](https://mgit.msbls.de/m/postcards/commit/61e07049df40bf56005ef6fa6228510386399051). **Not merged/deployed** — otto-head merges the `mai/*` branch and deploys to sendmy.cards. ### What shipped **Routes + UI** — `GET`/`POST /admin/cards/{token}/edit` behind the same admin session as the overview/delete, plus a **Bearbeiten** link per row in `admin.html` (next to Öffnen/Löschen) and an `✓ Karte aktualisiert.` flash on success. New template `web/templates/admin_edit.html` + edit-slot CSS. **Full editable scope** (mirrors compose): `message`/`sender_name`/`recipient_name`; `layout`/`theme`/`occasion` (+`occasion_label`); audience; **password** (blank = keep, new value = re-hash with bcrypt, explicit „Passwortschutz entfernen“ = clear — the hash is **never rendered**); `valid_until`/`max_visits`; `slug` (with the to/from namespace, gated on Shlink); and **media for images AND videos** — add (own upload OR gallery pick), replace, remove, reorder (per-item `position`). **Reuse, not duplication** — factored the shared scalar parsing/validation out of `handleComposeSubmit` into `parseCardFields` / `validateSlugFormat` / `validateLifecycle`, and reused the existing `saveImages`/`saveVideos`/`saveGalleryPicks` helpers. Compose and edit now share one form contract and can't drift. **Repo layer** — new `Repo.Update(ctx, *Postcard)`: `UPDATE` of the scalar fields **plus** reconcile of `postcard_images`/`postcard_videos` to the desired final set (rows + positions) **in a single transaction**; `token`/`id` immutable, `visit_count` untouched. It returns the storage keys no longer referenced (pure `orphanedMediaKeys`) so the handler `Store.Delete`s them — **zero orphaned objects** on replace/remove. The edit handler also rolls back freshly-saved objects + a freshly-created slug if the commit fails. **Shlink sync** — new/changed slug is **created up front** (a taken slug blocks the edit before any DB write, nothing to undo); after a successful commit the old slug is deleted (slug change/clear) or its expiry/cap is **PATCHed** to match the card (`shlink.UpdateShortURL`, new — JSON `null` clears a limit). ### Tests (all green: `go build ./... && go vet ./... && go test ./...`) - `orphanedMediaKeys`: add / replace / remove / reorder → correct orphan set, incl. images+videos and dedup. - `shlink.UpdateShortURL`: PATCH path/domain/body, null-clears limits, 404 = no-op. - Edit-form render: pre-filled across every field; **never leaks the password hash**; slug field gated on Shlink. - Admin auth: edit routes `disabled → 404`, `no session → 401`. - `resolveEditMedia`: keep/reorder/replace/remove + new upload, final positions 0..n, new objects saved. ### E2E (throwaway docker `postgres:16-alpine` + local storage, the established pattern) Composed a 2-image card → edited it: replaced img0, removed img1, added a new image, changed message/layout/theme, set `max_visits` + a password. Verified: scalars persisted; final image set correct with reassigned positions; **both orphaned objects deleted from storage (zero orphans)**; card became password-gated. Second edit: removed password + reordered (swap) + text-only → no-password card renders, order swapped, **no new objects churned**. Negative paths: empty card (all media removed, blank message) → `400` + re-render, card untouched; unknown token → `404`. _Live-site e2e (sendmy.cards) happens post-deploy by otto-head, as with prior issues. Shlink slug round-trip against the shared instance was left to post-deploy to avoid polluting prod Shlink; the client + reconcile branching are unit-tested._ ### Orphan cleanup (explicit acceptance criterion) The pre-existing production orphan from the manual ina swap — `-yXwfQmu5x-_wWqhmFuJfg/qezJ2WQVrPu09neO0w4j5Q.jpg` — was verified **unreferenced** in the prod DB (the card now points to `oQRJa3kcDD2grYk-9xQPMW.jpg`) and **deleted from the `postcards` bucket** (200 → deleted → 404). ### Note (pre-existing tech debt, not touched) `internal/server/media_key_test.go` is still not gofmt-clean (flagged back in #28). All files in this diff are gofmt-clean; left that one out to keep this commit scoped. Commit: https://mgit.msbls.de/m/postcards/commit/61e07049df40bf56005ef6fa6228510386399051
mAi closed this issue 2026-06-23 15:48:04 +00:00
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: m/postcards#31
No description provided.