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¶
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 toarchived; 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 defaultminimum_visitsto "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'stime.Locationand 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¶
backend/internal/services/content/library_store.go,stamp_guide_store.go,wire.gobackend/internal/models/tomoda_stamp.go,curio.go,challenge.go,stamp_guide_entry.go,content_translation.gointernal/migrate/manual.go(applyStampGuideMatchCheck,applyChallengesTriggersGINIndex)internal/async/handlers/cron.go(thechallenge_archivehandler)
Cross-links¶
- Passport - per-user earned/done state written against this catalog
- Game Engine - reads this catalog, writes passport progress
- Async - the
challenge_archivecron