Data Model¶
Tomoda's persistent state lives in a single Postgres database (PostGIS-enabled). The schema is defined by goose migrations in backend/db/migrations/; db.Migrate applies them at boot. The structs in backend/internal/models/ are plain, hand-written Go types the app passes around, not schema declarations. The models package is shared: every vertical domain (see Domains) reads and writes these structs through its own Store, which runs sqlc-generated queries over pgx/v5 and maps rows to *models.X. This page groups the tables by the domain that owns them.
Source of truth
The goose migrations in backend/db/migrations/*.sql are the authoritative schema; backend/db/schema/ is a generated per-domain snapshot that sqlc targets. The structs in backend/internal/models/*.go are plain Go types with no schema tags. The tables below name the load-bearing entities and their role, not every join column, soft-delete flag, or audit timestamp. For exact fields, read the migration or the struct.
Where enums live¶
Every finite value-set is a named string type declared next to the struct it belongs to, not in a central file. EventStatus lives in event.go, ChatMessageType in chat_message.go, TravelLogSource in travel_log.go. Shared enums live with their canonical owner: Visibility (used by profile, moment, stamp) in user_profile.go, ContentStatus in tomoda_stamp.go. Each type carries constants and an IsValid() used at trust boundaries. The tables below name the enums each entity carries.
Identity & auth¶
Owned by the user and auth domains.
| Entity | Role |
|---|---|
User |
Canonical account record: email, username, hashed password, AccountType, UserRole, OAuth linkage, Stripe fields, profile basics. Enums AccountType and UserRole live here. |
UserProfile |
Per-user display, preferences (ChatPreferences, MapPreferences), and denormalized visit stats materialized from Travel Log writes. Canonical home of the Visibility enum. |
PublicUser |
Read projection of a user for cross-domain briefs. |
Session |
Active login session, indexed by device; drives the "Active sessions" screen and per-session revocation. |
RefreshToken |
Long-lived opaque token used to mint new JWT access tokens; mirrored into Redis for fast validation. |
LoginHistory |
Per-login record exposed to users via the "Login history" screen. |
WebAuthnCredential |
Stored passkey public key + counter for one user. Multiple credentials per user are supported. |
OTP |
Short-lived one-time codes (signup, password reset, email change, phone verification), purpose-tagged. |
APIKey / SafeAPIKey |
Encrypted programmatic key with a scope list, authenticated via X-API-Key. SafeAPIKey is the redacted read shape. |
See Authentication for how these compose into login and session flows.
Social graph¶
Owned by the friend domain.
| Entity | Role |
|---|---|
Friendship |
Symmetric friendship with requester_id, addressee_id, and FriendshipStatus (pending, accepted, blocked). Source of truth for who sees whose live location and friends map. |
CloseFriend |
Owner-scoped inner-circle list backing the close_friends audience. Asymmetric: a row (alice, bob) means Alice added Bob. Presupposes an accepted friendship. |
Events¶
Owned by the event domain.
| Entity | Role |
|---|---|
Event |
Core social object: title, time range, category (a taxonomy string, not a foreign key), location, capacity, owner. Carries EventStatus, EventType, EventVisibility. |
EventParticipant |
Join row, user × event, with role (owner, host, guest) and EventParticipantStatus (pending, approved, rejected, left). |
EventItem |
Event-owned copy of a curated board card, snapshotted from a plan Item at promotion. Photos and links are JSONB (EventItemPhotos, EventItemLinks) so the board renders self-contained; links stay a promotion-time snapshot, while the host can replace an item's photos post-promotion via photo_keys on the event-item write path. Reuses ItemRole. |
EventItem is fully owned by the event: editing it never touches the source plan, and it survives the source Item being edited or deleted. See Snapshot event items at plan promotion.
Plans¶
Owned by the plan domain. A plan is a date-free, collaborative intention that promotes into an Event.
| Entity | Role |
|---|---|
Plan |
The canvas: a set of Items grouped by PlanLocation place-cards. Carries PlanVisibility, PlanStatus. |
Item |
One idea on a board. The board is the owner's stash (PlanID nil) or a plan's shared canvas (PlanID set); LocationID is the grouping key. Carries ItemRole, ItemStatus. content_key dedups re-shares against the owner's active stash (partial-unique on (owner_id, content_key)). |
ItemLink |
An item's read-only pointer at a shared parsed_links record (item_id, parsed_link_id, position); the API flattens the two. |
parsed_links (shared) |
One row per canonical_url: parsed content (Platform, ItemLinkStatus, ItemLinkSignals, LinkFacts, thumbnail, resolved place) + save_count. Shared across every item/user that saved the URL; re-parse propagates to all, GC'd when the last pointer is deleted. |
ItemPhoto |
Photo attached to an item. Carries ItemPhotoSource. A source = 'moment' photo also carries moment_id (FK to moments, ON DELETE CASCADE), referencing a moment's photo onto a saved place-card. |
ItemNote |
Free-text note on an item. |
PlanLocation |
A place-card on the canvas (PlanLocationStatus); PlanInterestVote records votes for the leading place. |
PlanAvailabilityOption / PlanAvailabilityVote |
The when-poll: proposed day/time options (PlanAvailabilityKind) and per-axis votes. |
PlanCollaborator |
Roster row with PlanCollaboratorRole, PlanCollaboratorStatus, PlanRSVP; PlanShareToken mints join links. |
Chat¶
Owned by the chat domain.
| Entity | Role |
|---|---|
Chat |
Either an event-scoped group room or a direct/group DM (ChatType = event / dm / group); holds title, avatar, ChatDisappearingMessages setting. |
ChatParticipant |
Membership row with read state (last_read_at), nickname, mute, last-seen. |
ChatMessage |
A message in a room: text, image, sticker, share, system, with optional expires_at. Carries ChatMessageType; JSONB MessageMetadata is the typed envelope (mention slots via Mention / MentionKind, tag notices via TagKind). MessageReaction holds reactions. |
SystemMessage |
Payload for system events (SystemMessageEvent), serialized into a ChatMessage and mapped 1:1 to frontend locale keys. |
Every Event has a Chat, but Chat is independent enough to back direct messages and ad-hoc group chats that belong to no event.
Moments¶
Owned by the moment domain.
| Entity | Role |
|---|---|
Moment |
Lightweight, ephemeral post anchored to a Location. Uses Visibility (public, friends, private), optional media, and TTL. |
MomentLike |
Like edge, user × moment, unique on the pair. |
MomentTag |
Tag edge (author tags friends at create-time). Each tag writes a UserTravelLog row so the tagged friend's visit is credited. |
Presence & check-ins¶
Owned by the presence domain.
| Entity | Role |
|---|---|
ActiveLocationSession |
The opt-in live-location broadcast a user shares so friends see where they are right now. |
UserCheckin |
Lowest-friction visit producer: no photo, no caption, anchored to a Location. Throttled 6h per (user, location). Its UserTravelLog row is the count-able fact; deleting the check-in leaves the log alone. |
CheckinCompanion |
Companion tag join, symmetric with MomentTag. Each row writes a UserTravelLog row for the tagged friend. |
Locations & maps¶
Owned by the location and maps domains. Location is the shared place layer everything pins to.
| Entity | Role |
|---|---|
Location |
Canonical place record: lat/lng, LocationProvenance (concrete enum: provider_api, serper_maps, osm_seed, wikidata_seed, geonames_seed, unesco_seed, bulk_import, promoted), and LocationTranslation per-language fields. Carries the LocationBusinessStatus enum (OPERATIONAL, CLOSED_PERMANENTLY, CLOSED_TEMPORARILY) and a RegionID FK (→ regions, nil until enrichment resolves it). Every external identity lives one-row-per-ref in location_providers ((provider, provider_id) composite unique); there is no primary provider column. Denormalized map fields (sub_category, save_count, moment_count, is_active) live on the same row, alongside a trigger-maintained search_text blob and an optional embedding (halfvec(1024)) for semantic search. Timezone is not stored (derived from coordinates). |
location_providers |
One row per external source ref for a location; its PK guarantees a (provider, provider_id) maps to exactly one location, and the dedup ladder resolves a place by any of its refs. |
Region / regions |
The administrative-area tree (ADM1/ADM2/ADM3 via AdminLevel) with self-referential parent_region_id; a location's region_id FK points into it. Localized names, identity (provider, provider_id) unique. An optional iso_3166_2 (e.g. "US-CA") carries the ADM1 subdivision code for the passport map. city_id is a self-FK on locations (cities are locations), assigned in one set-based nearest-city pass at load. An optional boundary (geometry(MultiPolygon,4326), GiST-indexed) holds the area polygon for point-in-polygon coords-to-admin resolution; ADM1 boundaries + iso_3166_2 come from the Natural Earth 10m admin_1 source at build time. Never SELECTed (only ST_Contains). |
Country / countries |
Gazetteer reference row keyed by ISO 3166-1 alpha-2 code (PK): display name, localizations, continent, centroid lat/lng, prominence; (provider, provider_id) unique. Backs locations.country_code. |
UserAffinity / UserSave / UserDismissal |
Per-user map personalization: category affinity, saved places, dismissed markers. |
RankingImpression / RankingSelection (ranking_impression / ranking_selection) |
Durable search-ranking label sink. An impression (sampled) is the ordered candidates a search showed (JSONB, with per-candidate feature sub-scores); a selection (every one) is the location the user picked. Joined on ranking_query_id, they are the offline weight tuner's labeled cases. Pruned after 180d by the ranking_label_prune cron. |
ActivityEvent (activity_events) |
Append-only cross-surface interaction firehose (verb, object, surface, jsonb context). Monthly RANGE-partitioned on created_at; partitions older than 30 days are dropped. Online serving never scans it; the activity_rollup cron folds it into interaction_edges. |
InteractionEdge (interaction_edges) |
Rolled-up per-(actor, edge_type, target) affinity the ranker reads at request time. Weight accumulates in the rollup and is decayed at read by age (last_at). Bounded per user; persists as the durable memory the raw firehose feeds. |
activity_rollup_state |
One high-water-mark row per rollup job so the incremental rollup only reads events newer than it has already folded. |
RankingWeights (ranking_weights) |
The applied composite-ranking weight vectors (the six 0..1 blend weights + applied_by / note provenance). Exactly one row is active (partial unique index ... WHERE active); the ranker resolves it over env config and hot-refreshes it on an interval, so an approved retune applies without a redeploy. |
Proximity dedupe and viewport clustering run on the PostGIS geometry index. See Backend → Locations service for the resolve, dedup, and provenance story. The ranking label sink feeds the offline weight tuner and is fed best-effort (a failed insert never blocks a search or selection).
Content catalog¶
Owned by the content domain: curated, admin-authored source-of-truth rows read by the game engine and profile surfaces.
| Entity | Role |
|---|---|
TomodaStamp |
Curated, geographic stamp catalog. Earned by satisfying MinimumVisits guide entries. Carries StampScope, StampSource, and the canonical ContentStatus enum. |
StampGuideEntry |
One curated place/city/region/country in a stamp's guide, carrying travel-guide content. |
Curio |
Tomoda Collection artwork earned when its backing challenge completes. Carries HiddenTier. |
Challenge |
The engagement unit users progress through; awards a reward curio on completion. Carries ChallengeScopeKind, ChallengeDuration, ChallengeTzMode. |
ContentTranslation |
Per-language JSONB payload for TomodaStamp / Curio / Challenge. |
Passport (earned state)¶
Owned by the passport domain: the per-user earned and progress rows the game engine writes and profile reads.
| Entity | Role |
|---|---|
UserTravelLog |
Append-only factual ledger: "user X was at location Y at time T." One row per visit; immutable. Scope columns (district/city/region/country/continent) snapshotted on write, plus stable ids region_id (FK into regions, ON DELETE SET NULL, indexed (user_id, region_id)) and country_code, both copied from the location so admin-1 grouping keys on a stable id rather than the region name. Carries TravelLogSource. |
UserStamp |
Passport row for an earned curated stamp, one per (user, tomoda_stamp). Carries UserStampKind. |
UserStampProgress |
Per-user progress toward a curated stamp; MatchedIDs accumulates satisfied guide-entry IDs. |
FirstDiscovery |
Records the user first to visit a location (one row per location), resolving first-arrival races atomically. Powers the discovery bonus. |
UserCurioEarned / CurioEarnEvent |
Curio dedup row plus the multi-row earn audit trail. CurioEarnSource names the origin. |
ChallengeProgress |
Per-(scope, challenge) progress, same shape for user- and circle-scoped challenges. Carries ChallengeProgressState. |
Partners (multi-tenant scoping)¶
Owned by the partner domain.
| Entity | Role |
|---|---|
Partner |
A non-user tenant (merchant, advertiser, organization), URL-scoped via :partner_id. Carries PartnerStatus; slug is unique and URL-safe. |
PartnerMembership |
Join row, user × partner, with PartnerMembershipRole (owner, admin, staff). Composite unique on (partner_id, user_id). PartnerMemberView is the roster read shape. |
User.account_type = partner is a flag that hides the account from consumer surfaces; it grants no partner permissions on its own. Only PartnerMembership rows do. See Partners service for the access model and the "≥1 owner per partner" invariant.
Notifications¶
Owned by the notification domain.
| Entity | Role |
|---|---|
Notification |
Inbox row per recipient with NotificationKind, group_key, actors (JSONB ActorPreview), payload, read_at, and NotificationResolution. Grouped at write time on (user_id, group_key) while unread. Purged after 90d. |
PushToken |
Per-device APNs / FCM / Web Push credential. Carries PushPlatform; unique on (user_id, platform, token). |
See Notifications for the hybrid seen/resolved grouping model.
Ops & audit¶
| Entity | Role |
|---|---|
AuditLog |
Append-only record of privileged or sensitive actions (admin changes, account deletions). Carries AuditAction. |
Account deletion is a schema property¶
Every column holding a user id references users(id), so erasing an account is one DELETE FROM users rather than a hand-maintained list of deletes that drifts as tables are added. The default action is ON DELETE CASCADE. A new column holding a user id is expected to declare the FK in the same migration that creates it, and to be typed uuid: a varchar user id cannot carry a foreign key, which is how a column silently falls out of the cascade.
| Group | Tables |
|---|---|
| Identity & credentials | sessions, refresh_tokens, login_histories, api_keys, web_authn_credentials, user_profiles |
| Social & content | friendships, close_friends, moments, moment_likes, moment_tags, events, event_participants, plans, plan_collaborators, plan_poll_votes, items, item_notes |
| Chat | chat_participants, message_reactions |
| Passport & rewards | user_stamps, user_stamp_progress, user_curios_earned, curio_earn_events, first_discoveries, user_travel_logs |
| Presence & discovery | user_checkins, checkin_companions, active_location_sessions, user_saves, user_affinity, user_dismissals |
| Ops & delivery | audit_logs, notifications, push_tokens, partner_memberships |
Deliberate exceptions, all ON DELETE SET NULL:
chat_messages.user_id. Group-chat history survives its author's departure with the sender anonymized, so the conversation stays readable for the remaining members. Direct rooms are deleted outright by the service before the cascade runs.plan_share_tokens.created_by,plan_poll_options.created_by,plan_collaborators.invited_by. These are creator attribution on a shared plan, not personal data owned by one account: a share link, a day/time candidate, and an invite record all stay meaningful to the remaining collaborators. The row survives, the identifier is erased. This is why the columns are nullable.
One case that reads like an exception but is not:
items.owner_idcascades even though items can sit in a shared plan. The column isNOT NULL, soSET NULLis not expressible, and an item is single-owner personal data.
Object storage has no equivalent guarantee: keys are enumerated by the service before the cascade, since the cascade destroys the only record of them. See Storage → Purge ordering.
Naming and lifecycle conventions¶
- UUIDs are primary keys throughout (
uuid.UUID,gen_random_uuid()). - Timestamps are stored UTC;
created_at/updated_atare standard. - Soft delete applies to high-value records (
User,Moment,ChatMessage) via adeleted_attimestamp column, filtered explicitly withWHERE deleted_at IS NULLin the queries; the dailypurgeandmoment_purgejobs hard-delete after a grace window. See System Overview. - Spatial columns use PostGIS (
geography(Point, 4326)); the sharedSpatialmixin adds denormalizedlat/lngfloats alongside for reads that don't need PostGIS. - Enums are named string types next to their struct, each with
IsValid()at trust boundaries. There is no central enums file. - Privacy toggles default off. A boolean that shares something about the user (
user_profiles.chat_pref_read_receipts,chats.read_receipts_enabled,is_location_shared,marketing_consent) carriesDEFAULT false, so a row created with no user action sits at the most private setting. Clients gate on=== true, never!== false, so an absent value on a cold cache reads as off.