Skip to main content

Architecture

Tech stack

  • Frontend: Remix (React) + TypeScript + Vite, deployed on Vercel
  • UI: shadcn/ui (Tailwind + Radix primitives), Lucide icons
  • Backend: Remix loader/action functions + Supabase (Postgres, Auth, Storage)
  • AI: OpenAI (chat, assistants, vector stores) — wired via app/.server/services/ai/
  • Background workers: TypeScript jobs in tasks/ deployed to a VPS, talking to the same Supabase

Route layout

Routes live under app/routes/. The prefix convention controls auth and layout:

  • _in.* — authenticated app routes with the sidebar
  • _out.* — public auth flows (login, signup, password reset)
  • api.* — JSON endpoints invoked from the client or external services

Inside _in, the URL pattern is /{team-slug}/{feature-path}. The selected team is persisted in localStorage via useSelectedAccount and threaded through every loader as the active account_id.

Major feature areas:

PathPurpose
/team/dashboard/*Emissions analytics and overview
/team/overview/*Carbon accounting data entry by category
/team/parcel/network/*Parcel/logistics emissions tracking
/team/write-texts/*AI-assisted ESRS/sustainability report drafting

Multi-tenancy

Built on Basejump, which provides:

  • basejump.accounts — teams and personal accounts (personal_account = true when id = user_id)
  • basejump.account_user — membership + role (owner, member, super_admin)
  • Helper SQL functions (basejump.has_role_on_account, basejump.get_accounts_with_role, …)

Every multi-tenant table in public is scoped by account_id and protected by RLS:

CREATE POLICY "Account members can access and modify" ON "table_name"
FOR ALL TO AUTHENTICATED
USING (account_id IN (SELECT basejump.get_accounts_with_role()))
WITH CHECK (account_id IN (SELECT basejump.get_accounts_with_role()));

Views must be SECURITY_INVOKER so RLS on underlying tables still applies:

ALTER VIEW "view_name" SET (SECURITY_INVOKER = ON);

Database schemas

SchemaWhat lives here
publicMain application tables — activities, emission factors, categories, accounting workflow
basejumpAuth + multi-tenant primitives (accounts, members, roles, billing)
parcelLogistics network — orders, transporter nodes/edges, route emissions

See data-model.md for entity relationships and schema/ for the per-table reference.

Frontend file organisation

app/
├── @/ # Shared infrastructure (kept @-prefixed so tsconfig paths resolve)
│ ├── components/ui/ # shadcn/ui primitives
│ ├── components/basejump/ # Auth-aware components
│ └── utils/supabase/ # Supabase clients (browser + server)
├── components/ # Domain components (categories, charts, dashboard, …)
├── routes/ # Pages — each file is a Remix route with loader/action
└── .server/ # Server-only code; never bundled to the client

@/ is the shared design system + framework glue. components/ is domain-specific UI. .server/ is anything that must never reach the browser (API keys, server-only services).

External services

ServiceWhat we use it forWhere it's configured
SupabaseDB, auth, storage.env (SUPABASE_*), supabase/config.toml
OpenAIChat/assistants/vector storesOPENAI_API_KEY, app/.server/services/ai/
SendGridTransactional emailSENDGRID_API_KEY, EMAIL_SENDER
MapboxMaps + geocodingMAPBOX_PUBLIC_KEY
Google Cloud StorageDaily order CSV importGOOGLE_* env vars + tasks/imported-list-*
BiluppgifterVehicle data lookupsBILUPPGIFTER_API_KEY

Feature flags + admin access

  • Set account_user.account_role to super_admin on a user's personal account (row where user_id = account_id) to unlock admin tooling.
  • Account-level flags live in accounts.public_metadata.feature_flags (string array).

Background tasks

The tasks/ directory holds standalone TypeScript services that run on a VPS, connect to the same Supabase, and process work the web app can't (long-running jobs, scheduled CSV imports, etc.). Each has its own README.md. Examples:

  • imported-list-processor — ingests orders from the daily CSV
  • order-relations-updater — continuously runs update_order_relations (~690 rows/min)
  • geo-tagging — backfills address geometry