Move the demo market to Mexico City, priced in US dollars

The showcase was a Barcelona market: Catalan names, +34 numbers, euro
rates and "Carrer Example 12" on every job. Presented to a Mexican
client, all of that reads as somebody else's product.

City comes from NEXT_PUBLIC_CITY_* as before, now Ciudad de México at
19.4326/-99.1332, with MAPBOX_COUNTRY=mx. The seed's fallbacks were
Barcelona literals, so an unset env quietly seeded a different city
than the app rendered — they now agree.

Two db tests pinned the Barcelona centre as a hardcoded constant, which
is why the deck returned zero cards on the first run here: every pro was
a continent outside the radius. They read the same env as the seed now,
so the trap cannot recur.

Money: formatCents defaults to USD/en-US, and the nine hardcoded euro
signs across the card, search rows, quote strip and forms are dollars.
The rate NUMBERS are unchanged and still read high for CDMX — that is a
pricing decision, not a currency one, and is left alone deliberately.

Seed people are Mexican, addressed on real Roma/Condesa streets rotated
by index rather than one placeholder repeated. Phones moved to +52 55,
which moves the demo login to +525500000000 / 000000.

Also in here, from the same session:
- Sending a job now confirms. The mutation always succeeded; the sheet
  just closed with no receipt, which from the customer's side is
  indistinguishable from a dead button. Dismissing that receipt resolves
  as 'sent', so the card does not return to the deck.
- Media moves to DigitalOcean Spaces, with the public origin derived
  from bucket and region instead of a second env var to keep in sync.
- Managed-Postgres TLS: DATABASE_CA_CERT takes a path or inline PEM.
- The client-facing project panel beside the running app.
- Two profiles removed and four renamed to match their photos.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
serfa
2026-08-23 10:56:31 -04:00
co-authored by Claude Opus 5
parent 5086a238ea
commit 1808ad4cba
113 changed files with 1944 additions and 472 deletions
+6 -6
View File
@@ -1,5 +1,5 @@
{
"name": "@linkder/api",
"name": "@linkdr/api",
"version": "0.0.0",
"private": true,
"type": "module",
@@ -14,11 +14,11 @@
"test": "vitest run"
},
"dependencies": {
"@linkder/db": "workspace:*",
"@linkder/geocode": "workspace:*",
"@linkder/notify": "workspace:*",
"@linkder/shared": "workspace:*",
"@linkder/storage": "workspace:*",
"@linkdr/db": "workspace:*",
"@linkdr/geocode": "workspace:*",
"@linkdr/notify": "workspace:*",
"@linkdr/shared": "workspace:*",
"@linkdr/storage": "workspace:*",
"@opentelemetry/api": "1.9.1",
"@trpc/server": "^11.18.0",
"drizzle-orm": "0.38.4",
+2 -2
View File
@@ -1,5 +1,5 @@
import type { Db } from '@linkder/db';
import type { Role, VerificationStatus } from '@linkder/shared';
import type { Db } from '@linkdr/db';
import type { Role, VerificationStatus } from '@linkdr/shared';
/**
* The session shape the API depends on.
+2 -2
View File
@@ -1,5 +1,5 @@
import { forward, GeocodeError, isConfigured, type GeocodeResult } from '@linkder/geocode';
import type { LatLng, LocationInput, LocationPrecision } from '@linkder/shared';
import { forward, GeocodeError, isConfigured, type GeocodeResult } from '@linkdr/geocode';
import type { LatLng, LocationInput, LocationPrecision } from '@linkdr/shared';
/**
* The one place a stored coordinate is decided.
+5 -5
View File
@@ -1,10 +1,10 @@
import { TRPCError } from '@trpc/server';
import { and, asc, count, desc, eq, inArray } from 'drizzle-orm';
import { z } from 'zod';
import { recomputeProStats, schema } from '@linkder/db';
import { notify } from '@linkder/notify';
import { createPresignedDownload } from '@linkder/storage';
import { assertTransition, VERIFICATION_STATUSES } from '@linkder/shared';
import { recomputeProStats, schema } from '@linkdr/db';
import { notify } from '@linkdr/notify';
import { createPresignedDownload } from '@linkdr/storage';
import { assertTransition, VERIFICATION_STATUSES } from '@linkdr/shared';
import { adminProcedure, router } from '../trpc';
/**
@@ -20,7 +20,7 @@ import { adminProcedure, router } from '../trpc';
* everyone else: an admin surface that announces itself is a target.
*
* Nothing in this router trusts a status it was handed. Each transition goes
* through the graph in @linkder/shared, and each writes an `audit_log` row —
* through the graph in @linkdr/shared, and each writes an `audit_log` row —
* these are the decisions that a regulator, an insurer or a court would ask us
* to account for.
*/
+3 -3
View File
@@ -1,8 +1,8 @@
import { TRPCError } from '@trpc/server';
import { desc, eq } from 'drizzle-orm';
import { z } from 'zod';
import { recomputeProStats, schema, type Db } from '@linkder/db';
import { assertTransition, cancellationOutcome, type BookingStatus } from '@linkder/shared';
import { recomputeProStats, schema, type Db } from '@linkdr/db';
import { assertTransition, cancellationOutcome, type BookingStatus } from '@linkdr/shared';
import { requireMatchParticipant } from './message';
import { protectedProcedure, router } from '../trpc';
@@ -162,7 +162,7 @@ export const bookingRouter = router({
* Call it off.
*
* Either side may, and who cancelled decides who pays — `cancellationOutcome`
* in @linkder/shared owns that rule and is already tested. The result is
* in @linkdr/shared owns that rule and is already tested. The result is
* written into the audit log now, while the scheduled time and the quote are
* still the facts they were; recomputing it later from a slot that has since
* passed would give a different answer.
+3 -3
View File
@@ -1,14 +1,14 @@
import { TRPCError } from '@trpc/server';
import { and, count, desc, eq, inArray, sql } from 'drizzle-orm';
import { z } from 'zod';
import { getDeck, getDeckCount, getShowcaseDeck, schema } from '@linkder/db';
import { notify } from '@linkder/notify';
import { getDeck, getDeckCount, getShowcaseDeck, schema } from '@linkdr/db';
import { notify } from '@linkdr/notify';
import {
DECK_PAGE_SIZE,
MAX_OPEN_REQUESTS_PER_JOB,
REQUEST_TTL_HOURS,
swipeSchema,
} from '@linkder/shared';
} from '@linkdr/shared';
import { clientProcedure, publicProcedure, router } from '../trpc';
import type { Context } from '../context';
+2 -2
View File
@@ -1,8 +1,8 @@
import { TRPCError } from '@trpc/server';
import { and, desc, eq, gt, isNull, or, sql } from 'drizzle-orm';
import { z } from 'zod';
import { schema } from '@linkder/db';
import { ENQUIRY_STALE_DAYS, MAX_OPEN_ENQUIRIES } from '@linkder/shared';
import { schema } from '@linkdr/db';
import { ENQUIRY_STALE_DAYS, MAX_OPEN_ENQUIRIES } from '@linkdr/shared';
import { clientProcedure, proProcedure, protectedProcedure, router } from '../trpc';
/**
+2 -2
View File
@@ -7,8 +7,8 @@ import {
MAX_SUGGESTIONS,
reverse,
type GeocodeResult,
} from '@linkder/geocode';
import { latLngSchema } from '@linkder/shared';
} from '@linkdr/geocode';
import { latLngSchema } from '@linkdr/shared';
import { protectedProcedure, router } from '../trpc';
/**
+2 -2
View File
@@ -1,8 +1,8 @@
import { TRPCError } from '@trpc/server';
import { and, desc, eq, sql } from 'drizzle-orm';
import { z } from 'zod';
import { schema } from '@linkder/db';
import { ACTIVE_JOB_STATUSES, assertTransition, createJobSchema } from '@linkder/shared';
import { schema } from '@linkdr/db';
import { ACTIVE_JOB_STATUSES, assertTransition, createJobSchema } from '@linkdr/shared';
import { resolveLocation } from '../location';
import { clientProcedure, proProcedure, publicProcedure, router } from '../trpc';
+2 -2
View File
@@ -1,8 +1,8 @@
import { TRPCError } from '@trpc/server';
import { and, desc, eq, isNull, ne, or, sql } from 'drizzle-orm';
import { z } from 'zod';
import { schema, type Db } from '@linkder/db';
import { PAST_JOB_STATUSES, sendMessageSchema, type JobStatus } from '@linkder/shared';
import { schema, type Db } from '@linkdr/db';
import { PAST_JOB_STATUSES, sendMessageSchema, type JobStatus } from '@linkdr/shared';
import { protectedProcedure, router } from '../trpc';
/**
+1 -1
View File
@@ -1,6 +1,6 @@
import { eq } from 'drizzle-orm';
import { z } from 'zod';
import { schema } from '@linkder/db';
import { schema } from '@linkdr/db';
import { protectedProcedure, router } from '../trpc';
/**
+3 -3
View File
@@ -2,8 +2,8 @@ import { TRPCError } from '@trpc/server';
import { and, desc, eq, inArray, lt } from 'drizzle-orm';
import { alias } from 'drizzle-orm/pg-core';
import { z } from 'zod';
import { eligibleProAtAnyDistance, schema, searchPros } from '@linkder/db';
import { notify } from '@linkder/notify';
import { eligibleProAtAnyDistance, schema, searchPros } from '@linkdr/db';
import { notify } from '@linkdr/notify';
import {
assertTransition,
credentialSchema,
@@ -12,7 +12,7 @@ import {
REVIEWS_PAGE_SIZE,
searchProsSchema,
updateSkillsSchema,
} from '@linkder/shared';
} from '@linkdr/shared';
import { resolveLocation } from '../location';
import { proProcedure, publicProcedure, router } from '../trpc';
+2 -2
View File
@@ -1,13 +1,13 @@
import { TRPCError } from '@trpc/server';
import { and, desc, eq } from 'drizzle-orm';
import { z } from 'zod';
import { schema } from '@linkder/db';
import { schema } from '@linkdr/db';
import {
assertTransition,
createBookingSchema,
createQuoteSchema,
QUOTE_VALIDITY_HOURS,
} from '@linkder/shared';
} from '@linkdr/shared';
import { requireMatchParticipant } from './message';
import { clientProcedure, protectedProcedure, router, verifiedProProcedure } from '../trpc';
+3 -3
View File
@@ -1,9 +1,9 @@
import { TRPCError } from '@trpc/server';
import { and, eq, gt, sql } from 'drizzle-orm';
import { z } from 'zod';
import { recomputeProStats, schema } from '@linkder/db';
import { notify } from '@linkder/notify';
import { assertTransition } from '@linkder/shared';
import { recomputeProStats, schema } from '@linkdr/db';
import { notify } from '@linkdr/notify';
import { assertTransition } from '@linkdr/shared';
import { proProcedure, router, verifiedProProcedure } from '../trpc';
/**
+2 -2
View File
@@ -1,12 +1,12 @@
import { TRPCError } from '@trpc/server';
import { and, eq, ne, sql } from 'drizzle-orm';
import { z } from 'zod';
import { recomputeProStats, schema } from '@linkder/db';
import { recomputeProStats, schema } from '@linkdr/db';
import {
createReviewSchema,
REVIEW_EMBARGO_HOURS,
REVIEW_WINDOW_DAYS,
} from '@linkder/shared';
} from '@linkdr/shared';
import { requireMatchParticipant } from './message';
import { protectedProcedure, router } from '../trpc';
+1 -1
View File
@@ -1,5 +1,5 @@
import { TRPCError } from '@trpc/server';
import { createPresignedUpload, publicUrl, uploadRequestSchema, StorageError } from '@linkder/storage';
import { createPresignedUpload, publicUrl, uploadRequestSchema, StorageError } from '@linkdr/storage';
import { protectedProcedure, router } from '../trpc';
/**
+2 -2
View File
@@ -2,8 +2,8 @@ import { randomUUID } from 'node:crypto';
import { TRPCError } from '@trpc/server';
import { and, desc, eq, isNull } from 'drizzle-orm';
import { z } from 'zod';
import { schema } from '@linkder/db';
import { assertTransition, isContactableEmail, updateLocationSchema } from '@linkder/shared';
import { schema } from '@linkdr/db';
import { assertTransition, isContactableEmail, updateLocationSchema } from '@linkdr/shared';
import { resolveLocation } from '../location';
import { protectedProcedure, publicProcedure, router } from '../trpc';
+1 -1
View File
@@ -1,7 +1,7 @@
import { TRPCError } from '@trpc/server';
import { and, desc, eq, isNull, sql } from 'drizzle-orm';
import { z } from 'zod';
import { schema } from '@linkder/db';
import { schema } from '@linkdr/db';
import { protectedProcedure, router } from '../trpc';
/**
+1 -1
View File
@@ -2,7 +2,7 @@ import { TRPCError, initTRPC } from '@trpc/server';
import superjson from 'superjson';
import { ZodError } from 'zod';
import { eq } from 'drizzle-orm';
import { schema } from '@linkder/db';
import { schema } from '@linkdr/db';
import type { Context } from './context';
const t = initTRPC.context<Context>().create({
+2 -2
View File
@@ -23,7 +23,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
config({ path: '../../.env' });
const { closePool, db } = await import('@linkder/db');
const { closePool, db } = await import('@linkdr/db');
const { appRouter } = await import('../src/root');
const { createInnerContext } = await import('../src/context');
const { createCallerFactory } = await import('../src/trpc');
@@ -86,7 +86,7 @@ async function makePro(name: string, status: string): Promise<string> {
${id}, 'Admin probe', 'Exists only for the admin router tests.', 3000,
-- Right on the city centre, so an approval is visible to a search run
-- from there and the "it lands on every surface" assertions are real.
ST_SetSRID(ST_MakePoint(2.1686, 41.3874), 4326)::geography, 20000, ${status}
ST_SetSRID(ST_MakePoint(-99.1332, 19.4326), 4326)::geography, 20000, ${status}
)
`);
+3 -3
View File
@@ -2,7 +2,7 @@
* Integration tests for the deck router, run against the live seeded database.
*
* pnpm services:up && pnpm db:migrate && pnpm db:seed
* pnpm --filter @linkder/api test
* pnpm --filter @linkdr/api test
*
* The point of these is authorization. The swipe path previously lived in a Next
* server action that trusted whatever jobId it was handed, so anyone could swipe
@@ -11,11 +11,11 @@
import { config } from 'dotenv';
import { sql } from 'drizzle-orm';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { MAX_OPEN_REQUESTS_PER_JOB } from '@linkder/shared';
import { MAX_OPEN_REQUESTS_PER_JOB } from '@linkdr/shared';
config({ path: '../../.env' });
const { closePool, db } = await import('@linkder/db');
const { closePool, db } = await import('@linkdr/db');
const { appRouter } = await import('../src/root');
const { createInnerContext } = await import('../src/context');
const { createCallerFactory } = await import('../src/trpc');
+2 -2
View File
@@ -12,11 +12,11 @@
import { config } from 'dotenv';
import { sql } from 'drizzle-orm';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { MAX_OPEN_ENQUIRIES } from '@linkder/shared';
import { MAX_OPEN_ENQUIRIES } from '@linkdr/shared';
config({ path: '../../.env' });
const { closePool, db } = await import('@linkder/db');
const { closePool, db } = await import('@linkdr/db');
const { appRouter } = await import('../src/root');
const { createInnerContext } = await import('../src/context');
const { createCallerFactory } = await import('../src/trpc');
+7 -7
View File
@@ -14,7 +14,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
config({ path: '../../.env' });
const { closePool, db } = await import('@linkder/db');
const { closePool, db } = await import('@linkdr/db');
const { appRouter } = await import('../src/root');
const { createInnerContext } = await import('../src/context');
const { createCallerFactory } = await import('../src/trpc');
@@ -37,8 +37,8 @@ const clientSession = (userId: string): Session => ({
const RUN = Math.random().toString(36).slice(2, 8);
const CITY = {
lat: Number(process.env.NEXT_PUBLIC_CITY_LAT ?? 41.3874),
lng: Number(process.env.NEXT_PUBLIC_CITY_LNG ?? 2.1686),
lat: Number(process.env.NEXT_PUBLIC_CITY_LAT ?? 19.4326),
lng: Number(process.env.NEXT_PUBLIC_CITY_LNG ?? -99.1332),
};
let client: string;
@@ -140,13 +140,13 @@ describe('job.create resolves the point server-side', () => {
const job = await callerFor(clientSession(client)).job.create({
...base,
categoryId: plumberCat,
place: { source: 'device', lat: 41.4036, lng: 2.1744, label: 'Gracia' },
place: { source: 'device', lat: 19.4194, lng: -99.1655, label: 'Condesa' },
});
const row = await readJob(job.id);
// A handset fix is real, so the coordinates are kept as sent...
expect(Number(row.lat)).toBeCloseTo(41.4036, 4);
expect(Number(row.lng)).toBeCloseTo(2.1744, 4);
expect(Number(row.lat)).toBeCloseTo(19.4194, 4);
expect(Number(row.lng)).toBeCloseTo(-99.1655, 4);
// ...but it is metres out on a good day, so it must not rank as a rooftop.
expect(row.precision).toBe('approximate');
});
@@ -157,7 +157,7 @@ describe('job.create resolves the point server-side', () => {
const job = await callerFor(clientSession(client)).job.create({
...base,
categoryId: plumberCat,
place: { source: 'place', placeId: 'made-up-id', label: 'Carrer de Sants 12' },
place: { source: 'place', placeId: 'made-up-id', label: 'Av. Álvaro Obregón 12' },
});
const row = await readJob(job.id);
+6 -6
View File
@@ -15,11 +15,11 @@
import { config } from 'dotenv';
import { sql } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { REVIEW_EMBARGO_HOURS } from '@linkder/shared';
import { REVIEW_EMBARGO_HOURS } from '@linkdr/shared';
config({ path: '../../.env' });
const { closePool, db } = await import('@linkder/db');
const { closePool, db } = await import('@linkdr/db');
const { appRouter } = await import('../src/root');
const { createInnerContext } = await import('../src/context');
const { createCallerFactory } = await import('../src/trpc');
@@ -98,7 +98,7 @@ beforeAll(async () => {
)
VALUES (
${pro}, 'Lifecycle pro', 'Exists only for the lifecycle tests.', 4000,
ST_SetSRID(ST_MakePoint(2.1686, 41.3874), 4326)::geography, 'exact',
ST_SetSRID(ST_MakePoint(-99.1332, 19.4326), 4326)::geography, 'exact',
15000, 'verified', now(), false
)
`);
@@ -109,8 +109,8 @@ beforeAll(async () => {
VALUES (
${owner}, ${plumber!.id}, 'Lifecycle fixture job',
'A job that exists to be quoted, booked, completed and reviewed.',
ST_SetSRID(ST_MakePoint(2.1686, 41.3874), 4326)::geography, 'exact',
'Carrer de Prova 1', 'matched'
ST_SetSRID(ST_MakePoint(-99.1332, 19.4326), 4326)::geography, 'exact',
'Calle Colima 1', 'matched'
)
RETURNING id
`);
@@ -220,7 +220,7 @@ describe('booking', () => {
)
VALUES (
${other}, 'Also waiting', 'A second pro left hanging on the same job.', 3500,
ST_SetSRID(ST_MakePoint(2.1686, 41.3874), 4326)::geography, 'exact',
ST_SetSRID(ST_MakePoint(-99.1332, 19.4326), 4326)::geography, 'exact',
15000, 'verified', now(), false
)
`);
+5 -5
View File
@@ -2,7 +2,7 @@
* Integration tests for chat, against the live seeded database.
*
* pnpm services:up && pnpm db:migrate && pnpm db:seed
* pnpm --filter @linkder/api test
* pnpm --filter @linkdr/api test
*
* A thread is a private conversation between exactly two people, so most of this
* file is about the third person: a stranger must not be able to read it, write
@@ -18,7 +18,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
config({ path: '../../.env' });
const { closePool, db } = await import('@linkder/db');
const { closePool, db } = await import('@linkdr/db');
const { appRouter } = await import('../src/root');
const { createInnerContext } = await import('../src/context');
const { createCallerFactory } = await import('../src/trpc');
@@ -78,8 +78,8 @@ async function insertJobWithMatch(categoryId: string, status: string): Promise<{
VALUES (
${owner}, ${categoryId}, 'Chat fixture job',
'A job that exists only so a conversation can hang off it.',
ST_SetSRID(ST_MakePoint(2.1686, 41.3874), 4326)::geography,
'Carrer de Prova 1', ${sql.raw(`'${status}'`)}
ST_SetSRID(ST_MakePoint(-99.1332, 19.4326), 4326)::geography,
'Calle Colima 1', ${sql.raw(`'${status}'`)}
)
RETURNING id
`);
@@ -116,7 +116,7 @@ beforeAll(async () => {
)
VALUES (
${pro}, 'Chat fixture pro', 'Exists only for the message router tests.', 4000,
ST_SetSRID(ST_MakePoint(2.1686, 41.3874), 4326)::geography, 15000, 'verified', now()
ST_SetSRID(ST_MakePoint(-99.1332, 19.4326), 4326)::geography, 15000, 'verified', now()
)
`);
+1 -1
View File
@@ -13,7 +13,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
config({ path: '../../.env' });
const { closePool, db } = await import('@linkder/db');
const { closePool, db } = await import('@linkdr/db');
const { appRouter } = await import('../src/root');
const { createInnerContext } = await import('../src/context');
const { createCallerFactory } = await import('../src/trpc');
+5 -5
View File
@@ -19,7 +19,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
config({ path: '../../.env' });
const { closePool, db } = await import('@linkder/db');
const { closePool, db } = await import('@linkdr/db');
const { appRouter } = await import('../src/root');
const { createInnerContext } = await import('../src/context');
const { createCallerFactory } = await import('../src/trpc');
@@ -45,12 +45,12 @@ let probeClient: string;
beforeAll(async () => {
const [marc] = await db.execute<{ id: string }>(
sql`SELECT id FROM users WHERE name = 'Marc Oliveras' LIMIT 1`,
sql`SELECT id FROM users WHERE name = 'Sergio Fabela' LIMIT 1`,
);
reviewedPro = marc!.id;
const [arnau] = await db.execute<{ id: string }>(
sql`SELECT id FROM users WHERE name = 'Away Arnau' LIMIT 1`,
sql`SELECT id FROM users WHERE name = 'Away Arturo' LIMIT 1`,
);
awayPro = arnau!.id;
@@ -194,7 +194,7 @@ describe('pro.reviews', () => {
});
it('is not a way to read a pro who is off the deck', async () => {
// Away Arnau has a seeded review history and is verified — only holiday mode
// Away Arturo has a seeded review history and is verified — only holiday mode
// hides him. If this stopped 404ing, reviews would be the way around
// publicProfile rather than a view onto it.
await expect(anon().pro.reviews({ proId: awayPro })).rejects.toThrow(/NOT_FOUND|not found/i);
@@ -205,7 +205,7 @@ describe('pro.reviews', () => {
it('404s for an unverified pro, exactly as the profile does', async () => {
const [ulla] = await db.execute<{ id: string }>(
sql`SELECT id FROM users WHERE name = 'Unverified Ulla' LIMIT 1`,
sql`SELECT id FROM users WHERE name = 'Unverified Ulises' LIMIT 1`,
);
await expect(anon().pro.reviews({ proId: ulla!.id })).rejects.toThrow(/NOT_FOUND|not found/i);
});
+6 -6
View File
@@ -14,7 +14,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
config({ path: '../../.env' });
const { closePool, db } = await import('@linkder/db');
const { closePool, db } = await import('@linkdr/db');
const { appRouter } = await import('../src/root');
const { createInnerContext } = await import('../src/context');
const { createCallerFactory } = await import('../src/trpc');
@@ -62,12 +62,12 @@ beforeAll(async () => {
client = aClient!.id;
const [marc] = await db.execute<{ id: string }>(
sql`SELECT id FROM users WHERE name = 'Marc Oliveras' LIMIT 1`,
sql`SELECT id FROM users WHERE name = 'Sergio Fabela' LIMIT 1`,
);
verifiedPro = marc!.id;
const [arnau] = await db.execute<{ id: string }>(
sql`SELECT id FROM users WHERE name = 'Away Arnau' LIMIT 1`,
sql`SELECT id FROM users WHERE name = 'Away Arturo' LIMIT 1`,
);
awayPro = arnau!.id;
@@ -121,7 +121,7 @@ describe('pro.search', () => {
const ids = results.map((p) => p.proId);
const names = results.map((p) => p.name);
expect(names).not.toContain('Unverified Ulla');
expect(names).not.toContain('Unverified Ulises');
expect(ids).not.toContain(awayPro);
expect(ids).not.toContain(bannedPro);
});
@@ -131,7 +131,7 @@ describe('pro.search', () => {
// the pros next to the city centre must fall out of range.
await db.execute(sql`
UPDATE users
SET location = ST_SetSRID(ST_MakePoint(2.1686, 41.5674), 4326)::geography,
SET location = ST_SetSRID(ST_MakePoint(-99.1332, 19.6126), 4326)::geography,
search_radius_m = 2000
WHERE id = ${client}
`);
@@ -166,7 +166,7 @@ describe('pro.publicProfile', () => {
it('refuses a pro who was never verified', async () => {
const [ulla] = await db.execute<{ id: string }>(
sql`SELECT id FROM users WHERE name = 'Unverified Ulla' LIMIT 1`,
sql`SELECT id FROM users WHERE name = 'Unverified Ulises' LIMIT 1`,
);
await expect(callerFor(null).pro.publicProfile({ proId: ulla!.id })).rejects.toThrow();
});
+6 -6
View File
@@ -13,7 +13,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
config({ path: '../../.env' });
const { closePool, db } = await import('@linkder/db');
const { closePool, db } = await import('@linkdr/db');
const { appRouter } = await import('../src/root');
const { createInnerContext } = await import('../src/context');
const { createCallerFactory } = await import('../src/trpc');
@@ -258,15 +258,15 @@ describe('location and range', () => {
// coordinates the server takes at face value, so this test does not need a
// geocoder to be configured.
await caller.user.updateLocation({
place: { source: 'device', lat: 41.4036, lng: 2.1744, label: 'Gracia, Barcelona' },
place: { source: 'device', lat: 19.4194, lng: -99.1655, label: 'Condesa, Ciudad de México' },
radiusM: 8_000,
});
const location = await caller.user.location();
expect(location.addressText).toBe('Gracia, Barcelona');
expect(location.addressText).toBe('Condesa, Ciudad de México');
expect(location.radiusM).toBe(8_000);
expect(location.location?.lat).toBeCloseTo(41.4036, 4);
expect(location.location?.lng).toBeCloseTo(2.1744, 4);
expect(location.location?.lat).toBeCloseTo(19.4194, 4);
expect(location.location?.lng).toBeCloseTo(-99.1655, 4);
});
it('changes only what it was given', async () => {
@@ -276,7 +276,7 @@ describe('location and range', () => {
const location = await caller.user.location();
expect(location.radiusM).toBe(25_000);
// The pin saved by the previous test is still there.
expect(location.location?.lat).toBeCloseTo(41.4036, 4);
expect(location.location?.lat).toBeCloseTo(19.4194, 4);
});
it('refuses a radius outside the supported range', async () => {
+17 -1
View File
@@ -1,13 +1,29 @@
import { readFileSync } from 'node:fs';
import { isAbsolute, resolve } from 'node:path';
import { config } from 'dotenv';
import { defineConfig } from 'drizzle-kit';
config({ path: '../../.env' });
/**
* Same CA rule as src/client.ts, restated because drizzle-kit runs this file on
* its own and cannot import from the package it is generating for.
*/
const caEnv = process.env.DATABASE_CA_CERT;
const ca = caEnv
? caEnv.includes('BEGIN CERTIFICATE')
? caEnv
: readFileSync(isAbsolute(caEnv) ? caEnv : resolve(process.cwd(), caEnv), 'utf8')
: undefined;
export default defineConfig({
schema: './src/schema/index.ts',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: { url: process.env.DATABASE_URL! },
dbCredentials: {
url: process.env.DATABASE_URL!,
...(ca ? { ssl: { ca, rejectUnauthorized: true } } : {}),
},
verbose: true,
strict: true,
});
+6 -4
View File
@@ -1,5 +1,5 @@
{
"name": "@linkder/db",
"name": "@linkdr/db",
"version": "0.0.0",
"private": true,
"type": "module",
@@ -17,13 +17,15 @@
"seed": "tsx src/seed.ts",
"recompute-stats": "tsx src/recompute-stats.ts",
"typecheck": "tsc --noEmit",
"test": "vitest run"
"test": "vitest run",
"assets:migrate": "tsx src/migrate-assets.ts"
},
"dependencies": {
"@linkder/shared": "workspace:*",
"@linkdr/shared": "workspace:*",
"@opentelemetry/api": "1.9.1",
"drizzle-orm": "0.38.4",
"postgres": "^3.4.5"
"postgres": "^3.4.5",
"@aws-sdk/client-s3": "^3.717.0"
},
"devDependencies": {
"dotenv": "^16.4.7",
+56 -9
View File
@@ -1,3 +1,5 @@
import { existsSync, readFileSync } from 'node:fs';
import { dirname, isAbsolute, resolve } from 'node:path';
import { drizzle, type PostgresJsDatabase } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema/index';
@@ -13,10 +15,52 @@ import * as schema from './schema/index';
* 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>;
__linkdrPool?: postgres.Sql;
__linkdrDb?: PostgresJsDatabase<typeof schema>;
};
/**
* TLS for a managed database.
*
* A hosted Postgres is reached over the public internet, so `sslmode=require`
* alone is not enough: it encrypts the connection but verifies nothing, which
* leaves it open to anyone who can answer for the hostname. Handing the
* provider's CA to the client turns that into a checked identity.
*
* `DATABASE_CA_CERT` takes either a path to the .crt or the certificate inline
* — a path locally where the file is on disk, the PEM itself on a platform
* where secrets are environment variables and there is no filesystem to put it
* on. Unset means a plain connection, which is what local Docker wants.
*/
/**
* Find the certificate file from wherever the caller happens to be.
*
* The scripts run from `packages/db`, Next runs from `apps/web`, and the cert
* sits at the repo root — so a relative path resolved against `process.cwd()`
* alone is wrong for every one of them. Walk up instead, the same way the
* scripts already reach `../../.env`.
*/
function readCaFile(path: string): string {
if (isAbsolute(path)) return readFileSync(path, 'utf8');
let dir = process.cwd();
for (let up = 0; up < 5; up += 1) {
const candidate = resolve(dir, path);
if (existsSync(candidate)) return readFileSync(candidate, 'utf8');
const parent = dirname(dir);
if (parent === dir) break;
dir = parent;
}
throw new Error(`DATABASE_CA_CERT points at ${path}, which was not found from ${process.cwd()}`);
}
export function readSsl(): postgres.Options<Record<string, never>>['ssl'] {
const ca = process.env.DATABASE_CA_CERT;
if (!ca) return undefined;
return { ca: ca.includes('BEGIN CERTIFICATE') ? ca : readCaFile(ca), rejectUnauthorized: true };
}
function createPool(): postgres.Sql {
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
@@ -24,25 +68,28 @@ function createPool(): postgres.Sql {
'DATABASE_URL is not set. Copy .env.example to .env and start Postgres with `pnpm services:up`.',
);
}
const ssl = readSsl();
return postgres(connectionString, {
max: Number(process.env.DB_POOL_MAX ?? 10),
idle_timeout: 20,
...(ssl ? { ssl } : {}),
});
}
export function getPool(): postgres.Sql {
const existing = globalForDb.__linkderPool;
const existing = globalForDb.__linkdrPool;
if (existing) return existing;
const created = createPool();
globalForDb.__linkderPool = created;
globalForDb.__linkdrPool = created;
return created;
}
function getDb(): PostgresJsDatabase<typeof schema> {
const existing = globalForDb.__linkderDb;
const existing = globalForDb.__linkdrDb;
if (existing) return existing;
const created = drizzle(getPool(), { schema });
globalForDb.__linkderDb = created;
globalForDb.__linkdrDb = created;
return created;
}
@@ -82,11 +129,11 @@ export const db: Db = new Proxy({} as Db, {
/** Close the pool. For scripts and test teardown — never call this from a request. */
export async function closePool(): Promise<void> {
const existing = globalForDb.__linkderPool;
const existing = globalForDb.__linkdrPool;
if (!existing) return;
await existing.end();
globalForDb.__linkderPool = undefined;
globalForDb.__linkderDb = undefined;
globalForDb.__linkdrPool = undefined;
globalForDb.__linkdrDb = undefined;
}
export { schema };
+175
View File
@@ -0,0 +1,175 @@
/**
* Pull every externally-hosted image into our own bucket.
*
* pnpm assets:migrate
*
* The seed used to point at pravatar, picsum and Unsplash. That is fine for a
* scratch database and wrong for anything anybody is shown: those services rate
* limit, change what a URL returns, and go down — and when they do, a demo is a
* grid of broken images with no way to fix it in the moment.
*
* This fetches each one once, stores it under a DETERMINISTIC key, and rewrites
* the row to our own origin. Idempotent: a key that already exists is left
* alone, so re-running after a reseed costs one HEAD per object rather than
* re-downloading the internet.
*/
import { readFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { sql } from 'drizzle-orm';
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import {
HeadObjectCommand,
PutObjectCommand,
S3Client,
} from '@aws-sdk/client-s3';
import * as schema from './schema/index';
import { readSsl } from './client';
for (const line of readFileSync(new URL('../../../.env', import.meta.url), 'utf8').split('\n')) {
const m = /^([A-Z_]+)=(.*)$/.exec(line.trim());
if (m?.[1]) process.env[m[1]] ??= m[2];
}
const url = process.env.DATABASE_URL;
if (!url) throw new Error('DATABASE_URL is not set');
const region = process.env.SPACES_REGION;
const bucket = process.env.SPACES_BUCKET;
if (!region || !bucket || !process.env.SPACES_KEY || !process.env.SPACES_SECRET) {
throw new Error('Spaces is not configured. Set SPACES_REGION / BUCKET / KEY / SECRET.');
}
const origin = process.env.SPACES_CDN_URL || `https://${bucket}.${region}.digitaloceanspaces.com`;
const client = new S3Client({
region,
endpoint: `https://${region}.digitaloceanspaces.com`,
credentials: {
accessKeyId: process.env.SPACES_KEY,
secretAccessKey: process.env.SPACES_SECRET,
},
});
const ssl = readSsl();
const pg = postgres(url, { max: 1, ...(ssl ? { ssl } : {}) });
const db = drizzle(pg, { schema });
const EXT: Record<string, string> = {
'image/jpeg': 'jpg',
'image/png': 'png',
'image/webp': 'webp',
'image/avif': 'avif',
};
/** Already ours? Then there is nothing to fetch. */
function isLocal(u: string): boolean {
return u.startsWith(origin);
}
async function exists(key: string): Promise<boolean> {
try {
await client.send(new HeadObjectCommand({ Bucket: bucket, Key: key }));
return true;
} catch {
return false;
}
}
/**
* Fetch one image and store it, returning the URL it now lives at.
*
* The key is a hash of the SOURCE url, so the same source always lands on the
* same object: reseeding gives every pro a new uuid, and keying on that would
* fill the bucket with a fresh copy of every photo on every run.
*/
async function adopt(source: string, prefix: string): Promise<string | null> {
const hash = createHash('sha1').update(source).digest('hex').slice(0, 16);
const response = await fetch(source, { redirect: 'follow' });
if (!response.ok) {
console.warn(` ! ${response.status} ${source.slice(0, 60)}`);
return null;
}
const type = (response.headers.get('content-type') ?? 'image/jpeg').split(';')[0]!.trim();
const key = `${prefix}/${hash}.${EXT[type] ?? 'jpg'}`;
if (await exists(key)) return `${origin}/${key}`;
const body = Buffer.from(await response.arrayBuffer());
await client.send(
new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: body,
ContentType: type,
// Public: these are the photos on the deck card. Credential documents are
// a different prefix and are never given an ACL.
ACL: 'public-read',
// A year. The key is content-addressed by source, so a changed image is a
// changed key rather than a stale cache.
CacheControl: 'public, max-age=31536000, immutable',
}),
);
console.log(` + ${(body.length / 1024).toFixed(0)}kb ${key}`);
return `${origin}/${key}`;
}
async function main() {
console.log(`Migrating assets into ${origin}\n`);
const media = await db
.select({ id: schema.proMedia.id, url: schema.proMedia.url, kind: schema.proMedia.kind })
.from(schema.proMedia);
const external = media.filter((m) => !isLocal(m.url));
console.log(`pro_media: ${media.length} rows, ${external.length} still external`);
// Sequential on purpose. Twenty parallel fetches against a free image host is
// how you get rate limited half way through and end up with a bucket that is
// partly migrated and rows that disagree with it.
let moved = 0;
for (const row of external) {
const hosted = await adopt(row.url, row.kind === 'photo' ? 'pro-media' : 'work-samples');
if (!hosted) continue;
await db
.update(schema.proMedia)
.set({ url: hosted })
.where(sql`${schema.proMedia.id} = ${row.id}`);
moved += 1;
}
// Job photos are uploaded by customers and already ours, but a seeded one
// could point outward too.
const jobs = await db
.select({ id: schema.jobs.id, photos: schema.jobs.photos })
.from(schema.jobs);
let jobPhotos = 0;
for (const job of jobs) {
const outward = job.photos.filter((p) => !isLocal(p));
if (outward.length === 0) continue;
const rewritten: string[] = [];
for (const photo of job.photos) {
rewritten.push(isLocal(photo) ? photo : ((await adopt(photo, 'job-photos')) ?? photo));
}
await db
.update(schema.jobs)
.set({ photos: rewritten })
.where(sql`${schema.jobs.id} = ${job.id}`);
jobPhotos += outward.length;
}
console.log(`\n${moved} pro images and ${jobPhotos} job photos now served from ${origin}`);
const left = (
await db.select({ url: schema.proMedia.url }).from(schema.proMedia)
).filter((m) => !isLocal(m.url));
if (left.length) console.warn(`${left.length} still external — rerun to retry.`);
}
await main();
await pg.end();
+5 -1
View File
@@ -3,13 +3,17 @@ import { drizzle } from 'drizzle-orm/postgres-js';
import { migrate } from 'drizzle-orm/postgres-js/migrator';
import { sql } from 'drizzle-orm';
import postgres from 'postgres';
import { readSsl } from './client';
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 });
// Same TLS rule as the app — a managed database is reached over the internet,
// and a migration is the last thing that should run unverified.
const ssl = readSsl();
const client = postgres(url, { max: 1, ...(ssl ? { ssl } : {}) });
const db = drizzle(client);
// PostGIS must exist before any migration that declares a geography column.
+1 -1
View File
@@ -1,5 +1,5 @@
import { sql } from 'drizzle-orm';
import { DECK_PAGE_SIZE, score, type RankingInput } from '@linkder/shared';
import { DECK_PAGE_SIZE, score, type RankingInput } from '@linkdr/shared';
import type { Db } from '../client';
import { eligiblePro } from './eligibility';
+1 -1
View File
@@ -1,5 +1,5 @@
import { sql, type SQL } from 'drizzle-orm';
import { score, type RankingInput } from '@linkder/shared';
import { score, type RankingInput } from '@linkdr/shared';
import type { Db } from '../client';
import { eligiblePro } from './eligibility';
import type { DeckCard } from './deck';
+1 -1
View File
@@ -5,7 +5,7 @@ import type { Db } from '../client';
* Recompute the denormalised ranking counters on `pro_profiles`.
*
* `rating_avg`, `rating_count`, `completed_jobs`, `response_rate` and
* `avg_response_minutes` are inputs to `score()` in @linkder/shared, and until
* `avg_response_minutes` are inputs to `score()` in @linkdr/shared, and until
* this existed nothing ever wrote them after the seed. The deck ranked on
* numbers that were invented once and never moved, and the card told customers
* "usually replies in 25 min" on the strength of it.
+3 -3
View File
@@ -9,7 +9,7 @@ import {
unique,
uuid,
} from 'drizzle-orm/pg-core';
import { DEFAULT_SERVICE_RADIUS_M } from '@linkder/shared';
import { DEFAULT_SERVICE_RADIUS_M } from '@linkdr/shared';
import { point } from '../postgis';
import { locationPrecision, userRole } from './enums';
@@ -38,7 +38,7 @@ export const users = pgTable(
/**
* Required and unique by better-auth. Phone-first users get a synthetic
* address on a domain we control — ALWAYS gate outbound mail on
* `isSyntheticEmail()` from @linkder/shared. Pros must supply a real
* `isSyntheticEmail()` from @linkdr/shared. Pros must supply a real
* address during onboarding; clients may never have one.
*/
email: text('email').notNull().unique(),
@@ -71,7 +71,7 @@ export const users = pgTable(
* "we do not know yet", and callers fall back to the city centre.
*/
location: point('location'),
/** Human label for `location` — "Gràcia, Barcelona". Display only; never matched on. */
/** Human label for `location` — "Condesa, Ciudad de México". Display only; never matched on. */
locationText: text('location_text'),
/**
* See jobs.location_precision. Lowest stakes of the three: this only centres
+3 -3
View File
@@ -7,10 +7,10 @@ import {
QUOTE_STATUSES,
REQUEST_STATUSES,
VERIFICATION_STATUSES,
} from '@linkder/shared';
} from '@linkdr/shared';
/**
* Enums mirror the status unions in @linkder/shared/state-machines.
* Enums mirror the status unions in @linkdr/shared/state-machines.
* Importing them here means a new status cannot be added to the DB without
* also being added to the transition graph.
*/
@@ -22,7 +22,7 @@ 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']);
/** How a stored point was obtained — see LOCATION_PRECISIONS in @linkder/shared. */
/** How a stored point was obtained — see LOCATION_PRECISIONS in @linkdr/shared. */
export const locationPrecision = pgEnum('location_precision', LOCATION_PRECISIONS);
export const swipeDirection = pgEnum('swipe_direction', ['left', 'right']);
export const credentialKind = pgEnum('credential_kind', ['id', 'licence', 'insurance']);
+1 -1
View File
@@ -9,7 +9,7 @@ import { users } from './auth';
* where an existing user has no preferences. Every column therefore defaults to
* the value we would use in the absence of a row.
*
* These are read on every send — see `notify()` in @linkder/notify, which maps
* These are read on every send — see `notify()` in @linkdr/notify, which maps
* each notification kind to the column that governs it and drops the message
* when the answer is false.
*/
+100 -49
View File
@@ -10,24 +10,51 @@ 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 { DEFAULT_SERVICE_RADIUS_M } from '@linkdr/shared';
import * as schema from './schema/index';
import { recomputeAllProStats } from './queries/stats';
import { readSsl } from './client';
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 });
// Same TLS rule as the app — the seed truncates and rewrites everything, so
// it is the last thing that should reach a managed database unverified.
const ssl = readSsl();
const client = postgres(url, { max: 1, ...(ssl ? { ssl } : {}) });
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),
name: process.env.NEXT_PUBLIC_CITY_NAME ?? 'Ciudad de México',
lat: Number(process.env.NEXT_PUBLIC_CITY_LAT ?? 19.4326),
lng: Number(process.env.NEXT_PUBLIC_CITY_LNG ?? -99.1332),
};
/**
* Real CDMX streets, rotated by index.
*
* A demo where every job is at "Example Street 12" reads as filler on the very
* screen — the job list — that is meant to look like real work. These are all
* in Roma/Condesa, which is inside the seeded pros' service radii.
*/
const STREETS = [
'Av. Álvaro Obregón',
'Calle Durango',
'Av. Ámsterdam',
'Calle Colima',
'Av. Michoacán',
'Calle Orizaba',
'Av. Nuevo León',
'Calle Tonalá',
];
/** "Calle Colima 34, Ciudad de México" — a house number and a street, per index. */
function streetAddress(number: number, k = 0): string {
return `${STREETS[k % STREETS.length]} ${number}, ${CITY.name}`;
}
/** 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;
@@ -118,6 +145,19 @@ interface SeedPro {
cat: string;
/** Free-text specialisms. Search matches these, so a few pros must have some. */
skills?: string[];
/**
* REQUIRED. The Unsplash photo shown on this pro's card.
*
* The deck is a people-picker: a card is somebody you are deciding whether to
* let into your home, so a stock portrait of a stranger under the word
* "Electrician" reads as a dating app, and a keyword search for "painter"
* returns oil paintings. Both were tried and both were wrong.
*
* Every id below was chosen by reading Unsplash's own written description and
* keeping only those that say a PERSON is doing THAT trade. Verified by text,
* not by luck — so if one looks wrong, the fix is to swap the id here.
*/
photo: string;
distanceM: number;
rating: number | null;
reviews: number;
@@ -129,43 +169,51 @@ interface SeedPro {
/** 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,
skills: ['Underfloor heating', 'Emergency callouts', 'Boiler swaps'] },
{ name: 'Ana Ferrer', cat: 'plumber', distanceM: 2_400, rating: 4.7, reviews: 23, radius: 15_000,
{ name: 'Antón Bautista', cat: 'plumber', photo: 'photo-1621905252507-b35492cc74b4', distanceM: 2_400, rating: 4.7, reviews: 23, radius: 15_000,
skills: ['Bathroom fitting', 'Leak detection'] },
{ 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 },
{ name: 'Jorge Pineda', cat: 'plumber', photo: 'photo-1659353588842-891391e6fcd4', distanceM: 6_500, rating: 4.5, reviews: 12, radius: 10_000 },
{ name: 'Norma Salgado', cat: 'plumber', photo: 'photo-1558618666-fcd25c85cd64', 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: 'Pablo Rivas', cat: 'plumber', photo: 'photo-1749532125405-70950966b0e5', distanceM: 22_000, rating: 4.8, reviews: 31, radius: 5_000 },
{ name: 'Martino Gómez', cat: 'electrician', photo: 'photo-1621905251189-08b45d6a269e', distanceM: 1_200, rating: 4.8, reviews: 56, radius: 20_000,
skills: ['EV chargers', 'Rewiring', 'Fuse boards'] },
{ 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: 'Omar Campos', cat: 'electrician', photo: 'photo-1660330589693-99889d60181e', distanceM: 3_900, rating: 4.6, reviews: 18, radius: 15_000 },
{ name: 'Marta Villanueva', cat: 'electrician', photo: 'photo-1646640381839-02748ae8ddf0', distanceM: 9_100, rating: 4.9, reviews: 71, radius: 30_000 },
{ name: 'Sergio Bonilla', cat: 'handyman', photo: 'photo-1621905251918-48416bd8575a', distanceM: 1_800, rating: 4.4, reviews: 9, radius: 12_000 },
{ name: 'Iván Serrano', cat: 'handyman', photo: 'photo-1698998882494-57c3e043f340', distanceM: 11_500, rating: 4.2, reviews: 6, radius: 15_000 },
{ name: 'Elena Prado', cat: 'painter', photo: 'photo-1717281234297-3def5ae3eee1', distanceM: 2_100, rating: 4.9, reviews: 28, radius: 20_000 },
{ name: 'Antonio Blanco', cat: 'painter', photo: 'photo-1652829069834-2c05031199c5', distanceM: 7_800, rating: 4.3, reviews: 15, radius: 15_000 },
{ name: 'Rosa Ventura', cat: 'carpenter', photo: 'photo-1659930087003-2d64e33181f7', distanceM: 3_300, rating: 4.8, reviews: 41, radius: 25_000,
skills: ['Fitted wardrobes', 'Listed buildings'] },
{ 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: 'Guillermo Cortés', cat: 'carpenter', photo: 'photo-1544164560-adac3045edb2', distanceM: 8_600, rating: 4.6, reviews: 19, radius: 15_000 },
{ name: 'Javier Durán', cat: 'locksmith', photo: 'photo-1676630656246-3047520adfdf', distanceM: 4_400, rating: 4.5, reviews: 22, radius: 20_000 },
{ name: 'Berta Loera', cat: 'appliance-repair', photo: 'photo-1698998882494-57c3e043f340', distanceM: 2_700, rating: 4.8, reviews: 37, radius: 18_000 },
{ name: 'Adrián Fonseca', cat: 'appliance-repair', photo: 'photo-1621905251918-48416bd8575a', distanceM: 10_200, rating: 4.4, reviews: 11, radius: 20_000 },
{ name: 'Carlo Rincón', cat: 'hvac', photo: 'photo-1642749776312-aa42ce20c9f5', distanceM: 3_100, rating: 4.9, reviews: 44, radius: 22_000,
skills: ['Split units', 'Heat pumps'] },
{ name: 'Marc Segura', cat: 'hvac', distanceM: 12_800, rating: 4.6, reviews: 27, radius: 25_000 },
{ name: 'Marco Segura', cat: 'hvac', photo: 'photo-1705579605238-24a90c8799c5', 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 },
{ name: 'Néstor Bosque', cat: 'plumber', photo: 'photo-1676210134188-4c05dd172f89', distanceM: 1_500, rating: null, reviews: 0, radius: 15_000, isNew: true },
// Five more plumbers with real trade photography, so the first deck a client
// sees is deep and looks like the job it is for.
{ name: 'Sergio Fabela', cat: 'plumber', photo: 'photo-1676210133055-eab6ef033ce3', distanceM: 1_100, rating: 4.8, reviews: 62, radius: 18_000,
skills: ['Underfloor heating', 'Emergency callouts', 'Blocked drains', 'Pipe relining'] },
{ name: 'Rogelio Amaya', cat: 'plumber', photo: 'photo-1676210134190-3f2c0d5cf58d', distanceM: 2_900, rating: 4.6, reviews: 38, radius: 20_000,
skills: ['Boiler servicing', 'Radiator installs'] },
{ name: 'Gerardo Solís', cat: 'plumber', photo: 'photo-1621905252507-b35492cc74b4', distanceM: 4_200, rating: 4.9, reviews: 84, radius: 15_000,
skills: ['Bathroom refits', 'Underfloor heating', 'Wet rooms'] },
{ name: 'Alejandro Dávila', cat: 'plumber', photo: 'photo-1659353588842-891391e6fcd4', distanceM: 5_600, rating: 4.4, reviews: 17, radius: 12_000,
skills: ['Leak detection', 'Tap and valve repairs'] },
{ name: 'Gabriel Torres', cat: 'plumber', photo: 'photo-1558618666-fcd25c85cd64', distanceM: 7_300, rating: 4.7, reviews: 29, radius: 25_000,
skills: ['Water heaters', 'Kitchen plumbing', 'Emergency callouts'] },
{ name: 'Julia Cázares', cat: 'electrician', photo: 'photo-1758101755915-462eddc23f57', 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 },
{ name: 'Unverified Ulises', cat: 'plumber', photo: 'photo-1749532125405-70950966b0e5', 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 },
{ name: 'Away Arturo', cat: 'plumber', photo: 'photo-1676210134188-4c05dd172f89', 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'];
const CLIENTS = ['Sofía Guzmán', 'Daniel Miranda', 'Emma Rivera', 'Lucas Ponce', 'Alba Tovar'];
/**
* Finished work, per trade, for the review histories below.
@@ -282,7 +330,7 @@ const GENERIC_WORK: SeedWork[] = [
*
* Built to AVERAGE to the pro's headline figure rather than scattered around
* it, because the counters are derived from these rows: deck.test.ts asserts
* Marc Oliveras rates 4.9, and that now has to come out of 47 individual
* Sergi Fabra rates 4.8, and that now has to come out of 62 individual
* scores rather than being asserted directly on the profile.
*
* So a 4.9 becomes forty-two 5s and five 4s. Whole stars only — nobody awards
@@ -333,7 +381,7 @@ async function main() {
* cannot log in as — which makes the seed data invisible in the app it
* exists to fill.
*/
phoneNumber: i === 0 ? '+34600000000' : `+3460000${String(i + 1).padStart(4, '0')}`,
phoneNumber: i === 0 ? '+525500000000' : `+52550000${String(i + 1).padStart(4, '0')}`,
role: 'client' as const,
emailVerified: true,
phoneNumberVerified: true,
@@ -344,9 +392,9 @@ async function main() {
console.log(` ${clientRows.length} clients`);
await db.insert(schema.users).values({
name: 'Linkder Admin',
name: 'Linkdr Admin',
email: 'admin@linkder.test',
phoneNumber: '+34600009999',
phoneNumber: '+525500009999',
role: 'admin',
emailVerified: true,
});
@@ -364,7 +412,7 @@ async function main() {
.values({
name: p.name,
email: `pro${i + 1}@linkder.test`,
phoneNumber: `+3461000${String(i + 1).padStart(4, '0')}`,
phoneNumber: `+52551000${String(i + 1).padStart(4, '0')}`,
role: 'pro' as const,
emailVerified: true,
phoneNumberVerified: true,
@@ -406,18 +454,21 @@ async function main() {
await db.insert(schema.proCategories).values({ proId: user.id, categoryId: catId });
proRows.push({ id: user.id, seed: p, categoryId: catId });
const slug = encodeURIComponent(p.name.toLowerCase().replace(/\s+/g, '-'));
// The first photo is the deck card, so it has to be a FACE. picsum returns
// landscape stock scenery — a locksmith on a railway track — which makes the
// deck unreadable as a people-picker no matter what the layout does.
// pravatar serves ~70 portraits; i is 0-based and img is 1-based.
const portrait = (i % 70) + 1;
/*
* The card photo, and a wider crop of the same shot as the work sample.
*
* Seeded with the ORIGINAL Unsplash url, then rewritten to our own bucket by
* `pnpm assets:migrate`. Keeping the source here rather than a hardcoded
* Spaces url means the seed still works on a machine with no bucket, and the
* migration stays the one place that knows where assets live.
*/
const card = `https://images.unsplash.com/${p.photo}?w=800&h=1000&fit=crop`;
await db.insert(schema.proMedia).values([
{ proId: user.id, url: `https://i.pravatar.cc/800?img=${portrait}`, position: 0 },
{ proId: user.id, url: card, position: 0 },
{
// The second slot is genuinely for work: scenery is fine here.
proId: user.id,
url: `https://picsum.photos/seed/${slug}-work/800/1000`,
url: `https://images.unsplash.com/${p.photo}?w=1200&h=900&fit=crop`,
kind: 'work_sample' as const,
position: 1,
},
@@ -494,7 +545,7 @@ async function main() {
urgency: 'flexible' as const,
location: { lat: CITY.lat, lng: CITY.lng },
locationPrecision: 'exact' as const,
addressText: `Carrer Example ${10 + (k % 40)}, ${CITY.name}`,
addressText: streetAddress(10 + (k % 40), k),
status: 'completed' as const,
createdAt: new Date(at(k).getTime() - 6 * 86_400_000),
};
@@ -510,7 +561,7 @@ async function main() {
urgency: 'this_week' as const,
location: { lat: CITY.lat, lng: CITY.lng },
locationPrecision: 'exact' as const,
addressText: `Carrer Example ${60 + (k % 20)}, ${CITY.name}`,
addressText: streetAddress(60 + (k % 20), k + 3),
status: 'cancelled' as const,
createdAt: new Date(at(n + k).getTime() - 6 * 86_400_000),
})),
@@ -623,7 +674,7 @@ async function main() {
budgetMaxCents: 20_000,
location: { lat: CITY.lat, lng: CITY.lng },
locationPrecision: 'exact' as const,
addressText: `Carrer Example 12, ${CITY.name}`,
addressText: streetAddress(12),
})
.returning();
console.log(` 1 open job at the city centre (${job?.id})`);
@@ -679,7 +730,7 @@ async function main() {
urgency: 'this_week' as const,
location: { lat: CITY.lat, lng: CITY.lng },
locationPrecision: 'exact' as const,
addressText: `Carrer Example 12, ${CITY.name}`,
addressText: streetAddress(12),
status: c.status,
})
.returning();
+30 -19
View File
@@ -2,7 +2,7 @@
* Integration test — runs against a live seeded database.
*
* pnpm services:up && pnpm db:migrate && pnpm db:seed
* pnpm --filter @linkder/db test
* pnpm --filter @linkdr/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
@@ -52,23 +52,34 @@ describe('getDeck', () => {
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']);
expect(names).toEqual([
'Alejandro Dávila',
'Antón Bautista',
'Gabriel Torres',
'Gerardo Solís',
'Jorge Pineda',
// Sorted by code unit, so accented letters land after plain ASCII ones.
'Norma Salgado',
'Néstor Bosque',
'Rogelio Amaya',
'Sergio Fabela',
]);
});
it('excludes a pro whose service radius does not reach the job', async () => {
// Pau Ribas is 22km away but only travels 5km.
// Pablo Rivas is 22km away but only travels 5km.
const deck = await getDeck(db, { jobId });
expect(deck.map((c) => c.name)).not.toContain('Pau Ribas');
expect(deck.map((c) => c.name)).not.toContain('Pablo Rivas');
});
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');
expect(deck.map((c) => c.name)).not.toContain('Unverified Ulises');
});
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');
expect(deck.map((c) => c.name)).not.toContain('Away Arturo');
});
it('excludes pros from other trades', async () => {
@@ -80,32 +91,32 @@ describe('getDeck', () => {
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);
const sergi = deck.find((c) => c.name === 'Sergio Fabela');
expect(sergi).toBeDefined();
expect(sergi!.distanceM).toBeGreaterThan(1_000);
expect(sergi!.distanceM).toBeLessThan(1_200);
});
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);
const sergi = deck.findIndex((c) => c.name === 'Sergio Fabela'); // 1.1km, 4.8 x62
const nuria = deck.findIndex((c) => c.name === 'Norma Salgado'); // 18km, 5.0 x3
expect(sergi).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');
const nil = deck.findIndex((c) => c.name === 'Néstor Bosque');
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')!;
const card = deck.find((c) => c.name === 'Sergio Fabela')!;
expect(card.photos.length).toBeGreaterThan(0);
expect(card.ratingAvg).toBeCloseTo(4.9, 1);
expect(card.ratingCount).toBe(47);
expect(card.ratingAvg).toBeCloseTo(4.8, 1);
expect(card.ratingCount).toBe(62);
expect(card.hourlyRateCents).toBeGreaterThan(0);
});
@@ -184,8 +195,8 @@ 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);
expect(job.location.lat).toBeCloseTo(Number(process.env.NEXT_PUBLIC_CITY_LAT ?? 19.4326), 4);
expect(job.location.lng).toBeCloseTo(Number(process.env.NEXT_PUBLIC_CITY_LNG ?? -99.1332), 4);
});
it('never puts the client on their own deck', async () => {
+16 -13
View File
@@ -2,7 +2,7 @@
* Integration test — runs against a live seeded database.
*
* pnpm services:up && pnpm db:migrate && pnpm db:seed
* pnpm --filter @linkder/db test
* pnpm --filter @linkdr/db test
*
* Search is a second door onto the same supply as the deck, so the test that
* matters most is the parity one: a pro who can be found here must be a pro who
@@ -19,7 +19,10 @@ const { searchPros } = await import('../src/queries/search');
const { getShowcaseDeck } = await import('../src/queries/deck');
/** The seed places every pro relative to this point. */
const CENTRE = { lat: 41.3874, lng: 2.1686 };
const CENTRE = {
lat: Number(process.env.NEXT_PUBLIC_CITY_LAT ?? 19.4326),
lng: Number(process.env.NEXT_PUBLIC_CITY_LNG ?? -99.1332),
};
let plumberId: string;
@@ -31,17 +34,17 @@ beforeAll(async () => {
plumberId = plumber.id;
// Seeded skills are empty, so text search has nothing to match until we give
// one pro something to find. Marc Oliveras is 800 m from the centre.
// one pro something to find. Sergio Fabela is 1.1 km from the centre.
await db.execute(sql`
UPDATE pro_profiles SET skills = ARRAY['Underfloor heating', 'Emergency callouts']
WHERE user_id = (SELECT id FROM users WHERE name = 'Marc Oliveras')
WHERE user_id = (SELECT id FROM users WHERE name = 'Sergio Fabela')
`);
});
afterAll(async () => {
await db.execute(sql`
UPDATE pro_profiles SET skills = '{}'::text[]
WHERE user_id = (SELECT id FROM users WHERE name = 'Marc Oliveras')
WHERE user_id = (SELECT id FROM users WHERE name = 'Sergio Fabela')
`);
await closePool();
});
@@ -66,17 +69,17 @@ describe('searchPros', () => {
it('excludes the unverified, the away and the too-far', async () => {
const names = (await searchPros(db, { ...CENTRE, limit: 50 })).map((p) => p.name);
expect(names).not.toContain('Unverified Ulla');
expect(names).not.toContain('Away Arnau');
expect(names).not.toContain('Pau Ribas'); // 22 km out, 5 km radius
expect(names).not.toContain('Unverified Ulises');
expect(names).not.toContain('Away Arturo');
expect(names).not.toContain('Pablo Rivas'); // 22 km out, 5 km radius
});
it('matches a skill', async () => {
const names = (await searchPros(db, { ...CENTRE, q: 'underfloor', limit: 50 })).map(
(p) => p.name,
);
expect(names).toContain('Marc Oliveras');
expect(names).not.toContain('Laia Mestre'); // an electrician with no such skill
expect(names).toContain('Sergio Fabela');
expect(names).not.toContain('Martino Gómez'); // an electrician with no such skill
});
it('matches a trade name', async () => {
@@ -86,8 +89,8 @@ describe('searchPros', () => {
});
it('matches a pro by name', async () => {
const names = (await searchPros(db, { ...CENTRE, q: 'oliveras', limit: 50 })).map((p) => p.name);
expect(names).toEqual(['Marc Oliveras']);
const names = (await searchPros(db, { ...CENTRE, q: 'fabela', limit: 50 })).map((p) => p.name);
expect(names).toEqual(['Sergio Fabela']);
});
it('treats wildcards as literal characters', async () => {
@@ -105,7 +108,7 @@ describe('searchPros', () => {
it("honours the searcher's own distance limit", async () => {
const near = await searchPros(db, { ...CENTRE, maxDistanceM: 3_000, limit: 50 });
expect(near.every((p) => p.distanceM <= 3_000)).toBe(true);
expect(near.map((p) => p.name)).not.toContain('Marta Vidal'); // 9.1 km out
expect(near.map((p) => p.name)).not.toContain('Marta Villanueva'); // 9.1 km out
});
it('sorts by distance, price and rating', async () => {
+19 -16
View File
@@ -2,7 +2,7 @@
* Integration test — runs against a live seeded database.
*
* pnpm services:up && pnpm db:migrate && pnpm db:seed
* pnpm --filter @linkder/db test
* pnpm --filter @linkdr/db test
*
* getShowcaseDeck feeds the entry screen, which is the one deck an anonymous
* visitor sees. Its whole promise is the word "verified": nobody may appear in
@@ -19,7 +19,10 @@ const { closePool, db } = await import('../src/client');
const { getShowcaseDeck } = await import('../src/queries/deck');
/** The seed places the fixture job, and every distance, relative to this point. */
const CENTRE = { lat: 41.3874, lng: 2.1686 };
const CENTRE = {
lat: Number(process.env.NEXT_PUBLIC_CITY_LAT ?? 19.4326),
lng: Number(process.env.NEXT_PUBLIC_CITY_LNG ?? -99.1332),
};
let names: (string | null)[];
@@ -38,23 +41,23 @@ describe('getShowcaseDeck', () => {
});
it('excludes a pro who will not travel this far', () => {
// Pau Ribas is seeded 22 km out with a 5 km service radius.
expect(names).not.toContain('Pau Ribas');
// Pablo Rivas is seeded 22 km out with a 5 km service radius.
expect(names).not.toContain('Pablo Rivas');
});
it('excludes a pro whose verification has not passed', () => {
// Unverified Ulla is 1 km away — close enough to prove distance is not
// Unverified Ulises is 1 km away — close enough to prove distance is not
// what is keeping her out.
expect(names).not.toContain('Unverified Ulla');
expect(names).not.toContain('Unverified Ulises');
});
it('excludes a verified pro who is not accepting work', () => {
// Away Arnau is verified and nearby, but on holiday mode.
expect(names).not.toContain('Away Arnau');
// Away Arturo is verified and nearby, but on holiday mode.
expect(names).not.toContain('Away Arturo');
});
it('includes the nearest eligible pro', () => {
expect(names).toContain('Marc Oliveras');
expect(names).toContain('Sergio Fabela');
});
it('honours the limit', async () => {
@@ -63,13 +66,13 @@ describe('getShowcaseDeck', () => {
});
it('honours the searcher own range, not just the pro one', async () => {
// Marta Vidal is seeded 9.1 km out with a 30 km radius: she would travel
// Marta Villanueva is seeded 9.1 km out with a 30 km radius: she would travel
// here happily, but someone who said "within 3 km" did not ask for her.
const near = await getShowcaseDeck(db, { ...CENTRE, maxDistanceM: 3_000, limit: 100 });
const nearNames = near.map((c) => c.name);
expect(nearNames).not.toContain('Marta Vidal');
expect(nearNames).toContain('Marc Oliveras'); // 800 m away
expect(nearNames).not.toContain('Marta Villanueva');
expect(nearNames).toContain('Sergio Fabela'); // 1.1 km away
expect(near.every((c) => c.distanceM <= 3_000)).toBe(true);
});
@@ -97,11 +100,11 @@ describe('getShowcaseDeck', () => {
const cards = await getShowcaseDeck(db, { ...CENTRE, categoryId: plumber.id, limit: 100 });
const filtered = cards.map((c) => c.name);
// Pau Ribas is a plumber — he is kept out by radius, not by trade, so this
// Pablo Rivas is a plumber — he is kept out by radius, not by trade, so this
// proves the category filter did not replace the eligibility rules.
expect(filtered).not.toContain('Pau Ribas');
expect(filtered).not.toContain('Unverified Ulla');
expect(filtered).not.toContain('Away Arnau');
expect(filtered).not.toContain('Pablo Rivas');
expect(filtered).not.toContain('Unverified Ulises');
expect(filtered).not.toContain('Away Arturo');
});
it('never returns a card with a distance beyond that pros own radius', async () => {
+2 -2
View File
@@ -1,5 +1,5 @@
{
"name": "@linkder/geocode",
"name": "@linkdr/geocode",
"version": "0.0.0",
"private": true,
"type": "module",
@@ -13,7 +13,7 @@
"test": "vitest run"
},
"dependencies": {
"@linkder/shared": "workspace:*",
"@linkdr/shared": "workspace:*",
"zod": "^3.24.1"
},
"devDependencies": {
+3 -3
View File
@@ -1,10 +1,10 @@
import { z } from 'zod';
import type { LatLng, LocationPrecision } from '@linkder/shared';
import type { LatLng, LocationPrecision } from '@linkdr/shared';
/**
* Address → coordinates.
*
* Linkder matches on distance: `ST_Distance(p.base_location, j.location)` ranks
* Linkdr matches on distance: `ST_Distance(p.base_location, j.location)` ranks
* every deck and `ST_DWithin(..., p.service_radius_m)` decides who is eligible
* at all. Before this package both operands were the city centre for any user
* who declined the browser's location prompt, so the ranking was ordering by
@@ -44,7 +44,7 @@ function readConfig(): GeocodeConfig {
throw new GeocodeError('Geocoding is not configured. Missing: MAPBOX_TOKEN');
}
// Bounding results to one country is a quality decision, not a security one:
// "Carrer de Sants" matches in several places and the wrong continent is a
// "Av. Juárez" matches in several places and the wrong continent is a
// worse answer than no answer.
return { token, country: process.env.MAPBOX_COUNTRY ?? 'es' };
}
+8 -8
View File
@@ -27,7 +27,7 @@ function feature(overrides: {
full_address: overrides.fullAddress,
name: overrides.name,
place_formatted: overrides.placeFormatted,
coordinates: { longitude: overrides.lng ?? 2.1686, latitude: overrides.lat ?? 41.3874 },
coordinates: { longitude: overrides.lng ?? -99.1332, latitude: overrides.lat ?? 19.4326 },
},
};
}
@@ -66,26 +66,26 @@ describe('parseResponse', () => {
feature({
featureType: 'address',
id: 'addr-1',
fullAddress: 'Carrer de Sants 12, 08014 Barcelona, Spain',
lat: 41.3751,
lng: 2.1339,
fullAddress: 'Av. Álvaro Obregón 12, 06700 Ciudad de México, Mexico',
lat: 19.4194,
lng: -99.1655,
}),
],
});
expect(result).toEqual({
providerId: 'addr-1',
label: 'Carrer de Sants 12, 08014 Barcelona, Spain',
coordinates: { lat: 41.3751, lng: 2.1339 },
label: 'Av. Álvaro Obregón 12, 06700 Ciudad de México, Mexico',
coordinates: { lat: 19.4194, lng: -99.1655 },
precision: 'exact',
});
});
it('builds a label from name and place when there is no full address', () => {
const [result] = parseResponse({
features: [feature({ featureType: 'street', name: 'Carrer de Sants', placeFormatted: 'Barcelona, Spain' })],
features: [feature({ featureType: 'street', name: 'Av. Álvaro Obregón', placeFormatted: 'Ciudad de México, Mexico' })],
});
expect(result?.label).toBe('Carrer de Sants, Barcelona, Spain');
expect(result?.label).toBe('Av. Álvaro Obregón, Ciudad de México, Mexico');
expect(result?.precision).toBe('approximate');
});
+3 -3
View File
@@ -1,5 +1,5 @@
{
"name": "@linkder/notify",
"name": "@linkdr/notify",
"version": "0.0.0",
"private": true,
"type": "module",
@@ -13,8 +13,8 @@
"test": "vitest run"
},
"dependencies": {
"@linkder/db": "workspace:*",
"@linkder/shared": "workspace:*",
"@linkdr/db": "workspace:*",
"@linkdr/shared": "workspace:*",
"drizzle-orm": "0.38.4"
},
"devDependencies": {
+7 -7
View File
@@ -1,6 +1,6 @@
import { eq } from 'drizzle-orm';
import { schema, type Db } from '@linkder/db';
import { isContactableEmail } from '@linkder/shared';
import { schema, type Db } from '@linkdr/db';
import { isContactableEmail } from '@linkdr/shared';
import { NotConfiguredError, sendEmail, sendSms } from './transport';
export { sendEmail, sendSms, NotConfiguredError } from './transport';
@@ -86,14 +86,14 @@ function render(input: NotifyInput): Message {
return {
subject: `New ${input.trade} job ${km}km away`,
body:
`Linkder: a ${input.trade.toLowerCase()} job ${km}km away is waiting on your answer. ` +
`Linkdr: a ${input.trade.toLowerCase()} job ${km}km away is waiting on your answer. ` +
`You have ${input.expiresInHours} hours before it goes to someone else.`,
};
}
case 'request.accepted':
return {
subject: `${input.proName} wants your job`,
body: `Linkder: ${input.proName} said yes to "${input.jobTitle}". Open the app to agree a price.`,
body: `Linkdr: ${input.proName} said yes to "${input.jobTitle}". Open the app to agree a price.`,
};
case 'verification.submitted':
return {
@@ -102,13 +102,13 @@ function render(input: NotifyInput): Message {
};
case 'verification.approved':
return {
subject: 'You are live on Linkder',
body: 'Linkder: you are verified. Customers in your area can see and swipe your card now.',
subject: 'You are live on Linkdr',
body: 'Linkdr: you are verified. Customers in your area can see and swipe your card now.',
};
case 'verification.rejected':
return {
subject: 'We could not approve your account yet',
body: `Linkder: we could not approve your account yet. ${input.notes} Fix it and submit again.`,
body: `Linkdr: we could not approve your account yet. ${input.notes} Fix it and submit again.`,
};
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { isContactableEmail } from '@linkder/shared';
import { isContactableEmail } from '@linkdr/shared';
/**
* The two ways we can put a message in front of somebody.
+1 -1
View File
@@ -24,7 +24,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
config({ path: '../../.env' });
const { closePool, db } = await import('@linkder/db');
const { closePool, db } = await import('@linkdr/db');
const { notify } = await import('../src/index');
const RUN = Math.random().toString(36).slice(2, 8);
+1 -1
View File
@@ -1,5 +1,5 @@
{
"name": "@linkder/shared",
"name": "@linkdr/shared",
"version": "0.0.0",
"private": true,
"type": "module",
+2 -2
View File
@@ -57,8 +57,8 @@ export const LATE_CANCELLATION_FEE_BPS = 2500; // 25% of the quote
/** Default platform commission. Overridden by PLATFORM_FEE_BPS env at runtime. */
export const DEFAULT_PLATFORM_FEE_BPS = 1500; // 15%
export const MIN_QUOTE_CENTS = 500; // 5 — below this, escrow overhead isn't worth it
export const MAX_QUOTE_CENTS = 2_000_000; // 20,000 sanity ceiling
export const MIN_QUOTE_CENTS = 500; // $5 — below this, escrow overhead isn't worth it
export const MAX_QUOTE_CENTS = 2_000_000; // $20,000 sanity ceiling
/** Free-text specialisms on a pro profile. A card nobody can read is worse than a short one. */
export const MAX_SKILLS = 12;
+1 -1
View File
@@ -39,7 +39,7 @@ export function splitCharge(amount: Cents, feeBps: Bps): { fee: Cents; payout: C
return { fee, payout: amount - fee };
}
export function formatCents(amount: Cents, currency = 'EUR', locale = 'en-IE'): string {
export function formatCents(amount: Cents, currency = 'USD', locale = 'en-US'): string {
return new Intl.NumberFormat(locale, { style: 'currency', currency }).format(amount / 100);
}
+1 -1
View File
@@ -47,7 +47,7 @@ describe('parseAmountToCents', () => {
expect(parseAmountToCents('150')).toBe(15_000);
expect(parseAmountToCents('150.50')).toBe(15_050);
expect(parseAmountToCents('150,50')).toBe(15_050);
expect(parseAmountToCents('150.50')).toBe(15_050);
expect(parseAmountToCents('$150.50')).toBe(15_050);
});
it('rounds to the nearest cent rather than truncating', () => {
+2 -2
View File
@@ -1,5 +1,5 @@
{
"name": "@linkder/storage",
"name": "@linkdr/storage",
"version": "0.0.0",
"private": true,
"type": "module",
@@ -20,6 +20,6 @@
"devDependencies": {
"typescript": "^5.7.3",
"vitest": "^2.1.8",
"@linkder/shared": "workspace:*"
"@linkdr/shared": "workspace:*"
}
}
+39 -20
View File
@@ -9,11 +9,14 @@ import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { z } from 'zod';
/**
* Direct-to-R2 uploads.
* Direct-to-Spaces uploads.
*
* DigitalOcean Spaces, which is S3-compatible — so this is the AWS SDK pointed
* at a different endpoint, and nothing above this file knows the difference.
*
* Files never pass through the Next server: the browser asks for a presigned
* PUT, uploads straight to R2, then tells us the key. That keeps a 10 MB licence
* scan off the request path and out of the serverless body limit.
* PUT, uploads straight to the bucket, then tells us the key. That keeps a 10 MB
* licence scan off the request path and out of the serverless body limit.
*
* The security property that matters: the server chooses the key and pins the
* content type and length. A client cannot upload a 2 GB file, cannot overwrite
@@ -75,7 +78,8 @@ export interface PresignedUpload {
export class StorageError extends Error {}
interface StorageConfig {
accountId: string;
/** Spaces datacentre, e.g. `nyc3`. Part of both the endpoint and the URL. */
region: string;
accessKeyId: string;
secretAccessKey: string;
bucket: string;
@@ -83,18 +87,16 @@ interface StorageConfig {
}
function readConfig(): StorageConfig {
const accountId = process.env.R2_ACCOUNT_ID;
const accessKeyId = process.env.R2_ACCESS_KEY_ID;
const secretAccessKey = process.env.R2_SECRET_ACCESS_KEY;
const bucket = process.env.R2_BUCKET;
const publicUrl = process.env.R2_PUBLIC_URL;
const region = process.env.SPACES_REGION;
const accessKeyId = process.env.SPACES_KEY;
const secretAccessKey = process.env.SPACES_SECRET;
const bucket = process.env.SPACES_BUCKET;
const missing = Object.entries({
R2_ACCOUNT_ID: accountId,
R2_ACCESS_KEY_ID: accessKeyId,
R2_SECRET_ACCESS_KEY: secretAccessKey,
R2_BUCKET: bucket,
R2_PUBLIC_URL: publicUrl,
SPACES_REGION: region,
SPACES_KEY: accessKeyId,
SPACES_SECRET: secretAccessKey,
SPACES_BUCKET: bucket,
})
.filter(([, v]) => !v)
.map(([k]) => k);
@@ -102,12 +104,27 @@ function readConfig(): StorageConfig {
if (missing.length) {
throw new StorageError(`Object storage is not configured. Missing: ${missing.join(', ')}`);
}
/*
* The public origin is DERIVED, not configured.
*
* Spaces serves every bucket at `https://<bucket>.<region>.digitaloceanspaces.com`,
* so a separate env var for it is a second place to be wrong — and the way it
* goes wrong is that objects upload to one bucket and render from another,
* which looks like a broken image rather than a misconfiguration.
*
* SPACES_CDN_URL overrides it for the case that genuinely needs one: the CDN
* endpoint, or a custom domain in front of the bucket.
*/
const publicUrl =
process.env.SPACES_CDN_URL ?? `https://${bucket!}.${region!}.digitaloceanspaces.com`;
return {
accountId: accountId!,
region: region!,
accessKeyId: accessKeyId!,
secretAccessKey: secretAccessKey!,
bucket: bucket!,
publicUrl: publicUrl!.replace(/\/$/, ''),
publicUrl: publicUrl.replace(/\/$/, ''),
};
}
@@ -117,8 +134,10 @@ function getClient() {
if (cached) return cached;
const config = readConfig();
const client = new S3Client({
region: 'auto',
endpoint: `https://${config.accountId}.r2.cloudflarestorage.com`,
// Spaces is S3-compatible, so the only difference from AWS is the endpoint.
// The region is real (not `auto`) because it is part of the signature.
region: config.region,
endpoint: `https://${config.region}.digitaloceanspaces.com`,
credentials: {
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
@@ -128,7 +147,7 @@ function getClient() {
return cached;
}
/** Extension for a content type. Keys carry one so R2 serves the right thing back. */
/** Extension for a content type. Keys carry one so Spaces serves the right thing back. */
const EXTENSIONS: Record<string, string> = {
'image/jpeg': 'jpg',
'image/png': 'png',
@@ -169,7 +188,7 @@ export function validateUpload(input: UploadRequest): void {
* Sign a one-shot PUT.
*
* `ContentLength` is signed too, so the client cannot request a small file and
* then push a huge one — R2 rejects a mismatched body.
* then push a huge one — the mismatched body is rejected at the edge.
*/
export async function createPresignedUpload(
input: UploadRequest & { ownerId: string },
+8 -8
View File
@@ -107,11 +107,11 @@ describe('configuration', () => {
beforeEach(() => {
resetStorageClient();
for (const k of [
'R2_ACCOUNT_ID',
'R2_ACCESS_KEY_ID',
'R2_SECRET_ACCESS_KEY',
'R2_BUCKET',
'R2_PUBLIC_URL',
'SPACES_REGION',
'SPACES_KEY',
'SPACES_SECRET',
'SPACES_BUCKET',
'SPACES_CDN_URL',
]) {
delete process.env[k];
}
@@ -131,7 +131,7 @@ describe('configuration', () => {
contentLength: 100,
ownerId: OWNER,
}),
).rejects.toThrow(/R2_ACCOUNT_ID.*R2_ACCESS_KEY_ID/s);
).rejects.toThrow(/SPACES_REGION.*SPACES_KEY/s);
});
it('validates the upload before it complains about configuration', async () => {
@@ -166,13 +166,13 @@ describe('key/URL contract with the API', () => {
});
it('accepts that key against the credential schema', async () => {
const { credentialSchema } = await import('@linkder/shared');
const { credentialSchema } = await import('@linkdr/shared');
const key = buildKey('credential', OWNER, 'application/pdf');
expect(credentialSchema.safeParse({ kind: 'insurance', fileKey: key }).success).toBe(true);
});
it('rejects an empty key rather than storing a dangling reference', async () => {
const { credentialSchema } = await import('@linkder/shared');
const { credentialSchema } = await import('@linkdr/shared');
expect(credentialSchema.safeParse({ kind: 'insurance', fileKey: '' }).success).toBe(false);
});
});