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>
105 lines
4.7 KiB
Markdown
105 lines
4.7 KiB
Markdown
# 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.
|