Containerise for Dokploy, and a demo login that survives production

Everything needed to build and run this on Dokploy at
linkdr.serfaty.site, plus the two things that turned out to be broken
the moment it left a laptop.

The build did not work in a container at all. `lib/auth.ts` throws when
AUTH_SECRET or NEXT_PUBLIC_APP_URL is missing — correct at boot, wrong
during `next build`, which imports every route module with
NODE_ENV=production and none of the runtime secrets. The only way past
it was baking a session key into an image layer, which is worse than
the problem the guard exists to prevent. Both checks now skip
NEXT_PHASE=phase-production-build and still fire on a real boot.

Corepack in node:22.12-alpine ships expired npm registry signing keys
and dies before it can download pnpm, so the image installs corepack
first and prepares the pinned version explicitly.

The image is the standalone trace, which needs outputFileTracingRoot at
the REPO root: pnpm hoists to a root .pnpm store and tracing from
apps/web silently omits every workspace package. 427MB, runs as
non-root, and its healthcheck talks to Postgres — a container that
cannot reach its database must never enter rotation, because a deploy
that goes green and then 500s does not roll back.

DEMO_LOGIN is a login bypass under NODE_ENV=production and there is no
honest way to describe it otherwise. It is a separate variable from
ALLOW_DEV_LOGIN so that copying a dev .env into a real environment
cannot enable it by accident, it still only affects the one seeded
number, and it prints a boot warning every single start so it cannot be
forgotten. That deployment holds nothing but fixtures. It comes out
before the platform sees a real signup.

Also: /api/health, and next/image hosts corrected to the Spaces bucket
rather than the R2 one this stopped using.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
serfa
2026-08-23 13:17:54 -04:00
co-authored by Claude Opus 5
parent ff1882598c
commit 35d99ce0e2
12 changed files with 838 additions and 64 deletions
+54
View File
@@ -0,0 +1,54 @@
# The build stage does `COPY . .`, so anything not excluded here ends up in an
# image layer — and a layer is readable by anyone who can pull the image, even
# if a later stage deletes the file.
# ---- Secrets. Non-negotiable. ----
# The platform injects the environment; a baked .env would ship live database
# and Spaces credentials inside the image.
.env
.env.*
!.env.example
# ---- Build inputs that must be produced inside the image ----
# A host node_modules is the wrong platform (linux/musl vs win32/darwin) and
# would silently shadow the one `pnpm install` builds in the deps stage.
node_modules
**/node_modules
.next
**/.next
.turbo
**/.turbo
dist
**/dist
out
**/out
# ---- Never needed at runtime ----
.git
.gitignore
.github
.vscode
.idea
**/test
**/tests
**/*.test.ts
**/*.test.tsx
**/vitest.config.ts
playwright-report
test-results
coverage
**/*.log
.DS_Store
Thumbs.db
# Docs and local tooling. Keeping them out is about layer size and churn: a
# README edit should not invalidate the build cache.
*.md
!README.md
docker-compose.yml
docker-compose.*.yml
Dockerfile
.dockerignore
# NOTE: ca-certificate.crt is deliberately NOT ignored. DATABASE_CA_CERT may
# point at it, and it is a public certificate — no private key. See db/client.ts.
+11 -1
View File
@@ -97,9 +97,19 @@ NEXT_PUBLIC_CITY_LAT=19.4326
NEXT_PUBLIC_CITY_LNG=-99.1332 NEXT_PUBLIC_CITY_LNG=-99.1332
TWILIO_FROM_NUMBER= TWILIO_FROM_NUMBER=
# Dev-only fixed login (+34600000000 / code 000000). MUST stay false/unset in production. # Dev-only fixed login (+52 55 0000 0000 / code 000000). Ignored entirely when
# NODE_ENV=production, so this cannot leak a bypass into a real deployment.
ALLOW_DEV_LOGIN=false ALLOW_DEV_LOGIN=false
# The SAME fixed login, deliberately allowed in a production build, for the
# client-demo deployment only. Separate from ALLOW_DEV_LOGIN so that copying a
# developer's .env into a real environment cannot switch it on by accident.
#
# Only +52 55 0000 0000 is affected; every other number still goes through
# Twilio. Prints a loud warning on every boot. MUST be unset before this
# platform accepts a real signup — see server/dev-login.ts.
DEMO_LOGIN=false
# Bugsink (Sentry-compatible error tracking). Write-only ingest key, safe in the # Bugsink (Sentry-compatible error tracking). Write-only ingest key, safe in the
# client bundle. Leave blank to disable reporting entirely. # client bundle. Leave blank to disable reporting entirely.
NEXT_PUBLIC_SENTRY_DSN= NEXT_PUBLIC_SENTRY_DSN=
+29
View File
@@ -458,6 +458,35 @@ A "use my current location" control sits below, and on success **must** fill the
reverse-geocoded label — a button that silently sets an invisible pin gives the user nothing to reverse-geocoded label — a button that silently sets an invisible pin gives the user nothing to
check. check.
### 6.17 Join as a pro
The one screen in this product that has to sell something, written under a system that bans
marketing heroes and feature grids (§9). The resolution is that it does not describe the
offer — it **shows the thing being offered**, then asks for an account.
Order is fixed:
1. `overline` kicker, `h1`, one sentence. No stat bar, no logo wall.
2. **The preview.** A non-interactive replica of an incoming job request, exactly as a pro
would receive it: trade, title, distance, urgency, budget, and the accept/decline pair.
It is the single raised element on the screen (§5) and it carries `aria-hidden`, because a
sample is not a control — a screen reader offering a fake Accept button is a trap.
Label it in visible text as an example; a mock that reads as live data is a lie.
3. **How it works** — an ordered list, three items, numeral in a `brand-500` pill. An ordered
list, not a grid: these are sequential, and a grid would both break §4 and imply they are not.
4. **What you will need** — the credentials `pro.submitForReview` actually gates on, with the
same required/optional split the wizard uses. Softening it here to raise sign-ups only moves
the drop-off to step four, where the person has already spent their time.
5. The primary action, sticky at the bottom (§9).
Every claim on this screen is load-bearing and must be traceable to behaviour that exists.
"We verify ID, insurance and licence" is true; a response time or an earnings figure is not,
and neither is a setup duration nobody has measured. Invent nothing here.
The account note is required, not decorative: `user.setRole` refuses once a job has been
posted, so a customer tapping this is creating a *second* account. A screen that lets them
believe otherwise produces a support ticket at the worst possible moment.
--- ---
## 7. Motion ## 7. Motion
+131
View File
@@ -0,0 +1,131 @@
# syntax=docker/dockerfile:1.7
###############################################################################
# Linkdr — production image
#
# Four stages so that a code change does not reinstall the dependency tree:
# `deps` is keyed on the lockfile alone, and Docker reuses it until that file
# changes. Installing inside the same layer as the source would rebuild ~1GB of
# node_modules on every commit.
#
# Node 22 rather than 23: 22 is the active LTS line, 23 is not and stops getting
# fixes. package.json says >=20; this pins the version we actually ship.
###############################################################################
ARG NODE_VERSION=22.12.0-alpine
# ─────────────────────────────── base ───────────────────────────────
FROM node:${NODE_VERSION} AS base
# libc6-compat: several native-ish npm packages ship glibc builds and fail on
# musl without it. Cheap, and the failure it prevents is an obscure one.
RUN apk add --no-cache libc6-compat
# pnpm, pinned to the version in `packageManager` so the image builds with the
# same resolver the lockfile was written by.
#
# `corepack enable` alone is not enough: the corepack bundled with Node 22.12
# carries expired npm registry signing keys and dies with "Cannot find matching
# keyid" before it ever downloads pnpm. Updating corepack first refreshes those
# keys, and `prepare --activate` fetches the exact version rather than asking
# the registry what "latest" is at build time.
ARG PNPM_VERSION=9.15.4
RUN npm install -g corepack@latest && corepack enable && corepack prepare pnpm@${PNPM_VERSION} --activate
WORKDIR /app
# ─────────────────────────────── deps ───────────────────────────────
# Every package.json in the workspace, and nothing else. Adding a source file
# here would defeat the layer cache this stage exists for.
FROM base AS deps
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc* ./
COPY apps/web/package.json apps/web/
COPY packages/api/package.json packages/api/
COPY packages/db/package.json packages/db/
COPY packages/geocode/package.json packages/geocode/
COPY packages/notify/package.json packages/notify/
COPY packages/shared/package.json packages/shared/
COPY packages/storage/package.json packages/storage/
# --frozen-lockfile: a lockfile that does not match package.json is a build
# failure, not something to silently resolve differently than developers did.
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm config set store-dir /pnpm/store && \
pnpm install --frozen-lockfile
# ─────────────────────────────── build ──────────────────────────────
FROM base AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/apps/web/node_modules ./apps/web/node_modules
COPY --from=deps /app/packages ./packages
COPY . .
# NEXT_PUBLIC_* is inlined into the client bundle at BUILD time — reading it
# from the container's environment at runtime is too late. Anything the browser
# must know therefore has to arrive here as a build argument.
ARG NEXT_PUBLIC_APP_URL
ARG NEXT_PUBLIC_CITY_NAME
ARG NEXT_PUBLIC_CITY_LAT
ARG NEXT_PUBLIC_CITY_LNG
ARG NEXT_PUBLIC_SENTRY_DSN
ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
ENV NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL \
NEXT_PUBLIC_CITY_NAME=$NEXT_PUBLIC_CITY_NAME \
NEXT_PUBLIC_CITY_LAT=$NEXT_PUBLIC_CITY_LAT \
NEXT_PUBLIC_CITY_LNG=$NEXT_PUBLIC_CITY_LNG \
NEXT_PUBLIC_SENTRY_DSN=$NEXT_PUBLIC_SENTRY_DSN \
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
ENV NEXT_TELEMETRY_DISABLED=1 \
NODE_ENV=production
# Every page that touches the database is `force-dynamic`, so no database is
# needed to build. If that ever stops being true this line is where it breaks,
# loudly, rather than at deploy time.
RUN pnpm --filter @linkdr/web build
# ─────────────────────────────── tools ──────────────────────────────
# Migrations, seeding and the asset migration. These need tsx, drizzle-kit and
# the drizzle/ SQL folder, none of which belong in the image that serves
# traffic — so they get their own target rather than bloating the runtime.
#
# Run as a one-shot alongside the app (see docker-compose.dokploy.yml), not as
# a long-lived service:
# docker compose run --rm migrate
FROM base AS tools
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/packages ./packages
COPY . .
ENV NODE_ENV=production
# Idempotent: drizzle records applied migrations, so re-running is a no-op and
# a restarted container cannot double-apply anything.
CMD ["pnpm", "--filter", "@linkdr/db", "migrate"]
# ────────────────────────────── runtime ─────────────────────────────
FROM base AS runtime
WORKDIR /app
ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \
PORT=3000 \
HOSTNAME=0.0.0.0
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
# The standalone bundle ships its own minimal node_modules and server.js.
# `public` and `.next/static` are NOT included in it and must be copied
# separately, or the site renders with no CSS and no images.
COPY --from=build --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
COPY --from=build --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
COPY --from=build --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public
USER nextjs
EXPOSE 3000
# Talks to Postgres, so a container that cannot reach its database never enters
# rotation. start-period covers first boot; see app/api/health/route.ts.
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
CMD ["node", "apps/web/server.js"]
+23 -5
View File
@@ -1,21 +1,39 @@
import path from 'node:path';
import { config as loadEnv } from 'dotenv'; import { config as loadEnv } from 'dotenv';
import { withSentryConfig } from '@sentry/nextjs'; import { withSentryConfig } from '@sentry/nextjs';
import type { NextConfig } from 'next'; import type { NextConfig } from 'next';
// The monorepo keeps one .env at the root; Next only looks in the app directory. // The monorepo keeps one .env at the root; Next only looks in the app directory.
// In a container the file does not exist and the platform supplies the
// environment instead — dotenv never overwrites an already-set variable, so
// this line is a no-op there rather than a conflict.
loadEnv({ path: '../../.env' }); loadEnv({ path: '../../.env' });
const config: NextConfig = { const config: NextConfig = {
reactStrictMode: true, reactStrictMode: true,
/**
* Traces the server build and its used dependencies into
* `.next/standalone`, so the runtime image carries a node_modules with only
* what actually runs. Without it a Docker image for this monorepo has to ship
* every workspace's dev dependencies — drizzle-kit, vitest, eslint, the whole
* toolchain — to start one server.
*
* `outputFileTracingRoot` must point at the REPO root, not the app: pnpm
* hoists to a root `node_modules/.pnpm` store, and tracing from apps/web
* silently omits every symlinked workspace package.
*/
output: 'standalone',
outputFileTracingRoot: path.join(__dirname, '../..'),
// The workspace packages ship TypeScript source, not build output. // The workspace packages ship TypeScript source, not build output.
transpilePackages: ['@linkdr/api', '@linkdr/db', '@linkdr/shared', '@linkdr/storage'], transpilePackages: ['@linkdr/api', '@linkdr/db', '@linkdr/shared', '@linkdr/storage'],
images: { images: {
remotePatterns: [ remotePatterns: [
// Seed data only — real pros upload to R2. The deck renders a plain <img>, // Where everything is served from once `pnpm assets:migrate` has run.
// so these matter only where next/image is used. { protocol: 'https', hostname: '**.digitaloceanspaces.com' },
{ protocol: 'https', hostname: 'i.pravatar.cc' }, { protocol: 'https', hostname: '**.cdn.digitaloceanspaces.com' },
{ protocol: 'https', hostname: 'picsum.photos' }, // The seed writes source urls and the migration rewrites them, so a
{ protocol: 'https', hostname: '**.r2.dev' }, // freshly seeded environment points here until that job has run.
{ protocol: 'https', hostname: 'images.unsplash.com' },
], ],
}, },
// postgres-js opens raw sockets; it must not be bundled into the server chunk. // postgres-js opens raw sockets; it must not be bundled into the server chunk.
+43
View File
@@ -0,0 +1,43 @@
import { sql } from 'drizzle-orm';
import { db } from '@linkdr/db';
/**
* Liveness and readiness for the deployment platform.
*
* Dokploy's health check decides whether a new container replaces the running
* one. A check that only proves Node is listening will happily promote a
* container that cannot reach its database — the deploy goes green and every
* request 500s, which is strictly worse than a failed deploy because nothing
* rolls back.
*
* So this touches Postgres. It is one trivial round trip and it is the single
* dependency without which no page on this site renders.
*
* Not checked here on purpose:
* - Redis. Nothing in the request path needs it yet (see routers/message.ts,
* where the throttle is deliberately in-process until M4).
* - Spaces, Mapbox, Twilio. Third-party outages must not take our own
* container out of rotation and trigger a rollback loop.
*/
export const dynamic = 'force-dynamic';
export const runtime = 'nodejs';
export async function GET() {
const startedAt = Date.now();
try {
await db.execute(sql`select 1`);
} catch (error) {
// The message can carry a connection string. Log it, never return it.
console.error('[health] database unreachable', error);
return Response.json(
{ status: 'error', database: 'unreachable' },
{ status: 503, headers: { 'cache-control': 'no-store' } },
);
}
return Response.json(
{ status: 'ok', database: 'ok', latencyMs: Date.now() - startedAt },
{ status: 200, headers: { 'cache-control': 'no-store' } },
);
}
+191
View File
@@ -0,0 +1,191 @@
import Link from 'next/link';
import { Check, Clock, MapPin, ShieldCheck, X } from 'lucide-react';
import { BackLink } from '@/components/chrome/back-link';
import { buttonClasses } from '@/components/ui';
export const metadata = { title: 'Join as a pro' };
/**
* DESIGN.md §6.17.
*
* The old "Join as a pro" button went straight to /sign-in, which asks a
* tradesperson to create an account before telling them what for. This screen
* is what sits in between: it shows the job request they would receive, states
* what we check and what we will need from them, and only then asks.
*
* A route rather than a panel. The rest of the app is one screen with no routes
* inside it (see showcase-deck.tsx), but this is a one-way door out of the
* customer product into a separate account — the same journey /sign-in already
* takes, and it should be linkable and back-able like one.
*
* Public on purpose: sign-in comes AFTER, and a pitch you have to log in to
* read is not a pitch.
*/
/** Three sequential steps, not three features. §6.17 — an ordered list. */
const STEPS = [
{
title: 'Tell us your trade',
body: 'What you do, where you are based, and how far you are willing to travel.',
},
{
title: 'We check you out',
body: 'A person reads your ID, insurance and licence by hand. It usually takes a day.',
},
{
title: 'Jobs start arriving',
body: 'Customers near you send work that matches your trade. Take the ones you want.',
},
];
/**
* What `pro.submitForReview` actually gates on, with the wizard's own
* required/optional split. Softening it here only moves the drop-off to step
* four, after the person has already spent their evening on it.
*/
const NEEDED = [
{ label: 'Photo ID', note: 'Required' },
{ label: 'Public liability insurance', note: 'Required' },
{ label: 'Trade licence', note: 'If your trade needs one' },
];
export default function ProJoinPage() {
return (
<main className="h-full overflow-y-auto bg-page px-5 pb-0 pt-[calc(0.5rem+env(safe-area-inset-top))]">
<BackLink href="/" label="Back" />
<p className="mt-6 text-overline uppercase text-accent">For tradespeople</p>
<h1 className="mt-3 text-balance text-h1">Get sent jobs near you</h1>
<p className="mt-3 max-w-[68ch] text-pretty text-body text-muted">
No bidding, no lead fees, no chasing. Verified customers send you the work directly and
you decide what to take.
</p>
{/*
The preview. The whole reason this screen exists: showing the thing beats
describing it, and it is the honest answer to "what would I actually get?"
aria-hidden and inert — a screen reader offering a fake Accept button is a
trap, and so is a sighted user tapping one. The caption above it says it is
an example in visible text, because a mock that reads as live data is a lie.
*/}
<p className="mt-10 flex items-center gap-2 text-meta text-faint">
<span className="h-px flex-1 bg-hairline" aria-hidden />
An example request
<span className="h-px flex-1 bg-hairline" aria-hidden />
</p>
<div
className="mt-4 rounded-card border border-hairline bg-raised p-5 shadow-lg"
aria-hidden
>
<div className="flex items-center justify-between gap-3">
<span className="inline-flex items-center rounded-pill border border-brand-200 bg-brand-100 px-3 py-1 text-meta text-ink-950">
Plumber
</span>
<span className="inline-flex items-center gap-1 text-meta text-sun-600">
<Clock className="h-3.5 w-3.5" aria-hidden />
Today if possible
</span>
</div>
<p className="mt-3 font-display text-h4 text-strong">
Kitchen sink leaking under the cupboard
</p>
<p className="mt-2 text-body-sm text-muted">
Water pooling under the sink, seems to be the trap. Free most evenings this week.
</p>
<p className="mt-3 flex flex-wrap items-center gap-x-2 gap-y-1 text-meta text-faint tabular-nums">
<MapPin className="h-3.5 w-3.5" aria-hidden />
<span>2.4 km away</span>
<span>·</span>
<span>Budget $80200</span>
</p>
{/*
Shown as the shapes of the two buttons, not as buttons. §6.17 — the
point is recognition, and a real control here would be tappable.
*/}
<div className="mt-5 flex gap-3">
<span className="inline-flex h-11 flex-1 items-center justify-center gap-2 rounded-pill border-[1.5px] border-ink-950 font-display text-body font-semibold text-strong dark:border-hairline">
<X className="h-4 w-4" aria-hidden />
Decline
</span>
<span className="inline-flex h-11 flex-1 items-center justify-center gap-2 rounded-pill bg-go-600 font-display text-body font-semibold text-white">
<Check className="h-4 w-4" aria-hidden />
Accept
</span>
</div>
</div>
<p className="mt-3 text-meta text-faint">
Accepting opens a private conversation. You agree a fixed price there before any work
starts we never quote on your behalf.
</p>
<h2 className="mt-10 text-h3">How it works</h2>
<ol className="mt-4 flex flex-col gap-6">
{STEPS.map((step, i) => (
<li key={step.title} className="flex gap-4">
<span
className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-pill bg-brand-500 font-display text-body-sm font-semibold text-white tabular-nums"
aria-hidden
>
{i + 1}
</span>
<span className="min-w-0">
<span className="block font-display text-h4 text-strong">{step.title}</span>
<span className="mt-1 block text-body-sm text-muted">{step.body}</span>
</span>
</li>
))}
</ol>
<h2 className="mt-10 text-h3">What you will need</h2>
<p className="mt-2 text-body-sm text-muted">
We check every pro before a single customer sees them. It is the only thing this
marketplace actually sells, so there is no way around this part.
</p>
<ul className="mt-4 flex flex-col gap-3">
{NEEDED.map((item) => (
<li
key={item.label}
className="flex items-center gap-3 rounded-lg border border-hairline px-4 py-3"
>
<ShieldCheck className="h-5 w-5 shrink-0 text-go-600" aria-hidden />
<span className="min-w-0 flex-1 text-body-sm text-strong">{item.label}</span>
<span className="shrink-0 text-meta text-faint">{item.note}</span>
</li>
))}
</ul>
<p className="mt-6 text-body-sm text-muted">
You set your own hourly rate and how far you travel, and you can turn new work off
whenever you are busy.
</p>
{/*
§9 — the primary action sits low and stays in thumb reach. Sticky rather
than placed after the copy, because this screen is longer than the fold
and a CTA below three sections is a CTA nobody sees.
*/}
<div className="sticky bottom-0 -mx-5 mt-10 border-t border-hairline bg-page/95 px-5 pt-4 pb-[calc(1rem+env(safe-area-inset-bottom))] backdrop-blur-[12px]">
<Link
href="/sign-in?next=/pro/onboarding"
className={buttonClasses({ variant: 'primary', size: 'lg', block: true })}
>
Create your pro account
</Link>
{/*
Not decorative. user.setRole refuses once a job has been posted, so a
customer tapping this is opening a SECOND account — finding that out
later is a support ticket at the worst possible moment.
*/}
<p className="mt-3 text-center text-meta text-faint">
Working as a pro needs its own account. You keep your customer one for hiring.
</p>
</div>
</main>
);
}
+88 -36
View File
@@ -6,7 +6,7 @@ import { Card } from '@/components/deck';
import { SignedOut } from '@/components/chrome/signed-out'; import { SignedOut } from '@/components/chrome/signed-out';
import { SkillsGroup } from '@/components/profile/skills-group'; import { SkillsGroup } from '@/components/profile/skills-group';
import { Banner, SettingsGroup, SettingsRow, SettingsToggle, buttonClasses } from '@/components/ui'; import { Banner, SettingsGroup, SettingsRow, SettingsToggle, buttonClasses } from '@/components/ui';
import { api, type RouterOutputs } from '@/lib/trpc'; import { api } from '@/lib/trpc';
/** /**
* The Profile tab. * The Profile tab.
@@ -35,15 +35,13 @@ export function ProfilePanel() {
); );
} }
return me.data.role === 'pro' ? <ProProfile /> : <ClientProfile me={me.data} />; return me.data.role === 'pro' ? <ProProfile /> : <ClientProfile />;
} }
function Shell({ children }: { children: React.ReactNode }) { function Shell({ children }: { children: React.ReactNode }) {
return <div className="min-h-0 flex-1 overflow-y-auto px-4 pb-4 pt-[3.25rem]">{children}</div>; return <div className="min-h-0 flex-1 overflow-y-auto px-4 pb-4 pt-[3.25rem]">{children}</div>;
} }
type Me = RouterOutputs['user']['me'];
/* ─────────────────────────────── pro ─────────────────────────────── */ /* ─────────────────────────────── pro ─────────────────────────────── */
/** What each verification status means commercially — this is the row that decides /** What each verification status means commercially — this is the row that decides
@@ -79,6 +77,27 @@ const STATUS: Record<
}, },
}; };
/**
* §6.11. The banner, the card and a group — in that order and at those sizes,
* so nothing jumps sideways when the two queries land.
*/
function ProSkeleton() {
return (
<div aria-busy>
<div className="mb-1 h-8 w-40 animate-pulse rounded-md bg-inset" />
<div className="mb-4 h-5 w-64 animate-pulse rounded-md bg-inset" />
<div className="mb-5 h-24 animate-pulse rounded-card bg-inset" />
<div className="mb-8 aspect-[3/4] w-full animate-pulse rounded-3xl bg-inset" />
<div className="h-40 animate-pulse rounded-card bg-inset" />
</div>
);
}
/** "Uploaded", or the reason nobody can see you yet. */
function Required({ present }: { present: boolean }) {
return present ? <>Uploaded</> : <span className="text-danger">Missing</span>;
}
function ProProfile() { function ProProfile() {
const utils = api.useUtils(); const utils = api.useUtils();
const preview = api.pro.previewCard.useQuery(); const preview = api.pro.previewCard.useQuery();
@@ -94,7 +113,7 @@ function ProProfile() {
if (preview.isLoading || profile.isLoading) { if (preview.isLoading || profile.isLoading) {
return ( return (
<Shell> <Shell>
<div className="h-64 animate-pulse rounded-deck bg-inset" /> <ProSkeleton />
</Shell> </Shell>
); );
} }
@@ -124,25 +143,38 @@ function ProProfile() {
return ( return (
<Shell> <Shell>
<h1 className="mb-1 text-h2">Your card</h1> <h1 className="mb-1 text-h2">Your card</h1>
<p className="mb-3 text-body-sm text-muted">Exactly what customers see when they swipe.</p> <p className="mb-4 text-body-sm text-muted">Exactly what customers see when they swipe.</p>
{/*
Above the card, not below it.
Whether the card is on the deck at all outranks what is printed on it —
a rejected pro reading this in order used to meet a polished preview of
something nobody can see, and only then the sentence explaining why.
*/}
<Banner tone={status.tone} title={status.label} className="mb-5">
{status.body}
</Banner>
{/* {/*
The real <Card>, not a lookalike — a copy would drift the moment either The real <Card>, not a lookalike — a copy would drift the moment either
side changed, and the whole point is that a pro can trust this preview. side changed, and the whole point is that a pro can trust this preview.
No onDecide, so it renders static and non-draggable. No onDecide, so it renders static and non-draggable.
A ratio rather than the 420px it used to be pinned at. Card is
`absolute inset-0`, so it needs a definite height from its parent, and a
fixed one made the preview a different shape from the real thing on
every screen that was not the one it was measured on — which for a
preview sold as "exactly what customers see" is the one thing it must
not do.
*/} */}
<div className="relative mb-2 h-[420px] w-full"> <div className="relative mb-2 aspect-[3/4] w-full">
<Card card={card} /> <Card card={card} />
</div> </div>
<p className="mb-5 text-meta text-faint"> <p className="mb-8 text-meta text-faint">
The distance shown is an example customers see how far you are from their own job. The distance shown is an example customers see how far you are from their own job.
</p> </p>
<Banner tone={status.tone} title={status.label}> <div>
{status.body}
</Banner>
<div className="mt-5">
<SettingsGroup title="Availability"> <SettingsGroup title="Availability">
<SettingsToggle <SettingsToggle
label="Accepting jobs" label="Accepting jobs"
@@ -157,8 +189,10 @@ function ProProfile() {
/> />
</SettingsGroup> </SettingsGroup>
{/* "Card details", not "Your card" — that is the h1 four hundred pixels
above, and two headings with one name meant two different things. */}
<SettingsGroup <SettingsGroup
title="Your card" title="Card details"
/* /*
* upsertProfile demotes a verified pro to `pending` when trade, * upsertProfile demotes a verified pro to `pending` when trade,
* location or radius changes, which silently drops them off the deck. * location or radius changes, which silently drops them off the deck.
@@ -185,9 +219,15 @@ function ProProfile() {
<SkillsGroup skills={p.skills} /> <SkillsGroup skills={p.skills} />
{/*
A missing ID or insurance certificate is the whole reason a draft pro
is not earning, and it used to render in the same grey as the optional
licence line. The colour goes on the STATE, not the label: "Photo ID"
is not the problem, "Missing" is.
*/}
<SettingsGroup title="Documents" note="Only our review team ever sees these."> <SettingsGroup title="Documents" note="Only our review team ever sees these.">
<SettingsRow label="Photo ID" value={has('id') ? 'Uploaded' : 'Missing'} /> <SettingsRow label="Photo ID" value={<Required present={has('id')} />} />
<SettingsRow label="Insurance" value={has('insurance') ? 'Uploaded' : 'Missing'} /> <SettingsRow label="Insurance" value={<Required present={has('insurance')} />} />
<SettingsRow label="Trade licence" value={has('licence') ? 'Uploaded' : 'Not provided'} /> <SettingsRow label="Trade licence" value={has('licence') ? 'Uploaded' : 'Not provided'} />
</SettingsGroup> </SettingsGroup>
@@ -210,48 +250,60 @@ function ProProfile() {
/* ───────────────────────────── client ────────────────────────────── */ /* ───────────────────────────── client ────────────────────────────── */
function ClientProfile({ me }: { me: Me }) { function ClientProfile() {
const jobs = api.job.mine.useQuery();
return ( return (
<Shell> <Shell>
<h1 className="mb-5 text-h2">Your profile</h1>
<SettingsGroup title="Account">
<SettingsRow label="Name" value={me.name ?? 'Not set'} />
<SettingsRow label="Phone" value={me.phoneNumber ?? '—'} />
<SettingsRow label="Jobs posted" value={jobs.data ? String(jobs.data.length) : '…'} />
</SettingsGroup>
{/* {/*
The most valuable thing on an otherwise empty screen. A marketplace with Full height, with the card taking whatever the title leaves. Three rows
no pros has no product, so recruiting supply beats decorating a client went from here — Name, Phone and Jobs posted — because the first two are
profile that has nothing on it. editable in Settings and were dead facts here, and a customer with two
screens showing their name, one of which does nothing when tapped, learns
that tapping things on this screen does nothing. The third is the Jobs
tab's entire subject.
Removing them left the card stranded at the top above six hundred pixels
of nothing, which reads as a screen that failed to load rather than one
with little to say. Centring it in the space makes the emptiness look
chosen, because it is: a customer profile genuinely has nothing on it,
and the recruitment card is the screen's real job.
*/} */}
<div className="rounded-card border border-hairline bg-raised p-5"> <div className="flex min-h-full flex-col">
<h1 className="mb-1 text-h2">Your profile</h1>
<p className="text-body-sm text-muted">
Your name, contact details and notifications live in Settings.
</p>
<div className="flex flex-1 items-center py-8">
{/*
A marketplace with no pros has no product, so recruiting supply beats
decorating a client profile that has nothing on it.
*/}
<div className="w-full rounded-card border border-hairline bg-raised p-5">
<span className="flex items-center gap-2 text-accent"> <span className="flex items-center gap-2 text-accent">
<Hammer className="h-5 w-5" aria-hidden /> <Hammer className="h-5 w-5" aria-hidden />
<span className="text-overline uppercase">For tradespeople</span> <span className="text-overline uppercase">For tradespeople</span>
</span> </span>
<h2 className="mt-3 text-h3">Work with us</h2> <h2 className="mt-3 text-h3">Work with us</h2>
<p className="mt-2 text-body-sm text-muted"> <p className="mt-2 text-body-sm text-muted">
Get sent local jobs that match your trade. We check every pro&rsquo;s ID, licence and Get sent local jobs that match your trade. We check every pro&rsquo;s ID, licence
insurance, so customers arrive ready to book. and insurance, so customers arrive ready to book.
</p> </p>
{/* {/*
user.setRole refuses once a job has been posted, so this must not read user.setRole refuses once a job has been posted, so this must not
as a switch that flips this account over. read as a switch that flips this account over.
*/} */}
<p className="mt-2 text-meta text-faint"> <p className="mt-2 text-meta text-faint">
Working as a pro needs its own account you keep this one for hiring. Working as a pro needs its own account you keep this one for hiring.
</p> </p>
<Link <Link
href="/sign-in?next=/pro/onboarding" href="/pro/join"
className={`mt-4 ${buttonClasses({ variant: 'primary', size: 'md', block: true })}`} className={`mt-4 ${buttonClasses({ variant: 'primary', size: 'md', block: true })}`}
> >
Join as a pro Join as a pro
</Link> </Link>
</div> </div>
</div>
</div>
</Shell> </Shell>
); );
} }
+13 -3
View File
@@ -151,11 +151,21 @@ function SignedIn({ me, onNavigate }: { me: Me; onNavigate?: (tab: PhoneTab) =>
offer, and settings is where somebody goes when they are looking for offer, and settings is where somebody goes when they are looking for
something they have not found. something they have not found.
*/ */
<SettingsGroup title="Working" note="Verification takes about a day."> <SettingsGroup
title="Working"
note="A pro account is separate from this one. You keep this for hiring."
>
{/*
/pro/join, not /pro/onboarding. Onboarding redirects a client role
straight back out to the role chooser, so the row pointed at a page
this reader can never reach — and it implied their account would
become a pro account, which `setRole` refuses once they have posted
a job. /pro/join is the recruitment page that explains both.
*/}
<SettingsRow <SettingsRow
label="Work on Linkdr" label="Work on Linkdr"
hint="Set up a pro profile and start getting jobs" hint="Get sent local jobs that match your trade"
href="/pro/onboarding" href="/pro/join"
/> />
</SettingsGroup> </SettingsGroup>
)} )}
+15 -2
View File
@@ -29,13 +29,26 @@ import { isDevLoginPhone, pinDevLoginCode } from '@/server/dev-login';
* unvalidated. The only real alternative is delegating the whole flow to Twilio * unvalidated. The only real alternative is delegating the whole flow to Twilio
* Verify, which we chose not to do. * Verify, which we chose not to do.
*/ */
/**
* `next build` imports this module to collect route metadata, and it does so
* with NODE_ENV=production but none of the runtime secrets — a build machine
* has no business holding a session key. Without this distinction the two
* guards below turn every containerised build into a failure, and the only way
* out is baking AUTH_SECRET into an image layer, which is worse than the
* problem they exist to prevent.
*
* Next sets NEXT_PHASE for the duration of the build and never at runtime, so
* the checks still fire on a real boot.
*/
const isBuildPhase = process.env.NEXT_PHASE === 'phase-production-build';
/** /**
* Without a secret, better-auth silently falls back to a built-in default — * Without a secret, better-auth silently falls back to a built-in default —
* which would mean every deployment signs sessions with the same publicly known * which would mean every deployment signs sessions with the same publicly known
* key. Fail the boot instead. * key. Fail the boot instead.
*/ */
const secret = process.env.AUTH_SECRET; const secret = process.env.AUTH_SECRET;
if (!secret && process.env.NODE_ENV === 'production') { if (!secret && process.env.NODE_ENV === 'production' && !isBuildPhase) {
throw new Error('AUTH_SECRET is not set. Generate one with: openssl rand -base64 32'); throw new Error('AUTH_SECRET is not set. Generate one with: openssl rand -base64 32');
} }
@@ -48,7 +61,7 @@ if (!secret && process.env.NODE_ENV === 'production') {
*/ */
const appUrl = const appUrl =
process.env.NEXT_PUBLIC_APP_URL ?? process.env.NEXT_PUBLIC_APP_URL ??
(process.env.NODE_ENV === 'production' (process.env.NODE_ENV === 'production' && !isBuildPhase
? (() => { ? (() => {
throw new Error('NEXT_PUBLIC_APP_URL is not set. Set it to the public https origin.'); throw new Error('NEXT_PUBLIC_APP_URL is not set. Set it to the public https origin.');
})() })()
+44 -1
View File
@@ -20,8 +20,51 @@ import { db, schema } from '@linkdr/db';
const DEV_PHONE = '+525500000000'; const DEV_PHONE = '+525500000000';
const DEV_CODE = '000000'; const DEV_CODE = '000000';
/**
* DEMO_LOGIN — the same fixed login, deliberately permitted in a production
* BUILD, for the client-demo deployment at linkdr.serfaty.site.
*
* This is a login bypass running under NODE_ENV=production and there is no way
* to dress that up. It is a separate variable from ALLOW_DEV_LOGIN on purpose:
* the two say different things, and someone copying a dev `.env` into a real
* environment must not be able to enable this by accident. Guard 3 still holds
* — only DEV_PHONE is affected, every other number goes through Twilio.
*
* What makes it acceptable HERE and nowhere else: that deployment contains
* nothing but seeded fixtures, and the account it opens is a seeded customer.
* There is no real person's data behind it.
*
* Before this platform takes a real signup, DEMO_LOGIN must be unset and this
* block deleted. The boot warning below exists so that is impossible to
* forget: it prints on every single start.
*/
const demoLogin = process.env.DEMO_LOGIN === 'true';
if (demoLogin && process.env.NODE_ENV === 'production') {
console.warn(
`
############################################################
` +
` # DEMO_LOGIN IS ON IN A PRODUCTION BUILD. #
` +
` # ${DEV_PHONE} signs in with a fixed code and NO SMS. #
` +
` # This is for the client demo only. Unset DEMO_LOGIN #
` +
` # before this platform accepts a real signup. #
` +
` ############################################################
`,
);
}
export function isDevLoginEnabled(): boolean { export function isDevLoginEnabled(): boolean {
return process.env.NODE_ENV !== 'production' && process.env.ALLOW_DEV_LOGIN === 'true'; // Local development: as before.
if (process.env.NODE_ENV !== 'production') {
return process.env.ALLOW_DEV_LOGIN === 'true';
}
// Production: only the explicit demo flag, never ALLOW_DEV_LOGIN.
return demoLogin;
} }
export function isDevLoginPhone(phone: string): boolean { export function isDevLoginPhone(phone: string): boolean {
+180
View File
@@ -0,0 +1,180 @@
# Linkdr — Dokploy deployment stack.
#
# Separate from docker-compose.yml, which exists only to give a developer a
# Postgres and a Redis on their laptop. Merging the two would mean one file
# that is wrong in both places.
#
# In Dokploy: create a **Compose** service, point it at this file, paste the
# variables from DEPLOY.md into the Environment tab, then Deploy.
#
# Traefik is Dokploy's ingress. It routes by the labels on `app` below and
# joins containers on the shared `dokploy-network`, which is why that network
# is declared external — Dokploy created it, this stack only attaches to it.
services:
# ─────────────────────────────── database ───────────────────────────────
# PostGIS, not plain Postgres. Every distance in this product is
# `ST_Distance` over a `geography(Point,4326)` column, and the first
# migration declares one — vanilla postgres:17 fails on migration 0001.
postgres:
image: postgis/postgis:17-3.5
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-linkdr}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
POSTGRES_DB: ${POSTGRES_DB:-linkdr}
volumes:
- pgdata:/var/lib/postgresql/data
# No `ports:` on purpose. The database is reachable on the compose network
# by every service that needs it; publishing 5432 puts it on the public
# internet of the droplet.
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER:-linkdr} -d ${POSTGRES_DB:-linkdr}']
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
networks: [internal]
# ──────────────────────────────── redis ─────────────────────────────────
# Not on the request path yet (routers/message.ts throttles in-process until
# M4). Here so the SSE fan-out and BullMQ queues have somewhere to land
# without a second deploy.
redis:
image: redis:7-alpine
restart: unless-stopped
command: redis-server --appendonly yes
volumes:
- redisdata:/data
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 10s
timeout: 3s
retries: 10
networks: [internal]
# ────────────────────────────── migrations ──────────────────────────────
# Runs to completion and exits. `app` waits for it, so a container can never
# serve traffic against a schema older than the code inside it.
#
# Idempotent — drizzle records what it has applied, so a redeploy re-runs
# this and it does nothing.
migrate:
build:
context: .
dockerfile: Dockerfile
target: tools
restart: 'no'
environment:
DATABASE_URL: ${DATABASE_URL}
DATABASE_CA_CERT: ${DATABASE_CA_CERT:-}
depends_on:
postgres:
condition: service_healthy
networks: [internal]
# ──────────────────────────────── the app ───────────────────────────────
app:
build:
context: .
dockerfile: Dockerfile
target: runtime
# NEXT_PUBLIC_* is inlined into the browser bundle when `next build`
# runs, so these MUST be build args. Setting them only under
# `environment:` below leaves the client bundle holding whatever was
# baked in — usually localhost — and sign-in breaks in a way that looks
# like a cookie bug.
args:
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL}
NEXT_PUBLIC_CITY_NAME: ${NEXT_PUBLIC_CITY_NAME}
NEXT_PUBLIC_CITY_LAT: ${NEXT_PUBLIC_CITY_LAT}
NEXT_PUBLIC_CITY_LNG: ${NEXT_PUBLIC_CITY_LNG}
NEXT_PUBLIC_SENTRY_DSN: ${NEXT_PUBLIC_SENTRY_DSN:-}
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: ${NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY:-}
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
migrate:
condition: service_completed_successfully
environment:
NODE_ENV: production
PORT: '3000'
HOSTNAME: 0.0.0.0
DATABASE_URL: ${DATABASE_URL}
DATABASE_CA_CERT: ${DATABASE_CA_CERT:-}
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
# better-auth derives cookie domain and Secure flag from this. An http://
# value here on an https:// site produces a login that appears to succeed
# and then has no session — see lib/auth.ts.
AUTH_SECRET: ${AUTH_SECRET:?AUTH_SECRET is required}
AUTH_URL: ${AUTH_URL}
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL}
AUTH_GOOGLE_ID: ${AUTH_GOOGLE_ID:-}
AUTH_GOOGLE_SECRET: ${AUTH_GOOGLE_SECRET:-}
AUTH_MICROSOFT_ID: ${AUTH_MICROSOFT_ID:-}
AUTH_MICROSOFT_SECRET: ${AUTH_MICROSOFT_SECRET:-}
AUTH_MICROSOFT_TENANT_ID: ${AUTH_MICROSOFT_TENANT_ID:-common}
AUTH_GITHUB_ID: ${AUTH_GITHUB_ID:-}
AUTH_GITHUB_SECRET: ${AUTH_GITHUB_SECRET:-}
TWILIO_ACCOUNT_SID: ${TWILIO_ACCOUNT_SID:-}
TWILIO_AUTH_TOKEN: ${TWILIO_AUTH_TOKEN:-}
TWILIO_VERIFY_SERVICE_SID: ${TWILIO_VERIFY_SERVICE_SID:-}
TWILIO_FROM_NUMBER: ${TWILIO_FROM_NUMBER:-}
MAPBOX_TOKEN: ${MAPBOX_TOKEN:-}
MAPBOX_COUNTRY: ${MAPBOX_COUNTRY:-mx}
SPACES_REGION: ${SPACES_REGION:-nyc3}
SPACES_BUCKET: ${SPACES_BUCKET:-}
SPACES_KEY: ${SPACES_KEY:-}
SPACES_SECRET: ${SPACES_SECRET:-}
SPACES_CDN_URL: ${SPACES_CDN_URL:-}
RESEND_API_KEY: ${RESEND_API_KEY:-}
EMAIL_FROM: ${EMAIL_FROM:-noreply@linkdr.serfaty.site}
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-}
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-}
PLATFORM_FEE_BPS: ${PLATFORM_FEE_BPS:-1500}
NEXT_PUBLIC_SENTRY_DSN: ${NEXT_PUBLIC_SENTRY_DSN:-}
# Read by dev-login.ts, which ALSO requires NODE_ENV !== 'production'.
# With NODE_ENV=production above, the fixed +52 55 0000 0000 / 000000
# login is off no matter what this says. See DEPLOY.md → "Signing in".
ALLOW_DEV_LOGIN: 'false'
networks: [internal, dokploy-network]
labels:
- traefik.enable=true
- traefik.docker.network=dokploy-network
# Dokploy's Traefik terminates TLS; the container speaks plain HTTP.
- traefik.http.services.linkdr.loadbalancer.server.port=3000
- traefik.http.routers.linkdr.rule=Host(`linkdr.serfaty.site`)
- traefik.http.routers.linkdr.entrypoints=websecure
- traefik.http.routers.linkdr.tls=true
- traefik.http.routers.linkdr.tls.certresolver=letsencrypt
# Send :80 to :443 rather than serving the app on both. Auth cookies are
# Secure, so the http origin cannot hold a session anyway.
- traefik.http.routers.linkdr-web.rule=Host(`linkdr.serfaty.site`)
- traefik.http.routers.linkdr-web.entrypoints=web
- traefik.http.routers.linkdr-web.middlewares=linkdr-https
- traefik.http.middlewares.linkdr-https.redirectscheme.scheme=https
- traefik.http.middlewares.linkdr-https.redirectscheme.permanent=true
volumes:
pgdata:
redisdata:
networks:
# Private to this stack. Postgres and Redis are reachable here and nowhere else.
internal:
# Created by Dokploy for Traefik. Only `app` joins it.
dokploy-network:
external: true