Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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),
Expand Down
11 changes: 11 additions & 0 deletions apps/api/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) {
return Object.assign(new Error(message), { statusCode: 409, details });
Expand Down
108 changes: 108 additions & 0 deletions apps/api/src/places/create.ts
Original file line number Diff line number Diff line change
@@ -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<CreatePlace, 'name' | 'location'>) {
const similarity = sql<number>`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<void> {
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<Place> {
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;
});
}
54 changes: 54 additions & 0 deletions apps/api/src/places/read.ts
Original file line number Diff line number Diff line change
@@ -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<number> | null) => ({
id: places.id,
name: places.name,
category: places.category,
lat: sql<number>`ST_Y(${places.location}::geometry)`,
lng: sql<number>`ST_X(${places.location}::geometry)`,
address: places.address,
isIndoor: places.isIndoor,
verificationStatus: places.verificationStatus,
verifiedAt: places.verifiedAt,
distanceM: distance ?? sql<null>`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<Place | undefined> {
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 });
}
38 changes: 38 additions & 0 deletions apps/api/src/places/search.ts
Original file line number Diff line number Diff line change
@@ -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<number>`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<PlaceSearchHit[]> {
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));
}
62 changes: 62 additions & 0 deletions apps/api/src/places/update.ts
Original file line number Diff line number Diff line change
@@ -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<typeof hasRole>[0] };

export async function updatePlace(
db: Db,
user: Editor,
placeId: string,
{ hours, location, ...fields }: UpdatePlace,
): Promise<Place> {
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;
});
}
Loading
Loading