Core Data Models
smo1 intermediate 8 min read
ELI5
SMO1 stores four main things: who you are (User), what links you made (Link), badges you unlocked (Achievement), and secret passwords for apps (API Key). Each one has a specific shape — like a form with required and optional fields — and they connect to each other like a family tree.
Technical Deep Dive
Entity Relationship
%%{init: {'theme': 'base', 'themeVariables': {'primaryColor': '#e8f4f8', 'primaryTextColor': '#2d3748', 'primaryBorderColor': '#90cdf4', 'lineColor': '#718096', 'secondaryColor': '#f0fff4', 'tertiaryColor': '#fefcbf'}}}%%erDiagram USER ||--o{ LINK : creates USER ||--o{ API_KEY : owns USER ||--o{ ACHIEVEMENT : earns USER ||--o{ XP_EVENT : generates LINK ||--o{ LINK_CLICK : receives
USER { text id PK text email text username text display_name text avatar_url enum subscription_tier text polar_customer_id text polar_subscription_id enum subscription_status timestamptz subscription_period_end boolean subscription_cancel bigint xp int level int streak_current int streak_longest timestamptz last_streak_at bigint total_clicks int total_links int monthly_links_used timestamptz created_at timestamptz updated_at timestamptz last_login_at }
LINK { uuid id PK text user_id FK text original_url text uid text custom_slug text display_name bigint total_clicks bigint unique_visitors timestamptz last_clicked_at text utm_source text utm_medium text utm_campaign text utm_term text utm_content enum protection_type text protection_hash text protection_hint int protection_max_attempts enum content_classification enum redirect_mode boolean is_active timestamptz expires_at timestamptz created_at timestamptz updated_at }
ACHIEVEMENT { uuid id PK text name text description enum category jsonb criteria text icon_url int xp_reward timestamptz created_at }
API_KEY { uuid id PK text user_id FK text key_hash text key_prefix text[] permissions timestamptz expires_at timestamptz revoked_at timestamptz last_used_at timestamptz created_at }User Model
The User record is created automatically on first Airlock JWT login. The id is a text field (not UUID) because it comes from the Airlock identity provider.
Key groups:
- Identity:
id,email,username,display_name,avatar_url - Subscription (Polar):
subscription_tier(free/pro/business/early_adopter),polar_customer_id,polar_subscription_id,subscription_status,subscription_period_end,subscription_cancel - Gamification:
xp,level,streak_current,streak_longest,last_streak_at - Stats:
total_clicks,total_links,monthly_links_used
Link Model
The central entity. Two identifiers coexist:
uid— 6-character auto-generated slug ([a-zA-Z0-9]), cryptographically randomcustom_slug— optional user-defined vanity slug (e.g.,my-event)
Protection fields:
protection_type:none|password|pinprotection_hash: bcrypt hash (never exposed in API;json:"-"tag)protection_hint: user-facing hint (e.g., “My dog’s name”)protection_max_attempts: 5 (enforced per IP per link)
Lifecycle:
is_active: soft-delete flagexpires_at: optional TTLredirect_mode:redirect(302) orproxy(reverse proxy)
LinkDTO (API Response)
The API never returns raw Link structs to clients. It returns LinkDTO with computed fields:
| Field | Source | Description |
|---|---|---|
short_url | uid or custom_slug | Fully qualified short URL |
clicks_7d | ClickHouse aggregation | Clicks in the last 7 days |
status | Algorithm | Cat-themed activity label (see smo1-012) |
pawprintz | Algorithm | 0–100 activity score |
treatz | Algorithm | 0–100 compression efficiency |
niblz | Algorithm | Raw characters saved |
qr_code_url | MinIO/S3 | Pre-generated QR code image URL |
API Key
- Prefix:
sk_(Stripe-style convention) - Storage: Only the SHA-256 hash is stored; the plaintext is shown once at creation
- Identification:
key_prefixstores the first 8 characters so the user can tell keys apart in the UI - Revocation:
revoked_attimestamp; revoked keys are filtered out at lookup time
Achievement
- Categories:
getting_started,link_master,analytics_pro,engagement_king,streak_champion,secret - Criteria: JSONB blob with unlock conditions (e.g.,
{ "min_links": 10, "min_clicks": 100 }) - Progress tracking: UserAchievement junction table tracks
current_valueandunlocked_at
Key Terms
- LinkDTO → API-facing data transfer object with computed scores and URLs
- uid → 6-character cryptographically random alphanumeric slug
- protection_hash → bcrypt hash of password/PIN; never exposed via API
- key_prefix → First 8 characters of an API key plaintext, stored for UI identification
- content_classification →
general,personal,work, ornsfw— used for safety filtering - Polar → Payment provider; stores customer and subscription IDs on the User record
Q&A
Q: Why is the user ID text instead of UUID? A: It is sourced from the Airlock OIDC provider. Using the provider’s subject identifier avoids identity fragmentation across systems.
Q: Can a link have both a uid and a custom_slug?
A: Yes. Both exist simultaneously. The short URL uses custom_slug if present, otherwise falls back to uid.
Q: Why store protection_hash with json:"-"?
A: This Go struct tag prevents the field from ever being serialized to JSON, ensuring the bcrypt hash never leaks through the API.
Q: How many API keys can a user have? A: There is no hard limit enforced in the schema, but the UI and service layer may impose reasonable caps per tier.
Examples
Think of a User as a gym membership card:
- Identity fields are your name and photo on the card
- Subscription fields are your membership tier (basic/premium) and renewal date
- Gamification fields are your loyalty points, visit streak, and current level
- Stats fields are the total number of times you checked in and how many guest passes you used this month
A Link is like a numbered locker:
- uid is the randomly assigned locker number
- custom_slug is a nickname you write on a sticky note
- original_url is what is actually inside the locker
- protection_type is whether the locker has a keypad, a password lock, or nothing
- redirect_mode is whether you tell people “go look in locker 42” (redirect) or you hand them the contents through a window (proxy)
neighbors on the map
- Database Architecture designing a query that spans transactional and analytical data