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,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.
|
||||
Reference in New Issue
Block a user