- Node 20+ (tested with 25)
- npm 11+
- A Supabase account (the free tier is enough)
git clone <repo>
cd HitFactor
npm install
cp .env.example .env.local
# edit .env.local with your Supabase credentialsVariables expected in .env.local:
NEXT_PUBLIC_SUPABASE_URL=https://xxxxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=sb_publishable_...
NEXT_PUBLIC_SITE_URL=http://localhost:3000
The absolute URL of the site. It is the source of truth for all SEO metadata:
canonical, sitemap, robots, Open Graph and JSON-LD. If it is not defined, the
local default is http://localhost:3000.
On Vercel it is set per environment (Production / Preview / Development) with each one's real domain — that way the canonical URL and previews point to the right host on every deploy.
Apply all the migrations in Supabase, in numeric order:
- Open the project's SQL Editor.
- Paste and run the files in order:
0001 is a squash of the history prior to May 2026, not a full bootstrap. After consolidating it the numbering started over, so 0002+ come after it and are still required: without them you have no
is_absent,min_shots,qr_code,is_admin, the ammunition and firearm usage tables, or the Storage bucket for imports. Run all of them, in order.
0025 does not paste and run on its own. It installs a webhook whose shared secret cannot live in this repository — the repository is public — so it reads the secret and your project URL from two session settings and aborts if either is missing. Its header has the two
set_configlines to run first, in the same editor tab.If it aborts, that is the migration working. Do not make it apply by pasting the secret into the file: that puts a live credential in a public repository, where rotating it is the least of the problems. Everything else here really is paste-and-run.
0026 needs
pg_cronenabled first (Supabase → *Database → Extensions →pg_cron). It also aborts rather than applying halfway, and for the same reason: it installs a log of feedback-notification outcomes — deliveries as well as failures — and a log that nothing ever writes to is indistinguishable from one with nothing to report. It also needs 0025's trigger already in place — it checks for it by name. Log rows are kept for 90 days.
| # | File | What it does |
|---|---|---|
| 0001 | 0001_initial_schema.sql |
Consolidated base schema: profiles, disciplines, divisions, shooters, matches, stages, match_entries, stage_results, firearms, match_firearm_log, clubs, audit_log, feedback + indexes, triggers, RLS and seeds |
| 0002 | 0002_my_discipline_counts.sql |
my_discipline_counts(p_user_id) RPC — aggregates the user's participation count per discipline in Postgres (consumed by the sidebar) |
| 0003 | 0003_security_advisor_fixes.sql |
Supabase Security Advisor fixes: fixed search_path on functions and the remaining linter warnings |
| 0004 | 0004_fat_pdf_source.sql |
Extends the matches.source_type CHECK with fat_pdf (official FAT rankings in PDF) |
| 0005 | 0005_profiles_admin.sql |
profiles.is_admin — enables the diagnostic views restricted to administrators |
| 0006 | 0006_match_dedup_utilities.sql |
Audit and cleanup utilities for entries duplicated by re-import (merge_duplicate_shooters and friends) |
| 0007 | 0007_match_entries_absent.sql |
match_entries.is_absent — distinguishes "did not compete" from "competed and did poorly" |
| 0008 | 0008_match_entries_update_extended.sql |
Widens the UPDATE RLS on match_entries: besides the importer, admins and the shooter themselves |
| 0009 | 0009_backfill_is_absent.sql |
Backfills is_absent on matches imported before 0007 (avoids re-importing) |
| 0010 | 0010_ammunition_types.sql |
Per-shooter ammunition catalog, factory and reload (#46) |
| 0011 | 0011_firearm_usage_log.sql |
Firearm usage log outside matches — training and practice (#47) |
| 0012 | 0012_firearm_qr_codes.sql |
Short code per firearm + /q/{code} route, so the QR stuck on the firearm stays small (#58) |
| 0013 | 0013_firearm_qr_code_default.sql |
Replaces the trigger that generated qr_code with a column DEFAULT, so gen types sees it as optional |
| 0014 | 0014_match_min_shots.sql |
matches.min_shots — the basis of the "extra rounds" KPI against rounds_fired (#75) |
| 0015 | 0015_steel_pdf_source.sql |
Extends the source_type CHECK with practiscore_steel_pdf (Steel Challenge PDFs) |
| 0016 | 0016_fbi_classic_division.sql |
CLASSIC division for Tiro FBI |
| 0017 | 0017_ipsc_classic_manual_division.sql |
CM (Classic Manual) division for IPSC — shotgun exported from PractiScore |
| 0018 | 0018_update_ui_prefs_rpc.sql |
update_ui_prefs RPC — atomic merge of ui_prefs, avoids lost updates (#126) |
| 0019 | 0019_fbi_optic_division.sql |
OPTIC division for Tiro FBI |
| 0020 | 0020_import_uploads_storage.sql |
Private match-imports bucket + per-user RLS policies, for staging import files |
| 0021 | 0021_fix_shooters_claim_rls.sql |
Fixes the UPDATE RLS on shooters: its using clause let any authenticated user edit or unlink someone else's shooter (#195) |
| 0022 | 0022_matches_delete_admin.sql |
matches_delete_admin — completes the admin scope over matches that 0014 started, so admin authority matches what the app offers (#197) |
| 0023 | 0023_rls_to_clause_and_initplan.sql |
Rewrites every pre-0021 policy to to authenticated + (select auth.uid()) — drops the deprecated auth.role() and clears the auth_rls_initplan advisory. No predicate changes (#207) |
| 0024 | 0024_steel_rfri_division.sql |
RFRI (rimfire rifle, labelled Minirifle) for Steel Challenge — the division was missing, so any match running it failed to import |
| 0025 | 0025_feedback_telegram_webhook.sql |
feedback_to_telegram — the trigger that posts new feedback to the feedback-telegram Edge Function. Existed in production since June, created by hand and never committed. Needs two session settings to apply; see the file's header |
| 0026 | 0026_feedback_notification_trail.sql |
ops.feedback_notification_log + a pg_cron sweep that copies each feedback webhook's outcome out of net._http_response before pg_net purges it, so a lost notification leaves a trace that outlives the hour nobody was watching (#288). Kept for 90 days, with a heartbeat so a stopped sweep says so rather than reading as healthy. Needs pg_cron and 0025's trigger; aborts without either |
| 0027 | 0027_correlate_sweep_join_by_time.sql |
Correlates the sweep's net._http_response lookup by time as well as by id. pg_net's request ids restart if the extension is recreated, and supabase_functions.hooks is never purged — so an id alone could hand an old hook a newer delivery's outcome, and report a failure as delivered. Paste-and-run; needs 0026 |
If you add a migration, add its row here: there is a test
(tests/migrations-doc.test.ts) that fails if the directory and this table
drift apart. This table was 18 migrations out of date precisely because
nothing verified it.
For development we recommend disabling email confirmation in Supabase → Authentication → Sign In / Providers → Email — that way you can create test accounts without having to confirm them.
npm run dev # dev server (localhost:3000)
npm run build # production build
npm run lint # eslint (errors fail, warnings are printed)
npm test # run the tests once
npm run test:watch # tests in watch mode
npm run db:types # regenerate src/lib/supabase/database.types.ts from the DBdb:types needs a SUPABASE_ACCESS_TOKEN in the environment (or a prior
supabase login). Run it after applying a migration that changes the schema,
so the typed Supabase client stays in sync.
You will be told if you forget. That file is what supabase-js derives every
row type from, so when it drifts tsc keeps checking against a schema that no
longer exists and says nothing (#269).
tests/database-types-drift.test.ts applies every migration to a real Postgres
compiled to wasm and compares the schema against the committed file: base
tables, their columns, nullability, type class, and the functions PostgREST
exposes. It needs no token and no network, so it runs in CI like any other
test. It does not look at Insert/Update shapes, Enums, Views,
CompositeTypes or relationships — the test header says why, and where to
extend it.
See architecture.md.
npm run lint && npx tsc --noEmit && npm test && npm run buildAll four have to pass. Currently: 847 tests green, typecheck clean, lint
clean (with the three warnings explained in eslint.config.mjs).
Note that npx tsc --noEmit and npm run build are not interchangeable.
next build also type-checks against the route types Next generates from the
directory structure (PageProps<...>, LayoutProps<...>), which plain tsc
never sees. A params type that satisfies tsc can still fail the build.