diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index deb22d7e..2cdb1a0c 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -38,6 +38,7 @@ import { defaultInternalJobs, internalRoutes, type InternalJobs } from './routes import { legalRoutes } from './routes/legal'; import { meRoutes } from './routes/me'; import { meExportRoutes } from './routes/me-export'; +import { placesRoutes } from './routes/places'; import { plannedRoutesPlugin } from './routes/planned'; import { routeRatingsPlugin } from './routes/ratings'; import { routeReportsRoutes } from './routes/route-reports'; @@ -182,6 +183,7 @@ export async function buildApp(config: Config, options: BuildAppOptions = {}) { }); await app.register(usersRoutes, { prefix: '/v1' }); await app.register(editorRoutes, { prefix: '/v1' }); + await app.register(placesRoutes, { prefix: '/v1' }); await app.register(geocodeRoutes, { prefix: '/v1', geocoder: options.geocoder ?? geocoderOf(config, app.log), diff --git a/apps/api/src/errors.ts b/apps/api/src/errors.ts index 0a03d85f..7916ca6a 100644 --- a/apps/api/src/errors.ts +++ b/apps/api/src/errors.ts @@ -54,6 +54,17 @@ export function pendingRoutesLimitReached(limit: number) { }); } +/** + * 429 `rate_limited` of a place creation over the daily limit of the account (wiki + * Securite-et-RGPD › Limitation de débit): `details.limit` is the number said to the creator. + */ +export function placeCreationLimitReached(limit: number) { + return Object.assign(new Error('Too many places created today'), { + statusCode: 429, + details: { reason: 'place_creation', limit }, + }); +} + /** 409 `conflict` of a request the current state refuses, that state in `details`. */ export function conflict(message: string, details: Record) { return Object.assign(new Error(message), { statusCode: 409, details }); diff --git a/apps/api/src/places/create.ts b/apps/api/src/places/create.ts new file mode 100644 index 00000000..a5dc6628 --- /dev/null +++ b/apps/api/src/places/create.ts @@ -0,0 +1,108 @@ +/** + * `POST /places` (E-13, D-049): a place of a user, `unverified`, in the city that holds it. A close + * place of a close name is offered instead (409), unless the creator insists. + */ +import { + placeCreationDailyLimit, + placeDuplicateNameSimilarity, + placeDuplicateRadiusM, + type CreatePlace, + type Place, +} from '@widoo/shared'; +import { and, count, eq, gt, ne, sql } from 'drizzle-orm'; +import type { Db, Tx } from '../db/client'; +import { cities, placeHours, places, users } from '../db/schema'; +import { bodyValidationError, conflict, placeCreationLimitReached } from '../errors'; +import { hitColumns, readPlace, toHit, type HitRow } from './read'; +import { distanceTo } from './search'; + +const dayMs = 24 * 60 * 60 * 1000; + +/** The city whose envelope holds the point; the cities of a new place are made by #340. */ +export async function cityHolding(tx: Db | Tx, point: { lat: number; lng: number }) { + const [city] = await tx + .select({ id: cities.id }) + .from(cities) + .where( + sql`ST_Covers(${cities.bounds}, ST_MakePoint(${point.lng}::float8, ${point.lat}::float8)::geography)`, + ) + .limit(1); + return city?.id; +} + +export const outsideAnyCity = () => + bodyValidationError([ + { + path: 'location', + code: 'outside_service_area', + message: 'No city holds this position yet', + }, + ]); + +/** The nearest place of a close name within the radius, if any: the one E-13 offers. */ +async function closePlace(tx: Tx, input: Pick) { + const similarity = sql`similarity(${places.name}, ${input.name})`; + const [row] = await tx + .select(hitColumns(distanceTo(input.location))) + .from(places) + .where( + and( + ne(places.verificationStatus, 'closed'), + sql`ST_DWithin(${places.location}, ST_MakePoint(${input.location.lng}::float8, ${input.location.lat}::float8)::geography, ${placeDuplicateRadiusM})`, + sql`${similarity} >= ${placeDuplicateNameSimilarity}`, + ), + ) + .orderBy(sql`${similarity} desc`) + .limit(1); + return row ? toHit(row as HitRow) : undefined; +} + +/** + * Throws 429 when the account already created the daily maximum. The account row is locked, so + * two creations at once cannot both take the last place. + */ +async function assertCreationRoom(tx: Tx, userId: string, now: Date): Promise { + await tx.select({ id: users.id }).from(users).where(eq(users.id, userId)).for('no key update'); + const [created] = await tx + .select({ count: count() }) + .from(places) + .where( + and(eq(places.createdBy, userId), gt(places.createdAt, new Date(now.getTime() - dayMs))), + ); + if ((created?.count ?? 0) >= placeCreationDailyLimit) { + throw placeCreationLimitReached(placeCreationDailyLimit); + } +} + +export async function createPlace( + db: Db, + userId: string, + { force, hours, ...input }: CreatePlace, + now: Date, +): Promise { + return db.transaction(async (tx) => { + await assertCreationRoom(tx, userId, now); + const cityId = await cityHolding(tx, input.location); + if (!cityId) throw outsideAnyCity(); + if (!force) { + const candidate = await closePlace(tx, input); + if (candidate) { + throw conflict('A place with a close name is nearby', { + reason: 'similar_place', + candidate, + }); + } + } + const [created] = await tx + .insert(places) + .values({ ...input, cityId, verificationStatus: 'unverified', createdBy: userId }) + .returning({ id: places.id }); + if (!created) throw new Error('Place not created'); + if (hours.length > 0) { + await tx.insert(placeHours).values(hours.map((slot) => ({ ...slot, placeId: created.id }))); + } + const place = await readPlace(tx, created.id); + if (!place) throw new Error('Place not found after creation'); + return place; + }); +} diff --git a/apps/api/src/places/read.ts b/apps/api/src/places/read.ts new file mode 100644 index 00000000..243f07ca --- /dev/null +++ b/apps/api/src/places/read.ts @@ -0,0 +1,54 @@ +/** Places as the API serves them (wiki API › Lieux): the full place, and the hit of a search. */ +import { Place, type PlaceSearchHit } from '@widoo/shared'; +import { eq, sql, type SQL } from 'drizzle-orm'; +import type { Db, Tx } from '../db/client'; +import { places } from '../db/schema'; +import { hoursByPlace } from '../planned/snapshot'; + +/** Columns of a hit; `distance` is the SQL of the distance to the point searched around, if any. */ +export const hitColumns = (distance: SQL | null) => ({ + id: places.id, + name: places.name, + category: places.category, + lat: sql`ST_Y(${places.location}::geometry)`, + lng: sql`ST_X(${places.location}::geometry)`, + address: places.address, + isIndoor: places.isIndoor, + verificationStatus: places.verificationStatus, + verifiedAt: places.verifiedAt, + distanceM: distance ?? sql`null`, +}); + +export type HitRow = { + id: string; + name: string; + category: Place['category']; + lat: number; + lng: number; + address: string; + isIndoor: boolean; + verificationStatus: Place['verificationStatus']; + verifiedAt: Date | null; + distanceM: number | null; +}; + +export const toHit = (row: HitRow): PlaceSearchHit => ({ + id: row.id, + name: row.name, + category: row.category, + location: { lat: row.lat, lng: row.lng }, + address: row.address, + isIndoor: row.isIndoor, + verificationStatus: row.verificationStatus, + verifiedAt: row.verifiedAt?.toISOString() ?? null, + distanceM: row.distanceM === null ? null : Math.round(Number(row.distanceM)), +}); + +/** The place with its hours; undefined when it does not exist. */ +export async function readPlace(db: Db | Tx, id: string): Promise { + const [row] = await db.select(hitColumns(null)).from(places).where(eq(places.id, id)); + if (!row) return undefined; + const hit = toHit(row); + const hours = (await hoursByPlace(db, [id])).get(id) ?? []; + return Place.parse({ ...hit, hours }); +} diff --git a/apps/api/src/places/search.ts b/apps/api/src/places/search.ts new file mode 100644 index 00000000..ac129f22 --- /dev/null +++ b/apps/api/src/places/search.ts @@ -0,0 +1,38 @@ +/** `GET /places/search`: the places of Widoo that a text, a position or a category designates (E-13). */ +import type { PlaceSearchHit, PlaceSearchQuery } from '@widoo/shared'; +import { and, asc, desc, eq, ne, sql, type SQL } from 'drizzle-orm'; +import type { Db } from '../db/client'; +import { places } from '../db/schema'; +import { hitColumns, toHit, type HitRow } from './read'; + +/** `%`, `_` and `\` of a text typed by a user are letters, not wildcards of `ILIKE`. */ +const likePattern = (text: string) => `%${text.replace(/[\\%_]/g, '\\$&')}%`; + +/** SQL of the distance in metres from a place to `near`. */ +export const distanceTo = (near: { lat: number; lng: number }) => + sql`ST_Distance(${places.location}, ST_MakePoint(${near.lng}::float8, ${near.lat}::float8)::geography)`; + +/** + * The places whose name holds the text (or is close to it, trigrams), in the category if given. + * Closed places are not offered. With a text, the closest names come first; then the nearest. + */ +export async function searchPlaces(db: Db, query: PlaceSearchQuery): Promise { + const { q, near, category, limit } = query; + const distance = near ? distanceTo(near) : null; + const conditions: SQL[] = [ne(places.verificationStatus, 'closed')]; + if (q) { + conditions.push(sql`(${places.name} ilike ${likePattern(q)} or ${places.name} % ${q})`); + } + if (category) conditions.push(eq(places.category, category)); + const rows = await db + .select(hitColumns(distance)) + .from(places) + .where(and(...conditions)) + .orderBy( + ...(q ? [desc(sql`similarity(${places.name}, ${q})`)] : []), + ...(distance ? [asc(distance)] : []), + asc(places.name), + ) + .limit(limit); + return rows.map((row) => toHit(row as HitRow)); +} diff --git a/apps/api/src/places/update.ts b/apps/api/src/places/update.ts new file mode 100644 index 00000000..bc3304b3 --- /dev/null +++ b/apps/api/src/places/update.ts @@ -0,0 +1,62 @@ +/** + * `PATCH /places/:id` (wiki API › Lieux): the creator of a place edits it as long as only their own + * routes use it; a moderator, or an admin, always can. + */ +import { hasRole, type Place, type UpdatePlace } from '@widoo/shared'; +import { and, eq, ne } from 'drizzle-orm'; +import type { Db } from '../db/client'; +import { placeHours, places, routes, steps } from '../db/schema'; +import { httpError } from '../errors'; +import { cityHolding, outsideAnyCity } from './create'; +import { readPlace } from './read'; + +type Editor = { id: string; role: Parameters[0] }; + +export async function updatePlace( + db: Db, + user: Editor, + placeId: string, + { hours, location, ...fields }: UpdatePlace, +): Promise { + return db.transaction(async (tx) => { + const [current] = await tx + .select({ createdBy: places.createdBy }) + .from(places) + .where(eq(places.id, placeId)) + .for('update'); + if (!current) throw httpError(404, 'Place not found'); + + if (!hasRole(user.role, 'moderator')) { + if (current.createdBy !== user.id) throw httpError(403, 'Not the creator of this place'); + const [usedByOthers] = await tx + .select({ routeId: steps.routeId }) + .from(steps) + .innerJoin(routes, eq(routes.id, steps.routeId)) + .where(and(eq(steps.placeId, placeId), ne(routes.authorId, user.id))) + .limit(1); + if (usedByOthers) throw httpError(403, 'Used by the routes of others'); + } + + let cityId: string | undefined; + if (location) { + cityId = await cityHolding(tx, location); + if (!cityId) throw outsideAnyCity(); + } + const changes = { ...fields, ...(location ? { location, cityId } : {}) }; + if (Object.keys(changes).length > 0) { + await tx + .update(places) + .set({ ...changes, updatedAt: new Date() }) + .where(eq(places.id, placeId)); + } + if (hours) { + await tx.delete(placeHours).where(eq(placeHours.placeId, placeId)); + if (hours.length > 0) { + await tx.insert(placeHours).values(hours.map((slot) => ({ ...slot, placeId }))); + } + } + const place = await readPlace(tx, placeId); + if (!place) throw new Error('Place not found after update'); + return place; + }); +} diff --git a/apps/api/src/routes/places.test.ts b/apps/api/src/routes/places.test.ts new file mode 100644 index 00000000..188f4d14 --- /dev/null +++ b/apps/api/src/routes/places.test.ts @@ -0,0 +1,310 @@ +import { ApiError, Place, PlaceSearchResult } from '@widoo/shared'; +import { eq, inArray } from 'drizzle-orm'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { buildApp } from '../app'; +import { TestTokenVerifier } from '../auth/test-verifier'; +import { places } from '../db/schema'; +import { demoPlaceIds, editorFixtures } from '../editor/test-fixtures'; +import { testConfig } from '../test-config'; + +let app: Awaited>; +const verifier = new TestTokenVerifier(); +const fixtures = editorFixtures(() => app, verifier); +const createdPlaceIds: string[] = []; + +beforeAll(async () => { + app = await buildApp(testConfig(), { tokenVerifier: verifier }); +}); +afterAll(async () => { + await fixtures.cleanUp(); + if (createdPlaceIds.length > 0) { + await app.db.delete(places).where(inArray(places.id, createdPlaceIds)); + } + await app.close(); +}); + +type Headers = Record; + +/** Rue de Sévigné, a few metres from the Musée Carnavalet of the demo. */ +const near = { lat: 48.85732, lng: 2.36247 }; +/** A corner of Saint-Germain-des-Prés without any place of the demo. */ +const flore = { lat: 48.8541, lng: 2.3327 }; +/** A new spot of Paris at each call, over 100 m from the last: two places never look alike by position. */ +let spots = 0; +const spot = () => ({ lat: 48.82 + (spots += 1) * 0.0012, lng: 2.3 }); + +/** About 20 m north of `flore` (0.00018° of latitude). */ +const floreNeighbour = { lat: 48.85428, lng: 2.3327 }; + +const search = (headers: Headers, query: string) => + app.inject({ url: `/v1/places/search?${query}`, headers }); +const create = (headers: Headers, payload: object) => + app.inject({ method: 'POST', url: '/v1/places', headers, payload }); +const edit = (headers: Headers, id: string, payload: object) => + app.inject({ method: 'PATCH', url: `/v1/places/${id}`, headers, payload }); + +/** A valid body; each test names its own place, as the database is shared. */ +const body = (name: string, location = spot(), extra: object = {}) => ({ + name, + category: 'cafe', + location, + address: '172 boulevard Saint-Germain, 75006 Paris', + ...extra, +}); + +async function createdBy(headers: Headers, name: string, location = spot(), extra: object = {}) { + const response = await create(headers, body(name, location, extra)); + expect(response.statusCode).toBe(201); + const place = Place.parse(response.json()); + createdPlaceIds.push(place.id); + return place; +} + +describe('GET /places/search', () => { + it('finds a place by a piece of its name, the nearest first, with its distance', async () => { + const headers = await fixtures.signIn(); + const response = await search(headers, `q=carnavalet&near=${near.lat},${near.lng}`); + expect(response.statusCode).toBe(200); + expect(response.headers['cache-control']).toBe('no-store'); + const { items } = PlaceSearchResult.parse(response.json()); + expect(items[0]).toMatchObject({ id: demoPlaceIds[1], name: 'Musée Carnavalet' }); + expect(items[0]?.distanceM).toBeLessThan(50); + }); + + it('answers a position alone with the nearest places, a text alone without distance', async () => { + const headers = await fixtures.signIn(); + const byPosition = PlaceSearchResult.parse( + (await search(headers, `near=${near.lat},${near.lng}&limit=3`)).json(), + ); + expect(byPosition.items).toHaveLength(3); + const distances = byPosition.items.map((item) => item.distanceM ?? Infinity); + expect(distances).toEqual([...distances].sort((a, b) => a - b)); + const byText = PlaceSearchResult.parse((await search(headers, 'q=carnavalet')).json()); + expect(byText.items[0]?.distanceM).toBeNull(); + }); + + it('filters by category', async () => { + const headers = await fixtures.signIn(); + const { items } = PlaceSearchResult.parse( + (await search(headers, 'q=carnavalet&category=museum')).json(), + ); + expect(items.map((item) => item.id)).toContain(demoPlaceIds[1]); + const none = PlaceSearchResult.parse( + (await search(headers, 'q=carnavalet&category=bakery')).json(), + ); + expect(none.items).toEqual([]); + }); + + it('takes % and _ of the text as letters', async () => { + const headers = await fixtures.signIn(); + const { items } = PlaceSearchResult.parse((await search(headers, 'q=%25%25')).json()); + expect(items).toEqual([]); + }); + + it('does not offer a closed place', async () => { + const headers = await fixtures.signIn(); + const place = await createdBy(headers, 'Fermé pour toujours ouistiti'); + await app.db + .update(places) + .set({ verificationStatus: 'closed' }) + .where(eq(places.id, place.id)); + const { items } = PlaceSearchResult.parse((await search(headers, 'q=ouistiti')).json()); + expect(items).toEqual([]); + }); + + it('refuses a search without text nor position, and an unknown key', async () => { + const headers = await fixtures.signIn(); + expect((await search(headers, 'category=cafe')).statusCode).toBe(400); + expect((await search(headers, 'q=ca&near=48,2&city=paris')).statusCode).toBe(400); + }); + + it('refuses an anonymous caller', async () => { + expect((await search({}, 'q=carnavalet')).statusCode).toBe(401); + }); +}); + +describe('POST /places', () => { + it('creates an unverified place of the city that holds it, with its hours', async () => { + const headers = await fixtures.signIn(); + const place = await createdBy(headers, 'Chez Ouistiti Une', flore, { + priceRange: 'low', + hours: [{ weekday: 0, opens: '08:00', closes: '18:00', validFrom: null, validTo: null }], + }); + expect(place).toMatchObject({ + name: 'Chez Ouistiti Une', + category: 'cafe', + location: flore, + verificationStatus: 'unverified', + verifiedAt: null, + isIndoor: false, + hours: [{ weekday: 0, opens: '08:00', closes: '18:00' }], + }); + const found = PlaceSearchResult.parse((await search(headers, 'q=ouistiti+une')).json()); + expect(found.items.map((item) => item.id)).toEqual([place.id]); + }); + + it('records the last day of an ephemeral place, and lets the creator clear it', async () => { + const headers = await fixtures.signIn(); + const endsOnOf = async (id: string) => { + const [row] = await app.db + .select({ endsOn: places.endsOn }) + .from(places) + .where(eq(places.id, id)); + return row?.endsOn; + }; + const ephemeral = await createdBy(headers, 'Expo Éphémère Sept', spot(), { + endsOn: '2027-01-17', + }); + expect(await endsOnOf(ephemeral.id)).toBe('2027-01-17'); + expect((await edit(headers, ephemeral.id, { endsOn: null })).statusCode).toBe(200); + expect(await endsOnOf(ephemeral.id)).toBeNull(); + const lasting = await createdBy(headers, 'Café Durable Huit'); + expect(await endsOnOf(lasting.id)).toBeNull(); + }); + + it('offers the close place of a close name instead, unless the creator insists', async () => { + const headers = await fixtures.signIn(); + const existing = await createdBy(headers, 'Café de Flore Doublon', flore); + const refused = await create(headers, body('Cafe de Flore Doublon', floreNeighbour)); + expect(refused.statusCode).toBe(409); + const error = ApiError.parse(refused.json()); + expect(error.code).toBe('conflict'); + expect(error.details).toMatchObject({ + reason: 'similar_place', + candidate: { id: existing.id, name: 'Café de Flore Doublon' }, + }); + const forced = await create( + headers, + body('Cafe de Flore Doublon', floreNeighbour, { force: true }), + ); + expect(forced.statusCode).toBe(201); + createdPlaceIds.push(Place.parse(forced.json()).id); + }); + + it('does not take a far place or another name for a duplicate', async () => { + const headers = await fixtures.signIn(); + await createdBy(headers, 'Salon Zéphyr Quatre', flore); + const far = { lat: 48.8548, lng: 2.3327 }; + for (const [name, location] of [ + ['Salon Zéphyr Quatre', far], + ['Boulangerie Totalement Autre', floreNeighbour], + ] as const) { + const response = await create(headers, body(name, location)); + expect(response.statusCode).toBe(201); + createdPlaceIds.push(Place.parse(response.json()).id); + } + }); + + it('refuses a position that no city holds', async () => { + const headers = await fixtures.signIn(); + const response = await create(headers, body('Café du Bout du Monde', { lat: 0, lng: 0 })); + expect(response.statusCode).toBe(422); + expect(ApiError.parse(response.json()).details).toMatchObject({ + issues: [{ location: 'body', path: 'location', code: 'outside_service_area' }], + }); + }); + + it('stops at 20 places a day, and counts only the places of the account', async () => { + const headers = await fixtures.signIn(); + const owner = await fixtures.signIn(); + for (let n = 1; n <= 20; n += 1) { + const response = await create( + owner, + body(`Lieu Quota ${String(n).padStart(2, '0')} ${'xyz'.repeat(n)}`, { + lat: 48.85 + n * 0.001, + lng: 2.3, + }), + ); + expect(response.statusCode).toBe(201); + createdPlaceIds.push(Place.parse(response.json()).id); + } + const over = await create(owner, body('Le Vingt-et-unième Lieu', { lat: 48.88, lng: 2.4 })); + expect(over.statusCode).toBe(429); + expect(ApiError.parse(over.json())).toMatchObject({ + code: 'rate_limited', + details: { reason: 'place_creation', limit: 20 }, + }); + const other = await create(headers, body('Lieu Quota Autre Compte', { lat: 48.88, lng: 2.41 })); + expect(other.statusCode).toBe(201); + createdPlaceIds.push(Place.parse(other.json()).id); + }); + + it('refuses a body that breaks a rule, markup, a link that is not https, an unknown key', async () => { + const headers = await fixtures.signIn(); + for (const extra of [ + { name: 'Gras' }, + { websiteUrl: 'http://exemple.fr' }, + { surprise: true }, + { category: 'spa' }, + { endsOn: '17/01/2027' }, + ]) { + const response = await create(headers, { ...body('Café Invalide'), ...extra }); + expect(response.statusCode).toBe(422); + } + }); + + it('refuses an anonymous caller', async () => { + expect((await create({}, body('Café Anonyme'))).statusCode).toBe(401); + }); +}); + +describe('PATCH /places/:id', () => { + it('lets the creator edit their place, hours replaced as a whole', async () => { + const headers = await fixtures.signIn(); + const place = await createdBy(headers, 'Chez Ouistiti Deux', spot(), { + hours: [{ weekday: 0, opens: '08:00', closes: '18:00', validFrom: null, validTo: null }], + }); + const response = await edit(headers, place.id, { + phone: '+33 1 23 45 67 89', + priceRange: 'medium', + hours: [{ weekday: 2, opens: '09:00', closes: '12:00', validFrom: null, validTo: null }], + }); + expect(response.statusCode).toBe(200); + expect(Place.parse(response.json())).toMatchObject({ + name: 'Chez Ouistiti Deux', + hours: [{ weekday: 2, opens: '09:00', closes: '12:00' }], + }); + }); + + it("refuses another user, and the creator once another author's route uses the place", async () => { + const creator = await fixtures.signIn(); + const stranger = await fixtures.signIn(); + const place = await createdBy(creator, 'Chez Ouistiti Trois'); + expect((await edit(stranger, place.id, { phone: '0102030405' })).statusCode).toBe(403); + + const route = await app.inject({ + method: 'POST', + url: '/v1/routes', + headers: stranger, + payload: { title: 'Un parcours qui passe par là' }, + }); + const put = await app.inject({ + method: 'PUT', + url: `/v1/routes/${route.json<{ id: string }>().id}/steps`, + headers: stranger, + payload: { steps: [{ placeId: place.id, durationMin: 30 }] }, + }); + expect(put.statusCode).toBe(200); + expect((await edit(creator, place.id, { phone: '0102030405' })).statusCode).toBe(403); + }); + + it('lets a moderator edit any place', async () => { + const creator = await fixtures.signIn(); + const moderator = await fixtures.signIn('moderator'); + const place = await createdBy(creator, 'Chez Ouistiti Quatre'); + const response = await edit(moderator, place.id, { category: 'bar' }); + expect(response.statusCode).toBe(200); + expect(Place.parse(response.json()).category).toBe('bar'); + }); + + it('refuses a new position that no city holds, an empty body, an unknown place', async () => { + const headers = await fixtures.signIn(); + const place = await createdBy(headers, 'Chez Ouistiti Cinq'); + expect((await edit(headers, place.id, { location: { lat: 0, lng: 0 } })).statusCode).toBe(422); + expect((await edit(headers, place.id, {})).statusCode).toBe(422); + const missing = await edit(headers, '01997a4e-8c00-7000-8000-0000000000ff', { + phone: '0102030405', + }); + expect(missing.statusCode).toBe(404); + }); +}); diff --git a/apps/api/src/routes/places.ts b/apps/api/src/routes/places.ts new file mode 100644 index 00000000..00f359e8 --- /dev/null +++ b/apps/api/src/routes/places.ts @@ -0,0 +1,73 @@ +import { + CreatePlace, + Place, + PlaceSearchQuery, + PlaceSearchResult, + UpdatePlace, +} from '@widoo/shared'; +import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'; +import { z } from 'zod'; +import { createPlace } from '../places/create'; +import { searchPlaces } from '../places/search'; +import { updatePlace } from '../places/update'; +import { currentUser } from './me'; + +const security = [{ firebaseIdToken: [] }]; +const tags = ['creation']; + +/** + * Search, creation and edition of places (E-13, wiki API › Lieux): signed in, never cached, as + * the answer is the creator's working material. + */ +export const placesRoutes: FastifyPluginAsyncZod = async (app) => { + app.addHook('onRequest', app.requireAuth); + app.addHook('onSend', async (_request, reply) => { + reply.header('cache-control', 'no-store'); + }); + + app.get( + '/places/search', + { + schema: { + tags, + summary: 'Places of Widoo by text, position and category', + security, + querystring: PlaceSearchQuery, + response: { 200: PlaceSearchResult }, + }, + }, + async (request) => ({ items: await searchPlaces(app.db, request.query) }), + ); + + app.post( + '/places', + { + schema: { + tags, + summary: 'Create a place; 409 with the close place unless `force`', + security, + body: CreatePlace, + response: { 201: Place }, + }, + }, + async (request, reply) => { + const place = await createPlace(app.db, currentUser(request).id, request.body, new Date()); + return reply.status(201).send(place); + }, + ); + + app.patch( + '/places/:id', + { + schema: { + tags, + summary: 'Edit a place: its creator while only their routes use it, or a moderator', + security, + params: z.strictObject({ id: z.uuid() }), + body: UpdatePlace, + response: { 200: Place }, + }, + }, + async (request) => updatePlace(app.db, currentUser(request), request.params.id, request.body), + ); +}; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 4f90f8d7..2b28df40 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -41,6 +41,7 @@ export * from './schemas/legal'; export * from './schemas/me'; export * from './schemas/moderation'; export * from './schemas/place'; +export * from './schemas/place-write'; export * from './schemas/planned'; export * from './schemas/place-import'; export * from './schemas/profile'; diff --git a/packages/shared/src/schemas/place-write.ts b/packages/shared/src/schemas/place-write.ts new file mode 100644 index 00000000..043b3d51 --- /dev/null +++ b/packages/shared/src/schemas/place-write.ts @@ -0,0 +1,126 @@ +/** + * Search, creation and edition of places (wiki API › Lieux, E-13, D-049): what the creator of a + * route types to find an existing place first, or to add the one that is missing. + */ +import { z } from 'zod'; +import { taxonomies } from '../taxonomies'; +import { HttpsUrl, LatLng, Latitude, Longitude, plainText } from './common'; +import { SearchText } from './geocode'; +import { Place, PlaceHours } from './place'; + +/** Name of a place, as long as a step title. */ +export const placeNameMaxLength = 80; +/** Formatted address, one line. */ +export const placeAddressMaxLength = 200; +/** Places one account creates in 24 hours (wiki Securite-et-RGPD › Limitation de débit). */ +export const placeCreationDailyLimit = 20; +/** A place this close (in metres) to the new one, with a close name, is offered instead (E-13). */ +export const placeDuplicateRadiusM = 50; +/** Similarity (0 to 1) of two names from which they are close, `pg_trgm` scale. */ +export const placeDuplicateNameSimilarity = 0.4; +/** Slots of the hours of a place: seven days, a few slots each. */ +export const placeHoursMax = 28; + +const numbers = z + .string() + .transform((value) => value.split(',').map((part) => (part.trim() ? Number(part) : Number.NaN))); + +/** + * Query string of `GET /places/search`: the text, the position the places are sorted around and + * the category, at least the text or the position. + */ +export const PlaceSearchQuery = z + .strictObject({ + q: SearchText.optional(), + /** `near=lat,lng`. */ + near: numbers + .pipe(z.tuple([Latitude, Longitude])) + .transform(([lat, lng]) => ({ lat, lng })) + .optional(), + category: z.enum(taxonomies.placeCategories).optional(), + limit: z.coerce.number().int().min(1).max(20).default(10), + }) + .refine((query) => query.q !== undefined || query.near !== undefined, { + path: ['q'], + message: 'Required without near', + }); +export type PlaceSearchQuery = z.infer; + +/** A place found, with its distance to `near` when given. */ +export const PlaceSearchHit = Place.omit({ hours: true }).extend({ + distanceM: z.number().min(0).nullable(), +}); +export type PlaceSearchHit = z.infer; + +export const PlaceSearchResult = z.object({ items: z.array(PlaceSearchHit) }); +export type PlaceSearchResult = z.infer; + +const AddressComponents = z.strictObject({ + street: plainText(120).optional(), + postcode: plainText(10).optional(), + arrondissement: plainText(20).optional(), + neighborhood: plainText(80).optional(), +}); + +/** The fields of a place its creator writes; hours replace the whole week. */ +const placeFields = { + name: plainText(placeNameMaxLength), + category: z.enum(taxonomies.placeCategories), + location: LatLng, + address: plainText(placeAddressMaxLength), + addressComponents: AddressComponents.optional(), + isIndoor: z.boolean(), + priceRange: z.enum(taxonomies.priceRanges).nullable(), + websiteUrl: HttpsUrl.max(300).nullable(), + phone: plainText(30).nullable(), + /** « Accessible à toute heure » (a park, a street): no hours (D-049). */ + alwaysOpen: z.boolean(), + /** Last day of an ephemeral place (an exhibition, a pop-up); after it, the place leaves (D-050). */ + endsOn: z.iso.date().nullable(), + hours: z.array(PlaceHours).max(placeHoursMax), +}; + +/** `POST /places`. `force` creates the place although a close one exists (E-13 › « Créer quand même »). */ +export const CreatePlace = z.strictObject({ + name: placeFields.name, + category: placeFields.category, + location: placeFields.location, + address: placeFields.address, + addressComponents: placeFields.addressComponents, + isIndoor: placeFields.isIndoor.default(false), + priceRange: placeFields.priceRange.default(null), + websiteUrl: placeFields.websiteUrl.default(null), + phone: placeFields.phone.default(null), + alwaysOpen: placeFields.alwaysOpen.default(false), + endsOn: placeFields.endsOn.default(null), + hours: placeFields.hours.default([]), + force: z.boolean().default(false), +}); +export type CreatePlace = z.infer; + +/** `PATCH /places/:id`: the fields that change, at least one. */ +export const UpdatePlace = z + .strictObject({ + name: placeFields.name, + category: placeFields.category, + location: placeFields.location, + address: placeFields.address, + addressComponents: placeFields.addressComponents, + isIndoor: placeFields.isIndoor, + priceRange: placeFields.priceRange, + websiteUrl: placeFields.websiteUrl, + phone: placeFields.phone, + alwaysOpen: placeFields.alwaysOpen, + endsOn: placeFields.endsOn, + hours: placeFields.hours, + }) + .partial() + .refine((body) => Object.keys(body).length > 0, 'At least one field'); +export type UpdatePlace = z.infer; + +/** `details` of the 409 `conflict` of `POST /places`: the place E-13 offers instead. */ +export const PlaceDuplicateDetails = z.object({ + reason: z.literal('similar_place'), + candidate: PlaceSearchHit, +}); +export type PlaceDuplicateDetails = z.infer;