1
Database Schema
PostgreSQL via Drizzle ORM. Connection string: DATABASE_URL environment variable. Internal hostname: helium. All tables live in the public schema.
users illustrative
The core identity table. One row per registered user.
| Column | DB Type | Drizzle Type | Nullable | Default |
id | character varying | varchar · PK | NO | gen_random_uuid() |
email | text | text · UNIQUE | NO | — |
first_name | character varying | varchar | YES | — |
last_name | character varying | varchar | YES | — |
created_at | timestamp | timestamp | NO | CURRENT_TIMESTAMP |
country | text | text | YES | — |
city | text | text | YES | — |
latitude | double precision | doublePrecision | YES | — |
longitude | double precision | doublePrecision | YES | — |
marketing_consent | boolean | boolean | YES | false |
member_number | integer | integer | YES | auto-assigned on registration |
founding_interest_at | timestamp | timestamp | YES | — |
magic_codes illustrative
One-time 6-digit sign-in codes sent by email. Codes expire after 10 minutes and are single-use.
| Column | DB Type | Drizzle Type | Nullable | Default |
id | integer | serial · PK | NO | auto-increment |
email | text | text | NO | — |
code | text | text | NO | — |
expires_at | timestamp | timestamp | NO | — |
used | boolean | boolean | NO | false |
created_at | timestamp | timestamp | NO | CURRENT_TIMESTAMP |
shared_skips illustrative
A record is created each time a user taps "Share" on a skip result. Used to generate public share URLs and the kudos page. photo_data stores a full base64-encoded JPEG inline.
| Column | DB Type | Drizzle Type | Nullable | Default |
id | character varying | varchar · PK | NO | gen_random_uuid() |
food_name | text | text | NO | — |
description | text | text | NO | — |
calories_saved | integer | integer | NO | — |
walking_minutes | integer | integer | NO | — |
photo_uri | text | text | NO | — |
photo_data | text | text | YES | — |
user_email | text | text | YES | — |
kudos_count | integer | integer | NO | 0 |
created_at | timestamp | timestamp | NO | CURRENT_TIMESTAMP |
kudos illustrative
One row per unique kudos given. Deduplication is by SHA-256 hash of (giver_ip + shared_skip_id) — one IP can only give kudos once per shared skip.
| Column | DB Type | Drizzle Type | Nullable | Default |
id | integer | serial · PK | NO | auto-increment |
shared_skip_id | character varying | varchar · FK → shared_skips.id | NO | — |
giver_hash | text | text | NO | — |
giver_name | text | text | YES | — |
created_at | timestamp | timestamp | NO | CURRENT_TIMESTAMP |
notifications illustrative
In-app notification inbox. Populated server-side whenever someone gives kudos on a shared skip that has a user_email attached.
| Column | DB Type | Drizzle Type | Nullable | Default |
id | integer | serial · PK | NO | auto-increment |
user_email | text | text | NO | — |
type | text | text | NO | — |
title | text | text | NO | — |
body | text | text | NO | — |
shared_skip_id | character varying | varchar | YES | — |
read | boolean | boolean | NO | false |
created_at | timestamp | timestamp | NO | CURRENT_TIMESTAMP |
user_sync illustrative
One row per user, overwritten on every sync. Stores the user's complete current app state as JSONB blobs. This is the server-side backup of local AsyncStorage. user_id FK is ON DELETE CASCADE.
| Column | DB Type | Drizzle Type | Nullable | Default |
id | integer | serial · PK | NO | auto-increment |
user_id | character varying | varchar · UNIQUE · FK → users.id (CASCADE) | NO | — |
skips | jsonb | jsonb | NO | [] |
treats | jsonb | jsonb | NO | [] |
streak | jsonb | jsonb | NO | {} |
updated_at | timestamp | timestamp | NO | now() |
2
Data Flow
Primary data store: Device (AsyncStorage)
The app's skip history, treat history, and streak data live on the user's device in React Native AsyncStorage. The server is a secondary backup, not the source of truth. All in-app screens read from AsyncStorage via lib/storage.ts. The server only receives data when the app explicitly syncs.
AsyncStorage Keys (all devices)
| Key | Content |
skip_a_snack_skips | Array of Skip objects — full skip history |
skip_a_snack_treats | Array of Treat objects — redeemed treats |
skip_a_snack_user | { email, userId } — logged-in user identity |
skip_a_snack_streak | { currentStreak, longestStreak, lastSkipDate, seenMilestones } |
skip_a_snack_scans:${date} | { skips: n, treats: n } — daily scan rate-limiter (max 5 snaps/day) |
Keys migrated from legacy didnt_eat_that_ prefix via one-time startup migration (sas_migrated_v1 flag). Migration runs in app/_layout.tsx before any context providers initialise.
users
| Direction | Actor | Details |
| Write | POST /api/auth/verify-code | Creates user on first successful login |
| Write | PUT /api/user/profile | Updates first_name, last_name, marketing_consent |
| Write | geolocateAndStore() | Updates country/city/lat/lng after first login via ip-api.com — fire-and-forget |
| Write | DELETE /api/user/account | Deletes the row |
| Read | GET /api/user/profile | Returns email, created_at, first/last name |
| Read | GET /api/stats/world | Returns lat/lng pins and country aggregates |
| Read | All auth-gated endpoints | getUserByEmail() validates caller identity |
magic_codes
| Direction | Actor | Details |
| Write | POST /api/auth/send-code | Inserts a new code; sends email via Resend |
| Write | verifyMagicCode() | Marks used code as used = true |
| Write | DELETE /api/user/account | Deletes all codes for the email |
| Read | verifyMagicCode() | Looks up unused, unexpired code matching email + code |
shared_skips
| Direction | Actor | Details |
| Write | POST /api/skips/share | Creates a row when user shares a skip |
| Write | PATCH /api/skips/:id/photo | Updates photo_data after sharing |
| Write | POST /api/skips/:id/kudos | Increments kudos_count atomically |
| Read | GET /api/skips/:id | Returns skip metadata for the share card |
| Read | GET /api/skips/:id/image | Returns raw JPEG from photo_data |
| Read | GET /shared/:id (HTML) | Renders the kudos page |
| Read | POST /api/skips/:id/kudos | Looks up user_email to create notification |
kudos
| Direction | Actor | Details |
| Write | POST /api/skips/:id/kudos | Inserts a row if the giver hash hasn't already voted |
| Read | addKudos() | Checks for existing kudos by (shared_skip_id, giver_hash) |
notifications
| Direction | Actor | Details |
| Write | POST /api/skips/:id/kudos | Creates a notification for the skip owner |
| Read | POST /api/notifications | Returns up to 50 most recent notifications |
| Write | POST /api/notifications/read | Marks all user's notifications as read |
| Write | DELETE /api/user/account | Deletes all notifications for the email |
user_sync
| Direction | Actor | Details |
| Write | POST /api/user/sync | Upserts entire current state (skips, treats, streak) from device — overwrites previous snapshot |
| Read | GET /api/user/restore | Returns stored JSON snapshot on login or device restore |
3
API Endpoints
All routes served by the Express server on port 5000. No authentication middleware — identity is passed as email or userId in the request body or query string.
Authentication
| Method | Path | Purpose | Tables Touched |
| POST |
/api/auth/send-code |
Generates a 6-digit code, stores it in DB, sends email via Resend. Code expires after 10 minutes. |
magic_codes W |
| POST |
/api/auth/verify-code |
Validates code, marks it used, creates user if first login, fires geolocation lookup (async). |
magic_codes R/W · users R/W |
Food Analysis
| Method | Path | Purpose | Tables Touched |
| POST |
/api/analyze-food |
Receives base64 image, sends to Claude claude-sonnet-4-6 via Anthropic API. Returns food name, description, calories, walking minutes, portion note, confidence. |
None |
Skips
| Method | Path | Purpose | Tables Touched |
| POST |
/api/skips/share |
Creates a shared skip record, returns shareUrl built from SHARE_BASE_URL env var or request host. |
shared_skips W |
| POST |
/api/skips/upload-photo |
Uploads photo to R2 and returns photoUrl. Called after every successful skip. Previously named /api/skips/log — DB write removed, R2 upload retained. |
None (R2 object storage) |
| GET |
/api/skips/:id |
Returns skip metadata (no photo binary) for the share card UI. |
shared_skips R |
| PATCH |
/api/skips/:id/photo |
Updates the base64 JPEG on an existing shared skip. |
shared_skips W |
| GET |
/api/skips/:id/image |
Returns the raw JPEG binary from photo_data, with long-lived cache headers. |
shared_skips R |
| POST |
/api/skips/:id/kudos |
Adds kudos (deduped by IP hash), increments counter on shared_skips, creates notification for skip owner. |
kudos R/W · shared_skips W · notifications W |
Notifications
| Method | Path | Purpose | Tables Touched |
| POST |
/api/notifications |
Returns up to 50 notifications + unread count for a user. Email passed in body. Semantically a read despite using POST. |
users R · notifications R |
| POST |
/api/notifications/read |
Marks all unread notifications as read for a user. |
users R · notifications W |
User Profile & Data
| Method | Path | Purpose | Tables Touched |
| GET |
/api/user/profile |
Returns email, created_at, first_name, last_name. Email passed as query param. |
users R |
| PUT |
/api/user/profile |
Updates first_name, last_name, and optionally marketing_consent. |
users W |
| DELETE |
/api/user/account |
Deletes user row plus their notifications and magic codes. Does not delete shared_skips, kudos, or skips_log rows. |
users D · notifications D · magic_codes D |
| POST |
/api/user/sync |
Upserts entire local state (skips, treats, streak JSON) from device. Overwrites previous snapshot. |
user_sync W |
| GET |
/api/user/restore |
Returns stored sync snapshot by userId. Used on login / device restore. |
user_sync R |
Stats
| Method | Path | Purpose | Tables Touched |
| GET |
/api/stats/world |
Returns lat/lng pins, country aggregates (name + count), total user count, and country count. Used by the world map on the Journey tab. |
users R |
Server-rendered HTML
| Method | Path | Purpose | Tables Touched |
| GET |
/shared/:id |
Renders the public kudos page for a shared skip using server/templates/kudos-page.html. Populated from shared_skips. |
shared_skips R |
4
File Structure
/
├── app/ Expo Router screens (mobile + web)
│ ├── _layout.tsx Root layout: fonts, providers, auth gate
│ ├── auth.tsx Email + 6-digit code sign-in screen
│ ├── onboarding.tsx 4-screen first-run onboarding flow
│ ├── capture.tsx Camera / photo capture screen
│ ├── result.tsx Post-capture result: food ID + calories
│ ├── skip-detail.tsx Detail view for a single past skip
│ ├── skip-limit.tsx Daily scan limit reached screen
│ ├── treat-history.tsx Full history of redeemed treats
│ ├── notifications.tsx In-app notification inbox
│ ├── +not-found.tsx 404 fallback
│ ├── +native-intent.tsx Deep link intent handler
│ └── (tabs)/
│ ├── _layout.tsx Tab bar layout (Home, Journey, Profile)
│ ├── index.tsx Home tab: today's skips, calorie counter, treats
│ ├── journey.tsx Journey tab: skip timeline, world map, stats
│ └── profile.tsx Profile tab: settings, notifications, account
│
├── components/
│ ├── ErrorBoundary.tsx React error boundary (class component)
│ ├── ErrorFallback.tsx Error fallback UI
│ ├── KeyboardAwareScrollViewCompat.tsx
│ ├── WorldMap.tsx Web stub (no-op — maps not rendered on web)
│ └── WorldMap.native.tsx Native world map — react-native-maps@1.18.0
│
├── constants/
│ └── colors.ts Design token colours (coral, cream, terracotta…)
│
├── lib/
│ ├── auth-context.tsx Auth state: login, logout, session restore
│ ├── skip-context.tsx Global skip/treat state + server sync trigger
│ ├── storage.ts ALL local AsyncStorage reads/writes
│ │ (skips, treats, streak, user, scan counts)
│ ├── theme-context.tsx Day/night theme toggle
│ ├── notification-context.tsx Unread notification badge state
│ └── query-client.ts React Query client + apiRequest() + getApiUrl()
│
├── server/
│ ├── index.ts Express setup: CORS, body parsing, Expo
│ │ manifest routing, kudos page, SPA catch-all
│ ├── routes.ts All /api/* route handlers
│ ├── storage.ts DatabaseStorage class — all DB R/W via Drizzle
│ ├── db.ts Drizzle + pg pool init from DATABASE_URL
│ └── templates/
│ ├── landing-page.html Expo Go QR landing page for browser visitors
│ └── kudos-page.html Public kudos share page (/shared/:id)
│
├── shared/
│ └── schema.ts Drizzle table definitions, Zod schemas, TS types
│
├── public/
│ └── (empty — marketing pages removed to separate website project)
│
├── scripts/
│ ├── build.js Production build (expo export + tsc)
│ └── start-frontend.sh Starts Expo dev server with env vars injected
│
├── app.json Expo config: bundle ID, splash, plugins
├── drizzle.config.ts Drizzle Kit config (db:push / migrations)
├── package.json Dependencies and npm scripts
└── TECHNICAL_REFERENCE.md Source document for this file
Key npm scripts
| Script | What it does |
npm run server:dev | Starts Express in dev mode via tsx (hot reload) |
npm run expo:dev | Starts Expo Metro bundler for development |
npm run db:push | Syncs Drizzle schema to the database safely (no destructive migration) |
npm run server:build | Compiles Express server TypeScript to JS |
npm run expo:static:build | Exports Expo web build to dist/ |
npm run server:prod | Runs the compiled server in production |
5
Issues, Notes & Resolved
Outstanding — To Do
-
All users currently have unlimited free access — deliberate during early access/testing phase. When ready to build: users.created_at already provides trial start date without a schema change. Implementation requires in-app prompt screens at day 6/7, a subscription purchase flow (RevenueCat or native StoreKit), and a server-side reset trigger for expired free users. RevenueCat is already in the skill set.
Notes & By Design
-
Stores full base64-encoded JPEGs as a plain text column. Fine at current volume (11 rows). R2 is already integrated for regular skip photos — the same upload pattern can be applied to the share flow if volume ever makes this worth addressing. Deferred until share volume grows meaningfully.
-
Semantically a read but uses POST so the email can be passed in the request body rather than as a URL query parameter. A pragmatic trade-off — email addresses in URLs create encoding, logging, and caching concerns. Causes no actual problems. Leave as-is.
-
No email is sent when a friend gives kudos. The in-app notification (written correctly to the notifications table) is the right channel for a lightweight social tap. Email-per-kudos would quickly become noise. Future work: kudos social sharing card with referral link (skipasnack.com/shared/:id?ref=userId) — share is owner-initiated, not auto-triggered.
Resolved
-
SHARE_BASE_URL env var now controls the share domain. /shared/:id is served by the same Express app as the marketing site. Resolved.
-
All five keys migrated to skip_a_snack_ prefix via one-time startup migration (sas_migrated_v1 flag) in app/_layout.tsx. Migration runs before any context providers initialise. Existing user data preserved. Resolved 21 March 2026.
-
Foreground sync added via AppState listener in skip-context.tsx. Triggers restoreFromServer every time app returns to foreground, debounced to 60-second minimum interval. restoreFromServer only overwrites local data if server has more skips — can never regress local state. Resolved 21 March 2026.
-
Table, schema definition, and startup migration removed. Route renamed from /api/skips/log to /api/skips/upload-photo — R2 photo upload logic retained, DB write stripped. result.tsx updated to call new route name. Resolved 21 March 2026.
-
Deletion sequence now correctly removes: kudos → shared_skips → notifications → magic_codes → user (user_sync cascades). skips_log removed entirely (R4). Full erasure compliant. Resolved.
-
Cleanup added to POST /api/auth/verify-code — fire-and-forget DELETE on every successful login removes rows where used = true OR expires_at < NOW(). Resolved.
Skip A Snack — Master Technical Reference
Updated 21 March 2026