Setu is an internal operations app built on Supabase and Vercel. The frontend is a Vercel-style static app; its API boundary is a Supabase Edge Function backed by Postgres.
It covers equipment and program requests, comments, roster scheduling, inventory, programs, home content, and user settings.
This architecture can run on the free tiers of Supabase and Vercel for both the Setu and Setu Dev environments, within each provider's current quotas and terms. Supabase hosts the database, Storage, and Edge Functions; Apps Script runs the 10-minute comment-email worker, and Vercel only serves the static frontend. Supabase's current Free plan includes two active projects, 500,000 Edge Function invocations, 1 GB of file storage, and 500 MB of database storage per project. Free projects pause after 7 days with no API activity — each project's pause timer is independent, so pinging one does not keep the other awake.
.github/workflows/keep-alive.yml pings
both the Setu and Setu Dev projects every 3 days to prevent this. It needs
four repository secrets (Settings → Secrets and variables → Actions in
GitHub), using the same URL/publishable key pair from each project's
Project Settings → API Keys page (see step 1 below for where to find
these):
SETU_SUPABASE_URL_PROD/SETU_SUPABASE_PUBLISHABLE_KEY_PROD— the Setu (production) projectSETU_SUPABASE_URL_DEV/SETU_SUPABASE_PUBLISHABLE_KEY_DEV— the Setu Dev project
Trigger a manual run from the Actions tab (Keep Supabase projects alive → Run workflow) to verify it after setup.
Comment email delivery depends on the Google Workspace account running the Apps Script worker. Apps Script has Workspace email-recipient and execution quotas, so this path is intended for Setu's low-volume internal notifications.
See the providers' current plan details before relying on these numbers: Supabase pricing, Vercel pricing, and Resend pricing.
Browser → Vercel static app → Supabase Auth
├→ Edge Function: api
├→ Postgres
└→ Storage
frontend/contains the browser application.supabase/migrations/contains the database schema.supabase/functions/api/contains the authenticated API boundary.shared/types.d.tscontains the frontend/API contract.
- Node.js 22 or newer
- A Supabase account and a development project
- A Google OAuth client configured for that Supabase project
- Git
Docker is not required for the recommended hosted-development workflow. The
Supabase CLI is installed as a project dependency by npm install.
git clone <repository-url>
cd setu
npm install
cp .env.example .env.localEdit .env.local with the URL and publishable/anon key for the Supabase
development project. In the Supabase dashboard, open the project (if your
organization has more than one, confirm which is the shared development
project before continuing — do not point .env.local at a production
project), then go to Project Settings → API Keys, "Publishable and secret
API keys" tab:
SETU_SUPABASE_URL=https://<project-ref>.supabase.co
SETU_SUPABASE_PUBLISHABLE_KEY=<the "Publishable key" value shown there>.env.local is ignored by Git. Never put a service-role/secret key or OAuth
secret in it — only the publishable key is safe to use here.
The frontend signs users in with Google OAuth, which needs an OAuth client in Google Cloud plus matching config in Supabase. Do this before your first sign-in attempt — see the note at the end of step 3 if you already tried signing in.
In Google Cloud Console (APIs & Credentials):
- Pick or create a GCP project for this app. If prompted, configure the OAuth consent screen first (app name, support email) — this is a one-time step per GCP project. While the app is in "Testing" mode, only accounts you explicitly add as test users can sign in.
- Create Credentials → OAuth client ID → Application type Web application.
- Under Authorized JavaScript origins, add
http://localhost:3000. - Under Authorized redirect URIs, add the callback URL shown in the
Supabase panel from the next step, typically
https://<project-ref>.supabase.co/auth/v1/callback. - Click Create. Google shows a Client ID
(
....apps.googleusercontent.com) and Client Secret (GOCSPX-...) — keep this tab open, you'll need both in the next step.
In the Supabase dashboard, under Authentication → Sign In / Providers (this section may just be labeled "Providers" in older dashboard versions), scroll to Auth Providers and click Google:
- Paste the Client ID into Client IDs and the Client Secret into Client Secret (for OAuth).
- Leave Skip nonce checks and Allow users without an email off — they're workarounds for platforms Setu doesn't need (native iOS flows, providers that omit email).
- Toggle Enable Sign in with Google on, then Save.
Without this provider setup, the browser will show
Unsupported provider: provider is not enabled.
Authenticate the Supabase CLI and connect it to the development project.
supabase login opens a browser tab to authorize the CLI — run it from an
interactive terminal, not a non-interactive/CI shell:
npx supabase login
npx supabase link --project-ref <project-ref>
npx supabase db push
npx supabase secrets set SETU_APP_ORIGIN=http://localhost:3000
npx supabase functions deploy apiComment email delivery
Comments enqueue email rows transactionally in email_outbox. The Apps Script
worker in src/EmailDispatcher.ts claims at most 100 pending rows through the
Supabase RPC, sends them using the Workspace account's MailApp permission, and
marks each row sent or failed. It uses a script lock to prevent overlapping
runs and never clears a row before its send succeeds.
After the first Apps Script deployment, run installEmailDispatcherTrigger
once from the Apps Script editor. Configure these Script Properties on the
Apps Script project: SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY.
Set APP_URL to the deployed Setu origin so comment emails can include
working “View request” links.
EMAIL_SENDER_NAME defaults to Live Stream Setu. Emails use MailApp with
noReply: true, matching the existing GCP monitor workflow. The service-role
key is stored only in Apps Script properties and must never be placed in
frontend code. Set CC_EMAIL to an additional address that should receive a
copy of every email; multiple comma-separated addresses are supported. The
configured address is added alongside the lead and request participants, with
duplicates and the primary recipient removed automatically.
Email subjects use the request serial, the same combined request title shown
in the detail view, and the start month/year, for example
PRG-7 - English Workshop Title | Sep 2026. The body starts with the author's
name and comment, renders the full session or inventory list as an HTML <ul>,
and ends with a clickable “View request” link to the request in Setu. A
plain-text body is included as a fallback.
The GitHub deployment workflow uses the previous clasp flow. It expects the
repository secrets CLASPRC_JSON, APPS_SCRIPT_ID, and
APPS_SCRIPT_DEPLOYMENT_ID; it runs for version tags (v*) or manually from
the Actions tab.
Supabase migrations and the api Edge Function are deployed automatically to
both Setu production and Setu Dev by
.github/workflows/deploy-supabase.yml when Supabase migrations, functions,
configuration, or dependency files change on master. Add the
SUPABASE_ACCESS_TOKEN, SUPABASE_PROJECT_REF_PROD, and
SUPABASE_PROJECT_REF_DEV as repository secrets, then use the workflow's
manual dispatch when needed. The token must have permission for both projects.
The workflow selects each project using its secret project ref, applies
migrations, and deploys the API function independently to each project; manual
dispatch runs both deployment areas.
Email-domain access control
The Admin Settings page manages the allowed email domains. An empty list keeps
the app open to all domains; adding the first domain switches the app to
allowlist mode. The migration creates the Supabase Before User Created Auth
Hook function. For hosted projects, enable it once in Authentication →
Hooks using the Postgres function
public.restrict_user_by_email_domain (the checked-in supabase/config.toml
contains the equivalent local configuration). A refreshed page is enough for
an existing session to pick up a changed allowlist.
db push applies the checked-in migrations, including the trigger that
creates a profiles row for each new Supabase Auth user. SETU_APP_ORIGIN
is required for the API's CORS policy.
Complete this step before your first Google sign-in. If you already
signed in earlier (e.g. while testing the OAuth setup in step 2, before
db push had run), your auth.users row exists but has no matching
profiles row — see Troubleshooting below.
npm run devOpen http://localhost:3000 and sign in with Google. The dev server uses the same Supabase API transport as the Vercel build.
npm run dev # Start the local frontend
npm run typecheck # Type-check backend and frontend
npm run format:check # Check formatting
npm run build:vercel # Build the Vercel output in web/
npx supabase db push # Apply migrations to the linked project
npx supabase functions deploy apiThere is no mock backend. If the Edge Function reports that an operation has not been migrated, that operation is not implemented yet.
Browser shows "Something went wrong / Failed to send a request to the Edge
Function": the api function isn't deployed to your linked project yet.
Run npx supabase functions deploy api (step 3).
Browser shows "Something went wrong / Edge Function returned a non-2xx
status code": check the function logs in the Supabase dashboard
(Edge Functions → api → Logs). An error there reading Cannot coerce the result to a single JSON object means a .single() query got zero rows —
almost always because the signed-in user has no matching public.profiles
row. This happens if you signed in before running db push, since the
profile-creation trigger only fires for auth users created after the
trigger exists. Fix it by backfilling the missing row(s) for any existing
auth.users from the Supabase dashboard's SQL Editor:
insert into public.profiles (id, email, name)
select u.id, coalesce(u.email, ''),
coalesce(u.raw_user_meta_data ->> 'full_name', u.raw_user_meta_data ->> 'name', '')
from auth.users u
left join public.profiles p on p.id = u.id
where p.id is null;This only inserts rows for users missing a profile, so it's safe to re-run.
New sign-ins after db push don't need this — the trigger handles them
automatically.
If every account has a profile and you still see this error only for an
admin/approver account: the profiles RLS policy lets admins/approvers
read every profile row, so an unfiltered select('*').single() query for
"my own profile" can return more than one row. The Edge Function code
filters explicitly by .eq('id', userId) before .single() for this
reason — if you're extending supabase/functions/api/index.ts, follow the
same pattern rather than relying on RLS alone to narrow a .single() query.
Browser shows "Unsupported provider: provider is not enabled": the Google provider isn't turned on yet, or Client ID/Secret aren't saved — see step 2.
Signing in on a Vercel deployment redirects to localhost instead of the
app: that project's Supabase Auth Site URL is still pointing at the
local dev default. See Deploy to Vercel step 2.
Not required for local development — npm run dev (step 4 above) runs the
full app against your Supabase dev project without Vercel. This section only
applies when you're ready to deploy a preview or production build, and it
typically uses its own Supabase project rather than the shared development
project from step 1.
Set these environment variables for the appropriate Vercel environments (Preview and Production):
SETU_SUPABASE_URLSETU_SUPABASE_PUBLISHABLE_KEY
Vercel uses npm run build:vercel and publishes web/ as configured in
vercel.json.
In the Supabase dashboard, for the project this Vercel deployment uses, go to Authentication → URL Configuration:
- Set Site URL to the deployed domain, e.g.
https://<vercel-domain>. - Add that same URL to Redirect URLs (use
https://<vercel-domain>/**, orhttps://*.vercel.app/**to also cover preview deployments).
Skip this and Google sign-in still completes, but the browser is sent back to
whatever Site URL happens to be set — often http://localhost:3000, left
over from local dev — instead of the Vercel domain.
Also add the Vercel domain to the Google OAuth client from step 2 above:
under Authorized JavaScript origins, add https://<vercel-domain>. The
Authorized redirect URIs entry doesn't need to change — Google always
redirects to the Supabase callback URL, not the app directly.
Vercel only builds and serves web/; the database schema and Edge Function
are deployed separately. Run these against the same project used in step 1
(pass --project-ref <project-ref> if it isn't the CLI's currently linked
project):
npx supabase secrets set SETU_APP_ORIGIN=https://<vercel-domain> --project-ref <project-ref>
npx supabase functions deploy api --project-ref <project-ref>
npx supabase db push --project-ref <project-ref>Skipping this step is the most common cause of a newly connected Vercel deployment showing "Something went wrong / getDashboard: Failed to send a request to the Edge Function" — see Troubleshooting.
The current Edge Function implements whoAmI, getDashboard, and
updateOwnProfile. The remaining CRUD and workflow operations are still being
migrated; the frontend contract remains in shared/types.d.ts so each
operation can be ported incrementally.
The database schema already replaces Sheets-era JSON and comma-separated
fields with relational tables. Image storage uses the public request-images
bucket for cacheable static assets; uploads and deletes still go through the
trusted Edge Function.
The database roles are admin, approver, viewer, and user:
| Role | Access |
|---|---|
admin |
Everything, including settings and role management |
approver |
Requests, approvals, scheduling, and read-only users |
viewer |
All requests and standard app sections, including roster and inventory types |
user |
Own and participant requests, plus roster and inventory types |
New Supabase Auth users receive a profile through the database trigger. Assign the first development administrator explicitly in the Supabase SQL editor:
update public.profiles
set role = 'admin'
where email = 'your-email@example.com';