Skip to content
LeitaguDocs

Loading…

Pages

Platform

Platform architecture

How the Leitagu apps, the shared database, sessions, the chat engine, product data and analytics fit together, and how to run, deploy and test them.

Updated 17 October 2026View as Markdown

Leitagu is one repository with five web apps and two shared packages. Each app is deployed on its own subdomain with its own login, and every app except this hub uses the same PostgreSQL database, whose migrations the apps apply themselves when they start. The shop answers chats with its own deterministic code; Claude is optional and only turns free text and room photos into structured data. This page describes the parts, how data moves between them, and how to run, deploy and test the platform.

Apps and subdomains

The repository is an npm workspaces monorepo. Each app is a separate Next.js project in apps/.

App Folder Address Local port Who uses it Login
Shop apps/web leitagu.ee 3000 Shoppers Any account (email and password)
Admin apps/admin admin.leitagu.ee 3001 The Leitagu team Admins only
Partner apps/partner partner.leitagu.ee 3002 Furniture stores: analytics, products, data connections, team Store members (by invite) and admins
CRM apps/crm crm.leitagu.ee 3003 The Leitagu team: research on potential partner companies Admins only
Knowledge hub apps/docs docs.leitagu.ee 3004 Everyone, including AI agents None; no database
  • Which app is which. Each app's next.config.ts sets LEITAGU_APP (web, admin, partner, crm or docs). The shared code reads it to choose the session cookie and to decide which sessions it accepts (packages/core/src/server/apps.ts).
  • Links between apps. appUrl() in the same file builds them from WEB_URL, ADMIN_URL, PARTNER_URL, CRM_URL and DOCS_URL, falling back to the local ports above.
  • One set of accounts. All apps use the same users table. An account's role is user or admin. Store membership is separate (store_members, with the role owner or member). The same email and password work in every app that accepts the account, but each app needs its own login.

Shared packages

  • packages/core (@leitagu/core)
    • src/server/db.ts: the database driver (see below).
    • src/server/migrations/: the schema, as ordered migrations. src/server/schema.mjs applies them and loads the example data from src/server/seed/.
    • src/server/auth.ts, apps.ts, http.ts: passwords, sessions, per-app cookies and the same-origin check.
    • src/server/data.ts: products (including the product cache), rooms, chats, messages, favourites and the audit log.
    • src/server/stores.ts: stores, members and invites.
    • src/server/events.ts: product events and the store analytics queries.
    • src/server/integrations/: importing products from Shopify, product feeds and CSV files (sources.ts, normalize.ts, fetchers.ts, secrets.ts, sync.ts).
    • src/shared/: code that runs on both server and browser: the fixed vocabulary, the keyword parser, search and matching, suggestions, room geometry, colour maths, types and translations.
  • packages/ui: shared React components (product cards, the 3D room, the floor plan, the login form, the language switch) and the design tokens.

The database

There is one PostgreSQL 16+ database for the shop, admin, partner and CRM apps. The hub reads Markdown files and has no database.

db.ts offers one small query interface over two drivers:

  • With DATABASE_URL: a PostgreSQL server through the pg connection pool. The pool size is DATABASE_POOL_MAX (default 10; npm run dev sets 4 per app).
  • Without it: PGlite, an embedded PostgreSQL stored in PGLITE_DIR (default .data/pglite, or memory:// for tests).

Both run the same SQL, so the schema and the queries are identical everywhere.

Migrations

The first time an app process touches the database, it runs the migrations and then the seed step:

  • Migrations. migrations/index.mjs lists them in order. Applied ones are recorded in schema_migrations, and each pending one runs in its own transaction.
  • One app at a time. With a PostgreSQL server, the apps take a session-level advisory lock (pg_advisory_lock) around this step. Apps that start together wait for each other, so the migrations run once.
  • Seed. If the products table is empty, the 50 example products are loaded. Then every product without a store record gets one, and old events get their store filled in. This is safe to run on every start.
Migration What it adds
001_init Users, sessions, products, rooms, room items, chats, messages, favourites, store clicks and the audit log
002_app_sessions sessions.app: which app a session belongs to
003_stores_partner Stores, store members and invites, integrations and their sync runs, product events; on products: store, source, integration, external id, corrections (overrides) and data problems (issues). Old store clicks move into product events.
004_crm The research CRM tables. See Internal CRM.
005_events_user_fk Product events lose their account link when the account is deleted (ON DELETE SET NULL).

Adding a migration:

  1. Add a file to packages/core/src/server/migrations/ that exports the SQL as a string.
  2. Import it in migrations/index.mjs and add it at the end of MIGRATIONS with a new name.
  3. Prefer statements that are safe to run twice (IF NOT EXISTS).
  4. Never edit a migration that has shipped. Databases that already applied it will not run it again.

Sessions and cookies

App Cookie Lifetime
Shop leitagu_session 30 days
Admin leitagu_admin 12 hours
Partner leitagu_partner 14 days
CRM leitagu_crm 12 hours
Knowledge hub none —
  • Tokens. The cookie holds 32 random bytes. The database stores only the token's SHA-256 hash as the session id, so a database leak does not leak usable sessions.
  • One app per session. Every session row records the app that created it (sessions.app). An app looks a token up only among its own sessions, so a shop session copied into the admin cookie is not accepted. The distinct cookie names also keep the apps apart on localhost, where cookies ignore the port.
  • Cookie settings. Host-only, httpOnly, SameSite=Lax, Secure in production, path /.
  • Sliding expiry. When less than half of a session's lifetime is left, the next request extends it to the full lifetime.
  • Signed out. Expired sessions and closed accounts count as signed out.
  • Who may log in. The admin app and the CRM accept only admin accounts. The partner app accepts accounts that belong to at least one store, and admins. The shop accepts any account.
  • Admins. Emails listed in ADMIN_EMAILS get the admin role when they sign up or log in. npm run make-admin -- <email> promotes an existing account.
  • Login throttling. After 10 failed attempts for the same email and client address, logins are refused for 15 minutes. The count is kept in memory.
  • Same-origin check. Every request that changes data (anything but GET and HEAD) is refused if its Origin header names another host (sameOrigin() in http.ts). The API route wrapper applies it, and the login, logout and invite routes call it directly. Requests without an Origin header, such as those from non-browser clients, pass.
  • Leaving a store. When someone is removed from their last store, their partner sessions are deleted.

How the shop answers a chat

Every chat turn is a request to POST /api/chats/<id>/messages with one of: free text, a tapped chip, a changed or removed filter, or a photo. The chat engine (apps/web/src/lib/server/engine.ts) handles it and saves the chat's state, the new messages and, when a new list of products is shown, the impressions.

  1. Chips and filter changes are handled directly. They never call Claude.
  2. Free text is turned into search settings:
    • With an API key (ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN), parseWithClaude() in apps/web/src/lib/server/llm.ts sends Claude the message with a little context: the reply language, the store names, the names of the shopper's rooms, the current settings, the filters the shopper removed, the question being asked, and the name and price of up to ten products on screen. Claude returns structured output: an intent, settings from Leitagu's fixed vocabulary, settings to remove, an optional room name and a one-sentence reply. The model is claude-opus-5-5, with structured outputs and the server-side fallback beta. The result is checked again against the vocabulary and the store list.
    • If there is no key, or Claude refuses or the call fails, the built-in keyword parser (fallbackParse() in packages/core/src/shared/parse.ts) reads the message instead, in Estonian or English.
  3. Everything after that is deterministic. The engine updates the settings, then asks the next guided question or runs search() or suggest() from packages/core/src/shared over the product list in memory. Claude never selects or ranks products, and it sees nothing of the catalogue beyond the names and prices of products already on screen.
  4. Room photos go to analyseRoomPhoto(), which asks Claude to describe the room and its furniture. Each user can analyse 20 photos an hour; the count is kept in memory.

Without an API key, the shop still works: the keyword parser handles common phrasings, but not corrections, match strictness or "what should match", and creating a room from a photo is turned off with a message saying why. See How Leitagu works. The browser tests run in this mode.

Product data flow

  1. Source. Products come from a store's data connection (Shopify, a product feed or an uploaded CSV file), from the admin app (added or edited by hand), or from the example data.
  2. Sync. runSync() in integrations/sync.ts reads the source, maps each item with normalize.ts, and inserts or updates it in products under the id <store slug>--<external id>. Products that disappeared from the source are made inactive, not deleted. See the partner integrations guide.
  3. Products table. Synced columns are overwritten on every sync. A store's corrections live in products.overrides (JSON) and are applied on top whenever a product is read, so they survive syncs. Data problems such as missing dimensions are recomputed on read.
  4. Product cache. getProducts() keeps every product in memory in each app process. At most every 5 seconds it checks a cheap signature, the row count and the latest updated_at, and reloads the whole list if either changed. The app that made a change clears its own cache at once. A sync in the partner app therefore reaches the shop within about 5 seconds.
  5. Search and suggestions. They run over the cached list. Inactive products, and products without a width, depth and height, are skipped.

Events and analytics

What shoppers do with products is recorded in one table, product_events. The server records impressions when a chat shows a new result list or new suggestions, keeps when a product is kept in a room, favourites when it is saved, and store visits when a shopper goes through the /go/<product> link. The browser reports opens and previews in small batches to /api/events. Each event stores the product, its store, the type, and where known the chat, the user and whether it was shown in results or suggestions. The partner app reads totals per store with the queries in events.ts, and the admin app shows 30-day totals per store. Exact definitions are in Analytics definitions.

Local development

Requirements: Node.js 20.9 or newer.

npm install
cp .env.example .env.local      # then put your email in ADMIN_EMAILS
npm run dev                     # shop :3000, admin :3001, partner :3002, CRM :3003, docs :3004

npm run dev runs scripts/dev.mjs, which:

  • reads .env.local and .env at the repository root (variables already set in the shell win);
  • without DATABASE_URL, opens the embedded PGlite database in .data/pglite, applies the migrations and example data once, and serves it over the PostgreSQL protocol on 127.0.0.1:5433, so all apps share one database;
  • starts each app with next dev on its port, with WEB_URL, ADMIN_URL and the others set to the local addresses.

Variations:

  • npm run dev -- web admin starts only those apps.
  • npm run dev -- docs starts only the hub, without a database.
  • npm run build && npm run dev -- --prod runs production builds with next start.
  • To start over with an empty database, stop everything and delete .data/.

Deployment

Each app is deployed as its own project, for example one Vercel project per app with the root directory set to apps/web, apps/admin, apps/partner, apps/crm or apps/docs, and its subdomain pointed at it. Serve everything over HTTPS.

Variable Apps Purpose
DATABASE_URL shop, admin, partner, CRM The same managed PostgreSQL 16+ database for all of them. Required in production.
ADMIN_EMAILS apps with a login Comma-separated emails that get the admin role when they sign up or log in.
ANTHROPIC_API_KEY (or ANTHROPIC_AUTH_TOKEN) shop Optional. Turns on Claude for free-text chat and room photos.
WEB_URL, ADMIN_URL, PARTNER_URL, CRM_URL, DOCS_URL all Public addresses of the apps, used for links between them, in store invite links (PARTNER_URL) and for the hub's absolute links (DOCS_URL). They default to the local ports.
INTEGRATION_SECRET_KEY partner Key for encrypting stores' integration secrets such as Shopify tokens (AES-256-GCM). Required in production: without it the app refuses to store or read secrets.
CRON_SECRET partner Protects the scheduled sync endpoint.
SHOPIFY_API_VERSION partner Optional. Shopify Admin API version, default 2026-07.
LEITAGU_EXAMPLE_DATA shop, admin, partner, CRM off stops the 50 example products from being loaded into an empty database. Set it for a real launch.
DATABASE_POOL_MAX, PGLITE_DIR shop, admin, partner, CRM Optional. Pool size, and where the embedded database lives when there is no DATABASE_URL.
  • Scheduled syncs. A scheduler must call the partner app every hour: POST /api/cron/sync (GET also works) with Authorization: Bearer $CRON_SECRET. It runs every enabled Shopify or feed connection that is due, one after another.
  • Migrations run automatically when an app first uses the database, under the advisory lock described above. That lock needs a direct or session-mode database connection; a transaction-mode pooler can leave an app waiting for it.
  • Step by step. DEPLOYMENT.md in the repository covers hosting on one server or on Vercel, the first admin, checks after a deploy and a security checklist.
  • The hub is built ahead of time from apps/docs/content, needs no database, and uses DOCS_URL for the absolute links in llms.txt, index.json and the sitemap.
  • Staff apps. The admin and CRM subdomains can be restricted further at the network level, for example with an IP allowlist or single sign-on in front, without code changes.

Testing

Command What it runs
npm test Unit tests (Vitest, packages/core/tests): the keyword parser, search and near misses, room geometry and suggestions, product import (sizes, prices, colours and categories; the CSV template; a Google Merchant XML feed; a semicolon CSV with Estonian headers; a Shopify product), encryption of integration secrets, and refusing feed addresses inside our network.
npm run e2e Browser tests (Playwright, e2e/) after npm run build. They start the shop, admin, partner, CRM and docs apps with next start on ports 3210–3214, against a fresh embedded database and without a Claude key. They cover the main shopper flows, admin-only access, the CRM from research to partner store, a store joining by invite and importing a CSV, store pages being closed to outsiders, and the hub's pages and links.
npm run typecheck TypeScript check of every package and app.
npm run research:validate Checks the research dataset in data/research against its schema and source rules.

Shopify and store feeds are not reachable from the build environment, so those paths are covered by unit tests with sample responses. The CSV path is covered end to end.

Security notes

  • Passwords. Hashed with scrypt. A login with an unknown email takes as long as one with a wrong password.
  • Sessions. Only hashes of session and invite tokens are stored. A session works only in the app that created it.
  • Requests. Writes must come from the same origin. Rooms, chats and room items are always looked up together with their owner. Partner pages and APIs check store membership, and data connections, the team and store settings need the owner role (or an admin).
  • Admins. Admin and CRM pages and API routes each check the admin role themselves. Opening a user's chat, room or profile in the admin app writes an audit log entry, as do store, invite, integration, CRM import, conversion and deletion actions.
  • Integration secrets. Encrypted with AES-256-GCM and never sent to browsers. See How secrets are stored.
  • Feed addresses. Requests to addresses that stores give us are limited to public http(s) hosts, with checked redirects and size and time limits. A DNS-rebinding attack is possible in theory, because the address check and the request each resolve the host name. See Feed fetch safety.
  • In-memory limits. The login throttle, the photo quota and the shopper event budget are kept in memory, so with several server instances each instance counts separately.
  • Shopper privacy towards stores. Stores see only totals. See Analytics definitions.