feat: card-level expiration (apply to the card, not just the custom link) #14

Closed
opened 2026-06-17 08:07:41 +00:00 by mAi · 3 comments
Collaborator

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

  • Expiration becomes a general card-level option in compose — settable on any card, not gated behind a custom slug.
  • When set, the card's /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.
  • If the card ALSO has a custom slug, keep setting Shlink validUntil too so the pretty link and the card expire together (consistent).
  • Reuse the existing slug_valid_until column or rename to a card-level valid_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

  • Mobile-first, German UI + Umlaute. No regression to #9's slug-level lifecycle for cards that do use a custom link. Coexists with password (#11): an expired card shows expired regardless of password.
  • Idempotent migration, postgres-role ownership.

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

  • Expiration settable on any card (no custom-link requirement); expired card → friendly expired page server-side; non-expiring cards unchanged.
  • Custom-link cards: slug + card expire together.
  • build/vet/test green; e2e (set short expiry, card 404s/expired after; no-expiry card fine); test data cleaned. Commit references this issue.
## 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 - **Expiration becomes a general card-level option** in compose — settable on any card, not gated behind a custom slug. - When set, the **card's `/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. - If the card ALSO has a custom slug, keep setting Shlink `validUntil` too so the pretty link and the card expire together (consistent). - Reuse the existing `slug_valid_until` column or rename to a card-level `valid_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 - Mobile-first, German UI + Umlaute. No regression to #9's slug-level lifecycle for cards that do use a custom link. Coexists with password (#11): an expired card shows expired regardless of password. - Idempotent migration, postgres-role ownership. ## 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 - Expiration settable on any card (no custom-link requirement); expired card → friendly expired page server-side; non-expiring cards unchanged. - Custom-link cards: slug + card expire together. - build/vet/test green; e2e (set short expiry, card 404s/expired after; no-expiry card fine); test data cleaned. Commit references this issue.
Author
Collaborator

m's call (PWA): max-views also card-level, same as expiration — not slug-bound. So #14 covers BOTH valid_until and max_visits at 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.

**m's call (PWA): max-views also card-level**, same as expiration — not slug-bound. So #14 covers BOTH `valid_until` and `max_visits` at 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.
mAi self-assigned this 2026-06-17 08:23:16 +00:00
Author
Collaborator

#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: 5efd624124
Branch: mai/hermes/issue-14-feat-card-level (not merged — otto-head merges/deploys).

What changed

  • Enforcement (internal/server/handlers.go): handleView now checks pc.IsExpired(now) before the password gate. An expired card serves a friendly „Diese Karte ist abgelaufen“ page (new web/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).
  • Compose (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.“
  • Slug coexistence (handlers.go): when the card also has a custom slug, the same expiry moment is still forwarded to Shlink as validUntil, so the pretty link and the card expire together. No regression to #9's slug-level lifecycle.
  • Schema (migrations/008_card_expiration.sql): renames slug_valid_untilvalid_until (card-level). Idempotent + owner-safe: the RENAME fires only when the old column still exists and valid_until does not yet exist; a one-time UPDATE … SET valid_until = NULL lives 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-boot UPDATE NULL would wipe legitimately-set expiries).
    • Root-cause fix in migrations/005_slug_lifecycle.sql: it used to ADD COLUMN IF NOT EXISTS slug_valid_until on every boot, which resurrected the renamed column behind 008 and made the rename collide with the already-present valid_until on the second start. Removed that ADD (008 now owns valid_until for both fresh and upgraded DBs). slug_max_visits is untouched.
  • Share banner (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.
  • Docs (README.md, deploy/README.md) and the now-dead .slug-life/.life-field CSS cleaned up (replaced by a styled input[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 (added TestIsExpired, TestShareBannerExpiryWithoutSlug, TestExpiredPageRenders; updated TestComposeShowsLifecycleFields + TestShareBannerShowsLifecycle).

E2E on a throwaway Postgres 16 (local storage, prod untouched):

  1. migration renamed the column (valid_until present, slug_valid_until gone);
  2. plain card → 200, content served;
  3. set its valid_until to the past → 410 abgelaufen, content gone;
  4. compose with a future expiry → 200, persisted valid_until, banner shows gültig bis 31.12.2099;
  5. expired + password410 abgelaufen (not the password prompt);
  6. restart → future expiry survives (one-time backfill didn't wipe it — idempotent);
  7. control: fresh no-expiry card still 200.

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 to valid_until, that row's value backfilled to NULL, other row data preserved. Containers + temp files cleaned up.

DoD

  • Expiration settable on any card (no custom-link requirement)
  • Expired card → friendly expired page server-side (410, no content, beats password)
  • Custom-link cards: slug + card expire together (Shlink validUntil)
  • Non-expiring cards unchanged
  • Idempotent, owner-safe migration; existing rows backfilled to NULL
  • build/vet/test green; e2e green; test data cleaned
  • Max-views decision made + flagged (left slug-level)
## #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: https://mgit.msbls.de/m/postcards/commit/5efd62412485961443c80794e150360e183c1abb Branch: `mai/hermes/issue-14-feat-card-level` (not merged — otto-head merges/deploys). ### What changed - **Enforcement** (`internal/server/handlers.go`): `handleView` now checks `pc.IsExpired(now)` **before** the password gate. An expired card serves a friendly **„Diese Karte ist abgelaufen“** page (new `web/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). - **Compose** (`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.“* - **Slug coexistence** (`handlers.go`): when the card also has a custom slug, the same expiry moment is still forwarded to Shlink as `validUntil`, so the pretty link and the card **expire together**. No regression to #9's slug-level lifecycle. - **Schema** (`migrations/008_card_expiration.sql`): renames `slug_valid_until` → `valid_until` (card-level). Idempotent + owner-safe: the `RENAME` fires only when the old column still exists **and** `valid_until` does not yet exist; a one-time `UPDATE … SET valid_until = NULL` lives **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-boot `UPDATE NULL` would wipe legitimately-set expiries). - **Root-cause fix in `migrations/005_slug_lifecycle.sql`**: it used to `ADD COLUMN IF NOT EXISTS slug_valid_until` on **every** boot, which resurrected the renamed column behind 008 and made the rename collide with the already-present `valid_until` on the *second* start. Removed that ADD (008 now owns `valid_until` for both fresh and upgraded DBs). `slug_max_visits` is untouched. - **Share banner** (`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. - Docs (`README.md`, `deploy/README.md`) and the now-dead `.slug-life`/`.life-field` CSS cleaned up (replaced by a styled `input[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 (added `TestIsExpired`, `TestShareBannerExpiryWithoutSlug`, `TestExpiredPageRenders`; updated `TestComposeShowsLifecycleFields` + `TestShareBannerShowsLifecycle`). E2E on a throwaway Postgres 16 (local storage, prod untouched): 1. migration renamed the column (`valid_until` present, `slug_valid_until` gone); 2. plain card → **200**, content served; 3. set its `valid_until` to the past → **410** *abgelaufen*, content gone; 4. compose with a future expiry → **200**, persisted `valid_until`, banner shows *gültig bis 31.12.2099*; 5. expired **+ password** → **410** *abgelaufen* (not the password prompt); 6. **restart** → future expiry **survives** (one-time backfill didn't wipe it — idempotent); 7. control: fresh no-expiry card still **200**. 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 to `valid_until`, that row's value backfilled to **NULL**, other row data preserved. Containers + temp files cleaned up. ### DoD - [x] Expiration settable on any card (no custom-link requirement) - [x] Expired card → friendly expired page server-side (410, no content, beats password) - [x] Custom-link cards: slug + card expire together (Shlink `validUntil`) - [x] Non-expiring cards unchanged - [x] Idempotent, owner-safe migration; existing rows backfilled to NULL - [x] build/vet/test green; e2e green; test data cleaned - [x] Max-views decision made + flagged (left slug-level)
Author
Collaborator

Merged & deployed — card-level expiration live on sendmy.cards

Merged 5efd624 (merge bc81dc4), deployed. Migration 008 applied cleanly on prod (slug_valid_untilvalid_until, app serving, no crash). Verified live: no-slug card 200 → set past expiry → 410 Gone, no content leak. Slug cards keep Shlink validUntil in 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.

## ✅ Merged & deployed — card-level expiration live on sendmy.cards Merged `5efd624` (merge `bc81dc4`), 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 Shlink `validUntil` in 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.
m closed this issue 2026-06-17 12:41:47 +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#14
No description provided.