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:
serfowi
2026-08-20 13:32:35 -04:00
co-authored by Claude Opus 5
commit 19623bcccb
66 changed files with 12412 additions and 0 deletions
+13
View File
@@ -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
+13
View File
@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1787246593084,
"tag": "0000_old_gorilla_man",
"breakpoints": true
}
]
}
+33
View File
@@ -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"
}
}
+29
View File
@@ -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');
+73
View File
@@ -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 };
+4
View File
@@ -0,0 +1,4 @@
export * from './client';
export * from './postgis';
export * as schema from './schema/index';
export * from './queries/deck';
+22
View File
@@ -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();
+78
View File
@@ -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 };
+183
View File
@@ -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;
}
+24
View File
@@ -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),
],
);
+83
View File
@@ -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),
}));
+113
View File
@@ -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] }),
}));
+28
View File
@@ -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']);
+9
View File
@@ -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';
+41
View File
@@ -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] }),
}));
+96
View File
@@ -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] }),
}));
+32
View File
@@ -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] }),
}));
+175
View File
@@ -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] }),
}));
+39
View File
@@ -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] }),
}));
+241
View File
@@ -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,
},
]);
// MonFri, 08:0018: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();
+188
View File
@@ -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();
});
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "noEmit": true },
"include": ["src/**/*.ts", "drizzle.config.ts"]
}
+14
View File
@@ -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,
},
});