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
+104
View File
@@ -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.** 3050 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.