feat: card-level max-views (per-card view counter) — follow-up to #14 #18

Closed
opened 2026-06-17 08:39:18 +00:00 by mAi · 2 comments
Collaborator

What

m asked for max-views at card level (alongside card-level expiration, #14). #14 shipped card-level expiration but kept max-views slug-level (Shlink maxVisits) because card-level max-views needs more than a timestamp compare.

Why it's separate

Card-level expiry = stateless (compare valid_until to now). Card-level max-views = stateful: needs a per-card view counter incremented on each /c/{token} render, enforced server-side (after N → 410/exhausted page, like expiry). Plus a decision on what counts as a 'view' (every GET? dedupe bots/prefetch/reloads within the unlock-cookie window?).

Scope

  • Add a card-level max_visits (int, nullable) + a visit_count (int, default 0) column — idempotent, owner-safe migration.
  • Compose: the „Max. Aufrufe“ field becomes card-level (settable on any card, like #14's expiry), not gated behind a slug.
  • /c/{token} handleView: increment visit_count on a real view; when visit_count >= max_visits, serve the friendly exhausted page (reuse #14's expired/410 pattern), no content. Decide + document the counting rule (suggest: count a successful content render; don't count the password prompt, the expired page, or HEAD/prefetch; the creator's auto-unlocked first view — your call, document it).
  • Slug coexistence: if a custom slug exists, keep Shlink maxVisits in sync so link + card exhaust together.

Constraints

  • Mobile-first, German. Coexists with expiry (#14), password (#11), slugs (#8/#9). Exhausted check vs expiry vs password ordering: expired OR exhausted → blocked regardless of password (same as #14).
  • Concurrency: increment should be safe under concurrent hits (atomic UPDATE … SET visit_count = visit_count + 1 RETURNING).

Sequencing

Compose + /c/{token} handler + schema → same single-worker compose track (after the other queued compose work). otto-head assigns when the track frees.

DoD

  • Max-views settable on any card; Nth+1 view → exhausted page server-side, no content; counter atomic.
  • Slug cards: link + card exhaust together. Non-capped cards unchanged.
  • build/vet/test green; e2e (cap=1 → 2nd view exhausted); test data cleaned. Commit references this issue.
## What m asked for max-views at **card level** (alongside card-level expiration, #14). #14 shipped card-level *expiration* but kept max-views **slug-level** (Shlink `maxVisits`) because card-level max-views needs more than a timestamp compare. ## Why it's separate Card-level expiry = stateless (compare `valid_until` to now). Card-level max-views = **stateful**: needs a per-card **view counter** incremented on each `/c/{token}` render, enforced server-side (after N → 410/exhausted page, like expiry). Plus a decision on what counts as a 'view' (every GET? dedupe bots/prefetch/reloads within the unlock-cookie window?). ## Scope - Add a card-level `max_visits` (int, nullable) + a `visit_count` (int, default 0) column — idempotent, owner-safe migration. - Compose: the *„Max. Aufrufe“* field becomes card-level (settable on any card, like #14's expiry), not gated behind a slug. - `/c/{token}` handleView: increment `visit_count` on a real view; when `visit_count >= max_visits`, serve the friendly exhausted page (reuse #14's expired/410 pattern), no content. Decide + document the counting rule (suggest: count a successful content render; don't count the password prompt, the expired page, or HEAD/prefetch; the creator's auto-unlocked first view — your call, document it). - Slug coexistence: if a custom slug exists, keep Shlink `maxVisits` in sync so link + card exhaust together. ## Constraints - Mobile-first, German. Coexists with expiry (#14), password (#11), slugs (#8/#9). Exhausted check vs expiry vs password ordering: expired OR exhausted → blocked regardless of password (same as #14). - Concurrency: increment should be safe under concurrent hits (atomic UPDATE … SET visit_count = visit_count + 1 RETURNING). ## Sequencing Compose + /c/{token} handler + schema → same single-worker compose track (after the other queued compose work). otto-head assigns when the track frees. ## DoD - Max-views settable on any card; Nth+1 view → exhausted page server-side, no content; counter atomic. - Slug cards: link + card exhaust together. Non-capped cards unchanged. - build/vet/test green; e2e (cap=1 → 2nd view exhausted); test data cleaned. Commit references this issue.
mAi self-assigned this 2026-06-17 12:25:22 +00:00
Author
Collaborator

#18 done — card-level max-views (per-card view counter)

Promoted the max-views cap from slug-level (Shlink-only) to a card-level property enforced server-side on /c/{token}, mirroring what #14 did for expiry. It's now settable on any card, not just cards with a custom slug.

Commit: fbaed3a041
Branch mai/hermes/issue-18-feat-card-level (pushed; head merges).

What counts as a view (the decision)

A view counts only on a successful content render by a real visitor. Deliberately not counted:

  • the password prompt / expired / exhausted pages (no content served);
  • HEAD / prefetch;
  • known bots & chat-app link-preview unfurlers (WhatsApp, Telegram, Slack, Discord, Facebook, Twitter/X, LinkedIn, …) — otherwise the first WhatsApp preview fetch would burn a cap=1 card before the recipient ever opened it;
  • the creator's own post-compose landing (?created=1) — their confirmation view must not consume one of the recipient's views.

Ordinary reloads do count„max. N Aufrufe“ is taken literally; a creator who wants re-reads picks a larger N. (?created=1 isn't a secret, so it's not a hard bypass guarantee — but anyone with the link already has full access to the content, so nothing leaks; only the courtesy counter could be nudged.) Full rationale is documented in internal/server/viewcount.go and the README.

How it's enforced

  • Schema (migrations/009_card_max_visits.sql): rename slug_max_visitsmax_visits (guarded + NULL-reset on rename, so no existing card silently starts enforcing a brand-new card-level cap) and add visit_count int NOT NULL DEFAULT 0. Idempotent & owner-safe, same pattern as #14's 008. Also fixed 005 so it no longer re-adds the renamed column on later boots (same resurrection fix #14 applied for valid_until).
  • Atomic counter (Repo.IncrementVisit): UPDATE … SET visit_count = visit_count + 1 WHERE id = $1 AND visit_count < max_visits RETURNING visit_count. The guard makes it race-free — under concurrent hits exactly N views ever see content; the (N+1)-th caller gets no row → exhausted page.
  • Ordering (handleView): not-found → expiredexhausted → password → render(+count). Expired or exhausted blocks regardless of any password, same as #14. Uncapped cards are never written to, so their behaviour is unchanged.
  • Slug coexistence: a slug card still forwards the cap to Shlink as maxVisits, so the pretty link and the card exhaust together.
  • UX: the „Max. Aufrufe“ field moved out of the slug section to sit next to the expiry (card-level, German, mobile-first). New exhausted.html (HTTP 410, „Diese Karte ist nicht mehr verfügbar“). Share banner + admin overview updated (admin shows views/cap and an aufgebraucht flag).

Verification

  • go build / go vet / gofmt clean; go test -race ./... green.
  • New unit tests: IsExhausted, isBot / countableView, exhausted-page render; updated share-banner & compose-lifecycle tests.
  • e2e against an ephemeral throwaway Postgres (migrations applied on boot, container torn down afterwards — no test data left behind):
    • cap=1: creator landing (?created=1) → 200, doesn't count; bot view → 200, doesn't count; human view #1 → 200 + content; human view #2 → 410 exhausted, no content leak; bot on the exhausted card → 410.
    • control: an uncapped card serves 200 on repeated views (never exhausts).

DoD met.

## #18 done — card-level max-views (per-card view counter) Promoted the max-views cap from **slug-level** (Shlink-only) to a **card-level** property enforced server-side on `/c/{token}`, mirroring what #14 did for expiry. It's now settable on **any** card, not just cards with a custom slug. **Commit:** https://mgit.msbls.de/m/postcards/commit/fbaed3a041375a58e2755c677afb9328a8c93ad0 Branch `mai/hermes/issue-18-feat-card-level` (pushed; head merges). ### What counts as a view (the decision) A view counts only on a **successful content render** by a real visitor. Deliberately **not** counted: - the password prompt / expired / exhausted pages (no content served); - `HEAD` / prefetch; - known bots & chat-app **link-preview unfurlers** (WhatsApp, Telegram, Slack, Discord, Facebook, Twitter/X, LinkedIn, …) — otherwise the first WhatsApp preview fetch would burn a `cap=1` card before the recipient ever opened it; - the creator's own post-compose landing (`?created=1`) — their confirmation view must not consume one of the recipient's views. **Ordinary reloads do count** — *„max. N Aufrufe“* is taken literally; a creator who wants re-reads picks a larger N. (`?created=1` isn't a secret, so it's not a hard bypass guarantee — but anyone with the link already has full access to the content, so nothing leaks; only the courtesy counter could be nudged.) Full rationale is documented in `internal/server/viewcount.go` and the README. ### How it's enforced - **Schema** (`migrations/009_card_max_visits.sql`): rename `slug_max_visits` → `max_visits` (guarded + NULL-reset on rename, so no existing card silently starts enforcing a brand-new card-level cap) and add `visit_count int NOT NULL DEFAULT 0`. Idempotent & owner-safe, same pattern as #14's `008`. Also fixed `005` so it no longer re-adds the renamed column on later boots (same resurrection fix #14 applied for `valid_until`). - **Atomic counter** (`Repo.IncrementVisit`): `UPDATE … SET visit_count = visit_count + 1 WHERE id = $1 AND visit_count < max_visits RETURNING visit_count`. The guard makes it race-free — under concurrent hits exactly N views ever see content; the (N+1)-th caller gets no row → exhausted page. - **Ordering** (`handleView`): not-found → **expired** → **exhausted** → password → render(+count). Expired **or** exhausted blocks regardless of any password, same as #14. Uncapped cards are never written to, so their behaviour is unchanged. - **Slug coexistence:** a slug card still forwards the cap to Shlink as `maxVisits`, so the pretty link and the card exhaust together. - **UX:** the *„Max. Aufrufe“* field moved out of the slug section to sit next to the expiry (card-level, German, mobile-first). New `exhausted.html` (HTTP 410, *„Diese Karte ist nicht mehr verfügbar“*). Share banner + admin overview updated (admin shows `views/cap` and an *aufgebraucht* flag). ### Verification - `go build` / `go vet` / `gofmt` clean; `go test -race ./...` green. - New unit tests: `IsExhausted`, `isBot` / `countableView`, exhausted-page render; updated share-banner & compose-lifecycle tests. - **e2e** against an ephemeral throwaway Postgres (migrations applied on boot, container torn down afterwards — no test data left behind): - `cap=1`: creator landing (`?created=1`) → 200, **doesn't** count; bot view → 200, **doesn't** count; **human view #1 → 200 + content**; **human view #2 → 410 exhausted, no content leak**; bot on the exhausted card → 410. - control: an **uncapped** card serves 200 on repeated views (never exhausts). DoD met.
Author
Collaborator

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

Merged fbaed3a (merge 7f4ee4f), deployed. Migration 009 applied (slug_max_visitsmax_visits + visit_count, guarded rename, NULL backfill — no card retroactively capped). Verified live: card-level max_visits=1 (no slug) → view#1 200, view#2 410 'schon oft genug geöffnet', before content/password. Slug cards keep Shlink maxVisits in sync. Atomic increment. build/vet/test -race green. Not closing — m closes.

## ✅ Merged & deployed — card-level max-views live on sendmy.cards Merged `fbaed3a` (merge `7f4ee4f`), deployed. Migration 009 applied (`slug_max_visits`→`max_visits` + `visit_count`, guarded rename, NULL backfill — no card retroactively capped). Verified live: card-level `max_visits=1` (no slug) → **view#1 200, view#2 410 'schon oft genug geöffnet'**, before content/password. Slug cards keep Shlink `maxVisits` in sync. Atomic increment. build/vet/test -race green. Not closing — m closes.
mAi closed this issue 2026-06-23 15:48:03 +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#18
No description provided.