feat: short-link lifecycle — duplicates, expiry, max-views (builds on #8) #9

Closed
opened 2026-06-16 15:27:40 +00:00 by mAi · 3 comments
Collaborator

What

The user-configurable short links (#3 / #8) need lifecycle controls, per m:

  • Dopplungen (duplicates) — what happens when two senders want the same slug.
  • Ablauf (expiry) — links that stop working after a date.
  • Maximale Aufrufe (max views) — links that stop working after N visits.

Good news: Shlink supports the last two nativelyvalidUntil (expiry date) and maxVisits (visit cap) are short-URL params on the create API. So expiry + max-views are mostly: expose options in the compose flow → pass to Shlink. Low implementation cost. Duplicate-rejection is also already enforced by Shlink (unique slug per domain).

Scope

  1. Expiry: compose option (e.g. 'Link gültig bis ' or 'läuft ab nach X Tagen') → pass validUntil to Shlink. After it, the slug 404s (card's /c/{token} still works directly — expiry is on the pretty link, not the card, unless we decide otherwise).
  2. Max views: compose option ('Link max. N Aufrufe') → pass maxVisits to Shlink. After N hits the slug stops resolving.
  3. Duplicates (DESIGN DECISION — needs a call): Shlink slugs are globally unique per domain, and this app has no user accounts (open compose). So sendmy.cards/to/oma-erika can only exist once, ever. Options:
    • (a) First-come-first-served + clear 'schon vergeben, nimm einen anderen' (current #3 behaviour). Simple, but popular slugs get squatted.
    • (b) Auto-suffix on collision (oma-erika-2) and show the sender the final URL.
    • (c) Scope slugs (e.g. by a sender token / lightweight per-card secret) so the same human-readable name can repeat — bigger change, needs a namespacing scheme.
      Recommend (a) for MVP (matches #3, no new concepts), with (b) as an easy upgrade. (c) only if m wants true per-user namespaces. Flag for m's decision before building this part.

Constraints

  • Builds on #8's /to/ /from/ slugs and #3's single-segment slugs — keep all working.
  • Mobile-first, German UI + Umlaute. Friendly messages for expired / exhausted / taken links.
  • Card itself (/c/{token}) is the durable artifact; lifecycle limits apply to the pretty short link. (Decide whether an expired/exhausted short link should also imply the card is gone — default: no, only the pretty link expires.)

Sequencing

Depends on #8 (same compose + Shlink code path — no parallel worker). otto-head assigns this once #8 is merged. Filed now to capture m's intent.

DoD

  • Compose offers expiry + max-views on a custom short link; both enforced via Shlink (validUntil / maxVisits); verified e2e (a maxVisits=1 link 404s on the 2nd hit; an expired link 404s).
  • Duplicate policy implemented per m's decision (default (a) unless m picks otherwise).
  • All prior link forms still work. build/vet/test green; test data cleaned. Commit references this issue.
## What The user-configurable short links (#3 / #8) need lifecycle controls, per m: - **Dopplungen** (duplicates) — what happens when two senders want the same slug. - **Ablauf** (expiry) — links that stop working after a date. - **Maximale Aufrufe** (max views) — links that stop working after N visits. Good news: **Shlink supports the last two natively** — `validUntil` (expiry date) and `maxVisits` (visit cap) are short-URL params on the create API. So expiry + max-views are mostly: expose options in the compose flow → pass to Shlink. Low implementation cost. Duplicate-rejection is also already enforced by Shlink (unique slug per domain). ## Scope 1. **Expiry**: compose option (e.g. 'Link gültig bis <date>' or 'läuft ab nach X Tagen') → pass `validUntil` to Shlink. After it, the slug 404s (card's `/c/{token}` still works directly — expiry is on the pretty link, not the card, unless we decide otherwise). 2. **Max views**: compose option ('Link max. N Aufrufe') → pass `maxVisits` to Shlink. After N hits the slug stops resolving. 3. **Duplicates (DESIGN DECISION — needs a call):** Shlink slugs are **globally unique per domain**, and this app has **no user accounts** (open compose). So `sendmy.cards/to/oma-erika` can only exist once, ever. Options: - (a) First-come-first-served + clear 'schon vergeben, nimm einen anderen' (current #3 behaviour). Simple, but popular slugs get squatted. - (b) Auto-suffix on collision (`oma-erika-2`) and show the sender the final URL. - (c) Scope slugs (e.g. by a sender token / lightweight per-card secret) so the same human-readable name can repeat — bigger change, needs a namespacing scheme. Recommend (a) for MVP (matches #3, no new concepts), with (b) as an easy upgrade. (c) only if m wants true per-user namespaces. **Flag for m's decision before building this part.** ## Constraints - Builds on #8's `/to/` `/from/` slugs and #3's single-segment slugs — keep all working. - Mobile-first, German UI + Umlaute. Friendly messages for expired / exhausted / taken links. - Card itself (`/c/{token}`) is the durable artifact; lifecycle limits apply to the pretty short link. (Decide whether an expired/exhausted short link should also imply the card is gone — default: no, only the pretty link expires.) ## Sequencing **Depends on #8** (same compose + Shlink code path — no parallel worker). otto-head assigns this once #8 is merged. Filed now to capture m's intent. ## DoD - Compose offers expiry + max-views on a custom short link; both enforced via Shlink (`validUntil` / `maxVisits`); verified e2e (a maxVisits=1 link 404s on the 2nd hit; an expired link 404s). - Duplicate policy implemented per m's decision (default (a) unless m picks otherwise). - All prior link forms still work. build/vet/test green; test data cleaned. Commit references this issue.
Author
Collaborator

m's decision on the duplicate policy (PWA, 2026-06-16): Option (a) — first-come-first-served. If a slug is already taken, show a friendly error asking the sender to pick another (same behaviour as #3's collision handling). No auto-suffix, no per-user namespace. Build this part with (a).

**m's decision on the duplicate policy (PWA, 2026-06-16): Option (a) — first-come-first-served.** If a slug is already taken, show a friendly error asking the sender to pick another (same behaviour as #3's collision handling). No auto-suffix, no per-user namespace. Build this part with (a).
mAi self-assigned this 2026-06-16 15:59:12 +00:00
Author
Collaborator

Expiry + max-views built end-to-end, verified live on post.msbls.de. Duplicate policy: shipped default (a) first-come-first-served — flagged to m for a call (see below).

Commit: https://mgit.msbls.de/m/postcards/commit/bd4ffde
Branch: mai/hermes/issue-9-feat-short-link (otto-head merges + deploys)

What it does

  • Ablauf / Expiry — compose offers „Gültig bis “. The date → Shlink validUntil set to end-of-day UTC (23:59:59) of the chosen day — a deliberately generous boundary so the link never dies before the day is over anywhere. After it the pretty slug 404s.
  • Max. Aufrufe / Max views — compose offers „Max. Aufrufe N“ → Shlink maxVisits. After N hits the slug 404s.
  • Both limits apply only to the pretty short link. The card's own /c/{token} stays reachable forever (the durable artifact), exactly as the issue's default said.
  • Both only make sense with a custom slug: setting a limit without a slug is a friendly validation error („… gelten nur für eine Wunsch-Adresse …“), not a silent no-op.
  • The share banner now shows gültig bis DD.MM.YYYY“ / „👁 höchstens N Aufrufe“ when set (persisted on the card, migration 005_slug_lifecycle.sql, nullable — so it shows without calling back to Shlink).

Duplicates — needs m's call (shipped (a), no block)

Shlink slugs are globally unique per domain and the app has no accounts, so a slug exists once ever. Shipped (a) = current #3 behaviour: a taken slug → „schon vergeben, bitte wähle eine andere“, zero new concepts, zero orphan cards. (b) auto-suffix (oma-erika-2) is an easy follow-up if m prefers it; (c) per-sender namespaces is a bigger change. No code shipped for (b)/(c) pending m's decision — current behaviour is unchanged and safe either way.

Validation (all before any side effect — bad input = clean retry, no orphan)

  • past expiry → „… liegt in der Vergangenheit …“
  • non-positive / non-numeric / absurd max-views → „… ganze Zahl ab 1 …“ (capped at 1,000,000)
  • limit without slug → „… nur für eine Wunsch-Adresse …“
  • maxVisits=1 slug → 1st hit 302, 2nd hit 404 (exhausted)
  • slug with past validUntil404 immediately
  • app forwards a future expiry correctly: 2026-12-31validUntil=2026-12-31T23:59:59+00:00 in Shlink
  • regression: bare slug (#3) 302s, /to/ /from/ (#8) unaffected, no-slug card works
  • migration 005 applied to prod (slug_valid_until timestamptz, slug_max_visits int); all test slugs + DB rows cleaned up afterwards.

DoD

  • Compose offers expiry + max-views on a custom short link; both enforced via Shlink (validUntil / maxVisits)
  • e2e: maxVisits=1 link 404s on 2nd hit; expired link 404s
  • Duplicate policy implemented (default (a); (b) flagged for m)
  • All prior link forms still work; build/vet/test -race green; test data cleaned
  • Commit references this issue

Deploy note for otto-head

No deploy change needed for the limits — Shlink enforces them and serves the 404 itself. For a friendlier expired/exhausted page than Shlink's bare 404, point Shlink's per-domain Invalid-short-URL redirect at a friendly URL (documented in deploy/README.md § 5). Optional polish; the DoD behaviour holds without it.

One open question for m: confirm duplicate policy (a) (shipped) or switch to (b) auto-suffix / (c) namespaces.

## #9 short-link lifecycle — shipped (shift-1) Expiry + max-views built end-to-end, verified live on `post.msbls.de`. Duplicate policy: shipped default **(a) first-come-first-served** — flagged to m for a call (see below). Commit: https://mgit.msbls.de/m/postcards/commit/bd4ffde Branch: `mai/hermes/issue-9-feat-short-link` (otto-head merges + deploys) ### What it does - **Ablauf / Expiry** — compose offers *„Gültig bis <Datum>“*. The date → Shlink `validUntil` set to **end-of-day UTC** (23:59:59) of the chosen day — a deliberately generous boundary so the link never dies before the day is over anywhere. After it the pretty slug 404s. - **Max. Aufrufe / Max views** — compose offers *„Max. Aufrufe N“* → Shlink `maxVisits`. After N hits the slug 404s. - Both limits apply **only to the pretty short link**. The card's own `/c/{token}` stays reachable forever (the durable artifact), exactly as the issue's default said. - Both only make sense **with a custom slug**: setting a limit without a slug is a friendly validation error (*„… gelten nur für eine Wunsch-Adresse …“*), not a silent no-op. - The share banner now shows *„⏳ gültig bis DD.MM.YYYY“* / *„👁 höchstens N Aufrufe“* when set (persisted on the card, migration `005_slug_lifecycle.sql`, nullable — so it shows without calling back to Shlink). ### Duplicates — needs m's call (shipped (a), no block) Shlink slugs are globally unique per domain and the app has no accounts, so a slug exists once ever. Shipped **(a)** = current #3 behaviour: a taken slug → *„schon vergeben, bitte wähle eine andere“*, zero new concepts, zero orphan cards. **(b)** auto-suffix (`oma-erika-2`) is an easy follow-up if m prefers it; **(c)** per-sender namespaces is a bigger change. No code shipped for (b)/(c) pending m's decision — current behaviour is unchanged and safe either way. ### Validation (all before any side effect — bad input = clean retry, no orphan) - past expiry → *„… liegt in der Vergangenheit …“* - non-positive / non-numeric / absurd max-views → *„… ganze Zahl ab 1 …“* (capped at 1,000,000) - limit without slug → *„… nur für eine Wunsch-Adresse …“* ### e2e (live `post.msbls.de`, real Shlink, prod DB) - `maxVisits=1` slug → **1st hit 302**, **2nd hit 404** (exhausted) ✅ - slug with **past `validUntil`** → **404** immediately ✅ - app forwards a future expiry correctly: `2026-12-31` → `validUntil=2026-12-31T23:59:59+00:00` in Shlink ✅ - regression: bare slug (#3) 302s, `/to/` `/from/` (#8) unaffected, no-slug card works ✅ - migration 005 applied to prod (`slug_valid_until timestamptz`, `slug_max_visits int`); all test slugs + DB rows cleaned up afterwards. ### DoD - [x] Compose offers expiry + max-views on a custom short link; both enforced via Shlink (`validUntil` / `maxVisits`) - [x] e2e: maxVisits=1 link 404s on 2nd hit; expired link 404s - [x] Duplicate policy implemented (default (a); (b) flagged for m) - [x] All prior link forms still work; build/vet/test `-race` green; test data cleaned - [x] Commit references this issue ### Deploy note for otto-head No deploy change needed for the limits — Shlink enforces them and serves the 404 itself. For a *friendlier* expired/exhausted page than Shlink's bare 404, point Shlink's per-domain *Invalid-short-URL* redirect at a friendly URL (documented in `deploy/README.md` § 5). Optional polish; the DoD behaviour holds without it. **One open question for m:** confirm duplicate policy **(a)** (shipped) or switch to **(b)** auto-suffix / **(c)** namespaces.
Author
Collaborator

Merged & deployed — lifecycle live on sendmy.cards

Merged bd4ffde (merge 2365076), deployed. Migration 005 applied. Verified live e2e on sendmy.cards: a max_visits=1 slug → 1st hit 302 → card, 2nd hit 404 (exhausted). Compose now has 'Gültig bis' (date) + 'Max. Aufrufe' (number); limits require a custom slug; the card's /c/{token} stays reachable forever. Test data cleaned.

Duplicate policy: (a) first-come-first-served — matches m's decision (recorded above). Shipped. build/vet/test -race green.

Not closing — m closes.

## ✅ Merged & deployed — lifecycle live on sendmy.cards Merged `bd4ffde` (merge `2365076`), deployed. Migration 005 applied. Verified live e2e on sendmy.cards: a `max_visits=1` slug → **1st hit 302 → card, 2nd hit 404** (exhausted). Compose now has 'Gültig bis' (date) + 'Max. Aufrufe' (number); limits require a custom slug; the card's `/c/{token}` stays reachable forever. Test data cleaned. **Duplicate policy: (a) first-come-first-served — matches m's decision** (recorded above). Shipped. build/vet/test -race green. Not closing — m closes.
mAi closed this issue 2026-06-23 15:48:02 +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#9
No description provided.