A full-stack SaaS-style lead and quote management dashboard for contractors and home-improvement businesses. Customers submit quote requests through a public form with photos; admins review submissions, update status, and add internal notes through a private dashboard.
Built as a portfolio demonstration of production full-stack engineering with a focus on security, type safety, and deployment hygiene.
- Public quote form: https://quoteyard.app/quote
- Admin dashboard: https://quoteyard.app/login (demo credentials available on request)
Replace these URLs with your deployed URLs. Adding demo credentials in the README is reasonable for a portfolio site as long as the demo data contains no real PII.
| Layer | Choice |
|---|---|
| Framework | Next.js 16 (App Router, Turbopack) |
| Language | TypeScript (strict + noUncheckedIndexedAccess + noImplicitOverride) |
| Database | Supabase Postgres |
| Auth | Supabase Auth |
| File storage | Supabase Storage (private bucket, signed URLs) |
| Resend | |
| Rate limiting | Upstash Redis |
| Forms | React Hook Form + Zod |
| Styling | Tailwind CSS v4 |
| Hosting | Vercel |
- Quote request form with React Hook Form + Zod validation (client and server)
- Optional photo uploads (up to 5 files, 25 MB total)
- Server-side rate limiting (5 submissions per hour, 20 per day)
- Honeypot anti-spam field
- Magic-byte file validation
- Confirmation page with a reference ID
- Email/password login (Supabase Auth, gated by
admin_usersmembership) - Paginated lead list with search and filters (status, service, city, date range)
- Per-lead detail page with contact info, project description, photo gallery, and internal notes
- Inline status updates and note add/delete
- CSV export (admin-gated, RFC 4180, formula-injection prevention)
- Signed-URL photo gallery (10-minute expiry, never persisted)
- Email notifications via Resend when a new lead arrives
- Defense in depth: edge gate (Next.js proxy) -> page layout -> Server Action -> Postgres RLS
- Synchronous best-effort cleanup for orphaned files
- Production deployment playbook (
docs/deployment.md)
The project uses a defense-in-depth model: every sensitive request passes through several independent gates, any one of which is sufficient to reject the request.
+-------------------------------------------------------------+
| Request to /dashboard/* |
+-------------------------------------------------------------+
|
v
+-------------------------------------------------------------+
| Gate 1: src/proxy.ts (Next.js edge, before any RSC) |
| - No session -> /login |
| - Session but not admin -> /login?error=not_authorized |
+-------------------------------------------------------------+
|
v
+-------------------------------------------------------------+
| Gate 2: dashboard/layout.tsx (Server Component) |
| - getCurrentAdmin() returns null -> /login |
+-------------------------------------------------------------+
|
v
+-------------------------------------------------------------+
| Gate 3: Server Action (per-mutation check) |
| - getCurrentAdmin() before any DB write |
+-------------------------------------------------------------+
|
v
+-------------------------------------------------------------+
| Gate 4: Postgres RLS (database, every query) |
| - is_admin() must succeed for any leads/notes/files row |
+-------------------------------------------------------------+
Removing any single gate would still leave a working secure system.
| Concern | Implementation |
|---|---|
| Server-only secrets | import 'server-only' on every module that touches SUPABASE_SERVICE_ROLE_KEY, RESEND_API_KEY, UPSTASH_REDIS_*. Build fails if a client component imports them. |
| CSRF | Next.js Server Actions enforce Origin matches Host by default in 15+. |
| Account enumeration | Login returns the identical error string for "wrong password", "no such user", and "authenticated but not in admin_users". |
| SQL/PostgREST injection | Zod validation server-side. Search input passes a strict allow-list regex before being interpolated into a PostgREST .or() clause. |
| File upload | 5 layers: client MIME accept, server allow-list, server magic-byte verification, Supabase Storage bucket-level MIME + size limits, Postgres lead_files.size_bytes CHECK constraint. |
| Storage path safety | leads/{uuid}/{uuid}.{ext} -- user-supplied filename never appears in the storage object key. |
| Private bucket | The lead-files bucket is private. Photos surface in the dashboard via short-lived signed URLs (10 min) generated server-side per render. |
| Email header injection | Subject sanitization strips C0 controls + DEL and caps at 200 chars. |
| CSV injection | Cells starting with =, +, -, @, \t, \r are prefixed with ' to disable formula interpretation in Excel. |
| Rate limiting | Upstash Redis sliding window, 5/hr + 20/day per IP. Runs before validation so the validation path isn't an oracle. |
| Orphaned files | Synchronous best-effort cleanup when metadata insert fails after upload. Production playbook documents a periodic GC for the residual case. |
Schema lives in supabase/migrations/. Five migrations applied in order:
0001_schema.sql-- six tables:services,cities,admin_users,leads,lead_notes,lead_files. Alead_statusenum (new/contacted/quoted/won/lost).0002_admin_fn.sql-- theis_admin()SQL function:security definer,stable,set search_path = public, execute revoked from public, granted only to authenticated.0003_rls.sql-- RLS enabled on every table. Anon can read activeservicesandcitiesonly. Every admin operation requiresis_admin(). Service-role (used by Server Actions) bypasses RLS by design.0004_seed.sql-- 6 services, 12 Metro Vancouver cities.0005_storage.sql-- privatelead-filesStorage bucket with 10 MB cap and MIME allow-list.
Verification queries are in docs/deployment.md -- they confirm rowsecurity = true on every public table, the expected policies exist, and is_admin() is hardened.
- Node.js 20+
- npm 10+
- A Supabase project (free tier is fine for local dev)
git clone <repo-url>
cd quoteyard
npm install
cp .env.example .env.local
# Fill in NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY,
# and SUPABASE_SERVICE_ROLE_KEY at minimum.Apply migrations in the Supabase SQL Editor in the order listed above.
Create one admin via the Supabase Auth UI, then bootstrap them:
insert into public.admin_users (user_id, email, full_name)
select id, email, coalesce(raw_user_meta_data->>'full_name', split_part(email, '@', 1))
from auth.users
where email = 'your-email@example.com';Then:
npm run dev
# Open http://localhost:3000
# Submit a quote at /quote
# Sign in at /login
# View leads at /dashboardResend and Upstash are optional locally. Without them:
- Resend: email notifications are logged and skipped (no
RESEND_API_KEYwarning). - Upstash: rate limiter falls back to in-memory (fine for dev; useless on Vercel).
Full production playbook in docs/deployment.md. Summary:
- Supabase -- create project, apply migrations, verify RLS.
- Resend -- verify a sending domain, create API key.
- Upstash Redis -- create a Global database.
- Vercel -- import the repo, set env vars, deploy.
- Bootstrap -- add the first admin via SQL.
- Smoke test -- run the 11-step checklist from the deployment doc.
These were intentionally left out of the initial build to keep the scope focused, but they're the obvious next steps:
- Audit log of admin mutations (status changes, note deletes) for compliance and accountability.
- Multi-role admins -- owner, sales rep, read-only.
- Email retry queue -- if Resend is down, queue the notification and retry instead of just logging.
- Trigram index for full-text search on
full_name/email/phoneonce dataset size warrants it. - Orphaned-file GC as a Supabase Edge Function cron (
docs/orphaned-files.mdhas the design). - MFA -- Supabase Auth supports TOTP; turn it on in production.
- Customer-facing status page -- a tokenized URL the customer can use to check progress without an account.
- Estimate workflow -- a real quote builder rather than a single
estimated_quote_centscolumn.
src/
├── app/
│ ├── (public)/
│ │ ├── quote/ -- public quote form + Server Action
│ │ └── thank-you/ -- confirmation page
│ └── (admin)/
│ ├── login/ -- Supabase Auth login
│ └── dashboard/
│ ├── leads/[id]/ -- detail page, photo gallery, notes
│ └── export/leads.csv/ -- CSV Route Handler
├── components/
│ └── dashboard/ -- table, filters, pagination, status badge
├── lib/
│ ├── supabase/ -- typed clients (browser, server, admin, auth, middleware)
│ ├── queries/ -- typed Server-Component query functions
│ ├── validations/ -- Zod schemas
│ ├── files/ -- magic-bytes, storage, signed URLs
│ ├── rate-limit/ -- Upstash + in-memory fallback
│ ├── email/ -- Resend
│ └── leads/ -- status display config
├── proxy.ts -- Next.js 16 edge proxy (admin gate)
└── types/database.ts -- Supabase generated types
supabase/migrations/ -- 5 SQL migrations
docs/ -- deployment.md, orphaned-files.md
Built by [your name] as a portfolio project for Junior Full-Stack Developer roles.
Source code is available on GitHub: [repo link].