Skip to content

Content

Purpose

The curated catalog for gamification: the admin-authored definitions of what can be earned. Read-only from the app's perspective. Every earnable thing (a curated stamp, a curio, a challenge) is a row this domain owns; the per-user earned state lives in passport, and the evaluation that moves users from "not earned" to "earned" lives in the game engine.

Content is an internal domain: backend/internal/services/content/. It has no handler.go or routes.go, so no HTTP surface of its own. Its stores are consumed by the game engine (candidate lookups, active-window reads) and by the daily archive cron.

Mental model

content (catalog, read-only)
LibraryStorestamps · curios · challenges
StampGuideStorestamp_guide_entries
↓ read
game engineresolvers
→ write
passportearned state
Content defines what exists. The engine reads it and writes earned rows into passport.

A catalog row moves through one lifecycle, tracked by ContentStatus:

draft ──publish──► published ───(daily archive cron)───► archived

Time-bounded challenges (duration = day | week | month | special) archive automatically once their latest-timezone window has closed. permanent challenges, stamps, and curios stay published until an admin archives them.

Surface area

Store Backs Consumed by
LibraryStore (content/library_store.go) tomoda_stamps, curios, challenges reads + active-window filter + archive sweep game engine, challenge_archive cron
StampGuideStore (content/stamp_guide_store.go) stamp_guide_entries geographic candidate matching game engine stamp resolver

Both are wired in content/wire.go. Constructors: NewLibraryStore, NewStampGuideStore.

LibraryStore

  • Point reads: GetTomodaStamp / …BySlug, GetCurio / …BySlug, GetChallenge / …BySlug.
  • List reads: ListPublishedTomodaStamps(scope, country, limit), ListPublishedCurios(category, limit), ListActiveChallengesForUser(userTZ, limit).
  • StampGuideEntriesFor(stampID) for the stamp detail screen.
  • ArchiveExpiredChallenges() flips time-bounded challenges past their UTC cutoff to archived; returns the count flipped.

StampGuideStore

  • CandidateStampsForVisit(userID, locationID, city, region, country), single round-trip returning published stamps the user has not earned that have at least one guide entry matching the visit geography.
  • MatchedEntriesForStamp(stampID, …), which of a stamp's entries a visit satisfies (a region stamp can list "Florence" and "Tuscany"; a Florence visit satisfies both).
  • CountForStamp(stampID), live entry count, used at admin-save time to default minimum_visits to "visit all".

Data model

tomoda_stamps

The curated stamp catalog, geographic-only (models.TomodaStamp in backend/internal/models/tomoda_stamp.go). Each stamp carries a guide (a list of stamp_guide_entries). A user earns it by satisfying minimum_visits of those entries.

Field Notes
scope (StampScope) landmark, district, city, region, country, multi_country. Drives the guide-entry match dimension.
continent / country / region / city / district Geographic anchor for the scope tier.
minimum_visits The X in "visit X of Y guide entries." Defaults to the full entry count.
asset_key / bespoke_asset_key S3 keys under stamps/ and stamps-bespoke/; asset_url / bespoke_asset_url are composed on read via storage.PublicURL in AfterFind.
status (ContentStatus) draft / published / archived.
source (StampSource) tomoda today; single-valued.
localizations JSONB per-language ContentTranslation.

stamp_guide_entries

One curated place, city, region, or country contributing toward a stamp (models.StampGuideEntry). Exactly one of location_id / city / region / country is populated per row, enforced by the CHECK constraint in internal/migrate/manual.go (applyStampGuideMatchCheck). Which field is used is governed by the parent stamp's scope. Entries carry user-facing guide content (title, description, photo_key, recommendations) so the stamp detail screen doubles as a travel guide.

curios + user_curios_earned + curio_earn_events

curios is the catalog: story-bearing artwork (models.Curio, with slug, name, story, category, hidden_tier, asset_key). A curio carries no rule of its own; it is earned as a challenge's reward via challenges.reward_curio_id.

The earned-side tables (user_curios_earned, curio_earn_events) live in passport; the game engine writes them. curio_earn_events is the multi-row audit trail per (user, curio); the originating earn is the row with the lowest occurred_at.

HiddenTier (shared with challenges) governs locked-state visibility:

Tier Locked-state UI
visible Silhouette, category, hint, progress count
hidden Silhouette, category, hint; no progress
secret No UI surface at all until earned

challenges + challenge_progress

challenges is the catalog (models.Challenge). Progress (challenge_progress) is per-(scope, challenge), lazy, and lives in passport.

Field Notes
rule (rules.Expression) The completion expression tree (leaf or composite). Validated against the leaf registry at write time. See game engine.
triggers (text[]) Union of ActionKind values across the rule's leaves, computed at publish time. GIN-indexed; the engine skips challenges whose triggers don't overlap a signal's kinds.
duration (ChallengeDuration) permanent / day / week / month / special. Governs the active window and auto-archive.
tz_mode (ChallengeTzMode) local (default) aligns windows to the user's midnight; fixed uses a single UTC window.
optin false = always-on; true = the user must explicitly start. Children inherit the parent's opt-in.
parent_challenge_id Groups children under a parent; the engine evaluates a child only when the parent has a progress row.
scope_kind (ChallengeScopeKind) user today; circle reserved for group challenges.
reward_curio_id Curio granted on completion, when set.
series_slug Groups related challenges for challenge_completed series rules.

Challenge duration semantics

duration controls the active window and the auto-archive cutoff. The active-window check uses calendar-aligned wall-clock comparison in the user's local timezone: the same wall-clock window for every user, different absolute UTC moments.

duration Active window Auto-archive cutoff
permanent Always (until manually archived) Never
day Calendar day containing published_at in user TZ published_at + 2 days (UTC)
week ISO week containing published_at in user TZ published_at + 8 days
month Calendar month containing published_at in user TZ published_at + 32 days
special [starts_at, ends_at) (UTC, admin-set) ends_at + 1 day

The grace period (2 / 8 / 32 days) covers the UTC-12 to UTC+14 spread (about 26 hours), so every user's wall-clock window has closed before the sweeper runs.

Two implementations of the active-window check

The predicate exists twice, and unit tests keep them in agreement:

  • SQL in LibraryStore.ListActiveChallengesForUser (the per-user hot read path).
  • Go in content.IsActiveChallengeWindowOpen(c, userTZ, now), used by the game engine when rule evaluation already holds the user's time.Location and wants to avoid a DB round-trip.

Background jobs

Cron Schedule What it does
cron:challenge_archive every 24h Calls LibraryStore.ArchiveExpiredChallenges via the handler in internal/async/handlers/cron.go. Flips time-bounded challenges past their conservative UTC cutoff to archived. Idempotent.

Registered in internal/async/scheduler.go (cronEntries) as TaskChallengeArchive. See Async.

Notable behavior

No earn evaluation here

Content owns catalog tables and read paths only. The game engine evaluates rules and writes earned rows into passport (user_stamp_progress, user_curios_earned, curio_earn_events, challenge_progress).

Catalog tables start empty

There is no seeded content in a fresh deployment. Admin authoring and bulk import populate the catalog; until then the resolvers find no candidates and nothing is ever earned.

Where to look

  • Passport - per-user earned/done state written against this catalog
  • Game Engine - reads this catalog, writes passport progress
  • Async - the challenge_archive cron