Skip to content

Supabase

Supabase is the primary backend platform for the Alhuda ERP. It provides the Postgres database, authentication, object storage, and the Deno-based Edge Functions runtime that hosts every server-side handler in the system.

Role in the stack

Capability How we use it
Postgres (v15) Primary datastore. All domain tables (Booking, Payment, JournalEntry, ...) live under public. Every column access is gated by RLS using auth_user_has_permission('x.y') — see docs/PERMISSIONS.md.
Auth Email/password + magic link. JWT sessions are persisted in localStorage and refreshed automatically. The frontend client is configured to use an in-tab processLock instead of the default navigatorLock.
Storage User-uploaded documents (passport scans, invoice attachments, customer documents). Bucket access is controlled by RLS policies on storage.objects.
Edge Functions Deno runtime, one folder per function under supabase/functions/. Used for privileged operations that need the service-role key (admin-users, mailer, razorpay-webhook, ...).

Project structure

supabase/
  config.toml        # Local dev + function-level overrides
  migrations/        # Timestamp-prefixed SQL migrations (188+ as of 2026-04-18)
  functions/         # One subdirectory per edge function
    _shared/         # Shared helpers (supabase.ts, auth.ts, whatsapp.ts)
    razorpay-webhook/
    mailer/
    ...

supabase/config.toml

Top-level fields configure the local dev stack (supabase start) — API port, Postgres port, Studio port, auth settings.

The [functions.*] sections override platform-level behaviour per function. The single knob we use repeatedly is verify_jwt = false, which disables the gateway-level JWT check for functions that authenticate differently (public webhooks, functions that parse the bearer themselves, or public helpers). Example from supabase/config.toml:87-88:

[functions.razorpay-webhook]
verify_jwt = false

The razorpay function is authenticated by HMAC signature, not JWT. See Razorpay for the signature protocol.

supabase/migrations/

Every schema change is a YYYYMMDDHHMMSS_description.sql file. Migrations must be idempotent (IF NOT EXISTS, CREATE OR REPLACE, guarded inserts) so a re-run against an already-migrated environment is a no-op. See docs/operations/database-migrations.md for the full workflow.

supabase/functions/

Each function is a Deno module with an index.ts entrypoint and an optional index.test.ts. Shared code lives in _shared/:

  • supabase/functions/_shared/supabase.ts exposes getServiceClient() — the only blessed way to create a service-role client inside a function.
  • supabase/functions/_shared/auth.ts — bearer-token validation helpers.
  • supabase/functions/_shared/whatsapp.ts — WhatsApp Graph API helpers.

Frontend client setup

The app uses a single shared Supabase client created in src/integrations/supabase/client.ts:17-32:

export const supabase: SupabaseClient = createClient(
  SUPABASE_URL,
  SUPABASE_PUBLISHABLE_KEY,
  {
    auth: {
      storage: localStorage,
      persistSession: true,
      autoRefreshToken: true,
      detectSessionInUrl: false,
      lock: processLock,
    },
  },
);

Two important choices:

  • Untyped client. The <Database> generic is intentionally omitted (client.ts:10-15) because the generated types file drifts behind the live schema. Narrow locally with as MyType when you need it.
  • processLock instead of navigatorLock. The default cross-tab navigatorLock times out under react-query polling + React strict-mode double-mount + Vite HMR and surfaces as NavigatorLockAcquireTimeoutError. processLock is an in-memory semaphore scoped to the current tab; cross-tab auth sync still works via the storage event.

Import it everywhere as:

import { supabase } from "@/integrations/supabase/client";

Environment variables

The frontend reads two Vite-exposed env vars at build time:

Variable Purpose
VITE_SUPABASE_URL Project URL, e.g. https://yzpfwdxpwalmfuodkxni.supabase.co
VITE_SUPABASE_PUBLISHABLE_KEY Anon public key (safe to ship in the bundle)

Copy .env.example to .env.local and fill in values from the Supabase dashboard.

Anon key vs service-role key

The anon key is public and intended for the browser. The service-role key must never appear in the frontend bundle. It lives only in edge function secrets (supabase secrets set SUPABASE_SERVICE_ROLE_KEY=...) and is fetched by getServiceClient() inside functions.

Typegen — src/integrations/supabase/types.ts

types.ts is the autogenerated Database type. It is produced with the Supabase CLI:

supabase gen types typescript --project-id <project-id> > src/integrations/supabase/types.ts

Regenerate whenever a migration adds/removes a column, changes an enum, or renames a table. The file header states it is auto-generated — do not hand-edit.

Types drift

The shared client is deliberately untyped because generated types historically fall behind. If you regenerate types.ts as part of a migration PR, do it in a separate commit so reviewers can eyeball the diff. Do not switch the client to createClient<Database>(...) without aligning every consumer first.

Edge function conventions

  • Runtime. Deno (not Node). Standard library imports come from https://deno.land/std@<version>/...; npm-style imports via esm.sh (e.g. https://esm.sh/@supabase/supabase-js@2).
  • Service client. Always import getServiceClient from ../_shared/supabase.ts. Do not hand-roll a service-role client in each function — centralising it makes secrets rotation a one-line change.
  • Env vars. Read with Deno.env.get("NAME"). Guard missing required vars and return 500 with a clear log line (see supabase/functions/razorpay-webhook/index.ts:382-386).
  • Raw body for HMAC. Read await req.text() before JSON.parse when you need to verify a signature — the HMAC is over the exact request bytes.
  • Top-level serve() guard. If you export helpers for tests, gate the listener so deno test does not hang on an open port:
if (import.meta.main && !Deno.env.get("MY_FUNCTION_TEST")) {
  serve(handleRequest);
}

See supabase/functions/razorpay-webhook/index.ts:430-432. - CORS. Return a preflight response for OPTIONS and echo access-control-allow-* headers on every response. - Idempotency. Any function that mutates data should tolerate being called twice with the same input (unique keys, maybeSingle() dedup lookups).

Testing

Run a single function's tests with Deno:

deno test --allow-env --allow-net \
  supabase/functions/razorpay-webhook/index.test.ts

Keep the unit test surface pure: exercise helpers directly (signature verification, event parsing) and stub the Supabase client for anything that would hit the database. Full integration tests against a live Supabase dev instance live in the Vitest suite.

Adding a new edge function

  1. Create supabase/functions/<name>/index.ts with a handleRequest export and the guarded serve() call shown above.
  2. If your function authenticates via something other than a Supabase JWT (webhook signature, custom bearer, public helper), add a [functions.<name>] block to supabase/config.toml with verify_jwt = false.
  3. Add an index.test.ts alongside index.ts covering the auth check and the happy path. Run deno test before committing.
  4. If the function needs a new secret, document it in docs/secrets-setup.md and set it via:
supabase secrets set MY_NEW_SECRET=...

!!! danger "Production secrets" supabase secrets set writes to the linked Supabase project. Run supabase link against the correct project (staging vs prod) before touching secrets. Prefer rotating via the dashboard over the CLI when you are not 100% certain which project is linked. 5. Deploy:

supabase functions deploy <name>
  1. If the function is called from the browser, add a typed helper in src/lib/ that invokes it via supabase.functions.invoke('<name>', ...).