M0: foundation — monorepo, PostGIS schema, deck query, app shell
Greenfield scaffold for Linkder, a swipe-to-hire marketplace for local
professional services.
- pnpm/turbo monorepo: apps/web, packages/{shared,db}
- Postgres 16 + PostGIS via docker compose (ports 5442/6389 to avoid
clashing with other local stacks)
- Drizzle schema, 23 tables, geography(Point,4326) with GiST indexes
- Domain core in packages/shared: integer-cent money, status transition
graphs, deck ranking weights, cancellation policy — 46 unit tests
- Deck query: filtering in Postgres on the GiST index, ranking in JS so
the weights stay tunable — 18 integration tests against a seeded DB
- Deterministic seed placing pros at known distances, including three
that must NOT appear on a deck (out of radius, unverified, away)
- Next.js 15 app shell with a working swipe deck
- CI: typecheck, lint, test, build against live postgres+redis
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
# ---- Core ----
|
||||
NODE_ENV=development
|
||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||
|
||||
# ---- Database (Postgres 16 + PostGIS) ----
|
||||
DATABASE_URL=postgresql://linkder:linkder@localhost:5442/linkder
|
||||
|
||||
# ---- Redis (pub/sub for SSE chat + BullMQ queues) ----
|
||||
REDIS_URL=redis://localhost:6389
|
||||
|
||||
# ---- Auth.js v5 ----
|
||||
# generate with: openssl rand -base64 32
|
||||
AUTH_SECRET=
|
||||
AUTH_URL=http://localhost:3000
|
||||
AUTH_GOOGLE_ID=
|
||||
AUTH_GOOGLE_SECRET=
|
||||
|
||||
# ---- Phone OTP (Twilio Verify) ----
|
||||
TWILIO_ACCOUNT_SID=
|
||||
TWILIO_AUTH_TOKEN=
|
||||
TWILIO_VERIFY_SERVICE_SID=
|
||||
|
||||
# ---- Stripe Connect ----
|
||||
STRIPE_SECRET_KEY=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
|
||||
# Platform commission in basis points (1500 = 15%)
|
||||
PLATFORM_FEE_BPS=1500
|
||||
|
||||
# ---- Didit (ID verification) ----
|
||||
DIDIT_API_KEY=
|
||||
DIDIT_WORKFLOW_ID=
|
||||
DIDIT_WEBHOOK_SECRET=
|
||||
|
||||
# ---- Cloudflare R2 (S3-compatible object storage) ----
|
||||
R2_ACCOUNT_ID=
|
||||
R2_ACCESS_KEY_ID=
|
||||
R2_SECRET_ACCESS_KEY=
|
||||
R2_BUCKET=linkder-uploads
|
||||
R2_PUBLIC_URL=
|
||||
|
||||
# ---- Resend (transactional email) ----
|
||||
RESEND_API_KEY=
|
||||
EMAIL_FROM=noreply@linkder.app
|
||||
|
||||
# ---- Launch market (city-scoped MVP) ----
|
||||
NEXT_PUBLIC_CITY_NAME=Barcelona
|
||||
NEXT_PUBLIC_CITY_LAT=41.3874
|
||||
NEXT_PUBLIC_CITY_LNG=2.1686
|
||||
@@ -0,0 +1,3 @@
|
||||
* text=auto eol=lf
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
@@ -0,0 +1,60 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgis/postgis:16-3.4
|
||||
env:
|
||||
POSTGRES_USER: linkder
|
||||
POSTGRES_PASSWORD: linkder
|
||||
POSTGRES_DB: linkder
|
||||
ports: ['5442:5432']
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U linkder -d linkder"
|
||||
--health-interval 5s --health-timeout 5s --health-retries 10
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports: ['6389:6379']
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 5s --health-timeout 3s --health-retries 10
|
||||
|
||||
env:
|
||||
DATABASE_URL: postgresql://linkder:linkder@localhost:5442/linkder
|
||||
REDIS_URL: redis://localhost:6389
|
||||
NEXT_PUBLIC_CITY_LAT: '41.3874'
|
||||
NEXT_PUBLIC_CITY_LNG: '2.1686'
|
||||
NEXT_PUBLIC_CITY_NAME: Barcelona
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
# The integration tests assert against exact seeded distances, so the
|
||||
# database must be migrated and seeded before they run.
|
||||
- run: cp .env.example .env
|
||||
- run: pnpm db:migrate
|
||||
- run: pnpm db:seed
|
||||
|
||||
- run: pnpm typecheck
|
||||
- run: pnpm lint
|
||||
- run: pnpm test
|
||||
- run: pnpm build
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
node_modules/
|
||||
.next/
|
||||
dist/
|
||||
out/
|
||||
.turbo/
|
||||
coverage/
|
||||
*.tsbuildinfo
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
!.env.example
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
playwright-report/
|
||||
test-results/
|
||||
drizzle/meta/_journal.json.bak
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100,
|
||||
"plugins": ["prettier-plugin-tailwindcss"]
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
# Linkder
|
||||
|
||||
Swipe-to-hire marketplace for local professional services. A client describes a job once, then
|
||||
swipes through **verified** local pros — plumbers, electricians, handymen. A right swipe sends the
|
||||
job to that pro; the pro accepts; chat, quote, booking, escrow payment and reviews all happen in
|
||||
the app.
|
||||
|
||||
Web first. The API layer is designed so a React Native app can reuse it verbatim.
|
||||
|
||||
## Status
|
||||
|
||||
**M0 — foundation. Complete and verified.**
|
||||
|
||||
| Milestone | State |
|
||||
|---|---|
|
||||
| M0 Foundation — monorepo, Postgres+PostGIS, schema, CI, app shell | ✅ done |
|
||||
| M1 Auth & profiles | ⬜ next |
|
||||
| M2 Verification & admin queue | ⬜ |
|
||||
| M3 The deck (jobs, swipes, requests, matches) | 🟡 deck query + swipe UI working; needs auth + tRPC |
|
||||
| M4 Chat & scheduling | ⬜ |
|
||||
| M5 Payments & escrow | ⬜ |
|
||||
| M6 Reviews & ranking | ⬜ |
|
||||
| M7 Launch readiness | ⬜ |
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
cp .env.example .env # ports 5442 / 6389 to avoid clashing with other local stacks
|
||||
pnpm services:up # postgres+postgis and redis in docker
|
||||
pnpm db:migrate
|
||||
pnpm db:seed
|
||||
pnpm dev # http://localhost:3000
|
||||
```
|
||||
|
||||
The landing page lists the seeded job. Open its deck to swipe.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
apps/web Next.js 15 (App Router) — client, pro and admin UIs
|
||||
apps/worker BullMQ worker (M3+): request expiry, payouts, reminders
|
||||
packages/shared money, state machines, ranking weights, zod schemas — no I/O, fully unit tested
|
||||
packages/db Drizzle schema, migrations, the deck query
|
||||
packages/api tRPC routers (M1) — the contract mobile will reuse
|
||||
packages/ui shared components (M1)
|
||||
```
|
||||
|
||||
### Where the important decisions live
|
||||
|
||||
- **`packages/shared/src/state-machines.ts`** — every legal status transition. Mutations must call
|
||||
`assertTransition`; nothing jumps from `scheduled` to `completed` because a payload said so.
|
||||
- **`packages/shared/src/ranking.ts`** — the deck scoring weights. This is the product; expect to
|
||||
tune it weekly against booking conversion.
|
||||
- **`packages/shared/src/money.ts`** — integer cents only. `splitCharge` always sums back to the
|
||||
original amount.
|
||||
- **`packages/db/src/queries/deck.ts`** — the deck query. Filtering runs in Postgres on a GiST
|
||||
index (`ST_DWithin`), ranking runs in JS so the weights stay tunable.
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
pnpm test # everything
|
||||
pnpm --filter @linkder/shared test # 46 unit tests, no database needed
|
||||
pnpm --filter @linkder/db test # 18 integration tests, needs a seeded database
|
||||
```
|
||||
|
||||
The seed is deterministic: every pro sits at a **known** distance and bearing from the city centre,
|
||||
and the fixture job sits exactly at the centre. So the expected deck is an exact list, not a vague
|
||||
"roughly the nearby ones". The seed deliberately includes pros that must **not** appear:
|
||||
|
||||
| Pro | Why they must be excluded |
|
||||
|---|---|
|
||||
| Pau Ribas | 22 km away but only travels 5 km |
|
||||
| Unverified Ulla | 1 km away, verification still `pending` |
|
||||
| Away Arnau | verified, but `is_accepting_jobs = false` |
|
||||
|
||||
## Notes and gotchas
|
||||
|
||||
- **PostGIS type generation.** drizzle-kit quotes type names it does not recognise, which turns
|
||||
`geography(Point,4326)` into an invalid quoted identifier. `packages/db/scripts/fix-postgis.mjs`
|
||||
unquotes them and runs automatically as part of `pnpm db:generate`. If you ever run
|
||||
`drizzle-kit generate` directly, run the script afterwards.
|
||||
- **Geography, not geometry.** Distances come back in metres and `ST_DWithin` is correct anywhere
|
||||
without picking a projection per city. Do not replace it with hand-rolled haversine — it will not
|
||||
use the GiST index.
|
||||
- **Ports.** Postgres is on `5442` and Redis on `6389`, not the defaults, so the stack can run
|
||||
alongside other local projects.
|
||||
- **The swipe write is currently a Next server action** (`apps/web/src/app/deck/[jobId]/actions.ts`)
|
||||
and **trusts the caller**. It moves into a tRPC procedure with a real session check in M1/M3. It
|
||||
must not ship as-is.
|
||||
- **`pnpm db:seed` truncates everything.** It is for local and CI only.
|
||||
|
||||
## Before taking real money
|
||||
|
||||
Flagged in the plan, unresolved by design — these are business decisions, not code:
|
||||
|
||||
1. **Escrow.** Holding client funds between charge and transfer is escrow-adjacent. Stripe Connect
|
||||
separate charges & transfers is the sanctioned marketplace pattern, but confirm the specific flow,
|
||||
merchant-of-record and VAT/invoicing position for your jurisdiction with Stripe.
|
||||
2. **Cold start.** 30–50 verified pros must exist in the launch city *before* any client opens the
|
||||
app. An empty deck kills the product on day one. This is the real launch blocker, not code.
|
||||
3. **Trade liability.** Licence and insurance checks are the legal exposure of the whole business.
|
||||
The admin review queue in M2 is not an afterthought.
|
||||
@@ -0,0 +1,14 @@
|
||||
import { dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { FlatCompat } from '@eslint/eslintrc';
|
||||
|
||||
// eslint-config-next still ships a legacy config, so it needs the compat shim
|
||||
// to run under ESLint 9 flat config.
|
||||
const compat = new FlatCompat({ baseDirectory: dirname(fileURLToPath(import.meta.url)) });
|
||||
|
||||
const config = [
|
||||
{ ignores: ['.next/**', 'node_modules/**', 'next-env.d.ts'] },
|
||||
...compat.extends('next/core-web-vitals', 'next/typescript'),
|
||||
];
|
||||
|
||||
export default config;
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -0,0 +1,21 @@
|
||||
import { config as loadEnv } from 'dotenv';
|
||||
import type { NextConfig } from 'next';
|
||||
|
||||
// The monorepo keeps one .env at the root; Next only looks in the app directory.
|
||||
loadEnv({ path: '../../.env' });
|
||||
|
||||
const config: NextConfig = {
|
||||
reactStrictMode: true,
|
||||
// The workspace packages ship TypeScript source, not build output.
|
||||
transpilePackages: ['@linkder/db', '@linkder/shared'],
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{ protocol: 'https', hostname: 'picsum.photos' },
|
||||
{ protocol: 'https', hostname: '**.r2.dev' },
|
||||
],
|
||||
},
|
||||
// postgres-js opens raw sockets; it must not be bundled into the server chunk.
|
||||
serverExternalPackages: ['postgres'],
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "@linkder/web",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev --port 3000",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@linkder/db": "workspace:*",
|
||||
"@linkder/shared": "workspace:*",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"drizzle-orm": "0.38.4",
|
||||
"lucide-react": "^0.469.0",
|
||||
"motion": "^11.15.0",
|
||||
"next": "^15.1.4",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"tailwind-merge": "^2.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "3.2.0",
|
||||
"@tailwindcss/postcss": "^4.0.0",
|
||||
"@types/react": "^19.0.7",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"dotenv": "16.4.7",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-next": "^15.1.4",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
const config = {
|
||||
plugins: { '@tailwindcss/postcss': {} },
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,82 @@
|
||||
'use server';
|
||||
|
||||
import { and, count, eq } from 'drizzle-orm';
|
||||
import { db, schema } from '@linkder/db';
|
||||
import { MAX_OPEN_REQUESTS_PER_JOB, REQUEST_TTL_HOURS, swipeSchema } from '@linkder/shared';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
|
||||
export interface SwipeResult {
|
||||
ok: boolean;
|
||||
/** Set when a right swipe actually created a request. */
|
||||
requested?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a swipe.
|
||||
*
|
||||
* A left swipe is just a tombstone that keeps the pro off this job's deck.
|
||||
* A right swipe additionally sends the job to that pro as a pending request,
|
||||
* subject to the open-request cap — that cap is what stops one client from
|
||||
* spraying every plumber in the city and burning the supply side's goodwill.
|
||||
*
|
||||
* TODO(M1): derive the client from the session and verify they own this job.
|
||||
* Until auth lands this trusts the caller, which is fine for local seeded data
|
||||
* and must not ship.
|
||||
*/
|
||||
export async function recordSwipe(input: {
|
||||
jobId: string;
|
||||
proId: string;
|
||||
direction: 'left' | 'right';
|
||||
}): Promise<SwipeResult> {
|
||||
const parsed = swipeSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
return { ok: false, error: parsed.error.issues[0]?.message ?? 'Invalid swipe' };
|
||||
}
|
||||
const { jobId, proId, direction } = parsed.data;
|
||||
|
||||
const job = await db.query.jobs.findFirst({ where: eq(schema.jobs.id, jobId) });
|
||||
if (!job) return { ok: false, error: 'Job not found' };
|
||||
if (job.status !== 'open' && job.status !== 'matched') {
|
||||
return { ok: false, error: 'This job is no longer taking offers' };
|
||||
}
|
||||
|
||||
// The unique index on (job_id, pro_id) is the real guard against double-swipes
|
||||
// from a double-tap or a replayed request.
|
||||
await db
|
||||
.insert(schema.swipes)
|
||||
.values({ jobId, proId, direction })
|
||||
.onConflictDoNothing({ target: [schema.swipes.jobId, schema.swipes.proId] });
|
||||
|
||||
if (direction === 'left') {
|
||||
revalidatePath(`/deck/${jobId}`);
|
||||
return { ok: true, requested: false };
|
||||
}
|
||||
|
||||
const [open] = await db
|
||||
.select({ n: count() })
|
||||
.from(schema.requests)
|
||||
.where(and(eq(schema.requests.jobId, jobId), eq(schema.requests.status, 'pending')));
|
||||
|
||||
if ((open?.n ?? 0) >= MAX_OPEN_REQUESTS_PER_JOB) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `You already have ${MAX_OPEN_REQUESTS_PER_JOB} pros considering this job. Wait for one to reply before sending more.`,
|
||||
};
|
||||
}
|
||||
|
||||
const ttlHours = REQUEST_TTL_HOURS[job.urgency];
|
||||
await db
|
||||
.insert(schema.requests)
|
||||
.values({
|
||||
jobId,
|
||||
proId,
|
||||
expiresAt: new Date(Date.now() + ttlHours * 3_600_000),
|
||||
})
|
||||
.onConflictDoNothing({ target: [schema.requests.jobId, schema.requests.proId] });
|
||||
|
||||
// TODO(M3): notify the pro — web push + email, via the BullMQ queue.
|
||||
|
||||
revalidatePath(`/deck/${jobId}`);
|
||||
return { ok: true, requested: true };
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
import type { DeckCard } from '@linkder/db';
|
||||
import { Deck } from '@/components/deck';
|
||||
import { recordSwipe } from './actions';
|
||||
|
||||
/**
|
||||
* Bridges the server-rendered deck to the swipe action.
|
||||
*
|
||||
* Swipes are optimistic: the card leaves immediately and the write happens in
|
||||
* the background. A failed right-swipe (usually the open-request cap) surfaces
|
||||
* as a banner rather than snapping the card back — the client has moved on, and
|
||||
* re-inserting a card they already dismissed is more confusing than a message.
|
||||
*/
|
||||
export function DeckClient({ jobId, cards }: { jobId: string; cards: DeckCard[] }) {
|
||||
const [notice, setNotice] = useState<{ kind: 'sent' | 'error'; text: string } | null>(null);
|
||||
|
||||
const onDecide = useCallback(
|
||||
async (proId: string, direction: 'left' | 'right') => {
|
||||
const card = cards.find((c) => c.proId === proId);
|
||||
const result = await recordSwipe({ jobId, proId, direction });
|
||||
|
||||
if (!result.ok) {
|
||||
setNotice({ kind: 'error', text: result.error ?? 'Something went wrong' });
|
||||
return;
|
||||
}
|
||||
if (result.requested) {
|
||||
setNotice({
|
||||
kind: 'sent',
|
||||
text: `Job sent to ${card?.name ?? 'the pro'}. You'll hear back once they accept.`,
|
||||
});
|
||||
}
|
||||
},
|
||||
[cards, jobId],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{notice && (
|
||||
<div
|
||||
role="status"
|
||||
className={
|
||||
notice.kind === 'sent'
|
||||
? 'rounded-xl border border-[var(--color-go-500)]/30 bg-[var(--color-go-500)]/10 px-4 py-3 text-sm'
|
||||
: 'rounded-xl border border-[var(--color-stop-500)]/30 bg-[var(--color-stop-500)]/10 px-4 py-3 text-sm'
|
||||
}
|
||||
>
|
||||
{notice.text}
|
||||
</div>
|
||||
)}
|
||||
<Deck cards={cards} onDecide={onDecide} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { notFound } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { db, getDeck, schema } from '@linkder/db';
|
||||
import { DeckClient } from './deck-client';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function DeckPage({ params }: { params: Promise<{ jobId: string }> }) {
|
||||
const { jobId } = await params;
|
||||
|
||||
const job = await db.query.jobs.findFirst({
|
||||
where: eq(schema.jobs.id, jobId),
|
||||
with: { category: true },
|
||||
});
|
||||
if (!job) notFound();
|
||||
|
||||
const cards = await getDeck(db, { jobId });
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-dvh max-w-2xl flex-col px-4 py-6">
|
||||
<header className="mb-6">
|
||||
<Link
|
||||
href="/"
|
||||
className="mb-4 inline-flex items-center gap-1.5 text-sm text-[var(--muted)] hover:text-[var(--fg)]"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" aria-hidden />
|
||||
Back
|
||||
</Link>
|
||||
<p className="text-sm text-[var(--muted)]">
|
||||
{job.category.name} · {job.addressText}
|
||||
</p>
|
||||
<h1 className="text-xl font-semibold">{job.title}</h1>
|
||||
</header>
|
||||
|
||||
<DeckClient jobId={jobId} cards={cards} />
|
||||
|
||||
<p className="mt-8 text-center text-xs text-[var(--muted)]">
|
||||
Swipe right to send this job to a pro, left to pass. Drag the card or use the buttons.
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import '@/styles/globals.css';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: 'Linkder — hire a verified local pro',
|
||||
template: '%s · Linkder',
|
||||
},
|
||||
description:
|
||||
'Describe the job once, then swipe through verified local plumbers, electricians and handymen. Quote, book and pay in one place.',
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: [
|
||||
{ media: '(prefers-color-scheme: light)', color: '#fbfbfd' },
|
||||
{ media: '(prefers-color-scheme: dark)', color: '#121319' },
|
||||
],
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
// The deck is a drag surface — double-tap zoom fights it.
|
||||
maximumScale: 1,
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className="min-h-dvh antialiased">{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import Link from 'next/link';
|
||||
import { desc } from 'drizzle-orm';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import { db, schema } from '@linkder/db';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* M0 landing page. It doubles as a smoke test: if the categories and the seeded
|
||||
* job render, then Next → Drizzle → PostGIS is wired correctly end to end.
|
||||
*/
|
||||
export default async function Home() {
|
||||
const [categories, jobs] = await Promise.all([
|
||||
db.select().from(schema.categories).orderBy(schema.categories.position),
|
||||
db.select().from(schema.jobs).orderBy(desc(schema.jobs.createdAt)).limit(5),
|
||||
]);
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-2xl px-6 py-16">
|
||||
<p className="text-sm font-medium text-[var(--color-brand-500)]">Linkder</p>
|
||||
<h1 className="mt-2 text-4xl font-semibold tracking-tight text-balance">
|
||||
Describe the job once. Swipe through verified local pros.
|
||||
</h1>
|
||||
<p className="mt-4 text-lg text-[var(--muted)] text-pretty">
|
||||
Every pro on the deck has had their ID, trade licence and insurance checked. Quote, book and
|
||||
pay in one place — your money is held until the work is done.
|
||||
</p>
|
||||
|
||||
<section className="mt-12">
|
||||
<h2 className="text-sm font-medium text-[var(--muted)]">Trades we cover</h2>
|
||||
<ul className="mt-3 flex flex-wrap gap-2">
|
||||
{categories.map((c) => (
|
||||
<li
|
||||
key={c.id}
|
||||
className="rounded-full border border-[var(--border)] px-3 py-1.5 text-sm"
|
||||
>
|
||||
{c.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="mt-12">
|
||||
<h2 className="text-sm font-medium text-[var(--muted)]">Open jobs (seed data)</h2>
|
||||
{jobs.length === 0 ? (
|
||||
<p className="mt-3 text-sm text-[var(--muted)]">
|
||||
No jobs yet — run <code className="font-mono">pnpm db:seed</code>.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="mt-3 space-y-2">
|
||||
{jobs.map((job) => (
|
||||
<li key={job.id}>
|
||||
<Link
|
||||
href={`/deck/${job.id}`}
|
||||
className="group flex items-center justify-between gap-4 rounded-xl border border-[var(--border)] bg-[var(--card)] px-4 py-3 transition hover:border-[var(--color-brand-500)]"
|
||||
>
|
||||
<span>
|
||||
<span className="block font-medium">{job.title}</span>
|
||||
<span className="block text-sm text-[var(--muted)]">{job.addressText}</span>
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-1 text-sm text-[var(--color-brand-500)]">
|
||||
Open deck
|
||||
<ArrowRight
|
||||
className="h-4 w-4 transition group-hover:translate-x-0.5"
|
||||
aria-hidden
|
||||
/>
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { AnimatePresence, motion, useMotionValue, useTransform } from 'motion/react';
|
||||
import { Check, MapPin, Star, X } from 'lucide-react';
|
||||
import type { DeckCard } from '@linkder/db';
|
||||
import { cn, formatDistance, formatResponseTime } from '@/lib/utils';
|
||||
|
||||
/** Horizontal drag past this many pixels commits the swipe. */
|
||||
const COMMIT_PX = 110;
|
||||
|
||||
export interface DeckProps {
|
||||
cards: DeckCard[];
|
||||
onDecide: (proId: string, direction: 'left' | 'right') => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function Deck({ cards, onDecide }: DeckProps) {
|
||||
const [index, setIndex] = useState(0);
|
||||
const remaining = useMemo(() => cards.slice(index), [cards, index]);
|
||||
|
||||
const decide = useCallback(
|
||||
(proId: string, direction: 'left' | 'right') => {
|
||||
setIndex((i) => i + 1);
|
||||
void onDecide(proId, direction);
|
||||
},
|
||||
[onDecide],
|
||||
);
|
||||
|
||||
if (remaining.length === 0) {
|
||||
return <EmptyDeck />;
|
||||
}
|
||||
|
||||
// Only the top three are mounted — the rest are just a visual stack.
|
||||
const visible = remaining.slice(0, 3);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<div className="relative h-[560px] w-full max-w-sm">
|
||||
<AnimatePresence initial={false}>
|
||||
{visible
|
||||
.map((card, i) => (
|
||||
<Card
|
||||
key={card.proId}
|
||||
card={card}
|
||||
depth={i}
|
||||
onDecide={i === 0 ? decide : undefined}
|
||||
/>
|
||||
))
|
||||
.reverse()}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-5">
|
||||
<ActionButton
|
||||
label="Not this one"
|
||||
variant="pass"
|
||||
onClick={() => visible[0] && decide(visible[0].proId, 'left')}
|
||||
/>
|
||||
<p className="w-28 text-center text-sm text-[var(--muted)] tabular-nums">
|
||||
{remaining.length} left
|
||||
</p>
|
||||
<ActionButton
|
||||
label="Send this job"
|
||||
variant="hire"
|
||||
onClick={() => visible[0] && decide(visible[0].proId, 'right')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Card({
|
||||
card,
|
||||
depth,
|
||||
onDecide,
|
||||
}: {
|
||||
card: DeckCard;
|
||||
depth: number;
|
||||
onDecide?: (proId: string, direction: 'left' | 'right') => void;
|
||||
}) {
|
||||
const x = useMotionValue(0);
|
||||
const rotate = useTransform(x, [-300, 0, 300], [-14, 0, 14]);
|
||||
const hireOpacity = useTransform(x, [40, COMMIT_PX], [0, 1]);
|
||||
const passOpacity = useTransform(x, [-COMMIT_PX, -40], [1, 0]);
|
||||
|
||||
const interactive = Boolean(onDecide);
|
||||
|
||||
return (
|
||||
<motion.article
|
||||
className={cn(
|
||||
'deck-card absolute inset-0 overflow-hidden rounded-3xl border shadow-xl',
|
||||
'border-[var(--border)] bg-[var(--card)]',
|
||||
interactive ? 'cursor-grab active:cursor-grabbing' : 'pointer-events-none',
|
||||
)}
|
||||
style={{ x, rotate, zIndex: 10 - depth }}
|
||||
initial={{ scale: 0.94, y: 14 * depth, opacity: depth === 2 ? 0 : 1 }}
|
||||
animate={{ scale: 1 - depth * 0.04, y: 14 * depth, opacity: 1 }}
|
||||
exit={{
|
||||
x: x.get() > 0 ? 400 : -400,
|
||||
opacity: 0,
|
||||
transition: { duration: 0.2 },
|
||||
}}
|
||||
drag={interactive ? 'x' : false}
|
||||
dragConstraints={{ left: 0, right: 0 }}
|
||||
dragElastic={0.6}
|
||||
onDragEnd={(_, info) => {
|
||||
if (!onDecide) return;
|
||||
if (info.offset.x > COMMIT_PX) onDecide(card.proId, 'right');
|
||||
else if (info.offset.x < -COMMIT_PX) onDecide(card.proId, 'left');
|
||||
}}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={card.photos[0] ?? '/placeholder-pro.jpg'}
|
||||
alt=""
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
draggable={false}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/25 to-transparent" />
|
||||
|
||||
{interactive && (
|
||||
<>
|
||||
<motion.div
|
||||
style={{ opacity: hireOpacity }}
|
||||
className="absolute left-6 top-6 rotate-[-12deg] rounded-lg border-4 border-[var(--color-go-500)] px-3 py-1 text-2xl font-black tracking-wide text-[var(--color-go-500)]"
|
||||
>
|
||||
SEND JOB
|
||||
</motion.div>
|
||||
<motion.div
|
||||
style={{ opacity: passOpacity }}
|
||||
className="absolute right-6 top-6 rotate-[12deg] rounded-lg border-4 border-[var(--color-stop-500)] px-3 py-1 text-2xl font-black tracking-wide text-[var(--color-stop-500)]"
|
||||
>
|
||||
PASS
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="absolute inset-x-0 bottom-0 p-6 text-white">
|
||||
<div className="mb-1 flex items-baseline gap-2">
|
||||
<h2 className="text-2xl font-semibold">{card.name}</h2>
|
||||
{card.ratingCount > 0 ? (
|
||||
<span className="flex items-center gap-1 text-sm">
|
||||
<Star className="h-4 w-4 fill-current" aria-hidden />
|
||||
{card.ratingAvg?.toFixed(1)}
|
||||
<span className="text-white/60">({card.ratingCount})</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="rounded-full bg-white/20 px-2 py-0.5 text-xs font-medium">New</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-white/80">{card.headline}</p>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-white/70">
|
||||
<span className="flex items-center gap-1">
|
||||
<MapPin className="h-4 w-4" aria-hidden />
|
||||
{formatDistance(card.distanceM)}
|
||||
</span>
|
||||
<span>€{(card.hourlyRateCents / 100).toFixed(0)}/hr</span>
|
||||
{card.completedJobs > 0 && <span>{card.completedJobs} jobs done</span>}
|
||||
</div>
|
||||
|
||||
{formatResponseTime(card.avgResponseMinutes) && (
|
||||
<p className="mt-1 text-xs text-white/60">
|
||||
{formatResponseTime(card.avgResponseMinutes)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="mt-3 line-clamp-2 text-sm text-white/75">{card.bio}</p>
|
||||
</div>
|
||||
</motion.article>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionButton({
|
||||
label,
|
||||
variant,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
variant: 'pass' | 'hire';
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const isHire = variant === 'hire';
|
||||
const Icon = isHire ? Check : X;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={cn(
|
||||
'flex h-16 w-16 items-center justify-center rounded-full border-2 bg-[var(--card)] shadow-lg',
|
||||
'transition hover:scale-105 active:scale-95',
|
||||
isHire
|
||||
? 'border-[var(--color-go-500)] text-[var(--color-go-500)]'
|
||||
: 'border-[var(--color-stop-500)] text-[var(--color-stop-500)]',
|
||||
)}
|
||||
>
|
||||
<Icon className="h-7 w-7" strokeWidth={3} aria-hidden />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyDeck() {
|
||||
return (
|
||||
<div className="mx-auto flex h-[560px] max-w-sm flex-col items-center justify-center gap-3 rounded-3xl border border-dashed border-[var(--border)] p-8 text-center">
|
||||
<h2 className="text-lg font-semibold">That’s everyone nearby</h2>
|
||||
<p className="text-sm text-[var(--muted)]">
|
||||
You’ve seen every verified pro who covers your area for this trade. We’ll notify
|
||||
you the moment a new one joins.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
/** "1.2 km away" / "800 m away" — pros are local, so precision matters up close. */
|
||||
export function formatDistance(metres: number): string {
|
||||
if (metres < 1000) return `${Math.round(metres / 50) * 50} m away`;
|
||||
return `${(metres / 1000).toFixed(1)} km away`;
|
||||
}
|
||||
|
||||
/** "usually replies in 25 min" */
|
||||
export function formatResponseTime(minutes: number | null): string | null {
|
||||
if (minutes === null) return null;
|
||||
if (minutes < 60) return `usually replies in ${minutes} min`;
|
||||
const hours = Math.round(minutes / 60);
|
||||
return `usually replies in ${hours} h`;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@theme {
|
||||
--color-ink-50: oklch(0.98 0.005 260);
|
||||
--color-ink-100: oklch(0.95 0.008 260);
|
||||
--color-ink-200: oklch(0.89 0.012 260);
|
||||
--color-ink-400: oklch(0.65 0.02 260);
|
||||
--color-ink-600: oklch(0.45 0.025 260);
|
||||
--color-ink-800: oklch(0.26 0.03 260);
|
||||
--color-ink-950: oklch(0.15 0.03 260);
|
||||
|
||||
--color-brand-400: oklch(0.72 0.15 25);
|
||||
--color-brand-500: oklch(0.64 0.19 25);
|
||||
--color-brand-600: oklch(0.56 0.2 25);
|
||||
|
||||
--color-go-500: oklch(0.7 0.17 150);
|
||||
--color-stop-500: oklch(0.64 0.2 20);
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg: var(--color-ink-50);
|
||||
--fg: var(--color-ink-950);
|
||||
--card: white;
|
||||
--muted: var(--color-ink-600);
|
||||
--border: var(--color-ink-200);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: var(--color-ink-950);
|
||||
--fg: var(--color-ink-50);
|
||||
--card: var(--color-ink-800);
|
||||
--muted: var(--color-ink-400);
|
||||
--border: color-mix(in oklch, var(--color-ink-400) 25%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
body {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
/* The deck is drag-driven; stop the browser from hijacking the gesture. */
|
||||
.deck-card {
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||
"jsx": "preserve",
|
||||
"noEmit": true,
|
||||
"allowJs": true,
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": { "@/*": ["./src/*"] }
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgis/postgis:16-3.4
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: linkder
|
||||
POSTGRES_PASSWORD: linkder
|
||||
POSTGRES_DB: linkder
|
||||
ports:
|
||||
- '5442:5432'
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U linkder -d linkder']
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
command: redis-server --appendonly yes
|
||||
ports:
|
||||
- '6389:6379'
|
||||
volumes:
|
||||
- redisdata:/data
|
||||
healthcheck:
|
||||
test: ['CMD', 'redis-cli', 'ping']
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
redisdata:
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "linkder",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@9.15.4",
|
||||
"engines": { "node": ">=20" },
|
||||
"scripts": {
|
||||
"dev": "turbo run dev",
|
||||
"build": "turbo run build",
|
||||
"lint": "turbo run lint",
|
||||
"typecheck": "turbo run typecheck",
|
||||
"test": "turbo run test",
|
||||
"test:e2e": "turbo run test:e2e",
|
||||
"format": "prettier --write \"**/*.{ts,tsx,md,json}\"",
|
||||
"db:generate": "pnpm --filter @linkder/db generate",
|
||||
"db:migrate": "pnpm --filter @linkder/db migrate",
|
||||
"db:seed": "pnpm --filter @linkder/db seed",
|
||||
"db:studio": "pnpm --filter @linkder/db studio",
|
||||
"services:up": "docker compose up -d postgres redis",
|
||||
"services:down": "docker compose down"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.5",
|
||||
"prettier": "^3.4.2",
|
||||
"turbo": "^2.3.3",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { config } from 'dotenv';
|
||||
import { defineConfig } from 'drizzle-kit';
|
||||
|
||||
config({ path: '../../.env' });
|
||||
|
||||
export default defineConfig({
|
||||
schema: './src/schema/index.ts',
|
||||
out: './drizzle',
|
||||
dialect: 'postgresql',
|
||||
dbCredentials: { url: process.env.DATABASE_URL! },
|
||||
verbose: true,
|
||||
strict: true,
|
||||
});
|
||||
@@ -0,0 +1,353 @@
|
||||
CREATE TYPE "public"."booking_status" AS ENUM('scheduled', 'in_progress', 'awaiting_confirmation', 'completed', 'cancelled', 'disputed');--> statement-breakpoint
|
||||
CREATE TYPE "public"."credential_kind" AS ENUM('id', 'licence', 'insurance');--> statement-breakpoint
|
||||
CREATE TYPE "public"."job_status" AS ENUM('open', 'matched', 'booked', 'completed', 'cancelled');--> statement-breakpoint
|
||||
CREATE TYPE "public"."media_kind" AS ENUM('photo', 'work_sample');--> statement-breakpoint
|
||||
CREATE TYPE "public"."payment_status" AS ENUM('pending', 'held', 'released', 'refunded', 'partially_refunded', 'failed');--> statement-breakpoint
|
||||
CREATE TYPE "public"."quote_kind" AS ENUM('fixed', 'hourly');--> statement-breakpoint
|
||||
CREATE TYPE "public"."quote_status" AS ENUM('sent', 'accepted', 'declined', 'withdrawn', 'expired');--> statement-breakpoint
|
||||
CREATE TYPE "public"."request_status" AS ENUM('pending', 'accepted', 'declined', 'expired');--> statement-breakpoint
|
||||
CREATE TYPE "public"."review_status" AS ENUM('pending', 'approved', 'rejected');--> statement-breakpoint
|
||||
CREATE TYPE "public"."swipe_direction" AS ENUM('left', 'right');--> statement-breakpoint
|
||||
CREATE TYPE "public"."urgency" AS ENUM('now', 'this_week', 'flexible');--> statement-breakpoint
|
||||
CREATE TYPE "public"."user_role" AS ENUM('client', 'pro', 'admin');--> statement-breakpoint
|
||||
CREATE TYPE "public"."verification_status" AS ENUM('draft', 'pending', 'verified', 'rejected', 'suspended');--> statement-breakpoint
|
||||
CREATE TABLE "accounts" (
|
||||
"user_id" uuid NOT NULL,
|
||||
"type" text NOT NULL,
|
||||
"provider" text NOT NULL,
|
||||
"provider_account_id" text NOT NULL,
|
||||
"refresh_token" text,
|
||||
"access_token" text,
|
||||
"expires_at" integer,
|
||||
"token_type" text,
|
||||
"scope" text,
|
||||
"id_token" text,
|
||||
"session_state" text,
|
||||
CONSTRAINT "accounts_provider_provider_account_id_pk" PRIMARY KEY("provider","provider_account_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "phone_otps" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"phone" text NOT NULL,
|
||||
"code_hash" text NOT NULL,
|
||||
"attempts" integer DEFAULT 0 NOT NULL,
|
||||
"consumed" boolean DEFAULT false NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "sessions" (
|
||||
"session_token" text PRIMARY KEY NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"expires" timestamp with time zone NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "users" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" text,
|
||||
"email" text,
|
||||
"email_verified" timestamp with time zone,
|
||||
"phone" text,
|
||||
"phone_verified" timestamp with time zone,
|
||||
"image" text,
|
||||
"role" "user_role" DEFAULT 'client' NOT NULL,
|
||||
"banned_at" timestamp with time zone,
|
||||
"last_active_at" timestamp with time zone DEFAULT now(),
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "users_email_unique" UNIQUE("email"),
|
||||
CONSTRAINT "users_phone_unique" UNIQUE("phone")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "verification_tokens" (
|
||||
"identifier" text NOT NULL,
|
||||
"token" text NOT NULL,
|
||||
"expires" timestamp with time zone NOT NULL,
|
||||
CONSTRAINT "verification_tokens_identifier_token_pk" PRIMARY KEY("identifier","token")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "categories" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"slug" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"icon" text,
|
||||
"position" integer DEFAULT 0 NOT NULL,
|
||||
"is_active" boolean DEFAULT true NOT NULL,
|
||||
CONSTRAINT "categories_slug_unique" UNIQUE("slug")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "credentials" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"pro_id" uuid NOT NULL,
|
||||
"kind" "credential_kind" NOT NULL,
|
||||
"file_url" text NOT NULL,
|
||||
"issuer" text,
|
||||
"expires_at" timestamp with time zone,
|
||||
"review_status" "review_status" DEFAULT 'pending' NOT NULL,
|
||||
"reviewed_by" uuid,
|
||||
"reviewed_at" timestamp with time zone,
|
||||
"review_notes" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "pro_availability" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"pro_id" uuid NOT NULL,
|
||||
"weekday" integer NOT NULL,
|
||||
"start_minute" integer NOT NULL,
|
||||
"end_minute" integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "pro_categories" (
|
||||
"pro_id" uuid NOT NULL,
|
||||
"category_id" uuid NOT NULL,
|
||||
CONSTRAINT "pro_categories_pro_id_category_id_pk" PRIMARY KEY("pro_id","category_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "pro_media" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"pro_id" uuid NOT NULL,
|
||||
"url" text NOT NULL,
|
||||
"kind" "media_kind" DEFAULT 'photo' NOT NULL,
|
||||
"position" integer DEFAULT 0 NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "pro_profiles" (
|
||||
"user_id" uuid PRIMARY KEY NOT NULL,
|
||||
"headline" text NOT NULL,
|
||||
"bio" text NOT NULL,
|
||||
"hourly_rate_cents" integer NOT NULL,
|
||||
"years_experience" integer DEFAULT 0 NOT NULL,
|
||||
"base_location" geography(Point,4326) NOT NULL,
|
||||
"service_radius_m" integer DEFAULT 15000 NOT NULL,
|
||||
"verification_status" "verification_status" DEFAULT 'draft' NOT NULL,
|
||||
"verified_at" timestamp with time zone,
|
||||
"suspended_reason" text,
|
||||
"is_accepting_jobs" boolean DEFAULT true NOT NULL,
|
||||
"rating_avg" numeric(3, 2),
|
||||
"rating_count" integer DEFAULT 0 NOT NULL,
|
||||
"completed_jobs" integer DEFAULT 0 NOT NULL,
|
||||
"response_rate" numeric(4, 3),
|
||||
"avg_response_minutes" integer,
|
||||
"stripe_account_id" text,
|
||||
"stripe_payouts_enabled" boolean DEFAULT false NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "pro_profiles_stripe_account_id_unique" UNIQUE("stripe_account_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "verification_sessions" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"pro_id" uuid NOT NULL,
|
||||
"provider" text DEFAULT 'didit' NOT NULL,
|
||||
"external_id" text NOT NULL,
|
||||
"status" text DEFAULT 'pending' NOT NULL,
|
||||
"decision" text,
|
||||
"raw_payload" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"completed_at" timestamp with time zone,
|
||||
CONSTRAINT "verification_sessions_external_id_unique" UNIQUE("external_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "jobs" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"client_id" uuid NOT NULL,
|
||||
"category_id" uuid NOT NULL,
|
||||
"title" text NOT NULL,
|
||||
"description" text NOT NULL,
|
||||
"photos" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||
"urgency" "urgency" DEFAULT 'flexible' NOT NULL,
|
||||
"budget_min_cents" integer,
|
||||
"budget_max_cents" integer,
|
||||
"location" geography(Point,4326) NOT NULL,
|
||||
"address_text" text NOT NULL,
|
||||
"status" "job_status" DEFAULT 'open' NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "matches" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"request_id" uuid NOT NULL,
|
||||
"job_id" uuid NOT NULL,
|
||||
"pro_id" uuid NOT NULL,
|
||||
"client_id" uuid NOT NULL,
|
||||
"last_message_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "matches_request_id_unique" UNIQUE("request_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "requests" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"job_id" uuid NOT NULL,
|
||||
"pro_id" uuid NOT NULL,
|
||||
"status" "request_status" DEFAULT 'pending' NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"responded_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "requests_job_pro_unique" UNIQUE("job_id","pro_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "swipes" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"job_id" uuid NOT NULL,
|
||||
"pro_id" uuid NOT NULL,
|
||||
"direction" "swipe_direction" NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "swipes_job_pro_unique" UNIQUE("job_id","pro_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "messages" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"match_id" uuid NOT NULL,
|
||||
"sender_id" uuid NOT NULL,
|
||||
"body" text NOT NULL,
|
||||
"attachments" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||
"read_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "bookings" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"match_id" uuid NOT NULL,
|
||||
"quote_id" uuid NOT NULL,
|
||||
"scheduled_start" timestamp with time zone NOT NULL,
|
||||
"scheduled_end" timestamp with time zone NOT NULL,
|
||||
"status" "booking_status" DEFAULT 'scheduled' NOT NULL,
|
||||
"pro_completed_at" timestamp with time zone,
|
||||
"client_confirmed_at" timestamp with time zone,
|
||||
"cancelled_at" timestamp with time zone,
|
||||
"cancelled_by" uuid,
|
||||
"cancellation_reason" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "payments" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"booking_id" uuid NOT NULL,
|
||||
"stripe_payment_intent_id" text,
|
||||
"stripe_transfer_id" text,
|
||||
"stripe_refund_id" text,
|
||||
"amount_cents" integer NOT NULL,
|
||||
"platform_fee_cents" integer NOT NULL,
|
||||
"platform_fee_bps" integer NOT NULL,
|
||||
"refunded_cents" integer DEFAULT 0 NOT NULL,
|
||||
"currency" text DEFAULT 'eur' NOT NULL,
|
||||
"status" "payment_status" DEFAULT 'pending' NOT NULL,
|
||||
"captured_at" timestamp with time zone,
|
||||
"released_at" timestamp with time zone,
|
||||
"failure_reason" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "payments_booking_id_unique" UNIQUE("booking_id"),
|
||||
CONSTRAINT "payments_stripe_payment_intent_id_unique" UNIQUE("stripe_payment_intent_id"),
|
||||
CONSTRAINT "payments_stripe_transfer_id_unique" UNIQUE("stripe_transfer_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "processed_stripe_events" (
|
||||
"event_id" text PRIMARY KEY NOT NULL,
|
||||
"type" text NOT NULL,
|
||||
"processed_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "quotes" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"match_id" uuid NOT NULL,
|
||||
"kind" "quote_kind" DEFAULT 'fixed' NOT NULL,
|
||||
"amount_cents" integer NOT NULL,
|
||||
"hours_estimate" integer,
|
||||
"scope" text NOT NULL,
|
||||
"status" "quote_status" DEFAULT 'sent' NOT NULL,
|
||||
"valid_until" timestamp with time zone NOT NULL,
|
||||
"responded_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "reviews" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"booking_id" uuid NOT NULL,
|
||||
"author_id" uuid NOT NULL,
|
||||
"subject_id" uuid NOT NULL,
|
||||
"rating" integer NOT NULL,
|
||||
"body" text NOT NULL,
|
||||
"published_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "reviews_booking_author_unique" UNIQUE("booking_id","author_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "audit_log" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"actor_id" uuid,
|
||||
"action" text NOT NULL,
|
||||
"entity" text NOT NULL,
|
||||
"entity_id" text,
|
||||
"metadata" jsonb,
|
||||
"ip" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "accounts" ADD CONSTRAINT "accounts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "credentials" ADD CONSTRAINT "credentials_pro_id_pro_profiles_user_id_fk" FOREIGN KEY ("pro_id") REFERENCES "public"."pro_profiles"("user_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "credentials" ADD CONSTRAINT "credentials_reviewed_by_users_id_fk" FOREIGN KEY ("reviewed_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "pro_availability" ADD CONSTRAINT "pro_availability_pro_id_pro_profiles_user_id_fk" FOREIGN KEY ("pro_id") REFERENCES "public"."pro_profiles"("user_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "pro_categories" ADD CONSTRAINT "pro_categories_pro_id_pro_profiles_user_id_fk" FOREIGN KEY ("pro_id") REFERENCES "public"."pro_profiles"("user_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "pro_categories" ADD CONSTRAINT "pro_categories_category_id_categories_id_fk" FOREIGN KEY ("category_id") REFERENCES "public"."categories"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "pro_media" ADD CONSTRAINT "pro_media_pro_id_pro_profiles_user_id_fk" FOREIGN KEY ("pro_id") REFERENCES "public"."pro_profiles"("user_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "pro_profiles" ADD CONSTRAINT "pro_profiles_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "verification_sessions" ADD CONSTRAINT "verification_sessions_pro_id_pro_profiles_user_id_fk" FOREIGN KEY ("pro_id") REFERENCES "public"."pro_profiles"("user_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "jobs" ADD CONSTRAINT "jobs_client_id_users_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "jobs" ADD CONSTRAINT "jobs_category_id_categories_id_fk" FOREIGN KEY ("category_id") REFERENCES "public"."categories"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "matches" ADD CONSTRAINT "matches_request_id_requests_id_fk" FOREIGN KEY ("request_id") REFERENCES "public"."requests"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "matches" ADD CONSTRAINT "matches_job_id_jobs_id_fk" FOREIGN KEY ("job_id") REFERENCES "public"."jobs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "matches" ADD CONSTRAINT "matches_pro_id_pro_profiles_user_id_fk" FOREIGN KEY ("pro_id") REFERENCES "public"."pro_profiles"("user_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "matches" ADD CONSTRAINT "matches_client_id_users_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "requests" ADD CONSTRAINT "requests_job_id_jobs_id_fk" FOREIGN KEY ("job_id") REFERENCES "public"."jobs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "requests" ADD CONSTRAINT "requests_pro_id_pro_profiles_user_id_fk" FOREIGN KEY ("pro_id") REFERENCES "public"."pro_profiles"("user_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "swipes" ADD CONSTRAINT "swipes_job_id_jobs_id_fk" FOREIGN KEY ("job_id") REFERENCES "public"."jobs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "swipes" ADD CONSTRAINT "swipes_pro_id_pro_profiles_user_id_fk" FOREIGN KEY ("pro_id") REFERENCES "public"."pro_profiles"("user_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "messages" ADD CONSTRAINT "messages_match_id_matches_id_fk" FOREIGN KEY ("match_id") REFERENCES "public"."matches"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "messages" ADD CONSTRAINT "messages_sender_id_users_id_fk" FOREIGN KEY ("sender_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "bookings" ADD CONSTRAINT "bookings_match_id_matches_id_fk" FOREIGN KEY ("match_id") REFERENCES "public"."matches"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "bookings" ADD CONSTRAINT "bookings_quote_id_quotes_id_fk" FOREIGN KEY ("quote_id") REFERENCES "public"."quotes"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "bookings" ADD CONSTRAINT "bookings_cancelled_by_users_id_fk" FOREIGN KEY ("cancelled_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "payments" ADD CONSTRAINT "payments_booking_id_bookings_id_fk" FOREIGN KEY ("booking_id") REFERENCES "public"."bookings"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "quotes" ADD CONSTRAINT "quotes_match_id_matches_id_fk" FOREIGN KEY ("match_id") REFERENCES "public"."matches"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "reviews" ADD CONSTRAINT "reviews_booking_id_bookings_id_fk" FOREIGN KEY ("booking_id") REFERENCES "public"."bookings"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "reviews" ADD CONSTRAINT "reviews_author_id_users_id_fk" FOREIGN KEY ("author_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "reviews" ADD CONSTRAINT "reviews_subject_id_users_id_fk" FOREIGN KEY ("subject_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "audit_log" ADD CONSTRAINT "audit_log_actor_id_users_id_fk" FOREIGN KEY ("actor_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "phone_otps_phone_idx" ON "phone_otps" USING btree ("phone","expires_at");--> statement-breakpoint
|
||||
CREATE INDEX "users_role_idx" ON "users" USING btree ("role");--> statement-breakpoint
|
||||
CREATE INDEX "users_phone_idx" ON "users" USING btree ("phone");--> statement-breakpoint
|
||||
CREATE INDEX "credentials_pro_idx" ON "credentials" USING btree ("pro_id");--> statement-breakpoint
|
||||
CREATE INDEX "credentials_review_idx" ON "credentials" USING btree ("review_status");--> statement-breakpoint
|
||||
CREATE INDEX "credentials_expiry_idx" ON "credentials" USING btree ("expires_at");--> statement-breakpoint
|
||||
CREATE INDEX "pro_availability_pro_idx" ON "pro_availability" USING btree ("pro_id","weekday");--> statement-breakpoint
|
||||
CREATE INDEX "pro_categories_category_idx" ON "pro_categories" USING btree ("category_id");--> statement-breakpoint
|
||||
CREATE INDEX "pro_media_pro_idx" ON "pro_media" USING btree ("pro_id","position");--> statement-breakpoint
|
||||
CREATE INDEX "pro_profiles_location_gist" ON "pro_profiles" USING gist ("base_location");--> statement-breakpoint
|
||||
CREATE INDEX "pro_profiles_deck_idx" ON "pro_profiles" USING btree ("verification_status","is_accepting_jobs") WHERE "pro_profiles"."verification_status" = 'verified' AND "pro_profiles"."is_accepting_jobs" = true;--> statement-breakpoint
|
||||
CREATE INDEX "verification_sessions_pro_idx" ON "verification_sessions" USING btree ("pro_id");--> statement-breakpoint
|
||||
CREATE INDEX "jobs_location_gist" ON "jobs" USING gist ("location");--> statement-breakpoint
|
||||
CREATE INDEX "jobs_client_idx" ON "jobs" USING btree ("client_id","status");--> statement-breakpoint
|
||||
CREATE INDEX "jobs_open_idx" ON "jobs" USING btree ("category_id") WHERE "jobs"."status" = 'open';--> statement-breakpoint
|
||||
CREATE INDEX "matches_pro_idx" ON "matches" USING btree ("pro_id","last_message_at");--> statement-breakpoint
|
||||
CREATE INDEX "matches_client_idx" ON "matches" USING btree ("client_id","last_message_at");--> statement-breakpoint
|
||||
CREATE INDEX "matches_job_idx" ON "matches" USING btree ("job_id");--> statement-breakpoint
|
||||
CREATE INDEX "requests_pending_idx" ON "requests" USING btree ("pro_id","expires_at") WHERE "requests"."status" = 'pending';--> statement-breakpoint
|
||||
CREATE INDEX "requests_job_idx" ON "requests" USING btree ("job_id","status");--> statement-breakpoint
|
||||
CREATE INDEX "swipes_job_idx" ON "swipes" USING btree ("job_id");--> statement-breakpoint
|
||||
CREATE INDEX "messages_match_idx" ON "messages" USING btree ("match_id","created_at");--> statement-breakpoint
|
||||
CREATE INDEX "messages_unread_idx" ON "messages" USING btree ("match_id","sender_id") WHERE "messages"."read_at" IS NULL;--> statement-breakpoint
|
||||
CREATE INDEX "bookings_match_idx" ON "bookings" USING btree ("match_id");--> statement-breakpoint
|
||||
CREATE INDEX "bookings_status_idx" ON "bookings" USING btree ("status","pro_completed_at");--> statement-breakpoint
|
||||
CREATE INDEX "bookings_schedule_idx" ON "bookings" USING btree ("scheduled_start");--> statement-breakpoint
|
||||
CREATE INDEX "payments_status_idx" ON "payments" USING btree ("status");--> statement-breakpoint
|
||||
CREATE INDEX "payments_intent_idx" ON "payments" USING btree ("stripe_payment_intent_id");--> statement-breakpoint
|
||||
CREATE INDEX "quotes_match_idx" ON "quotes" USING btree ("match_id","status");--> statement-breakpoint
|
||||
CREATE INDEX "reviews_subject_idx" ON "reviews" USING btree ("subject_id","published_at");--> statement-breakpoint
|
||||
CREATE INDEX "audit_log_entity_idx" ON "audit_log" USING btree ("entity","entity_id");--> statement-breakpoint
|
||||
CREATE INDEX "audit_log_actor_idx" ON "audit_log" USING btree ("actor_id","created_at");
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1787246593084,
|
||||
"tag": "0000_old_gorilla_man",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "@linkder/db",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./schema": "./src/schema/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"generate": "drizzle-kit generate && node scripts/fix-postgis.mjs",
|
||||
"migrate": "tsx src/migrate.ts",
|
||||
"push": "drizzle-kit push",
|
||||
"studio": "drizzle-kit studio",
|
||||
"seed": "tsx src/seed.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@linkder/shared": "workspace:*",
|
||||
"drizzle-orm": "0.38.4",
|
||||
"postgres": "^3.4.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"dotenv": "^16.4.7",
|
||||
"drizzle-kit": "^0.30.1",
|
||||
"tsx": "^4.19.2",
|
||||
"vitest": "^2.1.8",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* drizzle-kit quotes any type name it does not recognise, so a geography column
|
||||
* is emitted as "geography(Point,4326)" — a quoted identifier, which Postgres
|
||||
* rejects with `type "geography(Point,4326)" does not exist`.
|
||||
*
|
||||
* This unquotes them. It runs automatically as part of `pnpm db:generate`;
|
||||
* if you ever run `drizzle-kit generate` directly, run this afterwards.
|
||||
*/
|
||||
import { readdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const DIR = 'drizzle';
|
||||
const QUOTED = /"(geography|geometry)\(([^)"]*)\)"/g;
|
||||
|
||||
const files = (await readdir(DIR)).filter((f) => f.endsWith('.sql'));
|
||||
let patched = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const path = join(DIR, file);
|
||||
const before = await readFile(path, 'utf8');
|
||||
const after = before.replace(QUOTED, '$1($2)');
|
||||
if (after !== before) {
|
||||
await writeFile(path, after);
|
||||
patched++;
|
||||
console.log(` unquoted PostGIS types in ${file}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(patched ? `PostGIS fixup applied to ${patched} file(s)` : 'PostGIS fixup: nothing to do');
|
||||
@@ -0,0 +1,73 @@
|
||||
import { drizzle, type PostgresJsDatabase } from 'drizzle-orm/postgres-js';
|
||||
import postgres from 'postgres';
|
||||
import * as schema from './schema/index';
|
||||
|
||||
/**
|
||||
* The pool and the Drizzle instance are created on first use, not on import.
|
||||
*
|
||||
* Eager construction would make `next build` fail on any machine without a
|
||||
* database — including CI, which only needs to compile pages. Failing on first
|
||||
* query instead keeps the failure where it is actionable.
|
||||
*
|
||||
* The instance is cached on globalThis so Next's dev server does not open a new
|
||||
* pool on every hot reload and exhaust Postgres connections within a minute.
|
||||
*/
|
||||
const globalForDb = globalThis as unknown as {
|
||||
__linkderPool?: postgres.Sql;
|
||||
__linkderDb?: PostgresJsDatabase<typeof schema>;
|
||||
};
|
||||
|
||||
function createPool(): postgres.Sql {
|
||||
const connectionString = process.env.DATABASE_URL;
|
||||
if (!connectionString) {
|
||||
throw new Error(
|
||||
'DATABASE_URL is not set. Copy .env.example to .env and start Postgres with `pnpm services:up`.',
|
||||
);
|
||||
}
|
||||
return postgres(connectionString, {
|
||||
max: Number(process.env.DB_POOL_MAX ?? 10),
|
||||
idle_timeout: 20,
|
||||
});
|
||||
}
|
||||
|
||||
export function getPool(): postgres.Sql {
|
||||
const existing = globalForDb.__linkderPool;
|
||||
if (existing) return existing;
|
||||
const created = createPool();
|
||||
globalForDb.__linkderPool = created;
|
||||
return created;
|
||||
}
|
||||
|
||||
function getDb(): PostgresJsDatabase<typeof schema> {
|
||||
const existing = globalForDb.__linkderDb;
|
||||
if (existing) return existing;
|
||||
const created = drizzle(getPool(), { schema });
|
||||
globalForDb.__linkderDb = created;
|
||||
return created;
|
||||
}
|
||||
|
||||
export type Db = PostgresJsDatabase<typeof schema>;
|
||||
|
||||
/**
|
||||
* Lazily-initialised database handle. Behaves exactly like a Drizzle instance;
|
||||
* the connection is only opened when a property is first touched.
|
||||
*/
|
||||
export const db: Db = new Proxy({} as Db, {
|
||||
get(_target, prop, receiver) {
|
||||
return Reflect.get(getDb() as object, prop, receiver);
|
||||
},
|
||||
has(_target, prop) {
|
||||
return Reflect.has(getDb() as object, prop);
|
||||
},
|
||||
});
|
||||
|
||||
/** Close the pool. For scripts and test teardown — never call this from a request. */
|
||||
export async function closePool(): Promise<void> {
|
||||
const existing = globalForDb.__linkderPool;
|
||||
if (!existing) return;
|
||||
await existing.end();
|
||||
globalForDb.__linkderPool = undefined;
|
||||
globalForDb.__linkderDb = undefined;
|
||||
}
|
||||
|
||||
export { schema };
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './client';
|
||||
export * from './postgis';
|
||||
export * as schema from './schema/index';
|
||||
export * from './queries/deck';
|
||||
@@ -0,0 +1,22 @@
|
||||
import { config } from 'dotenv';
|
||||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import { migrate } from 'drizzle-orm/postgres-js/migrator';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import postgres from 'postgres';
|
||||
|
||||
config({ path: '../../.env' });
|
||||
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) throw new Error('DATABASE_URL is not set');
|
||||
|
||||
const client = postgres(url, { max: 1 });
|
||||
const db = drizzle(client);
|
||||
|
||||
// PostGIS must exist before any migration that declares a geography column.
|
||||
await db.execute(sql`CREATE EXTENSION IF NOT EXISTS postgis`);
|
||||
console.log('PostGIS extension ready');
|
||||
|
||||
await migrate(db, { migrationsFolder: './drizzle' });
|
||||
console.log('Migrations applied');
|
||||
|
||||
await client.end();
|
||||
@@ -0,0 +1,78 @@
|
||||
import { customType } from 'drizzle-orm/pg-core';
|
||||
import { sql, type SQL } from 'drizzle-orm';
|
||||
|
||||
export interface LatLng {
|
||||
lat: number;
|
||||
lng: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a PostGIS EWKB hex point, which is what the driver hands back for a
|
||||
* plain `SELECT base_location` (including RETURNING clauses).
|
||||
*
|
||||
* Layout: 1 byte endianness, 4 bytes type (high bit 0x20000000 = SRID present),
|
||||
* optional 4 bytes SRID, then two float64 ordinates as (x=lng, y=lat).
|
||||
*/
|
||||
function decodeEwkbPoint(hex: string): LatLng {
|
||||
const bytes = Buffer.from(hex, 'hex');
|
||||
if (bytes.length < 21) throw new Error(`Not an EWKB point: ${hex.slice(0, 24)}…`);
|
||||
|
||||
const littleEndian = bytes.readUInt8(0) === 1;
|
||||
const readU32 = (o: number) => (littleEndian ? bytes.readUInt32LE(o) : bytes.readUInt32BE(o));
|
||||
const readF64 = (o: number) => (littleEndian ? bytes.readDoubleLE(o) : bytes.readDoubleBE(o));
|
||||
|
||||
const typeWord = readU32(1);
|
||||
if ((typeWord & 0xff) !== 1) throw new Error(`Expected a POINT, got type ${typeWord & 0xff}`);
|
||||
|
||||
// Skip the 4-byte SRID when the flag is set.
|
||||
const offset = typeWord & 0x20000000 ? 9 : 5;
|
||||
return { lng: readF64(offset), lat: readF64(offset + 8) };
|
||||
}
|
||||
|
||||
/**
|
||||
* PostGIS `geography(Point, 4326)`.
|
||||
*
|
||||
* Geography rather than geometry so `ST_Distance` returns metres and
|
||||
* `ST_DWithin` is correct anywhere on the globe without choosing a projection
|
||||
* per city — the whole point of keeping the app city-agnostic.
|
||||
*
|
||||
* NOTE: drizzle-kit quotes unknown type names when generating migrations, which
|
||||
* produces invalid SQL. `scripts/fix-postgis.mjs` unquotes them and runs as part
|
||||
* of `pnpm db:generate`.
|
||||
*/
|
||||
export const point = customType<{
|
||||
data: LatLng;
|
||||
driverData: string;
|
||||
config: never;
|
||||
}>({
|
||||
dataType: () => 'geography(Point,4326)',
|
||||
fromDriver(value: string): LatLng {
|
||||
// GeoJSON when the caller selected ST_AsGeoJSON, EWKB hex otherwise.
|
||||
if (value.startsWith('{')) {
|
||||
const parsed = JSON.parse(value) as { coordinates: [number, number] };
|
||||
const [lng, lat] = parsed.coordinates;
|
||||
return { lat, lng };
|
||||
}
|
||||
return decodeEwkbPoint(value);
|
||||
},
|
||||
toDriver(value: LatLng): SQL {
|
||||
return sql`ST_SetSRID(ST_MakePoint(${value.lng}, ${value.lat}), 4326)::geography`;
|
||||
},
|
||||
});
|
||||
|
||||
/** Build a point literal for use inside a raw SQL fragment. */
|
||||
export function makePoint(lat: number, lng: number): SQL {
|
||||
return sql`ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326)::geography`;
|
||||
}
|
||||
|
||||
/** Metres between two geography points. */
|
||||
export function distanceM(a: SQL, b: SQL): SQL<number> {
|
||||
return sql<number>`ST_Distance(${a}, ${b})`;
|
||||
}
|
||||
|
||||
/** Index-accelerated radius filter. Uses the GiST index — do not replace with haversine. */
|
||||
export function withinRadius(column: SQL, target: SQL, radiusMeters: SQL | number): SQL {
|
||||
return sql`ST_DWithin(${column}, ${target}, ${radiusMeters})`;
|
||||
}
|
||||
|
||||
export { decodeEwkbPoint };
|
||||
@@ -0,0 +1,183 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { DECK_PAGE_SIZE, score, type RankingInput } from '@linkder/shared';
|
||||
import type { Db } from '../client';
|
||||
|
||||
export interface DeckCard {
|
||||
proId: string;
|
||||
name: string | null;
|
||||
image: string | null;
|
||||
headline: string;
|
||||
bio: string;
|
||||
hourlyRateCents: number;
|
||||
yearsExperience: number;
|
||||
ratingAvg: number | null;
|
||||
ratingCount: number;
|
||||
completedJobs: number;
|
||||
responseRate: number | null;
|
||||
avgResponseMinutes: number | null;
|
||||
distanceM: number;
|
||||
photos: string[];
|
||||
categories: string[];
|
||||
/** Debug/tuning aid — surfaced in admin, never in the client UI. */
|
||||
score: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* How many candidates to pull before scoring in JS.
|
||||
*
|
||||
* Filtering (verified / in-category / in-radius / not-yet-swiped) happens in
|
||||
* Postgres where the GiST index does the work. Ranking happens in JS so the
|
||||
* weights stay in one tunable place instead of being frozen into a SQL string.
|
||||
*
|
||||
* In a single launch city this pool is effectively "every eligible pro", so the
|
||||
* two-stage approach costs nothing. If a city ever exceeds this many matching
|
||||
* pros for one job, move `score()` into SQL rather than raising this blindly.
|
||||
*/
|
||||
const CANDIDATE_POOL = 200;
|
||||
|
||||
/**
|
||||
* The deck for one job.
|
||||
*
|
||||
* No cursor: swiped pros are excluded by the anti-join, so "the next page" is
|
||||
* simply the next call. A client who abandons mid-deck sees the same cards
|
||||
* again, which is what you want.
|
||||
*/
|
||||
export async function getDeck(
|
||||
db: Db,
|
||||
args: { jobId: string; limit?: number; now?: Date },
|
||||
): Promise<DeckCard[]> {
|
||||
const limit = args.limit ?? DECK_PAGE_SIZE;
|
||||
const now = args.now ?? new Date();
|
||||
|
||||
const rows = await db.execute<{
|
||||
pro_id: string;
|
||||
name: string | null;
|
||||
image: string | null;
|
||||
headline: string;
|
||||
bio: string;
|
||||
hourly_rate_cents: number;
|
||||
years_experience: number;
|
||||
rating_avg: string | null;
|
||||
rating_count: number;
|
||||
completed_jobs: number;
|
||||
response_rate: string | null;
|
||||
avg_response_minutes: number | null;
|
||||
distance_m: number;
|
||||
service_radius_m: number;
|
||||
last_active_at: string | null;
|
||||
created_at: string;
|
||||
photos: string[] | null;
|
||||
categories: string[] | null;
|
||||
}>(sql`
|
||||
SELECT
|
||||
p.user_id AS pro_id,
|
||||
u.name,
|
||||
u.image,
|
||||
p.headline,
|
||||
p.bio,
|
||||
p.hourly_rate_cents,
|
||||
p.years_experience,
|
||||
p.rating_avg,
|
||||
p.rating_count,
|
||||
p.completed_jobs,
|
||||
p.response_rate,
|
||||
p.avg_response_minutes,
|
||||
ST_Distance(p.base_location, j.location) AS distance_m,
|
||||
p.service_radius_m,
|
||||
u.last_active_at,
|
||||
p.created_at,
|
||||
COALESCE(
|
||||
(SELECT array_agg(m.url ORDER BY m.position)
|
||||
FROM pro_media m WHERE m.pro_id = p.user_id),
|
||||
'{}'
|
||||
) AS photos,
|
||||
COALESCE(
|
||||
(SELECT array_agg(c.name)
|
||||
FROM pro_categories pc
|
||||
JOIN categories c ON c.id = pc.category_id
|
||||
WHERE pc.pro_id = p.user_id),
|
||||
'{}'
|
||||
) AS categories
|
||||
FROM jobs j
|
||||
JOIN pro_categories pcat ON pcat.category_id = j.category_id
|
||||
JOIN pro_profiles p ON p.user_id = pcat.pro_id
|
||||
JOIN users u ON u.id = p.user_id
|
||||
WHERE j.id = ${args.jobId}
|
||||
AND p.verification_status = 'verified'
|
||||
AND p.is_accepting_jobs = true
|
||||
AND u.banned_at IS NULL
|
||||
-- the pro must be willing to travel to this job, index-accelerated
|
||||
AND ST_DWithin(p.base_location, j.location, p.service_radius_m)
|
||||
-- never show a card the client has already decided on
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM swipes s
|
||||
WHERE s.job_id = j.id AND s.pro_id = p.user_id
|
||||
)
|
||||
-- nor one who already has a pending request for this job
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM requests r
|
||||
WHERE r.job_id = j.id AND r.pro_id = p.user_id
|
||||
)
|
||||
-- a pro cannot be shown their own job
|
||||
AND p.user_id <> j.client_id
|
||||
ORDER BY ST_Distance(p.base_location, j.location) ASC
|
||||
LIMIT ${CANDIDATE_POOL}
|
||||
`);
|
||||
|
||||
const cards = rows.map((r) => {
|
||||
const ratingAvg = r.rating_avg === null ? null : Number(r.rating_avg);
|
||||
const responseRate = r.response_rate === null ? null : Number(r.response_rate);
|
||||
const input: RankingInput = {
|
||||
ratingAvg,
|
||||
ratingCount: Number(r.rating_count),
|
||||
responseRate,
|
||||
distanceM: Number(r.distance_m),
|
||||
serviceRadiusM: Number(r.service_radius_m),
|
||||
lastActiveAt: r.last_active_at ? new Date(r.last_active_at) : null,
|
||||
createdAt: new Date(r.created_at),
|
||||
now,
|
||||
};
|
||||
|
||||
return {
|
||||
proId: r.pro_id,
|
||||
name: r.name,
|
||||
image: r.image,
|
||||
headline: r.headline,
|
||||
bio: r.bio,
|
||||
hourlyRateCents: Number(r.hourly_rate_cents),
|
||||
yearsExperience: Number(r.years_experience),
|
||||
ratingAvg,
|
||||
ratingCount: Number(r.rating_count),
|
||||
completedJobs: Number(r.completed_jobs),
|
||||
responseRate,
|
||||
avgResponseMinutes: r.avg_response_minutes === null ? null : Number(r.avg_response_minutes),
|
||||
distanceM: Math.round(Number(r.distance_m)),
|
||||
photos: r.photos ?? [],
|
||||
categories: r.categories ?? [],
|
||||
score: score(input),
|
||||
} satisfies DeckCard;
|
||||
});
|
||||
|
||||
cards.sort((a, b) => b.score - a.score || a.distanceM - b.distanceM);
|
||||
return cards.slice(0, limit);
|
||||
}
|
||||
|
||||
/** How many cards are left, for the "deck is running dry" empty state. */
|
||||
export async function getDeckCount(db: Db, jobId: string): Promise<number> {
|
||||
const rows = await db.execute<{ count: number }>(sql`
|
||||
SELECT COUNT(*)::int AS count
|
||||
FROM jobs j
|
||||
JOIN pro_categories pcat ON pcat.category_id = j.category_id
|
||||
JOIN pro_profiles p ON p.user_id = pcat.pro_id
|
||||
JOIN users u ON u.id = p.user_id
|
||||
WHERE j.id = ${jobId}
|
||||
AND p.verification_status = 'verified'
|
||||
AND p.is_accepting_jobs = true
|
||||
AND u.banned_at IS NULL
|
||||
AND ST_DWithin(p.base_location, j.location, p.service_radius_m)
|
||||
AND NOT EXISTS (SELECT 1 FROM swipes s WHERE s.job_id = j.id AND s.pro_id = p.user_id)
|
||||
AND NOT EXISTS (SELECT 1 FROM requests r WHERE r.job_id = j.id AND r.pro_id = p.user_id)
|
||||
AND p.user_id <> j.client_id
|
||||
`);
|
||||
return rows[0]?.count ?? 0;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { index, jsonb, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
|
||||
import { users } from './auth';
|
||||
|
||||
/**
|
||||
* Append-only. Every admin action on a pro's verification, every suspension,
|
||||
* every manual refund. If it can affect someone's livelihood, it lands here.
|
||||
*/
|
||||
export const auditLog = pgTable(
|
||||
'audit_log',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
actorId: uuid('actor_id').references(() => users.id, { onDelete: 'set null' }),
|
||||
action: text('action').notNull(),
|
||||
entity: text('entity').notNull(),
|
||||
entityId: text('entity_id'),
|
||||
metadata: jsonb('metadata'),
|
||||
ip: text('ip'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
index('audit_log_entity_idx').on(t.entity, t.entityId),
|
||||
index('audit_log_actor_idx').on(t.actorId, t.createdAt),
|
||||
],
|
||||
);
|
||||
@@ -0,0 +1,83 @@
|
||||
import { relations } from 'drizzle-orm';
|
||||
import { boolean, index, integer, pgTable, primaryKey, text, timestamp, uuid } from 'drizzle-orm/pg-core';
|
||||
import { userRole } from './enums';
|
||||
|
||||
/** Auth.js v5 compatible tables, plus the fields the marketplace needs. */
|
||||
export const users = pgTable(
|
||||
'users',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
name: text('name'),
|
||||
email: text('email').unique(),
|
||||
emailVerified: timestamp('email_verified', { withTimezone: true }),
|
||||
/** E.164. The identity that actually matters on both sides of a local marketplace. */
|
||||
phone: text('phone').unique(),
|
||||
phoneVerified: timestamp('phone_verified', { withTimezone: true }),
|
||||
image: text('image'),
|
||||
role: userRole('role').notNull().default('client'),
|
||||
/** Set when an admin bans someone; checked in the auth callback. */
|
||||
bannedAt: timestamp('banned_at', { withTimezone: true }),
|
||||
lastActiveAt: timestamp('last_active_at', { withTimezone: true }).defaultNow(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [index('users_role_idx').on(t.role), index('users_phone_idx').on(t.phone)],
|
||||
);
|
||||
|
||||
export const accounts = pgTable(
|
||||
'accounts',
|
||||
{
|
||||
userId: uuid('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
type: text('type').notNull(),
|
||||
provider: text('provider').notNull(),
|
||||
providerAccountId: text('provider_account_id').notNull(),
|
||||
refresh_token: text('refresh_token'),
|
||||
access_token: text('access_token'),
|
||||
expires_at: integer('expires_at'),
|
||||
token_type: text('token_type'),
|
||||
scope: text('scope'),
|
||||
id_token: text('id_token'),
|
||||
session_state: text('session_state'),
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.provider, t.providerAccountId] })],
|
||||
);
|
||||
|
||||
export const sessions = pgTable('sessions', {
|
||||
sessionToken: text('session_token').primaryKey(),
|
||||
userId: uuid('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
expires: timestamp('expires', { withTimezone: true }).notNull(),
|
||||
});
|
||||
|
||||
export const verificationTokens = pgTable(
|
||||
'verification_tokens',
|
||||
{
|
||||
identifier: text('identifier').notNull(),
|
||||
token: text('token').notNull(),
|
||||
expires: timestamp('expires', { withTimezone: true }).notNull(),
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.identifier, t.token] })],
|
||||
);
|
||||
|
||||
/** Short-lived SMS codes for phone login. Separate from Auth.js email tokens. */
|
||||
export const phoneOtps = pgTable(
|
||||
'phone_otps',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
phone: text('phone').notNull(),
|
||||
codeHash: text('code_hash').notNull(),
|
||||
attempts: integer('attempts').notNull().default(0),
|
||||
consumed: boolean('consumed').notNull().default(false),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [index('phone_otps_phone_idx').on(t.phone, t.expiresAt)],
|
||||
);
|
||||
|
||||
export const usersRelations = relations(users, ({ many }) => ({
|
||||
accounts: many(accounts),
|
||||
sessions: many(sessions),
|
||||
}));
|
||||
@@ -0,0 +1,113 @@
|
||||
import { relations } from 'drizzle-orm';
|
||||
import { index, integer, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
|
||||
import { users } from './auth';
|
||||
import { matches } from './matching';
|
||||
import { bookingStatus, paymentStatus, quoteKind, quoteStatus } from './enums';
|
||||
|
||||
export const quotes = pgTable(
|
||||
'quotes',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
matchId: uuid('match_id')
|
||||
.notNull()
|
||||
.references(() => matches.id, { onDelete: 'cascade' }),
|
||||
kind: quoteKind('kind').notNull().default('fixed'),
|
||||
amountCents: integer('amount_cents').notNull(),
|
||||
hoursEstimate: integer('hours_estimate'),
|
||||
scope: text('scope').notNull(),
|
||||
status: quoteStatus('status').notNull().default('sent'),
|
||||
validUntil: timestamp('valid_until', { withTimezone: true }).notNull(),
|
||||
respondedAt: timestamp('responded_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [index('quotes_match_idx').on(t.matchId, t.status)],
|
||||
);
|
||||
|
||||
export const bookings = pgTable(
|
||||
'bookings',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
matchId: uuid('match_id')
|
||||
.notNull()
|
||||
.references(() => matches.id, { onDelete: 'cascade' }),
|
||||
quoteId: uuid('quote_id')
|
||||
.notNull()
|
||||
.references(() => quotes.id),
|
||||
scheduledStart: timestamp('scheduled_start', { withTimezone: true }).notNull(),
|
||||
scheduledEnd: timestamp('scheduled_end', { withTimezone: true }).notNull(),
|
||||
status: bookingStatus('status').notNull().default('scheduled'),
|
||||
/** Set when the pro marks the work done — starts the auto-confirm clock. */
|
||||
proCompletedAt: timestamp('pro_completed_at', { withTimezone: true }),
|
||||
clientConfirmedAt: timestamp('client_confirmed_at', { withTimezone: true }),
|
||||
cancelledAt: timestamp('cancelled_at', { withTimezone: true }),
|
||||
cancelledBy: uuid('cancelled_by').references(() => users.id),
|
||||
cancellationReason: text('cancellation_reason'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
index('bookings_match_idx').on(t.matchId),
|
||||
// The auto-confirm worker sweeps on this.
|
||||
index('bookings_status_idx').on(t.status, t.proCompletedAt),
|
||||
index('bookings_schedule_idx').on(t.scheduledStart),
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* One payment per booking. Stripe is the source of truth for money — this table
|
||||
* mirrors it so the app can render state without an API round trip, and is only
|
||||
* ever written from webhook handlers.
|
||||
*/
|
||||
export const payments = pgTable(
|
||||
'payments',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
bookingId: uuid('booking_id')
|
||||
.notNull()
|
||||
.unique()
|
||||
.references(() => bookings.id, { onDelete: 'cascade' }),
|
||||
stripePaymentIntentId: text('stripe_payment_intent_id').unique(),
|
||||
stripeTransferId: text('stripe_transfer_id').unique(),
|
||||
stripeRefundId: text('stripe_refund_id'),
|
||||
amountCents: integer('amount_cents').notNull(),
|
||||
/** Snapshotted at charge time so a later rate change cannot rewrite history. */
|
||||
platformFeeCents: integer('platform_fee_cents').notNull(),
|
||||
platformFeeBps: integer('platform_fee_bps').notNull(),
|
||||
refundedCents: integer('refunded_cents').notNull().default(0),
|
||||
currency: text('currency').notNull().default('eur'),
|
||||
status: paymentStatus('status').notNull().default('pending'),
|
||||
capturedAt: timestamp('captured_at', { withTimezone: true }),
|
||||
releasedAt: timestamp('released_at', { withTimezone: true }),
|
||||
failureReason: text('failure_reason'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
index('payments_status_idx').on(t.status),
|
||||
index('payments_intent_idx').on(t.stripePaymentIntentId),
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* Every Stripe event we have already processed. The webhook handler checks this
|
||||
* first — Stripe retries, and a replayed transfer is real money sent twice.
|
||||
*/
|
||||
export const processedStripeEvents = pgTable('processed_stripe_events', {
|
||||
eventId: text('event_id').primaryKey(),
|
||||
type: text('type').notNull(),
|
||||
processedAt: timestamp('processed_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const quotesRelations = relations(quotes, ({ one }) => ({
|
||||
match: one(matches, { fields: [quotes.matchId], references: [matches.id] }),
|
||||
}));
|
||||
|
||||
export const bookingsRelations = relations(bookings, ({ one }) => ({
|
||||
match: one(matches, { fields: [bookings.matchId], references: [matches.id] }),
|
||||
quote: one(quotes, { fields: [bookings.quoteId], references: [quotes.id] }),
|
||||
payment: one(payments, { fields: [bookings.id], references: [payments.bookingId] }),
|
||||
}));
|
||||
|
||||
export const paymentsRelations = relations(payments, ({ one }) => ({
|
||||
booking: one(bookings, { fields: [payments.bookingId], references: [bookings.id] }),
|
||||
}));
|
||||
@@ -0,0 +1,28 @@
|
||||
import { pgEnum } from 'drizzle-orm/pg-core';
|
||||
import {
|
||||
BOOKING_STATUSES,
|
||||
JOB_STATUSES,
|
||||
PAYMENT_STATUSES,
|
||||
QUOTE_STATUSES,
|
||||
REQUEST_STATUSES,
|
||||
VERIFICATION_STATUSES,
|
||||
} from '@linkder/shared';
|
||||
|
||||
/**
|
||||
* Enums mirror the status unions in @linkder/shared/state-machines.
|
||||
* Importing them here means a new status cannot be added to the DB without
|
||||
* also being added to the transition graph.
|
||||
*/
|
||||
export const userRole = pgEnum('user_role', ['client', 'pro', 'admin']);
|
||||
export const jobStatus = pgEnum('job_status', JOB_STATUSES);
|
||||
export const requestStatus = pgEnum('request_status', REQUEST_STATUSES);
|
||||
export const quoteStatus = pgEnum('quote_status', QUOTE_STATUSES);
|
||||
export const bookingStatus = pgEnum('booking_status', BOOKING_STATUSES);
|
||||
export const paymentStatus = pgEnum('payment_status', PAYMENT_STATUSES);
|
||||
export const verificationStatus = pgEnum('verification_status', VERIFICATION_STATUSES);
|
||||
export const urgency = pgEnum('urgency', ['now', 'this_week', 'flexible']);
|
||||
export const swipeDirection = pgEnum('swipe_direction', ['left', 'right']);
|
||||
export const credentialKind = pgEnum('credential_kind', ['id', 'licence', 'insurance']);
|
||||
export const reviewStatus = pgEnum('review_status', ['pending', 'approved', 'rejected']);
|
||||
export const quoteKind = pgEnum('quote_kind', ['fixed', 'hourly']);
|
||||
export const mediaKind = pgEnum('media_kind', ['photo', 'work_sample']);
|
||||
@@ -0,0 +1,9 @@
|
||||
export * from './enums';
|
||||
export * from './auth';
|
||||
export * from './pros';
|
||||
export * from './jobs';
|
||||
export * from './matching';
|
||||
export * from './messaging';
|
||||
export * from './commerce';
|
||||
export * from './reviews';
|
||||
export * from './audit';
|
||||
@@ -0,0 +1,41 @@
|
||||
import { relations, sql } from 'drizzle-orm';
|
||||
import { index, integer, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
|
||||
import { point } from '../postgis';
|
||||
import { users } from './auth';
|
||||
import { categories } from './pros';
|
||||
import { jobStatus, urgency } from './enums';
|
||||
|
||||
export const jobs = pgTable(
|
||||
'jobs',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
clientId: uuid('client_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
categoryId: uuid('category_id')
|
||||
.notNull()
|
||||
.references(() => categories.id),
|
||||
title: text('title').notNull(),
|
||||
description: text('description').notNull(),
|
||||
photos: text('photos').array().notNull().default(sql`'{}'::text[]`),
|
||||
urgency: urgency('urgency').notNull().default('flexible'),
|
||||
budgetMinCents: integer('budget_min_cents'),
|
||||
budgetMaxCents: integer('budget_max_cents'),
|
||||
location: point('location').notNull(),
|
||||
/** Street-level address, only revealed to the pro once a booking exists. */
|
||||
addressText: text('address_text').notNull(),
|
||||
status: jobStatus('status').notNull().default('open'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
index('jobs_location_gist').using('gist', t.location),
|
||||
index('jobs_client_idx').on(t.clientId, t.status),
|
||||
index('jobs_open_idx').on(t.categoryId).where(sql`${t.status} = 'open'`),
|
||||
],
|
||||
);
|
||||
|
||||
export const jobsRelations = relations(jobs, ({ one }) => ({
|
||||
client: one(users, { fields: [jobs.clientId], references: [users.id] }),
|
||||
category: one(categories, { fields: [jobs.categoryId], references: [categories.id] }),
|
||||
}));
|
||||
@@ -0,0 +1,96 @@
|
||||
import { relations, sql } from 'drizzle-orm';
|
||||
import { index, pgTable, timestamp, unique, uuid } from 'drizzle-orm/pg-core';
|
||||
import { users } from './auth';
|
||||
import { jobs } from './jobs';
|
||||
import { proProfiles } from './pros';
|
||||
import { requestStatus, swipeDirection } from './enums';
|
||||
|
||||
/**
|
||||
* Every card the client acts on. Left swipes matter as much as right ones —
|
||||
* they are what keeps a rejected pro from reappearing on the same job.
|
||||
*/
|
||||
export const swipes = pgTable(
|
||||
'swipes',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
jobId: uuid('job_id')
|
||||
.notNull()
|
||||
.references(() => jobs.id, { onDelete: 'cascade' }),
|
||||
proId: uuid('pro_id')
|
||||
.notNull()
|
||||
.references(() => proProfiles.userId, { onDelete: 'cascade' }),
|
||||
direction: swipeDirection('direction').notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
// One decision per pro per job — also the NOT EXISTS anti-join in the deck query.
|
||||
unique('swipes_job_pro_unique').on(t.jobId, t.proId),
|
||||
index('swipes_job_idx').on(t.jobId),
|
||||
],
|
||||
);
|
||||
|
||||
/** A right swipe. "I want you for this job" — pending until the pro answers. */
|
||||
export const requests = pgTable(
|
||||
'requests',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
jobId: uuid('job_id')
|
||||
.notNull()
|
||||
.references(() => jobs.id, { onDelete: 'cascade' }),
|
||||
proId: uuid('pro_id')
|
||||
.notNull()
|
||||
.references(() => proProfiles.userId, { onDelete: 'cascade' }),
|
||||
status: requestStatus('status').notNull().default('pending'),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
respondedAt: timestamp('responded_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
unique('requests_job_pro_unique').on(t.jobId, t.proId),
|
||||
// The pro's inbox and the TTL sweeper both hit this.
|
||||
index('requests_pending_idx')
|
||||
.on(t.proId, t.expiresAt)
|
||||
.where(sql`${t.status} = 'pending'`),
|
||||
index('requests_job_idx').on(t.jobId, t.status),
|
||||
],
|
||||
);
|
||||
|
||||
/** The pro accepted. Chat opens here. */
|
||||
export const matches = pgTable(
|
||||
'matches',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
requestId: uuid('request_id')
|
||||
.notNull()
|
||||
.unique()
|
||||
.references(() => requests.id, { onDelete: 'cascade' }),
|
||||
jobId: uuid('job_id')
|
||||
.notNull()
|
||||
.references(() => jobs.id, { onDelete: 'cascade' }),
|
||||
proId: uuid('pro_id')
|
||||
.notNull()
|
||||
.references(() => proProfiles.userId, { onDelete: 'cascade' }),
|
||||
clientId: uuid('client_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
lastMessageAt: timestamp('last_message_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
index('matches_pro_idx').on(t.proId, t.lastMessageAt),
|
||||
index('matches_client_idx').on(t.clientId, t.lastMessageAt),
|
||||
index('matches_job_idx').on(t.jobId),
|
||||
],
|
||||
);
|
||||
|
||||
export const requestsRelations = relations(requests, ({ one }) => ({
|
||||
job: one(jobs, { fields: [requests.jobId], references: [jobs.id] }),
|
||||
pro: one(proProfiles, { fields: [requests.proId], references: [proProfiles.userId] }),
|
||||
}));
|
||||
|
||||
export const matchesRelations = relations(matches, ({ one }) => ({
|
||||
request: one(requests, { fields: [matches.requestId], references: [requests.id] }),
|
||||
job: one(jobs, { fields: [matches.jobId], references: [jobs.id] }),
|
||||
pro: one(proProfiles, { fields: [matches.proId], references: [proProfiles.userId] }),
|
||||
client: one(users, { fields: [matches.clientId], references: [users.id] }),
|
||||
}));
|
||||
@@ -0,0 +1,32 @@
|
||||
import { relations, sql } from 'drizzle-orm';
|
||||
import { index, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
|
||||
import { users } from './auth';
|
||||
import { matches } from './matching';
|
||||
|
||||
export const messages = pgTable(
|
||||
'messages',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
matchId: uuid('match_id')
|
||||
.notNull()
|
||||
.references(() => matches.id, { onDelete: 'cascade' }),
|
||||
senderId: uuid('sender_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
body: text('body').notNull(),
|
||||
attachments: text('attachments').array().notNull().default(sql`'{}'::text[]`),
|
||||
readAt: timestamp('read_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
// Chat history is always "this match, newest last".
|
||||
index('messages_match_idx').on(t.matchId, t.createdAt),
|
||||
// Unread badge count.
|
||||
index('messages_unread_idx').on(t.matchId, t.senderId).where(sql`${t.readAt} IS NULL`),
|
||||
],
|
||||
);
|
||||
|
||||
export const messagesRelations = relations(messages, ({ one }) => ({
|
||||
match: one(matches, { fields: [messages.matchId], references: [matches.id] }),
|
||||
sender: one(users, { fields: [messages.senderId], references: [users.id] }),
|
||||
}));
|
||||
@@ -0,0 +1,175 @@
|
||||
import { relations, sql } from 'drizzle-orm';
|
||||
import {
|
||||
boolean,
|
||||
index,
|
||||
integer,
|
||||
numeric,
|
||||
pgTable,
|
||||
primaryKey,
|
||||
text,
|
||||
timestamp,
|
||||
uuid,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { point } from '../postgis';
|
||||
import { users } from './auth';
|
||||
import { credentialKind, mediaKind, reviewStatus, verificationStatus } from './enums';
|
||||
|
||||
export const categories = pgTable('categories', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
slug: text('slug').notNull().unique(),
|
||||
name: text('name').notNull(),
|
||||
icon: text('icon'),
|
||||
/** Display order on the job-posting picker. */
|
||||
position: integer('position').notNull().default(0),
|
||||
isActive: boolean('is_active').notNull().default(true),
|
||||
});
|
||||
|
||||
export const proProfiles = pgTable(
|
||||
'pro_profiles',
|
||||
{
|
||||
userId: uuid('user_id')
|
||||
.primaryKey()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
headline: text('headline').notNull(),
|
||||
bio: text('bio').notNull(),
|
||||
hourlyRateCents: integer('hourly_rate_cents').notNull(),
|
||||
yearsExperience: integer('years_experience').notNull().default(0),
|
||||
baseLocation: point('base_location').notNull(),
|
||||
serviceRadiusM: integer('service_radius_m').notNull().default(15000),
|
||||
|
||||
verificationStatus: verificationStatus('verification_status').notNull().default('draft'),
|
||||
verifiedAt: timestamp('verified_at', { withTimezone: true }),
|
||||
suspendedReason: text('suspended_reason'),
|
||||
|
||||
/** False when the pro is on holiday — keeps them off the deck without unverifying. */
|
||||
isAcceptingJobs: boolean('is_accepting_jobs').notNull().default(true),
|
||||
|
||||
/** Denormalised ranking inputs, recomputed on review/response events. */
|
||||
ratingAvg: numeric('rating_avg', { precision: 3, scale: 2 }),
|
||||
ratingCount: integer('rating_count').notNull().default(0),
|
||||
completedJobs: integer('completed_jobs').notNull().default(0),
|
||||
/** 0..1 — share of requests answered before expiry. */
|
||||
responseRate: numeric('response_rate', { precision: 4, scale: 3 }),
|
||||
avgResponseMinutes: integer('avg_response_minutes'),
|
||||
|
||||
/** Stripe Connect Express account. Null until the pro onboards for payouts. */
|
||||
stripeAccountId: text('stripe_account_id').unique(),
|
||||
stripePayoutsEnabled: boolean('stripe_payouts_enabled').notNull().default(false),
|
||||
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
// The deck query lives or dies on this GiST index.
|
||||
index('pro_profiles_location_gist').using('gist', t.baseLocation),
|
||||
// Partial index: the deck only ever looks at bookable pros.
|
||||
index('pro_profiles_deck_idx')
|
||||
.on(t.verificationStatus, t.isAcceptingJobs)
|
||||
.where(sql`${t.verificationStatus} = 'verified' AND ${t.isAcceptingJobs} = true`),
|
||||
],
|
||||
);
|
||||
|
||||
export const proCategories = pgTable(
|
||||
'pro_categories',
|
||||
{
|
||||
proId: uuid('pro_id')
|
||||
.notNull()
|
||||
.references(() => proProfiles.userId, { onDelete: 'cascade' }),
|
||||
categoryId: uuid('category_id')
|
||||
.notNull()
|
||||
.references(() => categories.id, { onDelete: 'cascade' }),
|
||||
},
|
||||
(t) => [
|
||||
primaryKey({ columns: [t.proId, t.categoryId] }),
|
||||
index('pro_categories_category_idx').on(t.categoryId),
|
||||
],
|
||||
);
|
||||
|
||||
export const proMedia = pgTable(
|
||||
'pro_media',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
proId: uuid('pro_id')
|
||||
.notNull()
|
||||
.references(() => proProfiles.userId, { onDelete: 'cascade' }),
|
||||
url: text('url').notNull(),
|
||||
kind: mediaKind('kind').notNull().default('photo'),
|
||||
position: integer('position').notNull().default(0),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [index('pro_media_pro_idx').on(t.proId, t.position)],
|
||||
);
|
||||
|
||||
/** Licence, insurance and ID documents. The legal exposure of the business. */
|
||||
export const credentials = pgTable(
|
||||
'credentials',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
proId: uuid('pro_id')
|
||||
.notNull()
|
||||
.references(() => proProfiles.userId, { onDelete: 'cascade' }),
|
||||
kind: credentialKind('kind').notNull(),
|
||||
fileUrl: text('file_url').notNull(),
|
||||
issuer: text('issuer'),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }),
|
||||
reviewStatus: reviewStatus('review_status').notNull().default('pending'),
|
||||
reviewedBy: uuid('reviewed_by').references(() => users.id),
|
||||
reviewedAt: timestamp('reviewed_at', { withTimezone: true }),
|
||||
reviewNotes: text('review_notes'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
index('credentials_pro_idx').on(t.proId),
|
||||
// Drives both the admin queue and the nightly expiry sweep.
|
||||
index('credentials_review_idx').on(t.reviewStatus),
|
||||
index('credentials_expiry_idx').on(t.expiresAt),
|
||||
],
|
||||
);
|
||||
|
||||
/** Didit identity-verification sessions. One row per attempt. */
|
||||
export const verificationSessions = pgTable(
|
||||
'verification_sessions',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
proId: uuid('pro_id')
|
||||
.notNull()
|
||||
.references(() => proProfiles.userId, { onDelete: 'cascade' }),
|
||||
provider: text('provider').notNull().default('didit'),
|
||||
externalId: text('external_id').notNull().unique(),
|
||||
status: text('status').notNull().default('pending'),
|
||||
decision: text('decision'),
|
||||
rawPayload: text('raw_payload'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
completedAt: timestamp('completed_at', { withTimezone: true }),
|
||||
},
|
||||
(t) => [index('verification_sessions_pro_idx').on(t.proId)],
|
||||
);
|
||||
|
||||
/** Simple weekly recurrence. Good enough for MVP; exceptions come later. */
|
||||
export const proAvailability = pgTable(
|
||||
'pro_availability',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
proId: uuid('pro_id')
|
||||
.notNull()
|
||||
.references(() => proProfiles.userId, { onDelete: 'cascade' }),
|
||||
/** 0 = Sunday .. 6 = Saturday */
|
||||
weekday: integer('weekday').notNull(),
|
||||
startMinute: integer('start_minute').notNull(),
|
||||
endMinute: integer('end_minute').notNull(),
|
||||
},
|
||||
(t) => [index('pro_availability_pro_idx').on(t.proId, t.weekday)],
|
||||
);
|
||||
|
||||
export const proProfilesRelations = relations(proProfiles, ({ one, many }) => ({
|
||||
user: one(users, { fields: [proProfiles.userId], references: [users.id] }),
|
||||
categories: many(proCategories),
|
||||
media: many(proMedia),
|
||||
credentials: many(credentials),
|
||||
availability: many(proAvailability),
|
||||
}));
|
||||
|
||||
export const proCategoriesRelations = relations(proCategories, ({ one }) => ({
|
||||
pro: one(proProfiles, { fields: [proCategories.proId], references: [proProfiles.userId] }),
|
||||
category: one(categories, { fields: [proCategories.categoryId], references: [categories.id] }),
|
||||
}));
|
||||
@@ -0,0 +1,39 @@
|
||||
import { relations } from 'drizzle-orm';
|
||||
import { index, integer, pgTable, text, timestamp, unique, uuid } from 'drizzle-orm/pg-core';
|
||||
import { users } from './auth';
|
||||
import { bookings } from './commerce';
|
||||
|
||||
/**
|
||||
* Two-way: the client reviews the pro and the pro reviews the client.
|
||||
* One review per author per booking.
|
||||
*/
|
||||
export const reviews = pgTable(
|
||||
'reviews',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
bookingId: uuid('booking_id')
|
||||
.notNull()
|
||||
.references(() => bookings.id, { onDelete: 'cascade' }),
|
||||
authorId: uuid('author_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
subjectId: uuid('subject_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
rating: integer('rating').notNull(),
|
||||
body: text('body').notNull(),
|
||||
/** Hidden until both sides have reviewed, or the window closes — stops retaliation. */
|
||||
publishedAt: timestamp('published_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
unique('reviews_booking_author_unique').on(t.bookingId, t.authorId),
|
||||
index('reviews_subject_idx').on(t.subjectId, t.publishedAt),
|
||||
],
|
||||
);
|
||||
|
||||
export const reviewsRelations = relations(reviews, ({ one }) => ({
|
||||
booking: one(bookings, { fields: [reviews.bookingId], references: [bookings.id] }),
|
||||
author: one(users, { fields: [reviews.authorId], references: [users.id] }),
|
||||
subject: one(users, { fields: [reviews.subjectId], references: [users.id] }),
|
||||
}));
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* Deterministic seed for the launch city.
|
||||
*
|
||||
* Pros are placed at KNOWN bearings and distances from the city centre so the
|
||||
* PostGIS radius filter has an assertable expected result — e.g. a job at the
|
||||
* centre with pros at 1/3/8/20km lets a test say exactly which cards must appear.
|
||||
* Nothing here is random; reseeding twice gives the same database.
|
||||
*/
|
||||
import { config } from 'dotenv';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import postgres from 'postgres';
|
||||
import { DEFAULT_SERVICE_RADIUS_M } from '@linkder/shared';
|
||||
import * as schema from './schema/index';
|
||||
|
||||
config({ path: '../../.env' });
|
||||
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) throw new Error('DATABASE_URL is not set');
|
||||
|
||||
const client = postgres(url, { max: 1 });
|
||||
const db = drizzle(client, { schema });
|
||||
|
||||
const CITY = {
|
||||
name: process.env.NEXT_PUBLIC_CITY_NAME ?? 'Barcelona',
|
||||
lat: Number(process.env.NEXT_PUBLIC_CITY_LAT ?? 41.3874),
|
||||
lng: Number(process.env.NEXT_PUBLIC_CITY_LNG ?? 2.1686),
|
||||
};
|
||||
|
||||
/** Move a known distance along a bearing from an origin. Accurate enough at city scale. */
|
||||
function offset(lat: number, lng: number, metres: number, bearingDeg: number) {
|
||||
const R = 6_371_000;
|
||||
const br = (bearingDeg * Math.PI) / 180;
|
||||
const dLat = (metres * Math.cos(br)) / R;
|
||||
const dLng = (metres * Math.sin(br)) / (R * Math.cos((lat * Math.PI) / 180));
|
||||
return {
|
||||
lat: lat + (dLat * 180) / Math.PI,
|
||||
lng: lng + (dLng * 180) / Math.PI,
|
||||
};
|
||||
}
|
||||
|
||||
const CATEGORIES = [
|
||||
{ slug: 'plumber', name: 'Plumber', icon: 'shower-head' },
|
||||
{ slug: 'electrician', name: 'Electrician', icon: 'zap' },
|
||||
{ slug: 'handyman', name: 'Handyman', icon: 'wrench' },
|
||||
{ slug: 'painter', name: 'Painter', icon: 'paint-roller' },
|
||||
{ slug: 'carpenter', name: 'Carpenter', icon: 'hammer' },
|
||||
{ slug: 'locksmith', name: 'Locksmith', icon: 'key-round' },
|
||||
{ slug: 'appliance-repair', name: 'Appliance Repair', icon: 'washing-machine' },
|
||||
{ slug: 'hvac', name: 'Heating & Cooling', icon: 'thermometer' },
|
||||
];
|
||||
|
||||
interface SeedPro {
|
||||
name: string;
|
||||
cat: string;
|
||||
distanceM: number;
|
||||
rating: number | null;
|
||||
reviews: number;
|
||||
radius: number;
|
||||
isNew?: boolean;
|
||||
unverified?: boolean;
|
||||
away?: boolean;
|
||||
}
|
||||
|
||||
/** distanceM is measured from the city centre — deck tests assert against these. */
|
||||
const PROS: SeedPro[] = [
|
||||
{ name: 'Marc Oliveras', cat: 'plumber', distanceM: 800, rating: 4.9, reviews: 47, radius: 15_000 },
|
||||
{ name: 'Ana Ferrer', cat: 'plumber', distanceM: 2_400, rating: 4.7, reviews: 23, radius: 15_000 },
|
||||
{ name: 'Jordi Puig', cat: 'plumber', distanceM: 6_500, rating: 4.5, reviews: 12, radius: 10_000 },
|
||||
{ name: 'Nuria Sala', cat: 'plumber', distanceM: 18_000, rating: 5.0, reviews: 3, radius: 25_000 },
|
||||
// Further away than they are willing to travel — must NOT appear for a central job.
|
||||
{ name: 'Pau Ribas', cat: 'plumber', distanceM: 22_000, rating: 4.8, reviews: 31, radius: 5_000 },
|
||||
{ name: 'Laia Mestre', cat: 'electrician', distanceM: 1_200, rating: 4.8, reviews: 56, radius: 20_000 },
|
||||
{ name: 'Oriol Camps', cat: 'electrician', distanceM: 3_900, rating: 4.6, reviews: 18, radius: 15_000 },
|
||||
{ name: 'Marta Vidal', cat: 'electrician', distanceM: 9_100, rating: 4.9, reviews: 71, radius: 30_000 },
|
||||
{ name: 'Sergi Bonet', cat: 'handyman', distanceM: 1_800, rating: 4.4, reviews: 9, radius: 12_000 },
|
||||
{ name: 'Clara Roca', cat: 'handyman', distanceM: 5_200, rating: 4.7, reviews: 34, radius: 18_000 },
|
||||
{ name: 'Ivan Serra', cat: 'handyman', distanceM: 11_500, rating: 4.2, reviews: 6, radius: 15_000 },
|
||||
{ name: 'Elena Prat', cat: 'painter', distanceM: 2_100, rating: 4.9, reviews: 28, radius: 20_000 },
|
||||
{ name: 'Toni Blanch', cat: 'painter', distanceM: 7_800, rating: 4.3, reviews: 15, radius: 15_000 },
|
||||
{ name: 'Rosa Ventura', cat: 'carpenter', distanceM: 3_300, rating: 4.8, reviews: 41, radius: 25_000 },
|
||||
{ name: 'Guillem Costa', cat: 'carpenter', distanceM: 8_600, rating: 4.6, reviews: 19, radius: 15_000 },
|
||||
{ name: 'Silvia Marti', cat: 'locksmith', distanceM: 950, rating: 4.7, reviews: 62, radius: 25_000 },
|
||||
{ name: 'Xavi Duran', cat: 'locksmith', distanceM: 4_400, rating: 4.5, reviews: 22, radius: 20_000 },
|
||||
{ name: 'Berta Lloret', cat: 'appliance-repair', distanceM: 2_700, rating: 4.8, reviews: 37, radius: 18_000 },
|
||||
{ name: 'Adria Font', cat: 'appliance-repair', distanceM: 10_200, rating: 4.4, reviews: 11, radius: 20_000 },
|
||||
{ name: 'Carla Ripoll', cat: 'hvac', distanceM: 3_100, rating: 4.9, reviews: 44, radius: 22_000 },
|
||||
{ name: 'Marc Segura', cat: 'hvac', distanceM: 12_800, rating: 4.6, reviews: 27, radius: 25_000 },
|
||||
// Brand new and unrated — proves the new-pro boost keeps fresh supply visible.
|
||||
{ name: 'Nil Bosch', cat: 'plumber', distanceM: 1_500, rating: null, reviews: 0, radius: 15_000, isNew: true },
|
||||
{ name: 'Julia Camps', cat: 'electrician', distanceM: 2_200, rating: null, reviews: 0, radius: 15_000, isNew: true },
|
||||
// Not verified — must never reach a deck.
|
||||
{ name: 'Unverified Ulla', cat: 'plumber', distanceM: 1_000, rating: null, reviews: 0, radius: 15_000, unverified: true },
|
||||
// Verified but on holiday — must never reach a deck.
|
||||
{ name: 'Away Arnau', cat: 'plumber', distanceM: 1_100, rating: 4.9, reviews: 20, radius: 15_000, away: true },
|
||||
];
|
||||
|
||||
const CLIENTS = ['Sofia Grau', 'Daniel Miro', 'Emma Rovira', 'Lucas Pons', 'Alba Torres'];
|
||||
|
||||
async function main() {
|
||||
console.log(`Seeding ${CITY.name} (${CITY.lat}, ${CITY.lng})`);
|
||||
|
||||
// Truncate in FK-safe order — reseeding must be idempotent.
|
||||
await db.execute(sql`
|
||||
TRUNCATE TABLE
|
||||
audit_log, reviews, payments, bookings, quotes, messages,
|
||||
matches, requests, swipes, jobs,
|
||||
pro_availability, verification_sessions, credentials,
|
||||
pro_media, pro_categories, pro_profiles,
|
||||
sessions, accounts, phone_otps, users, categories
|
||||
RESTART IDENTITY CASCADE
|
||||
`);
|
||||
|
||||
const cats = await db
|
||||
.insert(schema.categories)
|
||||
.values(CATEGORIES.map((c, i) => ({ ...c, position: i })))
|
||||
.returning();
|
||||
const catBySlug = new Map(cats.map((c) => [c.slug, c.id]));
|
||||
console.log(` ${cats.length} categories`);
|
||||
|
||||
const clientRows = await db
|
||||
.insert(schema.users)
|
||||
.values(
|
||||
CLIENTS.map((name, i) => ({
|
||||
name,
|
||||
email: `client${i + 1}@linkder.test`,
|
||||
phone: `+3460000${String(i + 1).padStart(4, '0')}`,
|
||||
role: 'client' as const,
|
||||
emailVerified: new Date(),
|
||||
phoneVerified: new Date(),
|
||||
})),
|
||||
)
|
||||
.returning();
|
||||
console.log(` ${clientRows.length} clients`);
|
||||
|
||||
await db.insert(schema.users).values({
|
||||
name: 'Linkder Admin',
|
||||
email: 'admin@linkder.test',
|
||||
phone: '+34600009999',
|
||||
role: 'admin',
|
||||
emailVerified: new Date(),
|
||||
});
|
||||
|
||||
const now = Date.now();
|
||||
for (const [i, p] of PROS.entries()) {
|
||||
const bearing = (i * 360) / PROS.length;
|
||||
const pos = offset(CITY.lat, CITY.lng, p.distanceM, bearing);
|
||||
|
||||
const [user] = await db
|
||||
.insert(schema.users)
|
||||
.values({
|
||||
name: p.name,
|
||||
email: `pro${i + 1}@linkder.test`,
|
||||
phone: `+3461000${String(i + 1).padStart(4, '0')}`,
|
||||
role: 'pro' as const,
|
||||
emailVerified: new Date(),
|
||||
phoneVerified: new Date(),
|
||||
lastActiveAt: new Date(now - (i % 5) * 86_400_000),
|
||||
})
|
||||
.returning();
|
||||
if (!user) throw new Error('failed to insert pro user');
|
||||
|
||||
const catName = CATEGORIES.find((c) => c.slug === p.cat)?.name ?? 'Pro';
|
||||
const status = p.unverified ? ('pending' as const) : ('verified' as const);
|
||||
|
||||
await db.insert(schema.proProfiles).values({
|
||||
userId: user.id,
|
||||
headline: `${catName} in ${CITY.name}`,
|
||||
bio: `${p.name} has been working across ${CITY.name} for years. Reliable, tidy, and turns up when they say they will. Fixed-price quotes agreed before any work starts.`,
|
||||
hourlyRateCents: 3_500 + (i % 6) * 500,
|
||||
yearsExperience: 2 + (i % 18),
|
||||
baseLocation: pos,
|
||||
serviceRadiusM: p.radius ?? DEFAULT_SERVICE_RADIUS_M,
|
||||
verificationStatus: status,
|
||||
verifiedAt: status === 'verified' ? new Date() : null,
|
||||
isAcceptingJobs: !p.away,
|
||||
ratingAvg: p.rating === null ? null : String(p.rating),
|
||||
ratingCount: p.reviews,
|
||||
completedJobs: p.reviews,
|
||||
responseRate: p.reviews === 0 ? null : String(Math.min(0.99, 0.6 + (i % 40) / 100)),
|
||||
avgResponseMinutes: 15 + (i % 8) * 20,
|
||||
createdAt: p.isNew ? new Date(now - 3 * 86_400_000) : new Date(now - 400 * 86_400_000),
|
||||
});
|
||||
|
||||
const catId = catBySlug.get(p.cat);
|
||||
if (!catId) throw new Error(`unknown category ${p.cat}`);
|
||||
await db.insert(schema.proCategories).values({ proId: user.id, categoryId: catId });
|
||||
|
||||
const slug = encodeURIComponent(p.name.toLowerCase().replace(/\s+/g, '-'));
|
||||
await db.insert(schema.proMedia).values([
|
||||
{ proId: user.id, url: `https://picsum.photos/seed/${slug}-1/800/1000`, position: 0 },
|
||||
{
|
||||
proId: user.id,
|
||||
url: `https://picsum.photos/seed/${slug}-2/800/1000`,
|
||||
kind: 'work_sample' as const,
|
||||
position: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
// Mon–Fri, 08:00–18:00
|
||||
await db.insert(schema.proAvailability).values(
|
||||
[1, 2, 3, 4, 5].map((weekday) => ({
|
||||
proId: user.id,
|
||||
weekday,
|
||||
startMinute: 8 * 60,
|
||||
endMinute: 18 * 60,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
const eligible = PROS.filter((p) => !p.unverified && !p.away).length;
|
||||
console.log(` ${PROS.length} pros (${eligible} deck-eligible)`);
|
||||
|
||||
// One open job at the exact city centre — the fixture every deck test uses.
|
||||
const firstClient = clientRows[0];
|
||||
const plumberCat = catBySlug.get('plumber');
|
||||
if (firstClient && plumberCat) {
|
||||
const [job] = await db
|
||||
.insert(schema.jobs)
|
||||
.values({
|
||||
clientId: firstClient.id,
|
||||
categoryId: plumberCat,
|
||||
title: 'Kitchen sink leaking under the cupboard',
|
||||
description:
|
||||
'Water pooling under the kitchen sink, seems to be coming from the trap. The cupboard floor is starting to swell. Available most evenings this week.',
|
||||
photos: [],
|
||||
urgency: 'now' as const,
|
||||
budgetMinCents: 8_000,
|
||||
budgetMaxCents: 20_000,
|
||||
location: { lat: CITY.lat, lng: CITY.lng },
|
||||
addressText: `Carrer Example 12, ${CITY.name}`,
|
||||
})
|
||||
.returning();
|
||||
console.log(` 1 open job at the city centre (${job?.id})`);
|
||||
}
|
||||
|
||||
console.log('\nSeed complete. Sign in as client1@linkder.test or pro1@linkder.test');
|
||||
}
|
||||
|
||||
await main();
|
||||
await client.end();
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Integration test — runs against a live seeded database.
|
||||
*
|
||||
* pnpm services:up && pnpm db:migrate && pnpm db:seed
|
||||
* pnpm --filter @linkder/db test
|
||||
*
|
||||
* The seed places every pro at a known distance from the city centre, and the
|
||||
* fixture job sits exactly at the centre, so the expected deck is not "roughly
|
||||
* the nearby ones" — it is an exact, assertable list.
|
||||
*/
|
||||
import { config } from 'dotenv';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
config({ path: '../../.env' });
|
||||
|
||||
const { closePool, db } = await import('../src/client');
|
||||
const { getDeck, getDeckCount } = await import('../src/queries/deck');
|
||||
const schema = await import('../src/schema/index');
|
||||
|
||||
let jobId: string;
|
||||
let clientId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const rows = await db.execute<{ id: string; client_id: string }>(
|
||||
sql`SELECT id, client_id FROM jobs ORDER BY created_at LIMIT 1`,
|
||||
);
|
||||
const row = rows[0];
|
||||
if (!row) throw new Error('No seeded job found — run `pnpm db:seed` first');
|
||||
jobId = row.id;
|
||||
clientId = row.client_id;
|
||||
|
||||
// Each test starts from a clean deck.
|
||||
await db.execute(sql`DELETE FROM swipes WHERE job_id = ${jobId}`);
|
||||
await db.execute(sql`DELETE FROM requests WHERE job_id = ${jobId}`);
|
||||
});
|
||||
|
||||
describe('getDeck', () => {
|
||||
it('returns exactly the eligible plumbers for a job at the city centre', async () => {
|
||||
const deck = await getDeck(db, { jobId });
|
||||
const names = deck.map((c) => c.name).sort();
|
||||
|
||||
expect(names).toEqual(['Ana Ferrer', 'Jordi Puig', 'Marc Oliveras', 'Nil Bosch', 'Nuria Sala']);
|
||||
});
|
||||
|
||||
it('excludes a pro whose service radius does not reach the job', async () => {
|
||||
// Pau Ribas is 22km away but only travels 5km.
|
||||
const deck = await getDeck(db, { jobId });
|
||||
expect(deck.map((c) => c.name)).not.toContain('Pau Ribas');
|
||||
});
|
||||
|
||||
it('excludes an unverified pro even though they are 1km away', async () => {
|
||||
const deck = await getDeck(db, { jobId });
|
||||
expect(deck.map((c) => c.name)).not.toContain('Unverified Ulla');
|
||||
});
|
||||
|
||||
it('excludes a verified pro who is not accepting jobs', async () => {
|
||||
const deck = await getDeck(db, { jobId });
|
||||
expect(deck.map((c) => c.name)).not.toContain('Away Arnau');
|
||||
});
|
||||
|
||||
it('excludes pros from other trades', async () => {
|
||||
const deck = await getDeck(db, { jobId });
|
||||
for (const card of deck) {
|
||||
expect(card.categories).toContain('Plumber');
|
||||
}
|
||||
});
|
||||
|
||||
it('reports distance in metres, ascending-ish and sane', async () => {
|
||||
const deck = await getDeck(db, { jobId });
|
||||
const marc = deck.find((c) => c.name === 'Marc Oliveras');
|
||||
expect(marc).toBeDefined();
|
||||
expect(marc!.distanceM).toBeGreaterThan(700);
|
||||
expect(marc!.distanceM).toBeLessThan(900);
|
||||
});
|
||||
|
||||
it('ranks a well-reviewed nearby pro above a distant one with a single review', async () => {
|
||||
const deck = await getDeck(db, { jobId });
|
||||
const marc = deck.findIndex((c) => c.name === 'Marc Oliveras'); // 800m, 4.9 x47
|
||||
const nuria = deck.findIndex((c) => c.name === 'Nuria Sala'); // 18km, 5.0 x3
|
||||
expect(marc).toBeLessThan(nuria);
|
||||
});
|
||||
|
||||
it('does not bury a brand-new unrated pro at the bottom', async () => {
|
||||
const deck = await getDeck(db, { jobId });
|
||||
const nil = deck.findIndex((c) => c.name === 'Nil Bosch');
|
||||
expect(nil).toBeGreaterThanOrEqual(0);
|
||||
expect(nil).toBeLessThan(deck.length - 1);
|
||||
});
|
||||
|
||||
it('carries the media and rating a card needs to render', async () => {
|
||||
const deck = await getDeck(db, { jobId });
|
||||
const card = deck.find((c) => c.name === 'Marc Oliveras')!;
|
||||
expect(card.photos.length).toBeGreaterThan(0);
|
||||
expect(card.ratingAvg).toBeCloseTo(4.9, 1);
|
||||
expect(card.ratingCount).toBe(47);
|
||||
expect(card.hourlyRateCents).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('never shows a card the client already swiped on', async () => {
|
||||
const before = await getDeck(db, { jobId });
|
||||
const target = before[0]!;
|
||||
|
||||
await db.insert(schema.swipes).values({
|
||||
jobId,
|
||||
proId: target.proId,
|
||||
direction: 'left',
|
||||
});
|
||||
|
||||
const after = await getDeck(db, { jobId });
|
||||
expect(after.map((c) => c.proId)).not.toContain(target.proId);
|
||||
expect(after).toHaveLength(before.length - 1);
|
||||
|
||||
await db.execute(sql`DELETE FROM swipes WHERE job_id = ${jobId} AND pro_id = ${target.proId}`);
|
||||
});
|
||||
|
||||
it('never shows a pro who already has a request for this job', async () => {
|
||||
const before = await getDeck(db, { jobId });
|
||||
const target = before[0]!;
|
||||
|
||||
await db.insert(schema.requests).values({
|
||||
jobId,
|
||||
proId: target.proId,
|
||||
expiresAt: new Date(Date.now() + 12 * 3_600_000),
|
||||
});
|
||||
|
||||
const after = await getDeck(db, { jobId });
|
||||
expect(after.map((c) => c.proId)).not.toContain(target.proId);
|
||||
|
||||
await db.execute(sql`DELETE FROM requests WHERE job_id = ${jobId} AND pro_id = ${target.proId}`);
|
||||
});
|
||||
|
||||
it('respects the page limit', async () => {
|
||||
const deck = await getDeck(db, { jobId, limit: 2 });
|
||||
expect(deck).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('scores every card in 0..1', async () => {
|
||||
const deck = await getDeck(db, { jobId });
|
||||
for (const card of deck) {
|
||||
expect(card.score).toBeGreaterThan(0);
|
||||
expect(card.score).toBeLessThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns the cards sorted by score, highest first', async () => {
|
||||
const deck = await getDeck(db, { jobId });
|
||||
const scores = deck.map((c) => c.score);
|
||||
expect(scores).toEqual([...scores].sort((a, b) => b - a));
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDeckCount', () => {
|
||||
it('agrees with the deck length', async () => {
|
||||
const [deck, count] = await Promise.all([getDeck(db, { jobId }), getDeckCount(db, jobId)]);
|
||||
expect(count).toBe(deck.length);
|
||||
});
|
||||
|
||||
it('drops as the client swipes', async () => {
|
||||
const before = await getDeckCount(db, jobId);
|
||||
const deck = await getDeck(db, { jobId });
|
||||
const target = deck[0]!;
|
||||
|
||||
await db.insert(schema.swipes).values({ jobId, proId: target.proId, direction: 'right' });
|
||||
expect(await getDeckCount(db, jobId)).toBe(before - 1);
|
||||
|
||||
await db.execute(sql`DELETE FROM swipes WHERE job_id = ${jobId} AND pro_id = ${target.proId}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PostGIS round-trip', () => {
|
||||
it('reads back the exact coordinates it wrote', async () => {
|
||||
const rows = await db.select().from(schema.jobs).limit(1);
|
||||
const job = rows[0]!;
|
||||
expect(job.location.lat).toBeCloseTo(Number(process.env.NEXT_PUBLIC_CITY_LAT ?? 41.3874), 4);
|
||||
expect(job.location.lng).toBeCloseTo(Number(process.env.NEXT_PUBLIC_CITY_LNG ?? 2.1686), 4);
|
||||
});
|
||||
|
||||
it('never puts the client on their own deck', async () => {
|
||||
const deck = await getDeck(db, { jobId });
|
||||
expect(deck.map((c) => c.proId)).not.toContain(clientId);
|
||||
});
|
||||
});
|
||||
|
||||
// Vitest hangs on an open pool otherwise.
|
||||
afterAll(async () => {
|
||||
await closePool();
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": { "noEmit": true },
|
||||
"include": ["src/**/*.ts", "drizzle.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['test/**/*.test.ts'],
|
||||
// Integration tests share one seeded database — running them in parallel
|
||||
// would have them deleting each other's swipes.
|
||||
fileParallelism: false,
|
||||
sequence: { concurrent: false },
|
||||
testTimeout: 20_000,
|
||||
hookTimeout: 20_000,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@linkder/shared",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": { ".": "./src/index.ts" },
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^2.1.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { FREE_CANCELLATION_HOURS, LATE_CANCELLATION_FEE_BPS } from './constants';
|
||||
import type { Cents } from './money';
|
||||
import { platformFee } from './money';
|
||||
|
||||
export type CancelledBy = 'client' | 'pro' | 'admin';
|
||||
|
||||
export interface CancellationOutcome {
|
||||
/** Returned to the client. */
|
||||
refundCents: Cents;
|
||||
/** Kept by the platform and/or paid to the pro as a late-cancellation fee. */
|
||||
feeCents: Cents;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Who eats the cost of a cancellation.
|
||||
*
|
||||
* A pro cancelling is always a full refund — they took the slot and dropped it,
|
||||
* that is not the client's problem. A client cancelling inside the window pays a
|
||||
* fee, because the pro has already turned down other work for that slot.
|
||||
*/
|
||||
export function cancellationOutcome(args: {
|
||||
amountCents: Cents;
|
||||
scheduledStart: Date;
|
||||
cancelledBy: CancelledBy;
|
||||
now?: Date;
|
||||
}): CancellationOutcome {
|
||||
const { amountCents, scheduledStart, cancelledBy } = args;
|
||||
const now = args.now ?? new Date();
|
||||
|
||||
if (cancelledBy !== 'client') {
|
||||
return {
|
||||
refundCents: amountCents,
|
||||
feeCents: 0,
|
||||
reason: `Cancelled by ${cancelledBy} — full refund`,
|
||||
};
|
||||
}
|
||||
|
||||
const hoursUntil = (scheduledStart.getTime() - now.getTime()) / 3_600_000;
|
||||
if (hoursUntil >= FREE_CANCELLATION_HOURS) {
|
||||
return {
|
||||
refundCents: amountCents,
|
||||
feeCents: 0,
|
||||
reason: `Cancelled ${Math.floor(hoursUntil)}h ahead — free cancellation`,
|
||||
};
|
||||
}
|
||||
|
||||
const feeCents = platformFee(amountCents, LATE_CANCELLATION_FEE_BPS);
|
||||
return {
|
||||
refundCents: amountCents - feeCents,
|
||||
feeCents,
|
||||
reason: `Cancelled inside the ${FREE_CANCELLATION_HOURS}h window — late cancellation fee applies`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/** Product rules that get tuned. Keep them in one place — you will change these weekly. */
|
||||
|
||||
/** Max open (pending) requests a client can have out on a single job. Stops city-spraying. */
|
||||
export const MAX_OPEN_REQUESTS_PER_JOB = 5;
|
||||
|
||||
/** How long a pro has to respond before a request auto-expires. */
|
||||
export const REQUEST_TTL_HOURS = {
|
||||
now: 12,
|
||||
this_week: 48,
|
||||
flexible: 48,
|
||||
} as const;
|
||||
|
||||
/** Client has this long to confirm completion before it auto-confirms and pays out. */
|
||||
export const AUTO_CONFIRM_HOURS = 72;
|
||||
|
||||
/** A quote is only good for this long. */
|
||||
export const QUOTE_VALIDITY_HOURS = 72;
|
||||
|
||||
/** Cards prefetched per deck page. */
|
||||
export const DECK_PAGE_SIZE = 20;
|
||||
|
||||
/** Free cancellation window before the booked slot. Inside it, a fee applies. */
|
||||
export const FREE_CANCELLATION_HOURS = 24;
|
||||
export const LATE_CANCELLATION_FEE_BPS = 2500; // 25% of the quote
|
||||
|
||||
/** Default platform commission. Overridden by PLATFORM_FEE_BPS env at runtime. */
|
||||
export const DEFAULT_PLATFORM_FEE_BPS = 1500; // 15%
|
||||
|
||||
export const MIN_QUOTE_CENTS = 500; // €5 — below this, escrow overhead isn't worth it
|
||||
export const MAX_QUOTE_CENTS = 2_000_000; // €20,000 sanity ceiling
|
||||
|
||||
/** Pro service radius bounds, metres. */
|
||||
export const MIN_SERVICE_RADIUS_M = 1_000;
|
||||
export const MAX_SERVICE_RADIUS_M = 50_000;
|
||||
export const DEFAULT_SERVICE_RADIUS_M = 15_000;
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './constants';
|
||||
export * from './money';
|
||||
export * from './state-machines';
|
||||
export * from './ranking';
|
||||
export * from './cancellation';
|
||||
export * from './schemas';
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Money is always integer minor units (cents). Never floats — 0.1 + 0.2 problems
|
||||
* become customer support tickets when they happen to someone's payout.
|
||||
*/
|
||||
|
||||
export type Cents = number;
|
||||
|
||||
export class MoneyError extends Error {}
|
||||
|
||||
export function assertCents(value: number, label = 'amount'): asserts value is Cents {
|
||||
if (!Number.isInteger(value)) throw new MoneyError(`${label} must be an integer, got ${value}`);
|
||||
if (value < 0) throw new MoneyError(`${label} must not be negative, got ${value}`);
|
||||
if (!Number.isSafeInteger(value)) throw new MoneyError(`${label} exceeds safe integer range`);
|
||||
}
|
||||
|
||||
/** Basis points: 1500 bps = 15%. */
|
||||
export type Bps = number;
|
||||
|
||||
/**
|
||||
* Platform commission. Rounds half-up so the platform never takes a fraction of a
|
||||
* cent more than stated, and the pro's share absorbs the remainder.
|
||||
*/
|
||||
export function platformFee(amount: Cents, feeBps: Bps): Cents {
|
||||
assertCents(amount);
|
||||
if (!Number.isInteger(feeBps) || feeBps < 0 || feeBps > 10_000) {
|
||||
throw new MoneyError(`feeBps must be an integer in [0, 10000], got ${feeBps}`);
|
||||
}
|
||||
return Math.round((amount * feeBps) / 10_000);
|
||||
}
|
||||
|
||||
/** What actually lands in the pro's connected account. */
|
||||
export function proPayout(amount: Cents, feeBps: Bps): Cents {
|
||||
return amount - platformFee(amount, feeBps);
|
||||
}
|
||||
|
||||
/** Split a charge into its parts. The two always sum back to `amount`. */
|
||||
export function splitCharge(amount: Cents, feeBps: Bps): { fee: Cents; payout: Cents } {
|
||||
const fee = platformFee(amount, feeBps);
|
||||
return { fee, payout: amount - fee };
|
||||
}
|
||||
|
||||
export function formatCents(amount: Cents, currency = 'EUR', locale = 'en-IE'): string {
|
||||
return new Intl.NumberFormat(locale, { style: 'currency', currency }).format(amount / 100);
|
||||
}
|
||||
|
||||
export function parseAmountToCents(input: string): Cents {
|
||||
const normalised = input.replace(/[^0-9.,-]/g, '').replace(',', '.');
|
||||
const parsed = Number.parseFloat(normalised);
|
||||
if (Number.isNaN(parsed)) throw new MoneyError(`Cannot parse "${input}" as an amount`);
|
||||
return Math.round(parsed * 100);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Deck ranking. Every pro shown to a client is scored here.
|
||||
*
|
||||
* These weights are the product. Expect to tune them weekly against booking
|
||||
* conversion — that is why they are one exported object and not sprinkled
|
||||
* through a SQL string.
|
||||
*/
|
||||
|
||||
export const RANKING_WEIGHTS = {
|
||||
rating: 0.35,
|
||||
responseRate: 0.25,
|
||||
proximity: 0.2,
|
||||
recency: 0.1,
|
||||
newProBoost: 0.1,
|
||||
} as const;
|
||||
|
||||
/** A pro with no reviews yet is treated as this rating, so they aren't buried at 0. */
|
||||
export const UNRATED_BASELINE = 4.0;
|
||||
/** Reviews needed before a pro's real rating fully replaces the baseline. */
|
||||
export const RATING_CONFIDENCE_N = 5;
|
||||
/** New pros get a decaying boost for this long so fresh supply gets seen. */
|
||||
export const NEW_PRO_GRACE_DAYS = 30;
|
||||
|
||||
export interface RankingInput {
|
||||
ratingAvg: number | null;
|
||||
ratingCount: number;
|
||||
/** 0..1 — accepted or declined within TTL, vs let expire. */
|
||||
responseRate: number | null;
|
||||
distanceM: number;
|
||||
serviceRadiusM: number;
|
||||
lastActiveAt: Date | null;
|
||||
createdAt: Date;
|
||||
now?: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bayesian-smoothed rating: pulls a 5.0-from-one-review pro back toward the
|
||||
* baseline until they have a real track record. Without this, the deck is topped
|
||||
* by whoever got a single review from a friend.
|
||||
*/
|
||||
export function smoothedRating(ratingAvg: number | null, ratingCount: number): number {
|
||||
if (ratingAvg === null || ratingCount === 0) return UNRATED_BASELINE;
|
||||
const n = RATING_CONFIDENCE_N;
|
||||
return (ratingAvg * ratingCount + UNRATED_BASELINE * n) / (ratingCount + n);
|
||||
}
|
||||
|
||||
/** Linear falloff across the pro's own radius — near the edge is worth less than next door. */
|
||||
export function proximityScore(distanceM: number, serviceRadiusM: number): number {
|
||||
if (serviceRadiusM <= 0) return 0;
|
||||
return clamp01(1 - distanceM / serviceRadiusM);
|
||||
}
|
||||
|
||||
/** Active today = 1, decaying to 0 over two weeks. Dormant pros don't respond. */
|
||||
export function recencyScore(lastActiveAt: Date | null, now: Date): number {
|
||||
if (!lastActiveAt) return 0;
|
||||
const days = (now.getTime() - lastActiveAt.getTime()) / 86_400_000;
|
||||
return clamp01(1 - days / 14);
|
||||
}
|
||||
|
||||
/** Decaying head start for pros in their first month. */
|
||||
export function newProBoost(createdAt: Date, now: Date): number {
|
||||
const days = (now.getTime() - createdAt.getTime()) / 86_400_000;
|
||||
return clamp01(1 - days / NEW_PRO_GRACE_DAYS);
|
||||
}
|
||||
|
||||
/** Final deck score, 0..1. Higher sorts first. */
|
||||
export function score(input: RankingInput): number {
|
||||
const now = input.now ?? new Date();
|
||||
const w = RANKING_WEIGHTS;
|
||||
return (
|
||||
w.rating * (smoothedRating(input.ratingAvg, input.ratingCount) / 5) +
|
||||
w.responseRate * clamp01(input.responseRate ?? 0.5) +
|
||||
w.proximity * proximityScore(input.distanceM, input.serviceRadiusM) +
|
||||
w.recency * recencyScore(input.lastActiveAt, now) +
|
||||
w.newProBoost * newProBoost(input.createdAt, now)
|
||||
);
|
||||
}
|
||||
|
||||
function clamp01(n: number): number {
|
||||
if (Number.isNaN(n)) return 0;
|
||||
return Math.min(1, Math.max(0, n));
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
MAX_QUOTE_CENTS,
|
||||
MAX_SERVICE_RADIUS_M,
|
||||
MIN_QUOTE_CENTS,
|
||||
MIN_SERVICE_RADIUS_M,
|
||||
} from './constants';
|
||||
|
||||
export const urgencySchema = z.enum(['now', 'this_week', 'flexible']);
|
||||
export type Urgency = z.infer<typeof urgencySchema>;
|
||||
|
||||
export const roleSchema = z.enum(['client', 'pro', 'admin']);
|
||||
export type Role = z.infer<typeof roleSchema>;
|
||||
|
||||
export const swipeDirectionSchema = z.enum(['left', 'right']);
|
||||
export type SwipeDirection = z.infer<typeof swipeDirectionSchema>;
|
||||
|
||||
export const latLngSchema = z.object({
|
||||
lat: z.number().min(-90).max(90),
|
||||
lng: z.number().min(-180).max(180),
|
||||
});
|
||||
export type LatLng = z.infer<typeof latLngSchema>;
|
||||
|
||||
export const centsSchema = z.number().int().nonnegative();
|
||||
|
||||
export const createJobSchema = z
|
||||
.object({
|
||||
categoryId: z.string().uuid(),
|
||||
title: z.string().min(5).max(120),
|
||||
description: z.string().min(20).max(4000),
|
||||
photos: z.array(z.string().url()).max(8).default([]),
|
||||
urgency: urgencySchema,
|
||||
budgetMinCents: centsSchema.optional(),
|
||||
budgetMaxCents: centsSchema.optional(),
|
||||
location: latLngSchema,
|
||||
addressText: z.string().min(3).max(255),
|
||||
})
|
||||
.refine(
|
||||
(v) =>
|
||||
v.budgetMinCents === undefined ||
|
||||
v.budgetMaxCents === undefined ||
|
||||
v.budgetMinCents <= v.budgetMaxCents,
|
||||
{ message: 'Minimum budget cannot exceed maximum', path: ['budgetMinCents'] },
|
||||
);
|
||||
export type CreateJobInput = z.infer<typeof createJobSchema>;
|
||||
|
||||
export const proProfileSchema = z.object({
|
||||
headline: z.string().min(5).max(100),
|
||||
bio: z.string().min(30).max(2000),
|
||||
hourlyRateCents: centsSchema.max(MAX_QUOTE_CENTS),
|
||||
yearsExperience: z.number().int().min(0).max(70),
|
||||
categoryIds: z.array(z.string().uuid()).min(1, 'Pick at least one trade').max(5),
|
||||
location: latLngSchema,
|
||||
serviceRadiusM: z.number().int().min(MIN_SERVICE_RADIUS_M).max(MAX_SERVICE_RADIUS_M),
|
||||
});
|
||||
export type ProProfileInput = z.infer<typeof proProfileSchema>;
|
||||
|
||||
export const swipeSchema = z.object({
|
||||
jobId: z.string().uuid(),
|
||||
proId: z.string().uuid(),
|
||||
direction: swipeDirectionSchema,
|
||||
});
|
||||
export type SwipeInput = z.infer<typeof swipeSchema>;
|
||||
|
||||
export const createQuoteSchema = z
|
||||
.object({
|
||||
matchId: z.string().uuid(),
|
||||
kind: z.enum(['fixed', 'hourly']),
|
||||
amountCents: centsSchema.min(MIN_QUOTE_CENTS).max(MAX_QUOTE_CENTS),
|
||||
hoursEstimate: z.number().positive().max(1000).optional(),
|
||||
scope: z.string().min(10).max(2000),
|
||||
})
|
||||
.refine((v) => v.kind !== 'hourly' || v.hoursEstimate !== undefined, {
|
||||
message: 'An hourly quote needs an hours estimate',
|
||||
path: ['hoursEstimate'],
|
||||
});
|
||||
export type CreateQuoteInput = z.infer<typeof createQuoteSchema>;
|
||||
|
||||
export const createBookingSchema = z
|
||||
.object({
|
||||
matchId: z.string().uuid(),
|
||||
quoteId: z.string().uuid(),
|
||||
scheduledStart: z.coerce.date(),
|
||||
scheduledEnd: z.coerce.date(),
|
||||
})
|
||||
.refine((v) => v.scheduledEnd > v.scheduledStart, {
|
||||
message: 'End must be after start',
|
||||
path: ['scheduledEnd'],
|
||||
})
|
||||
.refine((v) => v.scheduledStart.getTime() > Date.now() - 60_000, {
|
||||
message: 'Cannot book a slot in the past',
|
||||
path: ['scheduledStart'],
|
||||
});
|
||||
export type CreateBookingInput = z.infer<typeof createBookingSchema>;
|
||||
|
||||
export const createReviewSchema = z.object({
|
||||
bookingId: z.string().uuid(),
|
||||
rating: z.number().int().min(1).max(5),
|
||||
body: z.string().min(10).max(1500),
|
||||
});
|
||||
export type CreateReviewInput = z.infer<typeof createReviewSchema>;
|
||||
|
||||
export const sendMessageSchema = z.object({
|
||||
matchId: z.string().uuid(),
|
||||
body: z.string().min(1).max(4000),
|
||||
attachments: z.array(z.string().url()).max(5).default([]),
|
||||
});
|
||||
export type SendMessageInput = z.infer<typeof sendMessageSchema>;
|
||||
|
||||
export const credentialSchema = z.object({
|
||||
kind: z.enum(['id', 'licence', 'insurance']),
|
||||
fileUrl: z.string().url(),
|
||||
issuer: z.string().max(120).optional(),
|
||||
expiresAt: z.coerce.date().optional(),
|
||||
});
|
||||
export type CredentialInput = z.infer<typeof credentialSchema>;
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Every status transition in the product lives here as a pure function.
|
||||
* tRPC mutations MUST route through `assertTransition` — nothing gets to jump
|
||||
* from `scheduled` straight to `completed` because a client sent a crafted payload.
|
||||
*/
|
||||
|
||||
export class TransitionError extends Error {
|
||||
constructor(
|
||||
readonly entity: string,
|
||||
readonly from: string,
|
||||
readonly to: string,
|
||||
) {
|
||||
super(`Illegal ${entity} transition: ${from} -> ${to}`);
|
||||
this.name = 'TransitionError';
|
||||
}
|
||||
}
|
||||
|
||||
type Graph<T extends string> = Readonly<Record<T, readonly T[]>>;
|
||||
|
||||
export const JOB_STATUSES = ['open', 'matched', 'booked', 'completed', 'cancelled'] as const;
|
||||
export type JobStatus = (typeof JOB_STATUSES)[number];
|
||||
|
||||
export const REQUEST_STATUSES = ['pending', 'accepted', 'declined', 'expired'] as const;
|
||||
export type RequestStatus = (typeof REQUEST_STATUSES)[number];
|
||||
|
||||
export const QUOTE_STATUSES = ['sent', 'accepted', 'declined', 'withdrawn', 'expired'] as const;
|
||||
export type QuoteStatus = (typeof QUOTE_STATUSES)[number];
|
||||
|
||||
export const BOOKING_STATUSES = [
|
||||
'scheduled',
|
||||
'in_progress',
|
||||
'awaiting_confirmation',
|
||||
'completed',
|
||||
'cancelled',
|
||||
'disputed',
|
||||
] as const;
|
||||
export type BookingStatus = (typeof BOOKING_STATUSES)[number];
|
||||
|
||||
export const PAYMENT_STATUSES = [
|
||||
'pending',
|
||||
'held',
|
||||
'released',
|
||||
'refunded',
|
||||
'partially_refunded',
|
||||
'failed',
|
||||
] as const;
|
||||
export type PaymentStatus = (typeof PAYMENT_STATUSES)[number];
|
||||
|
||||
export const VERIFICATION_STATUSES = [
|
||||
'draft',
|
||||
'pending',
|
||||
'verified',
|
||||
'rejected',
|
||||
'suspended',
|
||||
] as const;
|
||||
export type VerificationStatus = (typeof VERIFICATION_STATUSES)[number];
|
||||
|
||||
const JOB_GRAPH: Graph<JobStatus> = {
|
||||
open: ['matched', 'cancelled'],
|
||||
matched: ['booked', 'open', 'cancelled'], // back to `open` when every match falls through
|
||||
booked: ['completed', 'cancelled'],
|
||||
completed: [],
|
||||
cancelled: [],
|
||||
};
|
||||
|
||||
const REQUEST_GRAPH: Graph<RequestStatus> = {
|
||||
pending: ['accepted', 'declined', 'expired'],
|
||||
accepted: [],
|
||||
declined: [],
|
||||
expired: [],
|
||||
};
|
||||
|
||||
const QUOTE_GRAPH: Graph<QuoteStatus> = {
|
||||
sent: ['accepted', 'declined', 'withdrawn', 'expired'],
|
||||
accepted: [],
|
||||
declined: [],
|
||||
withdrawn: [],
|
||||
expired: [],
|
||||
};
|
||||
|
||||
const BOOKING_GRAPH: Graph<BookingStatus> = {
|
||||
scheduled: ['in_progress', 'cancelled'],
|
||||
in_progress: ['awaiting_confirmation', 'cancelled', 'disputed'],
|
||||
awaiting_confirmation: ['completed', 'disputed'],
|
||||
completed: ['disputed'], // a dispute can still be raised inside the window
|
||||
cancelled: [],
|
||||
disputed: ['completed', 'cancelled'],
|
||||
};
|
||||
|
||||
const PAYMENT_GRAPH: Graph<PaymentStatus> = {
|
||||
pending: ['held', 'failed'],
|
||||
held: ['released', 'refunded', 'partially_refunded'],
|
||||
released: ['refunded', 'partially_refunded'],
|
||||
refunded: [],
|
||||
partially_refunded: ['refunded'],
|
||||
failed: ['pending'],
|
||||
};
|
||||
|
||||
const VERIFICATION_GRAPH: Graph<VerificationStatus> = {
|
||||
draft: ['pending'],
|
||||
pending: ['verified', 'rejected'],
|
||||
verified: ['suspended'],
|
||||
rejected: ['pending'],
|
||||
suspended: ['verified', 'rejected'],
|
||||
};
|
||||
|
||||
const GRAPHS = {
|
||||
job: JOB_GRAPH,
|
||||
request: REQUEST_GRAPH,
|
||||
quote: QUOTE_GRAPH,
|
||||
booking: BOOKING_GRAPH,
|
||||
payment: PAYMENT_GRAPH,
|
||||
verification: VERIFICATION_GRAPH,
|
||||
} as const;
|
||||
|
||||
export type Entity = keyof typeof GRAPHS;
|
||||
|
||||
export function canTransition(entity: Entity, from: string, to: string): boolean {
|
||||
const graph = GRAPHS[entity] as Graph<string>;
|
||||
const allowed = graph[from];
|
||||
return allowed !== undefined && allowed.includes(to);
|
||||
}
|
||||
|
||||
export function assertTransition(entity: Entity, from: string, to: string): void {
|
||||
if (!canTransition(entity, from, to)) throw new TransitionError(entity, from, to);
|
||||
}
|
||||
|
||||
export function nextStates(entity: Entity, from: string): readonly string[] {
|
||||
const graph = GRAPHS[entity] as Graph<string>;
|
||||
return graph[from] ?? [];
|
||||
}
|
||||
|
||||
export function isTerminal(entity: Entity, state: string): boolean {
|
||||
return nextStates(entity, state).length === 0;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { cancellationOutcome } from '../src/cancellation';
|
||||
|
||||
const NOW = new Date('2026-06-01T12:00:00Z');
|
||||
const inHours = (h: number) => new Date(NOW.getTime() + h * 3_600_000);
|
||||
|
||||
describe('cancellationOutcome', () => {
|
||||
it('refunds in full when the client cancels well ahead', () => {
|
||||
const out = cancellationOutcome({
|
||||
amountCents: 20_000,
|
||||
scheduledStart: inHours(48),
|
||||
cancelledBy: 'client',
|
||||
now: NOW,
|
||||
});
|
||||
expect(out.refundCents).toBe(20_000);
|
||||
expect(out.feeCents).toBe(0);
|
||||
});
|
||||
|
||||
it('charges a fee when the client cancels inside the window', () => {
|
||||
const out = cancellationOutcome({
|
||||
amountCents: 20_000,
|
||||
scheduledStart: inHours(3),
|
||||
cancelledBy: 'client',
|
||||
now: NOW,
|
||||
});
|
||||
expect(out.feeCents).toBe(5_000); // 25%
|
||||
expect(out.refundCents).toBe(15_000);
|
||||
});
|
||||
|
||||
it('treats exactly 24h out as still free', () => {
|
||||
const out = cancellationOutcome({
|
||||
amountCents: 20_000,
|
||||
scheduledStart: inHours(24),
|
||||
cancelledBy: 'client',
|
||||
now: NOW,
|
||||
});
|
||||
expect(out.feeCents).toBe(0);
|
||||
});
|
||||
|
||||
it('never charges the client when the pro drops the job', () => {
|
||||
const out = cancellationOutcome({
|
||||
amountCents: 20_000,
|
||||
scheduledStart: inHours(1),
|
||||
cancelledBy: 'pro',
|
||||
now: NOW,
|
||||
});
|
||||
expect(out.refundCents).toBe(20_000);
|
||||
expect(out.feeCents).toBe(0);
|
||||
});
|
||||
|
||||
it('refunds in full on an admin cancellation', () => {
|
||||
const out = cancellationOutcome({
|
||||
amountCents: 20_000,
|
||||
scheduledStart: inHours(1),
|
||||
cancelledBy: 'admin',
|
||||
now: NOW,
|
||||
});
|
||||
expect(out.refundCents).toBe(20_000);
|
||||
});
|
||||
|
||||
it('always splits the full amount, whatever the branch', () => {
|
||||
for (const hours of [-5, 0, 1, 23.9, 24, 100]) {
|
||||
const out = cancellationOutcome({
|
||||
amountCents: 12_345,
|
||||
scheduledStart: inHours(hours),
|
||||
cancelledBy: 'client',
|
||||
now: NOW,
|
||||
});
|
||||
expect(out.refundCents + out.feeCents).toBe(12_345);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { MoneyError, formatCents, parseAmountToCents, platformFee, proPayout, splitCharge } from '../src/money';
|
||||
|
||||
describe('platformFee', () => {
|
||||
it('takes the stated percentage', () => {
|
||||
expect(platformFee(10_000, 1500)).toBe(1500);
|
||||
expect(platformFee(15_000, 1200)).toBe(1800);
|
||||
});
|
||||
|
||||
it('rounds half-up to whole cents', () => {
|
||||
expect(platformFee(333, 1500)).toBe(50); // 49.95 -> 50
|
||||
expect(platformFee(1, 1500)).toBe(0); // 0.15 -> 0
|
||||
});
|
||||
|
||||
it('handles the boundaries', () => {
|
||||
expect(platformFee(10_000, 0)).toBe(0);
|
||||
expect(platformFee(10_000, 10_000)).toBe(10_000);
|
||||
});
|
||||
|
||||
it('rejects nonsense rates', () => {
|
||||
expect(() => platformFee(10_000, -1)).toThrow(MoneyError);
|
||||
expect(() => platformFee(10_000, 10_001)).toThrow(MoneyError);
|
||||
expect(() => platformFee(10_000, 12.5)).toThrow(MoneyError);
|
||||
});
|
||||
|
||||
it('rejects non-integer amounts — floats never reach Stripe', () => {
|
||||
expect(() => platformFee(99.99, 1500)).toThrow(MoneyError);
|
||||
expect(() => platformFee(-100, 1500)).toThrow(MoneyError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('splitCharge', () => {
|
||||
it('always sums back to the original amount', () => {
|
||||
for (const amount of [1, 7, 333, 999, 10_000, 123_457]) {
|
||||
const { fee, payout } = splitCharge(amount, 1500);
|
||||
expect(fee + payout).toBe(amount);
|
||||
}
|
||||
});
|
||||
|
||||
it('agrees with proPayout', () => {
|
||||
expect(splitCharge(20_000, 1500).payout).toBe(proPayout(20_000, 1500));
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseAmountToCents', () => {
|
||||
it('parses the shapes a human types', () => {
|
||||
expect(parseAmountToCents('150')).toBe(15_000);
|
||||
expect(parseAmountToCents('150.50')).toBe(15_050);
|
||||
expect(parseAmountToCents('150,50')).toBe(15_050);
|
||||
expect(parseAmountToCents('€150.50')).toBe(15_050);
|
||||
});
|
||||
|
||||
it('rounds to the nearest cent rather than truncating', () => {
|
||||
expect(parseAmountToCents('10.999')).toBe(1100);
|
||||
});
|
||||
|
||||
it('throws on junk', () => {
|
||||
expect(() => parseAmountToCents('abc')).toThrow(MoneyError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatCents', () => {
|
||||
it('renders whole currency units', () => {
|
||||
expect(formatCents(15_000)).toContain('150');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
UNRATED_BASELINE,
|
||||
newProBoost,
|
||||
proximityScore,
|
||||
recencyScore,
|
||||
score,
|
||||
smoothedRating,
|
||||
} from '../src/ranking';
|
||||
|
||||
const NOW = new Date('2026-06-01T12:00:00Z');
|
||||
const daysAgo = (n: number) => new Date(NOW.getTime() - n * 86_400_000);
|
||||
|
||||
describe('smoothedRating', () => {
|
||||
it('falls back to the baseline for an unrated pro', () => {
|
||||
expect(smoothedRating(null, 0)).toBe(UNRATED_BASELINE);
|
||||
});
|
||||
|
||||
it('does not let one 5-star review top the deck', () => {
|
||||
const oneReview = smoothedRating(5, 1);
|
||||
const manyReviews = smoothedRating(4.8, 50);
|
||||
expect(oneReview).toBeLessThan(manyReviews);
|
||||
});
|
||||
|
||||
it('converges on the true rating as reviews accumulate', () => {
|
||||
expect(smoothedRating(4.8, 500)).toBeCloseTo(4.8, 1);
|
||||
});
|
||||
|
||||
it('pulls a single bad review up toward the baseline too', () => {
|
||||
expect(smoothedRating(1, 1)).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('proximityScore', () => {
|
||||
it('is 1 next door and 0 at the radius edge', () => {
|
||||
expect(proximityScore(0, 10_000)).toBe(1);
|
||||
expect(proximityScore(10_000, 10_000)).toBe(0);
|
||||
});
|
||||
|
||||
it('clamps beyond the radius rather than going negative', () => {
|
||||
expect(proximityScore(50_000, 10_000)).toBe(0);
|
||||
});
|
||||
|
||||
it('handles a zero radius without dividing by zero', () => {
|
||||
expect(proximityScore(100, 0)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recencyScore', () => {
|
||||
it('rewards active pros and decays dormant ones to zero', () => {
|
||||
expect(recencyScore(NOW, NOW)).toBe(1);
|
||||
expect(recencyScore(daysAgo(7), NOW)).toBeCloseTo(0.5, 1);
|
||||
expect(recencyScore(daysAgo(30), NOW)).toBe(0);
|
||||
});
|
||||
|
||||
it('scores a pro who has never been active at zero', () => {
|
||||
expect(recencyScore(null, NOW)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('newProBoost', () => {
|
||||
it('gives fresh supply a decaying head start', () => {
|
||||
expect(newProBoost(NOW, NOW)).toBe(1);
|
||||
expect(newProBoost(daysAgo(15), NOW)).toBeCloseTo(0.5, 1);
|
||||
expect(newProBoost(daysAgo(60), NOW)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('score', () => {
|
||||
const base = {
|
||||
ratingAvg: 4.5,
|
||||
ratingCount: 20,
|
||||
responseRate: 0.9,
|
||||
distanceM: 2_000,
|
||||
serviceRadiusM: 10_000,
|
||||
lastActiveAt: NOW,
|
||||
createdAt: daysAgo(200),
|
||||
now: NOW,
|
||||
};
|
||||
|
||||
it('always lands in 0..1', () => {
|
||||
expect(score(base)).toBeGreaterThan(0);
|
||||
expect(score(base)).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('ranks the closer of two identical pros higher', () => {
|
||||
const near = score({ ...base, distanceM: 500 });
|
||||
const far = score({ ...base, distanceM: 9_000 });
|
||||
expect(near).toBeGreaterThan(far);
|
||||
});
|
||||
|
||||
it('ranks the more responsive of two identical pros higher', () => {
|
||||
expect(score({ ...base, responseRate: 0.95 })).toBeGreaterThan(
|
||||
score({ ...base, responseRate: 0.2 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not bury a brand-new pro beneath an established one', () => {
|
||||
const newbie = score({ ...base, ratingAvg: null, ratingCount: 0, responseRate: null, createdAt: NOW });
|
||||
expect(newbie).toBeGreaterThan(0.3);
|
||||
});
|
||||
|
||||
it('assumes an unknown response rate is average rather than terrible', () => {
|
||||
const unknown = score({ ...base, responseRate: null });
|
||||
const terrible = score({ ...base, responseRate: 0 });
|
||||
expect(unknown).toBeGreaterThan(terrible);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
BOOKING_STATUSES,
|
||||
TransitionError,
|
||||
assertTransition,
|
||||
canTransition,
|
||||
isTerminal,
|
||||
nextStates,
|
||||
} from '../src/state-machines';
|
||||
|
||||
describe('booking transitions', () => {
|
||||
it('walks the happy path', () => {
|
||||
expect(canTransition('booking', 'scheduled', 'in_progress')).toBe(true);
|
||||
expect(canTransition('booking', 'in_progress', 'awaiting_confirmation')).toBe(true);
|
||||
expect(canTransition('booking', 'awaiting_confirmation', 'completed')).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses to skip straight to completed — the payout guard', () => {
|
||||
expect(canTransition('booking', 'scheduled', 'completed')).toBe(false);
|
||||
expect(() => assertTransition('booking', 'scheduled', 'completed')).toThrow(TransitionError);
|
||||
});
|
||||
|
||||
it('cannot resurrect a cancelled booking', () => {
|
||||
expect(isTerminal('booking', 'cancelled')).toBe(true);
|
||||
expect(canTransition('booking', 'cancelled', 'scheduled')).toBe(false);
|
||||
});
|
||||
|
||||
it('still allows a dispute after completion', () => {
|
||||
expect(canTransition('booking', 'completed', 'disputed')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects unknown states instead of silently allowing them', () => {
|
||||
expect(canTransition('booking', 'nonsense', 'completed')).toBe(false);
|
||||
expect(nextStates('booking', 'nonsense')).toEqual([]);
|
||||
});
|
||||
|
||||
it('every declared status appears in the graph', () => {
|
||||
for (const status of BOOKING_STATUSES) {
|
||||
expect(() => nextStates('booking', status)).not.toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('payment transitions', () => {
|
||||
it('holds before it releases', () => {
|
||||
expect(canTransition('payment', 'pending', 'held')).toBe(true);
|
||||
expect(canTransition('payment', 'held', 'released')).toBe(true);
|
||||
});
|
||||
|
||||
it('never releases straight from pending — money only moves after capture', () => {
|
||||
expect(canTransition('payment', 'pending', 'released')).toBe(false);
|
||||
});
|
||||
|
||||
it('lets a failed payment be retried', () => {
|
||||
expect(canTransition('payment', 'failed', 'pending')).toBe(true);
|
||||
});
|
||||
|
||||
it('treats a full refund as final', () => {
|
||||
expect(isTerminal('payment', 'refunded')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verification transitions', () => {
|
||||
it('requires review before a pro is verified', () => {
|
||||
expect(canTransition('verification', 'draft', 'verified')).toBe(false);
|
||||
expect(canTransition('verification', 'draft', 'pending')).toBe(true);
|
||||
expect(canTransition('verification', 'pending', 'verified')).toBe(true);
|
||||
});
|
||||
|
||||
it('can suspend a verified pro and reinstate them', () => {
|
||||
expect(canTransition('verification', 'verified', 'suspended')).toBe(true);
|
||||
expect(canTransition('verification', 'suspended', 'verified')).toBe(true);
|
||||
});
|
||||
|
||||
it('lets a rejected pro reapply', () => {
|
||||
expect(canTransition('verification', 'rejected', 'pending')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('request transitions', () => {
|
||||
it('is one-shot — an accepted request cannot change', () => {
|
||||
expect(isTerminal('request', 'accepted')).toBe(true);
|
||||
expect(canTransition('request', 'accepted', 'declined')).toBe(false);
|
||||
expect(canTransition('request', 'expired', 'accepted')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": { "outDir": "dist", "noEmit": true },
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: { environment: 'node', include: ['test/**/*.test.ts'] },
|
||||
});
|
||||
Generated
+5838
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
packages:
|
||||
- "apps/*"
|
||||
- "packages/*"
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"exclude": ["node_modules", "dist", ".next"]
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"$schema": "https://turbo.build/schema.json",
|
||||
"globalDependencies": [".env"],
|
||||
"globalEnv": [
|
||||
"NODE_ENV",
|
||||
"DATABASE_URL",
|
||||
"REDIS_URL",
|
||||
"AUTH_SECRET",
|
||||
"AUTH_URL",
|
||||
"NEXT_PUBLIC_APP_URL"
|
||||
],
|
||||
"tasks": {
|
||||
"build": { "dependsOn": ["^build"], "outputs": [".next/**", "!.next/cache/**", "dist/**"] },
|
||||
"dev": { "cache": false, "persistent": true },
|
||||
"lint": { "dependsOn": ["^build"] },
|
||||
"typecheck": { "dependsOn": ["^build"] },
|
||||
"test": { "dependsOn": ["^build"], "outputs": ["coverage/**"] },
|
||||
"test:e2e": { "dependsOn": ["^build"], "cache": false }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user