feat: card-level max-views (per-card view counter) — follow-up to #14 #18
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 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_untilto 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
max_visits(int, nullable) + avisit_count(int, default 0) column — idempotent, owner-safe migration./c/{token}handleView: incrementvisit_counton a real view; whenvisit_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).maxVisitsin sync so link + card exhaust together.Constraints
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
#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:
fbaed3a041Branch
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:
HEAD/ prefetch;cap=1card before the recipient ever opened it;?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=1isn'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 ininternal/server/viewcount.goand the README.How it's enforced
migrations/009_card_max_visits.sql): renameslug_max_visits→max_visits(guarded + NULL-reset on rename, so no existing card silently starts enforcing a brand-new card-level cap) and addvisit_count int NOT NULL DEFAULT 0. Idempotent & owner-safe, same pattern as #14's008. Also fixed005so it no longer re-adds the renamed column on later boots (same resurrection fix #14 applied forvalid_until).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.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.maxVisits, so the pretty link and the card exhaust together.exhausted.html(HTTP 410, „Diese Karte ist nicht mehr verfügbar“). Share banner + admin overview updated (admin showsviews/capand an aufgebraucht flag).Verification
go build/go vet/gofmtclean;go test -race ./...green.IsExhausted,isBot/countableView, exhausted-page render; updated share-banner & compose-lifecycle tests.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.DoD met.
✅ Merged & deployed — card-level max-views live on sendmy.cards
Merged
fbaed3a(merge7f4ee4f), deployed. Migration 009 applied (slug_max_visits→max_visits+visit_count, guarded rename, NULL backfill — no card retroactively capped). Verified live: card-levelmax_visits=1(no slug) → view#1 200, view#2 410 'schon oft genug geöffnet', before content/password. Slug cards keep ShlinkmaxVisitsin sync. Atomic increment. build/vet/test -race green. Not closing — m closes.