Admin: full edit flow for existing cards (text + media + all fields) #31
Reference in New Issue
Block a user
No description provided.
Delete Branch "%!s()"
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?
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/inaneeded 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.web/templates/admin.html), next to the existing Delete.Editable scope (full edit)
Every field a card carries, mirroring
/compose:message,sender_name,recipient_name.layout(single/gallery/flip),theme,occasion(+occasion_labelwhen occasion = custom).internal/postcard/password.go).valid_until,max_visits.positioncolumn) — 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 theStore, 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(onlyCreate,GetByToken,Delete, …). Add one:Repo.Update(ctx, *Postcard)—UPDATE postcards.postcards SET …for the scalar fields, plus reconcilepostcard_images/postcard_videosto the desired final set (rows + positions) in a single transaction.Store.Deletethem. 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).tokenandidare 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.go build ./... && go vet ./... && go test ./...all green.DoD
/admin, any card can be fully edited: text, all presentation fields, password, limits, slug, and media (add/replace/remove/reorder for images and videos).mai/*branch and deploys to sendmy.cards (do not self-merge to main); comment the commit link on this issue and set thedonelabel (do not close — m closes).Out of scope
token(immutable by design).Pointers
internal/server/server.go(admin block).handleComposeSubmitininternal/server/handlers.go.internal/server/handlers_admin.go,web/templates/admin*.html.internal/postcard/postcard.go(addUpdate); gallery copy path:internal/postcard/gallery.go.internal/store(Save/Read/PublicURL/Delete).internal/shlink.~/dev/web/postcards. App serves sendmy.cards (and post.msbls.de).#31 implemented — admin full edit flow (text + media + all fields)
Branch
mai/hermes/issue-31-admin-full-edit, commit61e0704. Not merged/deployed — otto-head merges themai/*branch and deploys to sendmy.cards.What shipped
Routes + UI —
GET/POST /admin/cards/{token}/editbehind the same admin session as the overview/delete, plus a Bearbeiten link per row inadmin.html(next to Öffnen/Löschen) and an✓ Karte aktualisiert.flash on success. New templateweb/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-itemposition).Reuse, not duplication — factored the shared scalar parsing/validation out of
handleComposeSubmitintoparseCardFields/validateSlugFormat/validateLifecycle, and reused the existingsaveImages/saveVideos/saveGalleryPickshelpers. Compose and edit now share one form contract and can't drift.Repo layer — new
Repo.Update(ctx, *Postcard):UPDATEof the scalar fields plus reconcile ofpostcard_images/postcard_videosto the desired final set (rows + positions) in a single transaction;token/idimmutable,visit_countuntouched. It returns the storage keys no longer referenced (pureorphanedMediaKeys) so the handlerStore.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 — JSONnullclears 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.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 tooQRJa3kcDD2grYk-9xQPMW.jpg) and deleted from thepostcardsbucket (200 → deleted → 404).Note (pre-existing tech debt, not touched)
internal/server/media_key_test.gois 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