feat: card-level expiration (apply to the card, not just the custom link) #14
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?
What
m: „die expiration soll generell gelten - nicht nur für den custom link.“
In #9 expiry (and max-views) were tied to the custom short link — you could only set them when you also set a slug, and they only made the pretty
/to/<slug>link 404; the card's/c/{token}stayed reachable forever. m now wants expiration to apply to the card itself, available on any card (independent of whether a custom link is set).Behaviour
/c/{token}view enforces it: after the expiry the card shows a friendly „Diese Karte ist abgelaufen“ page (or 404), server-side. The card content is not served past expiry.validUntiltoo so the pretty link and the card expire together (consistent).slug_valid_untilcolumn or rename to a card-levelvalid_until(your call; keep migration idempotent + owner-safe, see #1-#7). Backfill existing rows to NULL (no expiry = unchanged).Max-views note (decide / flag)
m explicitly named expiration. Max-views (
maxVisits) currently stays slug-level. Consider whether max-views should also become card-level for consistency — if it's cheap and coherent, do it the same way; otherwise leave slug-level and flag it for m. Don't over-reach silently — note what you chose.Constraints
Sequencing
Touches compose + the
/c/{token}render/handler + schema → after #13 (the in-flight CSS regression fix on the same files). otto-head assigns once #13 lands.DoD
m's call (PWA): max-views also card-level, same as expiration — not slug-bound. So #14 covers BOTH
valid_untilandmax_visitsat card level (enforced on/c/{token}), available on any card independent of a custom link; if a custom slug exists, the Shlink slug gets the matching validUntil/maxVisits so link + card stay consistent.#14 card-level expiration — done (shift-1)
Expiration is now a card-level property, settable on any card (no custom-link requirement), enforced server-side at
/c/{token}.Commit:
5efd624124Branch:
mai/hermes/issue-14-feat-card-level(not merged — otto-head merges/deploys).What changed
internal/server/handlers.go):handleViewnow checkspc.IsExpired(now)before the password gate. An expired card serves a friendly „Diese Karte ist abgelaufen“ page (newweb/templates/expired.html) with HTTP 410 Gone and no card content — message, media URLs and metadata never reach the client. Checked before the password gate, so an expired card reads as expired regardless of any password (constraint from #11).web/templates/compose.html): the „Gültig bis“ date field moved out of the{{if .ShlinkEnabled}}block — it is now a card-level option shown on every card (even with Shlink off). Hint updated: „Nach diesem Datum ist die Karte nicht mehr abrufbar – sie zeigt dann ‚abgelaufen‘. Hat die Karte eine Wunsch-Adresse, läuft auch diese mit ab.“handlers.go): when the card also has a custom slug, the same expiry moment is still forwarded to Shlink asvalidUntil, so the pretty link and the card expire together. No regression to #9's slug-level lifecycle.migrations/008_card_expiration.sql): renamesslug_valid_until→valid_until(card-level). Idempotent + owner-safe: theRENAMEfires only when the old column still exists andvalid_untildoes not yet exist; a one-timeUPDATE … SET valid_until = NULLlives inside that same guard so existing rows' (slug-scoped) expiries are reset to NULL — no existing card suddenly expires — and the backfill never re-runs (a per-bootUPDATE NULLwould wipe legitimately-set expiries).migrations/005_slug_lifecycle.sql: it used toADD COLUMN IF NOT EXISTS slug_valid_untilon every boot, which resurrected the renamed column behind 008 and made the rename collide with the already-presentvalid_untilon the second start. Removed that ADD (008 now ownsvalid_untilfor both fresh and upgraded DBs).slug_max_visitsis untouched.web/templates/postcard.html): the „gültig bis …“ line now shows for any card with an expiry (not only slug cards); the visit-cap line stays slug-only.README.md,deploy/README.md) and the now-dead.slug-life/.life-fieldCSS cleaned up (replaced by a styledinput[type=date]).Max-views decision (flagged, as requested)
Kept slug-level (Shlink
maxVisits), not promoted to card-level. Reason: a card-level visit cap needs a per-card hit counter — mutable per-view state, plus bot/prefetch-counting questions — whereas expiry is a stateless timestamp compare. Not cheap/coherent enough to fold in silently. If you want card-level max-views too, say so and I'll add a counter column + increment path.Verification
go build/go vet/go test -race ./...all green (addedTestIsExpired,TestShareBannerExpiryWithoutSlug,TestExpiredPageRenders; updatedTestComposeShowsLifecycleFields+TestShareBannerShowsLifecycle).E2E on a throwaway Postgres 16 (local storage, prod untouched):
valid_untilpresent,slug_valid_untilgone);valid_untilto the past → 410 abgelaufen, content gone;valid_until, banner shows gültig bis 31.12.2099;Separately verified the prod-upgrade path: a DB pre-seeded with
slug_valid_until+ a row holding a value → after boot the column is renamed tovalid_until, that row's value backfilled to NULL, other row data preserved. Containers + temp files cleaned up.DoD
validUntil)✅ Merged & deployed — card-level expiration live on sendmy.cards
Merged
5efd624(mergebc81dc4), deployed. Migration 008 applied cleanly on prod (slug_valid_until→valid_until, app serving, no crash). Verified live: no-slug card 200 → set past expiry → 410 Gone, no content leak. Slug cards keep ShlinkvalidUntilin sync. Existing cards backfilled to NULL (none suddenly expire).Max-views: kept slug-level this shift — promoting it to card-level needs a per-card view counter (mutable state + bot/prefetch counting), unlike the stateless expiry compare. m asked for card-level max-views too, so tracked as follow-up #18. Not closing — m closes.