From 1e73fc8995751ba36ad92a09e186a8f1217ff861 Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Tue, 20 Oct 2026 13:47:47 +0700 Subject: [PATCH 01/14] feat(shared): describe the B2B partners of the admin, their routes and their public page (A-10) Types of the partners, their statuses, the 3 to 10 routes of a page, the card, the body of each write and the public page the site reads. The slug never changes. Co-Authored-By: Claude Sonnet 5.5 --- packages/shared/src/index.ts | 1 + packages/shared/src/schemas/admin-b2b.test.ts | 82 ++++++++++ packages/shared/src/schemas/admin-b2b.ts | 147 ++++++++++++++++++ 3 files changed, 230 insertions(+) create mode 100644 packages/shared/src/schemas/admin-b2b.test.ts create mode 100644 packages/shared/src/schemas/admin-b2b.ts diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 9d0bcb2a..08e94b65 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -24,6 +24,7 @@ export { zoneMinSpanM, zoneSearchBox } from './zone'; export * from './schemas/admin'; export * from './schemas/admin-actions'; export * from './schemas/admin-affiliation'; +export * from './schemas/admin-b2b'; export * from './schemas/admin-settings'; export * from './schemas/admin-reports'; export * from './schemas/admin-signals'; diff --git a/packages/shared/src/schemas/admin-b2b.test.ts b/packages/shared/src/schemas/admin-b2b.test.ts new file mode 100644 index 00000000..df1ea71d --- /dev/null +++ b/packages/shared/src/schemas/admin-b2b.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; +import { + B2bClientInput, + B2bClientPatch, + B2bClientRoutesInput, + B2bSlug, + b2bSourceOf, +} from './admin-b2b'; + +const partner = { slug: 'hotel-du-nord', name: 'Hôtel du Nord', type: 'hotel' } as const; +const ids = [ + '0198c1de-0000-7000-8000-000000000001', + '0198c1de-0000-7000-8000-000000000002', + '0198c1de-0000-7000-8000-000000000003', +]; + +describe('B2bClientInput', () => { + it('takes a partner, a prospect without contact or price unless said otherwise', () => { + expect(B2bClientInput.parse(partner)).toMatchObject({ + status: 'prospect', + contactEmail: null, + monthlyPriceCents: null, + startedAt: null, + }); + const full = B2bClientInput.parse({ + ...partner, + status: 'active', + contactName: 'Camille Durand', + contactEmail: 'camille@hotel.example', + monthlyPriceCents: 4900, + startedAt: '2026-11-01', + }); + expect(full).toMatchObject({ status: 'active', monthlyPriceCents: 4900 }); + }); + + it('refuses markup, a bad e-mail, a price out of bounds, an unknown type and field', () => { + expect(B2bClientInput.safeParse({ ...partner, name: 'x' }).success).toBe(false); + expect(B2bClientInput.safeParse({ ...partner, contactEmail: 'not an address' }).success).toBe( + false, + ); + expect(B2bClientInput.safeParse({ ...partner, monthlyPriceCents: -1 }).success).toBe(false); + expect(B2bClientInput.safeParse({ ...partner, monthlyPriceCents: 1e9 }).success).toBe(false); + expect(B2bClientInput.safeParse({ ...partner, type: 'bank' }).success).toBe(false); + expect(B2bClientInput.safeParse({ ...partner, extra: 1 }).success).toBe(false); + }); +}); + +describe('B2bClientPatch', () => { + it('takes some fields, not none, and never the slug', () => { + expect(B2bClientPatch.parse({ status: 'suspended' })).toEqual({ status: 'suspended' }); + expect(B2bClientPatch.safeParse({}).success).toBe(false); + expect(B2bClientPatch.safeParse({ slug: 'other-slug' }).success).toBe(false); + }); +}); + +describe('B2bSlug', () => { + it('takes lowercase words joined by single hyphens', () => { + expect(B2bSlug.safeParse('hotel-du-nord-2').success).toBe(true); + for (const bad of ['ab', 'Hotel', 'hotel--nord', '-hotel', 'hotel-', 'hôtel', 'a b c']) { + expect(B2bSlug.safeParse(bad).success).toBe(false); + } + }); + + it('gives the measurement source written in the QR codes', () => { + expect(b2bSourceOf('hotel-du-nord')).toBe('b2b-hotel-du-nord'); + }); +}); + +describe('B2bClientRoutesInput', () => { + it('takes 3 to 10 different routes', () => { + expect(B2bClientRoutesInput.safeParse({ routeIds: ids }).success).toBe(true); + expect(B2bClientRoutesInput.safeParse({ routeIds: ids.slice(0, 2) }).success).toBe(false); + expect(B2bClientRoutesInput.safeParse({ routeIds: [...ids, ids[0]] }).success).toBe(false); + const idsOf = (count: number) => + Array.from( + { length: count }, + (_, i) => `0198c1de-0000-7000-8000-${String(i + 1).padStart(12, '0')}`, + ); + expect(B2bClientRoutesInput.safeParse({ routeIds: idsOf(10) }).success).toBe(true); + expect(B2bClientRoutesInput.safeParse({ routeIds: idsOf(11) }).success).toBe(false); + }); +}); diff --git a/packages/shared/src/schemas/admin-b2b.ts b/packages/shared/src/schemas/admin-b2b.ts new file mode 100644 index 00000000..e3b0387b --- /dev/null +++ b/packages/shared/src/schemas/admin-b2b.ts @@ -0,0 +1,147 @@ +import { z } from 'zod'; +import { plainText } from './common'; +import { RouteCard } from './route'; + +/** What kind of business a partner is (wiki Modele-de-Donnees › Admin web). */ +export const b2bClientTypes = ['hotel', 'concierge', 'tourist_office', 'other'] as const; +export type B2bClientType = (typeof b2bClientTypes)[number]; + +/** Where a partner stands: `active` supposes a signed contract (wiki Admin-Web › A-10). */ +export const b2bClientStatuses = ['prospect', 'active', 'suspended', 'ended'] as const; +export type B2bClientStatus = (typeof b2bClientStatuses)[number]; + +/** A partner chooses between 3 and 10 routes, in the order of its page. */ +export const minPartnerRoutes = 3; +export const maxPartnerRoutes = 10; + +/** Upper bound of a monthly price (excluding tax), in cents: a typing error, not a price. */ +export const maxMonthlyPriceCents = 10_000_000; + +/** + * The slug of a partner: the end of its page address and of its measurement source. Lowercase + * letters and digits, separated by single hyphens. It never changes once the partner exists: the + * QR codes printed with it would stop measuring. + */ +export const B2bSlug = z + .string() + .min(3) + .max(60) + .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'Lowercase letters, digits and single hyphens only'); + +/** The source of measure of a partner, written in the links of its QR codes. */ +export const b2bSourceOf = (slug: string) => `b2b-${slug}`; + +/** A route a partner chose, at its place in the page. */ +export const B2bClientRoute = z.object({ + id: z.uuid(), + title: z.string(), + /** From 0: the order of the page. */ + position: z.int().nonnegative(), + /** Only a published route shows on the page of the partner. */ + isPublished: z.boolean(), +}); +export type B2bClientRoute = z.infer; + +const listFields = { + id: z.uuid(), + name: z.string(), + slug: z.string(), + type: z.enum(b2bClientTypes), + status: z.enum(b2bClientStatuses), + /** Monthly price excluding tax, in cents; null while unknown (a prospect). */ + monthlyPriceCents: z.int().nonnegative().nullable(), + /** Start of the contract, a day; null until it is signed. */ + startedAt: z.iso.date().nullable(), + /** The cities of the routes chosen: the data model gives a partner no city of its own. */ + cityNames: z.array(z.string()), + routeCount: z.int().nonnegative(), +}; + +/** A partner as a row of the list (A-10). */ +export const B2bClientListItem = z.object(listFields); +export type B2bClientListItem = z.infer; + +/** Body of `GET /admin/b2b-clients`: every partner, the active ones first. */ +export const B2bClients = z.object({ items: z.array(B2bClientListItem) }); +export type B2bClients = z.infer; + +/** The card of a partner (A-10): the list fields, the contact, the notes and the routes chosen. */ +export const B2bClient = z.object({ + ...listFields, + contactName: z.string().nullable(), + contactEmail: z.string().nullable(), + contactPhone: z.string().nullable(), + address: z.string().nullable(), + notes: z.string().nullable(), + /** `b2b-`: what the page and the QR codes carry for the measure. */ + source: z.string(), + routes: z.array(B2bClientRoute), + createdAt: z.iso.datetime(), +}); +export type B2bClient = z.infer; + +const clientFields = { + name: plainText(100), + type: z.enum(b2bClientTypes), + status: z.enum(b2bClientStatuses), + contactName: plainText(100).nullable(), + contactEmail: z.email().max(254).nullable(), + contactPhone: plainText(30).nullable(), + address: plainText(300).nullable(), + monthlyPriceCents: z.int().min(0).max(maxMonthlyPriceCents).nullable(), + startedAt: z.iso.date().nullable(), + notes: plainText(2000).nullable(), +}; + +/** Body of `POST /admin/b2b-clients`: a prospect without a contact or a price unless said otherwise. */ +export const B2bClientInput = z.strictObject({ + slug: B2bSlug, + ...clientFields, + status: clientFields.status.default('prospect'), + contactName: clientFields.contactName.default(null), + contactEmail: clientFields.contactEmail.default(null), + contactPhone: clientFields.contactPhone.default(null), + address: clientFields.address.default(null), + monthlyPriceCents: clientFields.monthlyPriceCents.default(null), + startedAt: clientFields.startedAt.default(null), + notes: clientFields.notes.default(null), +}); +export type B2bClientInput = z.infer; + +/** + * Body of `PATCH /admin/b2b-clients/:id`: the fields to change, at least one, never the slug. + * Built from the fields without their defaults, so that a field left out stays as it is. + */ +export const B2bClientPatch = z + .strictObject(clientFields) + .partial() + .refine((patch) => Object.keys(patch).length > 0, { message: 'Give at least one field' }); +export type B2bClientPatch = z.infer; + +/** Body of `PUT /admin/b2b-clients/:id/routes`: 3 to 10 different routes, in the order of the page. */ +export const B2bClientRoutesInput = z.strictObject({ + routeIds: z + .array(z.uuid()) + .min(minPartnerRoutes) + .max(maxPartnerRoutes) + .refine((ids) => new Set(ids).size === ids.length, { message: 'A route is chosen once' }), +}); +export type B2bClientRoutesInput = z.infer; + +/** `details` of a 409 `conflict` on a partner: what is wrong, read by the admin. */ +export const b2bConflictReasons = ['slug_taken', 'routes_unavailable'] as const; +export type B2bConflictReason = (typeof b2bConflictReasons)[number]; + +/** + * The page of a partner for the site (`GET /public/partners/:slug`, ticket #347): its name, the + * routes it chose that are published, in order, as cards. Never its contact, price or notes. A + * partner that is not active is served for a preview, and the site keeps it out of the search + * engines; a suspended or ended one is a 404. + */ +export const PublicPartner = z.object({ + slug: z.string(), + name: z.string(), + isActive: z.boolean(), + routes: z.array(RouteCard), +}); +export type PublicPartner = z.infer; From 3d0871a2d27907306fcca890bf5f432e2d1e998b Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Tue, 20 Oct 2026 13:47:47 +0700 Subject: [PATCH 02/14] feat(api): add the tables of the B2B partners and their routes (A-10) b2b_clients and b2b_client_routes, with a unique slug that is checked in the database, a price bounded in cents and at most 10 routes in order. Co-Authored-By: Claude Sonnet 5.5 --- .../migrations/0021_add_b2b_clients.sql | 34 + .../migrations/meta/0021_snapshot.json | 3896 +++++++++++++++++ .../generated/migrations/meta/_journal.json | 7 + apps/api/src/db/schema.ts | 61 + 4 files changed, 3998 insertions(+) create mode 100644 apps/api/src/db/generated/migrations/0021_add_b2b_clients.sql create mode 100644 apps/api/src/db/generated/migrations/meta/0021_snapshot.json diff --git a/apps/api/src/db/generated/migrations/0021_add_b2b_clients.sql b/apps/api/src/db/generated/migrations/0021_add_b2b_clients.sql new file mode 100644 index 00000000..48528d2d --- /dev/null +++ b/apps/api/src/db/generated/migrations/0021_add_b2b_clients.sql @@ -0,0 +1,34 @@ +CREATE TYPE "public"."b2b_client_status" AS ENUM('prospect', 'active', 'suspended', 'ended');--> statement-breakpoint +CREATE TYPE "public"."b2b_client_type" AS ENUM('hotel', 'concierge', 'tourist_office', 'other');--> statement-breakpoint +CREATE TABLE "b2b_client_routes" ( + "client_id" uuid NOT NULL, + "route_id" uuid NOT NULL, + "position" smallint NOT NULL, + CONSTRAINT "b2b_client_routes_client_id_route_id_pk" PRIMARY KEY("client_id","route_id"), + CONSTRAINT "b2b_client_routes_position_unique" UNIQUE("client_id","position"), + CONSTRAINT "b2b_client_routes_position_check" CHECK ("b2b_client_routes"."position" between 0 and 9) +); +--> statement-breakpoint +CREATE TABLE "b2b_clients" ( + "id" uuid PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "slug" text NOT NULL, + "type" "b2b_client_type" NOT NULL, + "contact_name" text, + "contact_email" text, + "contact_phone" text, + "address" text, + "status" "b2b_client_status" DEFAULT 'prospect' NOT NULL, + "monthly_price_cents" integer, + "started_at" date, + "notes" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "b2b_clients_slug_unique" UNIQUE("slug"), + CONSTRAINT "b2b_clients_slug_check" CHECK ("b2b_clients"."slug" ~ '^[a-z0-9]+(-[a-z0-9]+)*$'), + CONSTRAINT "b2b_clients_price_check" CHECK ("b2b_clients"."monthly_price_cents" is null or "b2b_clients"."monthly_price_cents" between 0 and 10000000) +); +--> statement-breakpoint +ALTER TABLE "b2b_client_routes" ADD CONSTRAINT "b2b_client_routes_client_id_b2b_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."b2b_clients"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "b2b_client_routes" ADD CONSTRAINT "b2b_client_routes_route_id_routes_id_fk" FOREIGN KEY ("route_id") REFERENCES "public"."routes"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "b2b_client_routes_route_idx" ON "b2b_client_routes" USING btree ("route_id"); \ No newline at end of file diff --git a/apps/api/src/db/generated/migrations/meta/0021_snapshot.json b/apps/api/src/db/generated/migrations/meta/0021_snapshot.json new file mode 100644 index 00000000..fd9bd656 --- /dev/null +++ b/apps/api/src/db/generated/migrations/meta/0021_snapshot.json @@ -0,0 +1,3896 @@ +{ + "id": "33fa06d5-f85a-4109-926d-95cda9d23ac0", + "prevId": "f5d94a97-e228-41ce-9b50-4e436f411d3c", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.admin_actions": { + "name": "admin_actions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "before": { + "name": "before", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "after": { + "name": "after", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "admin_actions_created_at_idx": { + "name": "admin_actions_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "admin_actions_target_idx": { + "name": "admin_actions_target_idx", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "admin_actions_actor_idx": { + "name": "admin_actions_actor_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "admin_actions_actor_id_users_id_fk": { + "name": "admin_actions_actor_id_users_id_fk", + "tableFrom": "admin_actions", + "tableTo": "users", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.affiliate_programs": { + "name": "affiliate_programs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "network": { + "name": "network", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "commission_note": { + "name": "commission_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cookie_days": { + "name": "cookie_days", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "affiliate_programs_name_unique": { + "name": "affiliate_programs_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": { + "affiliate_programs_cookie_days_check": { + "name": "affiliate_programs_cookie_days_check", + "value": "\"affiliate_programs\".\"cookie_days\" between 0 and 365" + } + }, + "isRLSEnabled": false + }, + "public.anecdote_questions": { + "name": "anecdote_questions", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "anecdote_questions_key_check": { + "name": "anecdote_questions_key_check", + "value": "\"anecdote_questions\".\"key\" ~ '^[a-z0-9]+(_[a-z0-9]+)*$'" + } + }, + "isRLSEnabled": false + }, + "public.b2b_client_routes": { + "name": "b2b_client_routes", + "schema": "", + "columns": { + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "route_id": { + "name": "route_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "smallint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "b2b_client_routes_route_idx": { + "name": "b2b_client_routes_route_idx", + "columns": [ + { + "expression": "route_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "b2b_client_routes_client_id_b2b_clients_id_fk": { + "name": "b2b_client_routes_client_id_b2b_clients_id_fk", + "tableFrom": "b2b_client_routes", + "tableTo": "b2b_clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "b2b_client_routes_route_id_routes_id_fk": { + "name": "b2b_client_routes_route_id_routes_id_fk", + "tableFrom": "b2b_client_routes", + "tableTo": "routes", + "columnsFrom": [ + "route_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "b2b_client_routes_client_id_route_id_pk": { + "name": "b2b_client_routes_client_id_route_id_pk", + "columns": [ + "client_id", + "route_id" + ] + } + }, + "uniqueConstraints": { + "b2b_client_routes_position_unique": { + "name": "b2b_client_routes_position_unique", + "nullsNotDistinct": false, + "columns": [ + "client_id", + "position" + ] + } + }, + "policies": {}, + "checkConstraints": { + "b2b_client_routes_position_check": { + "name": "b2b_client_routes_position_check", + "value": "\"b2b_client_routes\".\"position\" between 0 and 9" + } + }, + "isRLSEnabled": false + }, + "public.b2b_clients": { + "name": "b2b_clients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "b2b_client_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "contact_name": { + "name": "contact_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contact_email": { + "name": "contact_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contact_phone": { + "name": "contact_phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "b2b_client_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'prospect'" + }, + "monthly_price_cents": { + "name": "monthly_price_cents", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "b2b_clients_slug_unique": { + "name": "b2b_clients_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": { + "b2b_clients_slug_check": { + "name": "b2b_clients_slug_check", + "value": "\"b2b_clients\".\"slug\" ~ '^[a-z0-9]+(-[a-z0-9]+)*$'" + }, + "b2b_clients_price_check": { + "name": "b2b_clients_price_check", + "value": "\"b2b_clients\".\"monthly_price_cents\" is null or \"b2b_clients\".\"monthly_price_cents\" between 0 and 10000000" + } + }, + "isRLSEnabled": false + }, + "public.booking_clicks": { + "name": "booking_clicks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "step_id": { + "name": "step_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "link_origin": { + "name": "link_origin", + "type": "link_origin", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "booking_clicks_step_idx": { + "name": "booking_clicks_step_idx", + "columns": [ + { + "expression": "step_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "booking_clicks_user_id_users_id_fk": { + "name": "booking_clicks_user_id_users_id_fk", + "tableFrom": "booking_clicks", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cities": { + "name": "cities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "center": { + "name": "center", + "type": "geography(Point,4326)", + "primaryKey": false, + "notNull": true + }, + "bounds": { + "name": "bounds", + "type": "geography(Polygon,4326)", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "cities_slug_unique": { + "name": "cities_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.devices": { + "name": "devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "push_token": { + "name": "push_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "devices_user_updated_idx": { + "name": "devices_user_updated_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "devices_user_id_users_id_fk": { + "name": "devices_user_id_users_id_fk", + "tableFrom": "devices", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "devices_push_token_unique": { + "name": "devices_push_token_unique", + "nullsNotDistinct": false, + "columns": [ + "push_token" + ] + } + }, + "policies": {}, + "checkConstraints": { + "devices_locale_check": { + "name": "devices_locale_check", + "value": "(\"devices\".\"locale\" in ('fr', 'en'))" + } + }, + "isRLSEnabled": false + }, + "public.direction_cache": { + "name": "direction_cache", + "schema": "", + "columns": { + "profile": { + "name": "profile", + "type": "directions_profile", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "from_lat_e5": { + "name": "from_lat_e5", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "from_lng_e5": { + "name": "from_lng_e5", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "to_lat_e5": { + "name": "to_lat_e5", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "to_lng_e5": { + "name": "to_lng_e5", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "distance_m": { + "name": "distance_m", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "duration_s": { + "name": "duration_s", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "geometry": { + "name": "geometry", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "direction_cache_from_idx": { + "name": "direction_cache_from_idx", + "columns": [ + { + "expression": "from_lat_e5", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "from_lng_e5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "direction_cache_to_idx": { + "name": "direction_cache_to_idx", + "columns": [ + { + "expression": "to_lat_e5", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "to_lng_e5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "direction_cache_pkey": { + "name": "direction_cache_pkey", + "columns": [ + "profile", + "from_lat_e5", + "from_lng_e5", + "to_lat_e5", + "to_lng_e5" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "direction_cache_measures_check": { + "name": "direction_cache_measures_check", + "value": "\"direction_cache\".\"distance_m\" >= 0 and \"direction_cache\".\"duration_s\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.media": { + "name": "media", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "owner_type": { + "name": "owner_type", + "type": "media_owner_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "storage_path": { + "name": "storage_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "media_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'upload'" + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "license": { + "name": "license", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "media_owner_idx": { + "name": "media_owner_idx", + "columns": [ + { + "expression": "owner_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "media_step_position_idx": { + "name": "media_step_position_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"media\".\"owner_type\" not in ('route', 'place')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "media_uploaded_by_users_id_fk": { + "name": "media_uploaded_by_users_id_fk", + "tableFrom": "media", + "tableTo": "users", + "columnsFrom": [ + "uploaded_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "media_step_position_check": { + "name": "media_step_position_check", + "value": "\"media\".\"owner_type\" in ('route', 'place')\n or \"media\".\"position\" between 0 and 4" + }, + "media_credit_check": { + "name": "media_credit_check", + "value": "(\"media\".\"source\" = 'upload'\n and \"media\".\"author\" is null and \"media\".\"license\" is null and \"media\".\"source_url\" is null)\n or (\"media\".\"source\" = 'commons'\n and coalesce(\"media\".\"author\", '') <> '' and coalesce(\"media\".\"license\", '') <> ''\n and coalesce(\"media\".\"source_url\", '') like 'https://%')" + } + }, + "isRLSEnabled": false + }, + "public.moderation_actions": { + "name": "moderation_actions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "route_id": { + "name": "route_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "moderation_action", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "reason_code": { + "name": "reason_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "moderation_actions_route_idx": { + "name": "moderation_actions_route_idx", + "columns": [ + { + "expression": "route_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "moderation_actions_route_id_routes_id_fk": { + "name": "moderation_actions_route_id_routes_id_fk", + "tableFrom": "moderation_actions", + "tableTo": "routes", + "columnsFrom": [ + "route_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "moderation_actions_actor_id_users_id_fk": { + "name": "moderation_actions_actor_id_users_id_fk", + "tableFrom": "moderation_actions", + "tableTo": "users", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notifications": { + "name": "notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "notification_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "read_at": { + "name": "read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sent_push_at": { + "name": "sent_push_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sent_email_at": { + "name": "sent_email_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notifications_user_created_idx": { + "name": "notifications_user_created_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notifications_user_id_users_id_fk": { + "name": "notifications_user_id_users_id_fk", + "tableFrom": "notifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.place_hours": { + "name": "place_hours", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "place_id": { + "name": "place_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "weekday": { + "name": "weekday", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "opens": { + "name": "opens", + "type": "time", + "primaryKey": false, + "notNull": true + }, + "closes": { + "name": "closes", + "type": "time", + "primaryKey": false, + "notNull": true + }, + "valid_from": { + "name": "valid_from", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "valid_to": { + "name": "valid_to", + "type": "date", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "place_hours_place_idx": { + "name": "place_hours_place_idx", + "columns": [ + { + "expression": "place_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "place_hours_place_id_places_id_fk": { + "name": "place_hours_place_id_places_id_fk", + "tableFrom": "place_hours", + "tableTo": "places", + "columnsFrom": [ + "place_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "place_hours_weekday_check": { + "name": "place_hours_weekday_check", + "value": "\"place_hours\".\"weekday\" between 0 and 6" + } + }, + "isRLSEnabled": false + }, + "public.places": { + "name": "places", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "city_id": { + "name": "city_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "place_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "location": { + "name": "location", + "type": "geography(Point,4326)", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address_components": { + "name": "address_components", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_indoor": { + "name": "is_indoor", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "price_range": { + "name": "price_range", + "type": "price_range", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "partner_booking_url": { + "name": "partner_booking_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "partner_program_id": { + "name": "partner_program_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "external_refs": { + "name": "external_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "verification_status": { + "name": "verification_status", + "type": "verification_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "always_open": { + "name": "always_open", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ends_on": { + "name": "ends_on", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "confirmations_since_verified": { + "name": "confirmations_since_verified", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "places_location_idx": { + "name": "places_location_idx", + "columns": [ + { + "expression": "location", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gist", + "with": {} + }, + "places_city_category_idx": { + "name": "places_city_category_idx", + "columns": [ + { + "expression": "city_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "places_name_trgm_idx": { + "name": "places_name_trgm_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "places_city_id_cities_id_fk": { + "name": "places_city_id_cities_id_fk", + "tableFrom": "places", + "tableTo": "cities", + "columnsFrom": [ + "city_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "places_partner_program_id_affiliate_programs_id_fk": { + "name": "places_partner_program_id_affiliate_programs_id_fk", + "tableFrom": "places", + "tableTo": "affiliate_programs", + "columnsFrom": [ + "partner_program_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "places_created_by_users_id_fk": { + "name": "places_created_by_users_id_fk", + "tableFrom": "places", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "places_partner_link_check": { + "name": "places_partner_link_check", + "value": "(\"places\".\"partner_booking_url\" is null or \"places\".\"partner_booking_url\" like 'https://%')\n and (\"places\".\"partner_program_id\" is null or \"places\".\"partner_booking_url\" is not null)" + } + }, + "isRLSEnabled": false + }, + "public.planned_routes": { + "name": "planned_routes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "route_id": { + "name": "route_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "route_revision_snapshot": { + "name": "route_revision_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "starts_at": { + "name": "starts_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "planned_route_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'planned'" + }, + "timeline": { + "name": "timeline", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "checklist": { + "name": "checklist", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "closed_by": { + "name": "closed_by", + "type": "planned_route_closer", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "planned_routes_user_starts_idx": { + "name": "planned_routes_user_starts_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "starts_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "planned_routes_route_status_idx": { + "name": "planned_routes_route_status_idx", + "columns": [ + { + "expression": "route_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "planned_routes_one_in_progress_idx": { + "name": "planned_routes_one_in_progress_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"planned_routes\".\"status\" = 'in_progress'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "planned_routes_user_id_users_id_fk": { + "name": "planned_routes_user_id_users_id_fk", + "tableFrom": "planned_routes", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "planned_routes_route_id_routes_id_fk": { + "name": "planned_routes_route_id_routes_id_fk", + "tableFrom": "planned_routes", + "tableTo": "routes", + "columnsFrom": [ + "route_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "planned_routes_closed_by_check": { + "name": "planned_routes_closed_by_check", + "value": "\"planned_routes\".\"closed_by\" is null or \"planned_routes\".\"status\" in ('completed', 'stopped')" + }, + "planned_routes_completed_at_check": { + "name": "planned_routes_completed_at_check", + "value": "\"planned_routes\".\"completed_at\" is null or \"planned_routes\".\"status\" = 'completed'" + } + }, + "isRLSEnabled": false + }, + "public.planned_steps": { + "name": "planned_steps", + "schema": "", + "columns": { + "planned_route_id": { + "name": "planned_route_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "step_id": { + "name": "step_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "arrived_at": { + "name": "arrived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "done_at": { + "name": "done_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "skipped_at": { + "name": "skipped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "planned_steps_planned_route_id_planned_routes_id_fk": { + "name": "planned_steps_planned_route_id_planned_routes_id_fk", + "tableFrom": "planned_steps", + "tableTo": "planned_routes", + "columnsFrom": [ + "planned_route_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "planned_steps_pkey": { + "name": "planned_steps_pkey", + "columns": [ + "planned_route_id", + "step_id" + ] + } + }, + "uniqueConstraints": { + "planned_steps_route_position_unique": { + "name": "planned_steps_route_position_unique", + "nullsNotDistinct": false, + "columns": [ + "planned_route_id", + "position" + ] + } + }, + "policies": {}, + "checkConstraints": { + "planned_steps_done_or_skipped_check": { + "name": "planned_steps_done_or_skipped_check", + "value": "\"planned_steps\".\"done_at\" is null or \"planned_steps\".\"skipped_at\" is null" + } + }, + "isRLSEnabled": false + }, + "public.route_ratings": { + "name": "route_ratings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "route_id": { + "name": "route_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "planned_route_id": { + "name": "planned_route_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "rating": { + "name": "rating", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "route_rating_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'visible'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "route_ratings_route_created_idx": { + "name": "route_ratings_route_created_idx", + "columns": [ + { + "expression": "route_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "route_ratings_user_idx": { + "name": "route_ratings_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "route_ratings_route_id_routes_id_fk": { + "name": "route_ratings_route_id_routes_id_fk", + "tableFrom": "route_ratings", + "tableTo": "routes", + "columnsFrom": [ + "route_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "route_ratings_user_id_users_id_fk": { + "name": "route_ratings_user_id_users_id_fk", + "tableFrom": "route_ratings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "route_ratings_planned_route_id_planned_routes_id_fk": { + "name": "route_ratings_planned_route_id_planned_routes_id_fk", + "tableFrom": "route_ratings", + "tableTo": "planned_routes", + "columnsFrom": [ + "planned_route_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "route_ratings_route_user_unique": { + "name": "route_ratings_route_user_unique", + "nullsNotDistinct": false, + "columns": [ + "route_id", + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "route_ratings_rating_check": { + "name": "route_ratings_rating_check", + "value": "\"route_ratings\".\"rating\" between 1 and 5" + }, + "route_ratings_comment_check": { + "name": "route_ratings_comment_check", + "value": "\"route_ratings\".\"comment\" is null or (btrim(\"route_ratings\".\"comment\") <> '' and char_length(\"route_ratings\".\"comment\") <= 280)" + } + }, + "isRLSEnabled": false + }, + "public.route_reports": { + "name": "route_reports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "route_id": { + "name": "route_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "route_report_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "step_id": { + "name": "step_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "report_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "reviewed_by": { + "name": "reviewed_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "route_reports_route_user_idx": { + "name": "route_reports_route_user_idx", + "columns": [ + { + "expression": "route_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "route_reports_open_idx": { + "name": "route_reports_open_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"route_reports\".\"status\" = 'open'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "route_reports_one_open_low_rating_idx": { + "name": "route_reports_one_open_low_rating_idx", + "columns": [ + { + "expression": "route_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"route_reports\".\"reason\" = 'low_rating' and \"route_reports\".\"status\" = 'open'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "route_reports_route_id_routes_id_fk": { + "name": "route_reports_route_id_routes_id_fk", + "tableFrom": "route_reports", + "tableTo": "routes", + "columnsFrom": [ + "route_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "route_reports_user_id_users_id_fk": { + "name": "route_reports_user_id_users_id_fk", + "tableFrom": "route_reports", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "route_reports_reviewed_by_users_id_fk": { + "name": "route_reports_reviewed_by_users_id_fk", + "tableFrom": "route_reports", + "tableTo": "users", + "columnsFrom": [ + "reviewed_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "route_reports_reporter_check": { + "name": "route_reports_reporter_check", + "value": "(\"route_reports\".\"user_id\" is null) = (\"route_reports\".\"reason\" = 'low_rating')" + }, + "route_reports_step_check": { + "name": "route_reports_step_check", + "value": "(\"route_reports\".\"step_id\" is not null) = (\"route_reports\".\"reason\" in ('place_changed'))" + }, + "route_reports_comment_check": { + "name": "route_reports_comment_check", + "value": "\"route_reports\".\"comment\" is null or ((\"route_reports\".\"reason\" in ('place_changed', 'other')) and btrim(\"route_reports\".\"comment\") <> '' and char_length(\"route_reports\".\"comment\") <= 80)" + }, + "route_reports_comment_required_check": { + "name": "route_reports_comment_required_check", + "value": "\"route_reports\".\"comment\" is not null or not (\"route_reports\".\"reason\" in ('other'))" + }, + "route_reports_review_check": { + "name": "route_reports_review_check", + "value": "(\"route_reports\".\"status\" = 'open') = (\"route_reports\".\"reviewed_at\" is null)" + } + }, + "isRLSEnabled": false + }, + "public.route_revisions": { + "name": "route_revisions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "route_id": { + "name": "route_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "route_revision_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "submitted_at": { + "name": "submitted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "decided_at": { + "name": "decided_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "route_revisions_one_open_idx": { + "name": "route_revisions_one_open_idx", + "columns": [ + { + "expression": "route_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"route_revisions\".\"status\" in ('draft', 'pending', 'changes_requested')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "route_revisions_route_idx": { + "name": "route_revisions_route_idx", + "columns": [ + { + "expression": "route_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "route_revisions_route_id_routes_id_fk": { + "name": "route_revisions_route_id_routes_id_fk", + "tableFrom": "route_revisions", + "tableTo": "routes", + "columnsFrom": [ + "route_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "route_revisions_author_id_users_id_fk": { + "name": "route_revisions_author_id_users_id_fk", + "tableFrom": "route_revisions", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.route_unlocks": { + "name": "route_unlocks", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "route_id": { + "name": "route_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "store_transaction_id": { + "name": "store_transaction_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "plan_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'store'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "route_unlocks_route_idx": { + "name": "route_unlocks_route_idx", + "columns": [ + { + "expression": "route_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "route_unlocks_user_id_users_id_fk": { + "name": "route_unlocks_user_id_users_id_fk", + "tableFrom": "route_unlocks", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "route_unlocks_route_id_routes_id_fk": { + "name": "route_unlocks_route_id_routes_id_fk", + "tableFrom": "route_unlocks", + "tableTo": "routes", + "columnsFrom": [ + "route_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "route_unlocks_pkey": { + "name": "route_unlocks_pkey", + "columns": [ + "user_id", + "route_id" + ] + } + }, + "uniqueConstraints": { + "route_unlocks_store_transaction_id_unique": { + "name": "route_unlocks_store_transaction_id_unique", + "nullsNotDistinct": false, + "columns": [ + "store_transaction_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "route_unlocks_transaction_check": { + "name": "route_unlocks_transaction_check", + "value": "\"route_unlocks\".\"source\" = 'gift' or \"route_unlocks\".\"store_transaction_id\" is not null" + } + }, + "isRLSEnabled": false + }, + "public.routes": { + "name": "routes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "city_id": { + "name": "city_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "is_official": { + "name": "is_official", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "status": { + "name": "status", + "type": "route_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "access": { + "name": "access", + "type": "plan", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "sponsored": { + "name": "sponsored", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "share_token": { + "name": "share_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "moods": { + "name": "moods", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "audiences": { + "name": "audiences", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "conditions": { + "name": "conditions", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "transport": { + "name": "transport", + "type": "transport", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'walk'" + }, + "suggested_start": { + "name": "suggested_start", + "type": "time", + "primaryKey": false, + "notNull": false + }, + "tips": { + "name": "tips", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "tips_hidden": { + "name": "tips_hidden", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "start_location": { + "name": "start_location", + "type": "geography(Point,4326)", + "primaryKey": false, + "notNull": false + }, + "bounds": { + "name": "bounds", + "type": "geography(Polygon,4326)", + "primaryKey": false, + "notNull": false + }, + "computed": { + "name": "computed", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "duration_bucket": { + "name": "duration_bucket", + "type": "duration_bucket", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "budget_bucket": { + "name": "budget_bucket", + "type": "budget_bucket", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "unpublished_at": { + "name": "unpublished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "stats": { + "name": "stats", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "recommendation": { + "name": "recommendation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "routes_bounds_idx": { + "name": "routes_bounds_idx", + "columns": [ + { + "expression": "bounds", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gist", + "with": {} + }, + "routes_start_location_idx": { + "name": "routes_start_location_idx", + "columns": [ + { + "expression": "start_location", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gist", + "with": {} + }, + "routes_city_status_idx": { + "name": "routes_city_status_idx", + "columns": [ + { + "expression": "city_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routes_moods_idx": { + "name": "routes_moods_idx", + "columns": [ + { + "expression": "moods", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "routes_audiences_idx": { + "name": "routes_audiences_idx", + "columns": [ + { + "expression": "audiences", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "routes_conditions_idx": { + "name": "routes_conditions_idx", + "columns": [ + { + "expression": "conditions", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "routes_duration_bucket_idx": { + "name": "routes_duration_bucket_idx", + "columns": [ + { + "expression": "duration_bucket", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routes_budget_bucket_idx": { + "name": "routes_budget_bucket_idx", + "columns": [ + { + "expression": "budget_bucket", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routes_city_id_cities_id_fk": { + "name": "routes_city_id_cities_id_fk", + "tableFrom": "routes", + "tableTo": "cities", + "columnsFrom": [ + "city_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "routes_author_id_users_id_fk": { + "name": "routes_author_id_users_id_fk", + "tableFrom": "routes", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "routes_share_token_unique": { + "name": "routes_share_token_unique", + "nullsNotDistinct": false, + "columns": [ + "share_token" + ] + } + }, + "policies": {}, + "checkConstraints": { + "routes_moods_check": { + "name": "routes_moods_check", + "value": "\"routes\".\"moods\" <@ ARRAY['culture', 'nature', 'shopping', 'food', 'romantic', 'unusual', 'instagrammable', 'relax', 'sport']::text[]" + }, + "routes_audiences_check": { + "name": "routes_audiences_check", + "value": "\"routes\".\"audiences\" <@ ARRAY['solo', 'couple', 'family', 'friends', 'dog_friendly', 'kids_friendly']::text[]" + }, + "routes_conditions_check": { + "name": "routes_conditions_check", + "value": "\"routes\".\"conditions\" <@ ARRAY['no_booking', 'wheelchair', 'indoor', 'outdoor', 'sunny', 'rainy']::text[]" + }, + "routes_title_length_check": { + "name": "routes_title_length_check", + "value": "char_length(\"routes\".\"title\") <= 60" + }, + "routes_tips_check": { + "name": "routes_tips_check", + "value": "(cardinality(\"routes\".\"tips\") = 0 or (array_ndims(\"routes\".\"tips\") = 1\n and array_position(\"routes\".\"tips\", null) is null\n and array_position(\"routes\".\"tips\", '') is null)) and cardinality(\"routes\".\"tips\") <= 3" + }, + "routes_tips_hidden_check": { + "name": "routes_tips_hidden_check", + "value": "(cardinality(\"routes\".\"tips_hidden\") = 0 or (array_ndims(\"routes\".\"tips_hidden\") = 1\n and array_position(\"routes\".\"tips_hidden\", null) is null\n and array_position(\"routes\".\"tips_hidden\", '') is null))" + } + }, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.steps": { + "name": "steps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "route_id": { + "name": "route_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "place_id": { + "name": "place_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration_min": { + "name": "duration_min", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "cost_per_person": { + "name": "cost_per_person", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "booking_required": { + "name": "booking_required", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "booking_url": { + "name": "booking_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "booking_provider": { + "name": "booking_provider", + "type": "booking_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "booking_note": { + "name": "booking_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "booking_is_affiliate": { + "name": "booking_is_affiliate", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ends_on": { + "name": "ends_on", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "transition_note": { + "name": "transition_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transition": { + "name": "transition", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "highlights": { + "name": "highlights", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "audio_path": { + "name": "audio_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "audio_duration_s": { + "name": "audio_duration_s", + "type": "smallint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "steps_place_idx": { + "name": "steps_place_idx", + "columns": [ + { + "expression": "place_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "steps_route_id_routes_id_fk": { + "name": "steps_route_id_routes_id_fk", + "tableFrom": "steps", + "tableTo": "routes", + "columnsFrom": [ + "route_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "steps_place_id_places_id_fk": { + "name": "steps_place_id_places_id_fk", + "tableFrom": "steps", + "tableTo": "places", + "columnsFrom": [ + "place_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "steps_route_position_unique": { + "name": "steps_route_position_unique", + "nullsNotDistinct": false, + "columns": [ + "route_id", + "position" + ] + } + }, + "policies": {}, + "checkConstraints": { + "steps_highlights_check": { + "name": "steps_highlights_check", + "value": "\"steps\".\"highlights\" is null or (array_ndims(\"steps\".\"highlights\") = 1\n and cardinality(\"steps\".\"highlights\") between 1 and 3\n and array_position(\"steps\".\"highlights\", null) is null\n and array_position(\"steps\".\"highlights\", '') is null)" + }, + "steps_audio_check": { + "name": "steps_audio_check", + "value": "(\"steps\".\"audio_path\" is null and \"steps\".\"audio_duration_s\" is null)\n or (\"steps\".\"audio_path\" is not null and \"steps\".\"audio_duration_s\" is not null and \"steps\".\"audio_duration_s\" > 0)" + }, + "steps_description_length_check": { + "name": "steps_description_length_check", + "value": "char_length(\"steps\".\"description\") <= 600" + }, + "steps_booking_note_length_check": { + "name": "steps_booking_note_length_check", + "value": "char_length(\"steps\".\"booking_note\") <= 80" + }, + "steps_booking_affiliate_check": { + "name": "steps_booking_affiliate_check", + "value": "not \"steps\".\"booking_is_affiliate\" or \"steps\".\"booking_url\" is not null" + } + }, + "isRLSEnabled": false + }, + "public.support_messages": { + "name": "support_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reply_email": { + "name": "reply_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "topic": { + "name": "topic", + "type": "support_topic", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "app_version": { + "name": "app_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device": { + "name": "device", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "handled_at": { + "name": "handled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "support_messages_user_idx": { + "name": "support_messages_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "support_messages_unhandled_idx": { + "name": "support_messages_unhandled_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"support_messages\".\"handled_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "support_messages_user_id_users_id_fk": { + "name": "support_messages_user_id_users_id_fk", + "tableFrom": "support_messages", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "support_messages_message_length_check": { + "name": "support_messages_message_length_check", + "value": "char_length(\"support_messages\".\"message\") between 1 and 1000" + }, + "support_messages_locale_check": { + "name": "support_messages_locale_check", + "value": "(\"support_messages\".\"locale\" in ('fr', 'en'))" + } + }, + "isRLSEnabled": false + }, + "public.user_anecdotes": { + "name": "user_anecdotes", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "question_key": { + "name": "question_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "answer": { + "name": "answer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "smallint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_anecdotes_user_id_users_id_fk": { + "name": "user_anecdotes_user_id_users_id_fk", + "tableFrom": "user_anecdotes", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_anecdotes_question_key_anecdote_questions_key_fk": { + "name": "user_anecdotes_question_key_anecdote_questions_key_fk", + "tableFrom": "user_anecdotes", + "tableTo": "anecdote_questions", + "columnsFrom": [ + "question_key" + ], + "columnsTo": [ + "key" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_anecdotes_pkey": { + "name": "user_anecdotes_pkey", + "columns": [ + "user_id", + "position" + ] + } + }, + "uniqueConstraints": { + "user_anecdotes_user_question_unique": { + "name": "user_anecdotes_user_question_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "question_key" + ] + } + }, + "policies": {}, + "checkConstraints": { + "user_anecdotes_position_check": { + "name": "user_anecdotes_position_check", + "value": "\"user_anecdotes\".\"position\" between 1 and 3" + }, + "user_anecdotes_answer_length_check": { + "name": "user_anecdotes_answer_length_check", + "value": "char_length(\"user_anecdotes\".\"answer\") between 1 and 150" + } + }, + "isRLSEnabled": false + }, + "public.user_featured_routes": { + "name": "user_featured_routes", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "route_id": { + "name": "route_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "smallint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "user_featured_routes_route_idx": { + "name": "user_featured_routes_route_idx", + "columns": [ + { + "expression": "route_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_featured_routes_user_id_users_id_fk": { + "name": "user_featured_routes_user_id_users_id_fk", + "tableFrom": "user_featured_routes", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_featured_routes_route_id_routes_id_fk": { + "name": "user_featured_routes_route_id_routes_id_fk", + "tableFrom": "user_featured_routes", + "tableTo": "routes", + "columnsFrom": [ + "route_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_featured_routes_pkey": { + "name": "user_featured_routes_pkey", + "columns": [ + "user_id", + "position" + ] + } + }, + "uniqueConstraints": { + "user_featured_routes_user_route_unique": { + "name": "user_featured_routes_user_route_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "route_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "user_featured_routes_position_check": { + "name": "user_featured_routes_position_check", + "value": "\"user_featured_routes\".\"position\" between 1 and 3" + } + }, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "firebase_uid": { + "name": "firebase_uid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "plan": { + "name": "plan", + "type": "plan", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "plan_expires_at": { + "name": "plan_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "plan_source": { + "name": "plan_source", + "type": "plan_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'store'" + }, + "revenuecat_app_user_id": { + "name": "revenuecat_app_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "neighborhood": { + "name": "neighborhood", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_certified": { + "name": "is_certified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "certified_override": { + "name": "certified_override", + "type": "certified_override", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "certified_at": { + "name": "certified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "trust_score": { + "name": "trust_score", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "notification_prefs": { + "name": "notification_prefs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "suspension_reason": { + "name": "suspension_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "users_firebase_uid_active_idx": { + "name": "users_firebase_uid_active_idx", + "columns": [ + { + "expression": "firebase_uid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"users\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_firebase_uid_deleted_idx": { + "name": "users_firebase_uid_deleted_idx", + "columns": [ + { + "expression": "firebase_uid", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "users_bio_length_check": { + "name": "users_bio_length_check", + "value": "char_length(\"users\".\"bio\") <= 150" + }, + "users_locale_check": { + "name": "users_locale_check", + "value": "\"users\".\"locale\" is null or (\"users\".\"locale\" in ('fr', 'en'))" + } + }, + "isRLSEnabled": false + } + }, + "enums": { + "public.b2b_client_status": { + "name": "b2b_client_status", + "schema": "public", + "values": [ + "prospect", + "active", + "suspended", + "ended" + ] + }, + "public.b2b_client_type": { + "name": "b2b_client_type", + "schema": "public", + "values": [ + "hotel", + "concierge", + "tourist_office", + "other" + ] + }, + "public.booking_provider": { + "name": "booking_provider", + "schema": "public", + "values": [ + "restaurant", + "ticketing", + "activity", + "other" + ] + }, + "public.budget_bucket": { + "name": "budget_bucket", + "schema": "public", + "values": [ + "free", + "low", + "medium", + "high" + ] + }, + "public.certified_override": { + "name": "certified_override", + "schema": "public", + "values": [ + "none", + "granted", + "revoked" + ] + }, + "public.directions_profile": { + "name": "directions_profile", + "schema": "public", + "values": [ + "walking", + "cycling", + "driving" + ] + }, + "public.duration_bucket": { + "name": "duration_bucket", + "schema": "public", + "values": [ + "1_2h", + "half_day", + "full_day", + "weekend" + ] + }, + "public.link_origin": { + "name": "link_origin", + "schema": "public", + "values": [ + "creator", + "creator_affiliate", + "partner" + ] + }, + "public.media_owner_type": { + "name": "media_owner_type", + "schema": "public", + "values": [ + "route", + "place", + "step" + ] + }, + "public.media_source": { + "name": "media_source", + "schema": "public", + "values": [ + "upload", + "commons" + ] + }, + "public.moderation_action": { + "name": "moderation_action", + "schema": "public", + "values": [ + "publish", + "request_changes", + "reject", + "unpublish", + "edit" + ] + }, + "public.notification_kind": { + "name": "notification_kind", + "schema": "public", + "values": [ + "go_closed", + "report_resolved" + ] + }, + "public.place_category": { + "name": "place_category", + "schema": "public", + "values": [ + "restaurant", + "cafe", + "bar", + "bakery", + "museum", + "gallery", + "monument", + "park", + "viewpoint", + "shop", + "activity", + "event_venue", + "walk", + "other" + ] + }, + "public.plan": { + "name": "plan", + "schema": "public", + "values": [ + "free", + "premium" + ] + }, + "public.plan_source": { + "name": "plan_source", + "schema": "public", + "values": [ + "store", + "gift" + ] + }, + "public.planned_route_closer": { + "name": "planned_route_closer", + "schema": "public", + "values": [ + "user", + "auto" + ] + }, + "public.planned_route_status": { + "name": "planned_route_status", + "schema": "public", + "values": [ + "planned", + "in_progress", + "completed", + "stopped", + "cancelled", + "missed" + ] + }, + "public.price_range": { + "name": "price_range", + "schema": "public", + "values": [ + "free", + "low", + "medium", + "high" + ] + }, + "public.report_status": { + "name": "report_status", + "schema": "public", + "values": [ + "open", + "accepted", + "rejected" + ] + }, + "public.route_rating_status": { + "name": "route_rating_status", + "schema": "public", + "values": [ + "visible", + "hidden" + ] + }, + "public.route_report_reason": { + "name": "route_report_reason", + "schema": "public", + "values": [ + "place_changed", + "inappropriate", + "dangerous_place", + "hidden_ad", + "copied_content", + "false_info", + "other", + "low_rating" + ] + }, + "public.route_revision_status": { + "name": "route_revision_status", + "schema": "public", + "values": [ + "draft", + "pending", + "changes_requested", + "rejected", + "published" + ] + }, + "public.route_status": { + "name": "route_status", + "schema": "public", + "values": [ + "draft", + "private", + "pending", + "changes_requested", + "published", + "needs_fix", + "rejected", + "unpublished" + ] + }, + "public.support_topic": { + "name": "support_topic", + "schema": "public", + "values": [ + "question", + "problem", + "data", + "other" + ] + }, + "public.transport": { + "name": "transport", + "schema": "public", + "values": [ + "walk", + "bike", + "metro", + "car" + ] + }, + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": [ + "user", + "editor", + "moderator", + "admin" + ] + }, + "public.verification_status": { + "name": "verification_status", + "schema": "public", + "values": [ + "unverified", + "verified", + "stale", + "flagged", + "closed" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/api/src/db/generated/migrations/meta/_journal.json b/apps/api/src/db/generated/migrations/meta/_journal.json index 419c052f..f0f2c511 100644 --- a/apps/api/src/db/generated/migrations/meta/_journal.json +++ b/apps/api/src/db/generated/migrations/meta/_journal.json @@ -148,6 +148,13 @@ "when": 1792469898176, "tag": "0020_add_affiliation", "breakpoints": true + }, + { + "idx": 21, + "version": "7", + "when": 1792475741279, + "tag": "0021_add_b2b_clients", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index 8389d8b4..3de309b9 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -18,6 +18,10 @@ import { plannedRouteStatuses, planSources, profileSlots, + b2bClientStatuses, + b2bClientTypes, + maxMonthlyPriceCents, + maxPartnerRoutes, ratingCommentMaxLength, reportStatuses, routeTipsMax, @@ -108,6 +112,8 @@ export const notificationKind = pgEnum('notification_kind', notificationKinds); export const supportTopic = pgEnum('support_topic', supportTopics); /** Whether a rating shows on the sheet, or was masked by a moderator; never displayed as is. */ export const routeRatingStatus = pgEnum('route_rating_status', routeRatingStatuses); +export const b2bClientType = pgEnum('b2b_client_type', b2bClientTypes); +export const b2bClientStatus = pgEnum('b2b_client_status', b2bClientStatuses); export const moderationAction = pgEnum('moderation_action', moderationActions); /** UUID v7 primary key, generated by the API (Postgres 16 has no `uuidv7()`). */ @@ -1000,3 +1006,58 @@ export const bookingClicks = pgTable( }, (t) => [index('booking_clicks_step_idx').on(t.stepId, t.createdAt)], ); + +/** + * A business that pays for routes under its name (hotel, concierge, tourist office; A-10, #346). + * `slug` ends its page address and its source of measure `b2b-`: it never changes. + * `monthly_price_cents` is excluding tax; billing is done by hand at the launch. + */ +export const b2bClients = pgTable( + 'b2b_clients', + { + id: id(), + name: text('name').notNull(), + slug: text('slug').notNull().unique(), + type: b2bClientType('type').notNull(), + contactName: text('contact_name'), + contactEmail: text('contact_email'), + contactPhone: text('contact_phone'), + address: text('address'), + status: b2bClientStatus('status').notNull().default('prospect'), + monthlyPriceCents: integer('monthly_price_cents'), + startedAt: date('started_at', { mode: 'string' }), + notes: text('notes'), + createdAt: createdAt(), + updatedAt: updatedAt(), + }, + (t) => [ + check('b2b_clients_slug_check', sql`${t.slug} ~ '^[a-z0-9]+(-[a-z0-9]+)*$'`), + check( + 'b2b_clients_price_check', + sql`${t.monthlyPriceCents} is null or ${t.monthlyPriceCents} between 0 and ${sql.raw(String(maxMonthlyPriceCents))}`, + ), + ], +); + +/** The routes a partner chose, in the order of its page: `position` from 0, at most 10 (#346). */ +export const b2bClientRoutes = pgTable( + 'b2b_client_routes', + { + clientId: uuid('client_id') + .notNull() + .references(() => b2bClients.id, { onDelete: 'cascade' }), + routeId: uuid('route_id') + .notNull() + .references(() => routes.id, { onDelete: 'cascade' }), + position: smallint('position').notNull(), + }, + (t) => [ + primaryKey({ columns: [t.clientId, t.routeId] }), + unique('b2b_client_routes_position_unique').on(t.clientId, t.position), + index('b2b_client_routes_route_idx').on(t.routeId), + check( + 'b2b_client_routes_position_check', + sql`${t.position} between 0 and ${sql.raw(String(maxPartnerRoutes - 1))}`, + ), + ], +); From 2447ecdf3c63758171998e1b80bdb3d809e0b3b1 Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Tue, 20 Oct 2026 13:47:47 +0700 Subject: [PATCH 03/14] feat(api): list, read and register the B2B partners (A-10) GET /admin/b2b-clients and /:id, and POST: the active partners first, the cities of their routes, a source of measure b2b-, a journal line on each write. Co-Authored-By: Claude Sonnet 5.5 --- apps/api/src/app.ts | 2 + apps/api/src/b2b/clients.ts | 155 ++++++++++++++++++++++++++ apps/api/src/routes/admin-b2b.test.ts | 128 +++++++++++++++++++++ apps/api/src/routes/admin-b2b.ts | 78 +++++++++++++ 4 files changed, 363 insertions(+) create mode 100644 apps/api/src/b2b/clients.ts create mode 100644 apps/api/src/routes/admin-b2b.test.ts create mode 100644 apps/api/src/routes/admin-b2b.ts diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index ac3a085e..492abc2e 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -24,6 +24,7 @@ import { adminModerationRoutes } from './routes/admin-moderation'; import { adminReportsRoutes } from './routes/admin-reports'; import { adminActionsRoutes } from './routes/admin-actions'; import { adminAffiliationRoutes } from './routes/admin-affiliation'; +import { adminB2bRoutes } from './routes/admin-b2b'; import { adminSettingsRoutes } from './routes/admin-settings'; import { adminUsersRoutes } from './routes/admin-users'; import { goRoutes } from './routes/go'; @@ -164,6 +165,7 @@ export async function buildApp(config: Config, options: BuildAppOptions = {}) { await app.register(adminSettingsRoutes, { prefix: '/v1' }); await app.register(adminActionsRoutes, { prefix: '/v1' }); await app.register(adminAffiliationRoutes, { prefix: '/v1' }); + await app.register(adminB2bRoutes, { prefix: '/v1' }); await app.register(goRoutes, { prefix: '/v1' }); await app.register(searchRoutesPlugin, { prefix: '/v1' }); await app.register(routeRatingsPlugin, { prefix: '/v1' }); diff --git a/apps/api/src/b2b/clients.ts b/apps/api/src/b2b/clients.ts new file mode 100644 index 00000000..cc4cd113 --- /dev/null +++ b/apps/api/src/b2b/clients.ts @@ -0,0 +1,155 @@ +import { + b2bSourceOf, + type B2bClient, + type B2bClientInput, + type B2bClientListItem, + type B2bClients, +} from '@widoo/shared'; +import { asc, DrizzleQueryError, eq, sql } from 'drizzle-orm'; +import { recordAdminAction } from '../admin/actions'; +import type { Db, Tx } from '../db/client'; +import { b2bClientRoutes, b2bClients, routes } from '../db/schema'; +import { httpError } from '../errors'; + +type ClientRow = typeof b2bClients.$inferSelect; + +const slugTaken = () => + httpError(409, 'A partner already has this slug', 'conflict', { reason: 'slug_taken' }); + +const isSlugViolation = (error: unknown) => + error instanceof DrizzleQueryError && + (error.cause as { constraint_name?: string } | undefined)?.constraint_name === + 'b2b_clients_slug_unique'; + +const listItemOf = ( + row: ClientRow, + routeCount: number, + cityNames: string[], +): B2bClientListItem => ({ + id: row.id, + name: row.name, + slug: row.slug, + type: row.type, + status: row.status, + monthlyPriceCents: row.monthlyPriceCents, + startedAt: row.startedAt, + cityNames, + routeCount, +}); + +type Counts = { route_count: number; city_names: string[] | null }; + +/** The number of routes of a partner and the cities they start in, by partner. */ +async function countsOf(db: Db | Tx, clientId?: string): Promise> { + const rows = await db.execute(sql` + select c.id, + (select count(*)::int from b2b_client_routes cr where cr.client_id = c.id) as route_count, + (select array_agg(distinct ci.name order by ci.name) + from b2b_client_routes cr + join routes r on r.id = cr.route_id + join cities ci on ci.id = r.city_id + where cr.client_id = c.id) as city_names + from b2b_clients c + ${clientId ? sql`where c.id = ${clientId}` : sql``}`); + return new Map(rows.map((row) => [row.id, row])); +} + +/** The partners, the active ones first then by name, each with the cities of its routes. */ +export async function readClients(db: Db): Promise { + const [counts, clients] = await Promise.all([countsOf(db), db.select().from(b2bClients)]); + return { + items: clients + .sort( + (a, b) => + Number(b.status === 'active') - Number(a.status === 'active') || + a.name.localeCompare(b.name, 'fr'), + ) + .map((client) => + listItemOf( + client, + counts.get(client.id)?.route_count ?? 0, + counts.get(client.id)?.city_names ?? [], + ), + ), + }; +} + +/** The card of a partner, or null when it does not exist. */ +export async function readClient(db: Db | Tx, id: string): Promise { + const [row] = await db.select().from(b2bClients).where(eq(b2bClients.id, id)); + if (!row) { + return null; + } + const [counts, chosen] = await Promise.all([ + countsOf(db, id), + db + .select({ + id: routes.id, + title: routes.title, + status: routes.status, + position: b2bClientRoutes.position, + }) + .from(b2bClientRoutes) + .innerJoin(routes, eq(routes.id, b2bClientRoutes.routeId)) + .where(eq(b2bClientRoutes.clientId, id)) + .orderBy(asc(b2bClientRoutes.position)), + ]); + return { + ...listItemOf(row, counts.get(id)?.route_count ?? 0, counts.get(id)?.city_names ?? []), + contactName: row.contactName, + contactEmail: row.contactEmail, + contactPhone: row.contactPhone, + address: row.address, + notes: row.notes, + source: b2bSourceOf(row.slug), + routes: chosen.map((route) => ({ + id: route.id, + title: route.title, + position: route.position, + isPublished: route.status === 'published', + })), + createdAt: row.createdAt.toISOString(), + }; +} + +async function cardOf(tx: Tx, id: string): Promise { + const card = await readClient(tx, id); + if (!card) { + throw httpError(404, 'Partner not found'); + } + return card; +} + +/** Registers a partner (A-10), with its journal line. 409 `slug_taken` for a slug already used. */ +export async function createClient( + db: Db, + actorId: string, + input: B2bClientInput, +): Promise { + try { + return await db.transaction(async (tx) => { + const [taken] = await tx + .select({ id: b2bClients.id }) + .from(b2bClients) + .where(eq(b2bClients.slug, input.slug)); + if (taken) { + throw slugTaken(); + } + const [row] = await tx.insert(b2bClients).values(input).returning(); + if (!row) { + throw new Error('Partner not created'); + } + await recordAdminAction(tx, { + actorId, + action: 'b2b_client.create', + targetType: 'b2b_client', + targetId: row.id, + after: input, + }); + return cardOf(tx, row.id); + }); + } catch (error) { + // Two partners created at once with the same slug: the unique constraint answers the second. + throw isSlugViolation(error) ? slugTaken() : error; + } +} diff --git a/apps/api/src/routes/admin-b2b.test.ts b/apps/api/src/routes/admin-b2b.test.ts new file mode 100644 index 00000000..89481093 --- /dev/null +++ b/apps/api/src/routes/admin-b2b.test.ts @@ -0,0 +1,128 @@ +import { ApiError, B2bClient, type UserRole } from '@widoo/shared'; +import { eq, inArray, like } from 'drizzle-orm'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { buildApp } from '../app'; +import { TestTokenVerifier } from '../auth/test-verifier'; +import { adminActions, b2bClients, users } from '../db/schema'; +import { uuidv7 } from '../db/uuid'; +import { testConfig } from '../test-config'; + +// Shared database, parallel files: slugs carry a marker, routes are copies of the demo, accounts +// are new, and only those are listed, journaled or deleted here. +const verifier = new TestTokenVerifier(); +const teamUids: string[] = []; +const marker = `b2bt${uuidv7().slice(-8)}`; +let slugCount = 0; +const slugOf = (name: string) => `${marker}-${name}-${(slugCount += 1)}`; + +let app: Awaited>; +beforeAll(async () => { + app = await buildApp(testConfig(), { tokenVerifier: verifier }); +}); +afterAll(async () => { + const mine = await app.db + .select({ id: b2bClients.id }) + .from(b2bClients) + .where(like(b2bClients.slug, `${marker}-%`)); + const ids = mine.map((row) => row.id); + if (ids.length > 0) { + await app.db.delete(adminActions).where(inArray(adminActions.targetId, ids)); + await app.db.delete(b2bClients).where(inArray(b2bClients.id, ids)); + } + await app.db.delete(users).where(inArray(users.firebaseUid, teamUids)); + await app.close(); +}); + +async function signIn(role: UserRole) { + const { token, uid } = verifier.issue({ secondFactor: 'totp' }); + teamUids.push(uid); + const [user] = await app.db.insert(users).values({ firebaseUid: uid, role }).returning(); + if (!user) throw new Error('User not created'); + return { id: user.id, headers: { authorization: `Bearer ${token}` } }; +} + +type Headers = Record; +const send = ( + method: 'GET' | 'POST' | 'PATCH' | 'PUT', + headers: Headers, + url: string, + payload?: object, +) => app.inject({ method, url: `/v1/admin/b2b-clients${url}`, headers, payload }); +const journalOf = async (targetId: string) => + (await app.db.select().from(adminActions).where(eq(adminActions.targetId, targetId))).map( + (row) => row.action, + ); + +/** A partner created by the API, with the card it answered. */ +async function addPartner(headers: Headers, values: Record = {}) { + const response = await send('POST', headers, '', { + slug: slugOf('hotel'), + name: `Hôtel ${marker}`, + type: 'hotel', + ...values, + }); + expect(response.statusCode).toBe(201); + return B2bClient.parse(response.json()); +} + +describe('who may read and write the partners', () => { + it('is for editors and admins, not for a moderator, nor for someone signed out', async () => { + const editor = await signIn('editor'); + const admin = await signIn('admin'); + const moderator = await signIn('moderator'); + + expect((await send('GET', editor.headers, '')).statusCode).toBe(200); + expect((await send('GET', admin.headers, '')).statusCode).toBe(200); + expect((await send('GET', moderator.headers, '')).statusCode).toBe(403); + expect( + (await send('POST', moderator.headers, '', { slug: 'x-y-z', name: 'X', type: 'hotel' })) + .statusCode, + ).toBe(403); + expect((await app.inject({ url: '/v1/admin/b2b-clients' })).statusCode).toBe(401); + }); +}); + +describe('POST /v1/admin/b2b-clients', () => { + it('registers a prospect with its source of measure, and journals it', async () => { + const editor = await signIn('editor'); + const slug = slugOf('nord'); + + const created = await addPartner(editor.headers, { + slug, + contactEmail: 'camille@hotel.example', + monthlyPriceCents: 4900, + }); + + expect(created).toMatchObject({ + slug, + status: 'prospect', + source: `b2b-${slug}`, + contactEmail: 'camille@hotel.example', + monthlyPriceCents: 4900, + routes: [], + routeCount: 0, + cityNames: [], + }); + expect(await journalOf(created.id)).toEqual(['b2b_client.create']); + }); + + it('refuses a slug already used, a bad slug, markup and an unknown field', async () => { + const editor = await signIn('editor'); + const first = await addPartner(editor.headers); + + const again = await send('POST', editor.headers, '', { + slug: first.slug, + name: 'Autre', + type: 'other', + }); + expect(again.statusCode).toBe(409); + expect(ApiError.parse(again.json()).details).toEqual({ reason: 'slug_taken' }); + for (const payload of [ + { slug: 'Bad Slug', name: 'X', type: 'hotel' }, + { slug: slugOf('x'), name: 'x', type: 'hotel' }, + { slug: slugOf('y'), name: 'Y', type: 'hotel', extra: 1 }, + ]) { + expect((await send('POST', editor.headers, '', payload)).statusCode).toBe(422); + } + }); +}); diff --git a/apps/api/src/routes/admin-b2b.ts b/apps/api/src/routes/admin-b2b.ts new file mode 100644 index 00000000..761d0b5a --- /dev/null +++ b/apps/api/src/routes/admin-b2b.ts @@ -0,0 +1,78 @@ +import { B2bClient, B2bClientInput, B2bClients } from '@widoo/shared'; +import type { FastifyRequest } from 'fastify'; +import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'; +import { z } from 'zod'; +import { createClient, readClient, readClients } from '../b2b/clients'; +import { httpError } from '../errors'; + +const security = [{ firebaseIdToken: [] }]; +const tags = ['admin']; +const ClientParams = z.object({ id: z.uuid() }); + +/** Set by the guard of the section, which every route of this plugin runs first. */ +function actorIdOf(request: FastifyRequest): string { + if (!request.user) { + throw httpError(401, 'Missing bearer token'); + } + return request.user.id; +} + +/** + * The B2B partners of the admin (A-10, wiki API › Admin): hotels, concierges and tourist offices + * that pay for routes under their name, for editors and admins. Each write is in the journal. + * Never cached. + */ +export const adminB2bRoutes: FastifyPluginAsyncZod = async (app) => { + app.addHook('onRequest', app.requireAdminSection('b2bClients')); + app.addHook('onSend', async (_request, reply) => { + reply.header('cache-control', 'no-store'); + }); + + app.get( + '/admin/b2b-clients', + { + schema: { + tags, + summary: 'The B2B partners', + security, + response: { 200: B2bClients }, + }, + }, + async () => readClients(app.db), + ); + + app.get( + '/admin/b2b-clients/:id', + { + schema: { + tags, + summary: 'The card of a B2B partner', + security, + params: ClientParams, + response: { 200: B2bClient }, + }, + }, + async (request) => { + const client = await readClient(app.db, request.params.id); + if (!client) { + throw httpError(404, 'Partner not found'); + } + return client; + }, + ); + + app.post( + '/admin/b2b-clients', + { + schema: { + tags, + summary: 'Register a B2B partner', + security, + body: B2bClientInput, + response: { 201: B2bClient }, + }, + }, + async (request, reply) => + reply.status(201).send(await createClient(app.db, actorIdOf(request), request.body)), + ); +}; From 2f20f02662ddf432aec69b8ce0eaa37a8ee77a61 Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Tue, 20 Oct 2026 13:47:47 +0700 Subject: [PATCH 04/14] feat(api): change a B2B partner, never its slug (A-10) PATCH /admin/b2b-clients/:id changes the fields given and journals the before and after. The slug is refused: the QR codes printed with it would stop measuring. Co-Authored-By: Claude Sonnet 5.5 --- apps/api/src/b2b/clients.ts | 31 ++++++++++++++++ apps/api/src/routes/admin-b2b.test.ts | 52 ++++++++++++++++++++++++++- apps/api/src/routes/admin-b2b.ts | 19 ++++++++-- 3 files changed, 99 insertions(+), 3 deletions(-) diff --git a/apps/api/src/b2b/clients.ts b/apps/api/src/b2b/clients.ts index cc4cd113..89a13927 100644 --- a/apps/api/src/b2b/clients.ts +++ b/apps/api/src/b2b/clients.ts @@ -3,6 +3,7 @@ import { type B2bClient, type B2bClientInput, type B2bClientListItem, + type B2bClientPatch, type B2bClients, } from '@widoo/shared'; import { asc, DrizzleQueryError, eq, sql } from 'drizzle-orm'; @@ -153,3 +154,33 @@ export async function createClient( throw isSlugViolation(error) ? slugTaken() : error; } } + +/** Changes the fields given of a partner, with its journal line (before and after). 404 if unknown. */ +export async function updateClient( + db: Db, + actorId: string, + id: string, + patch: B2bClientPatch, + now = new Date(), +): Promise { + return db.transaction(async (tx) => { + const [current] = await tx.select().from(b2bClients).where(eq(b2bClients.id, id)).for('update'); + if (!current) { + throw httpError(404, 'Partner not found'); + } + await tx + .update(b2bClients) + .set({ ...patch, updatedAt: now }) + .where(eq(b2bClients.id, id)); + const changed = Object.keys(patch) as (keyof B2bClientPatch)[]; + await recordAdminAction(tx, { + actorId, + action: 'b2b_client.update', + targetType: 'b2b_client', + targetId: id, + before: Object.fromEntries(changed.map((field) => [field, current[field]])), + after: patch, + }); + return cardOf(tx, id); + }); +} diff --git a/apps/api/src/routes/admin-b2b.test.ts b/apps/api/src/routes/admin-b2b.test.ts index 89481093..591ca2e5 100644 --- a/apps/api/src/routes/admin-b2b.test.ts +++ b/apps/api/src/routes/admin-b2b.test.ts @@ -1,10 +1,11 @@ import { ApiError, B2bClient, type UserRole } from '@widoo/shared'; -import { eq, inArray, like } from 'drizzle-orm'; +import { and, eq, inArray, like } from 'drizzle-orm'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { buildApp } from '../app'; import { TestTokenVerifier } from '../auth/test-verifier'; import { adminActions, b2bClients, users } from '../db/schema'; import { uuidv7 } from '../db/uuid'; +import { plannedFixtures } from '../planned/test-fixtures'; import { testConfig } from '../test-config'; // Shared database, parallel files: slugs carry a marker, routes are copies of the demo, accounts @@ -16,6 +17,7 @@ let slugCount = 0; const slugOf = (name: string) => `${marker}-${name}-${(slugCount += 1)}`; let app: Awaited>; +const fixtures = plannedFixtures(() => app, verifier); beforeAll(async () => { app = await buildApp(testConfig(), { tokenVerifier: verifier }); }); @@ -29,6 +31,7 @@ afterAll(async () => { await app.db.delete(adminActions).where(inArray(adminActions.targetId, ids)); await app.db.delete(b2bClients).where(inArray(b2bClients.id, ids)); } + await fixtures.cleanUp(); await app.db.delete(users).where(inArray(users.firebaseUid, teamUids)); await app.close(); }); @@ -126,3 +129,50 @@ describe('POST /v1/admin/b2b-clients', () => { } }); }); + +describe('PATCH /v1/admin/b2b-clients/:id', () => { + it('changes the fields given, keeps the others, and journals the before and after', async () => { + const admin = await signIn('admin'); + const created = await addPartner(admin.headers, { notes: 'À rappeler' }); + + const response = await send('PATCH', admin.headers, `/${created.id}`, { + status: 'active', + monthlyPriceCents: 6000, + startedAt: '2026-11-01', + }); + + expect(response.statusCode).toBe(200); + expect(B2bClient.parse(response.json())).toMatchObject({ + status: 'active', + monthlyPriceCents: 6000, + startedAt: '2026-11-01', + notes: 'À rappeler', + slug: created.slug, + }); + const [entry] = await app.db + .select() + .from(adminActions) + .where( + and(eq(adminActions.targetId, created.id), eq(adminActions.action, 'b2b_client.update')), + ); + expect(entry).toMatchObject({ + actorId: admin.id, + before: { status: 'prospect', monthlyPriceCents: null, startedAt: null }, + after: { status: 'active', monthlyPriceCents: 6000, startedAt: '2026-11-01' }, + }); + }); + + it('refuses the slug, an empty change, and answers 404 to an unknown partner', async () => { + const admin = await signIn('admin'); + const created = await addPartner(admin.headers); + + expect( + (await send('PATCH', admin.headers, `/${created.id}`, { slug: 'new-slug' })).statusCode, + ).toBe(422); + expect((await send('PATCH', admin.headers, `/${created.id}`, {})).statusCode).toBe(422); + expect((await send('PATCH', admin.headers, `/${uuidv7()}`, { name: 'X' })).statusCode).toBe( + 404, + ); + expect((await send('GET', admin.headers, `/${uuidv7()}`)).statusCode).toBe(404); + }); +}); diff --git a/apps/api/src/routes/admin-b2b.ts b/apps/api/src/routes/admin-b2b.ts index 761d0b5a..a1658092 100644 --- a/apps/api/src/routes/admin-b2b.ts +++ b/apps/api/src/routes/admin-b2b.ts @@ -1,8 +1,8 @@ -import { B2bClient, B2bClientInput, B2bClients } from '@widoo/shared'; +import { B2bClient, B2bClientInput, B2bClientPatch, B2bClients } from '@widoo/shared'; import type { FastifyRequest } from 'fastify'; import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'; import { z } from 'zod'; -import { createClient, readClient, readClients } from '../b2b/clients'; +import { createClient, readClient, readClients, updateClient } from '../b2b/clients'; import { httpError } from '../errors'; const security = [{ firebaseIdToken: [] }]; @@ -75,4 +75,19 @@ export const adminB2bRoutes: FastifyPluginAsyncZod = async (app) => { async (request, reply) => reply.status(201).send(await createClient(app.db, actorIdOf(request), request.body)), ); + + app.patch( + '/admin/b2b-clients/:id', + { + schema: { + tags, + summary: 'Change a B2B partner (never its slug)', + security, + params: ClientParams, + body: B2bClientPatch, + response: { 200: B2bClient }, + }, + }, + async (request) => updateClient(app.db, actorIdOf(request), request.params.id, request.body), + ); }; From 5c52fcf22119340181e444096176f85db11f4e20 Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Tue, 20 Oct 2026 13:47:47 +0700 Subject: [PATCH 05/14] feat(api): choose the 3 to 10 routes of a B2B partner, in order (A-10) PUT /admin/b2b-clients/:id/routes replaces the routes of a partner, 3 to 10, in the order of its page. Only published routes can be chosen. Co-Authored-By: Claude Sonnet 5.5 --- apps/api/src/b2b/clients.ts | 54 ++++++++++++++++++++- apps/api/src/routes/admin-b2b.test.ts | 68 ++++++++++++++++++++++++++- apps/api/src/routes/admin-b2b.ts | 32 ++++++++++++- 3 files changed, 150 insertions(+), 4 deletions(-) diff --git a/apps/api/src/b2b/clients.ts b/apps/api/src/b2b/clients.ts index 89a13927..9e68309e 100644 --- a/apps/api/src/b2b/clients.ts +++ b/apps/api/src/b2b/clients.ts @@ -6,7 +6,7 @@ import { type B2bClientPatch, type B2bClients, } from '@widoo/shared'; -import { asc, DrizzleQueryError, eq, sql } from 'drizzle-orm'; +import { and, asc, DrizzleQueryError, eq, inArray, sql } from 'drizzle-orm'; import { recordAdminAction } from '../admin/actions'; import type { Db, Tx } from '../db/client'; import { b2bClientRoutes, b2bClients, routes } from '../db/schema'; @@ -184,3 +184,55 @@ export async function updateClient( return cardOf(tx, id); }); } + +/** + * Sets the routes of a partner, in the order of its page, replacing the former choice (A-10). + * Only published routes can be chosen: 409 `routes_unavailable` names the ones that cannot. + */ +export async function setClientRoutes( + db: Db, + actorId: string, + id: string, + routeIds: string[], +): Promise { + return db.transaction(async (tx) => { + const [client] = await tx + .select({ id: b2bClients.id }) + .from(b2bClients) + .where(eq(b2bClients.id, id)) + .for('update'); + if (!client) { + throw httpError(404, 'Partner not found'); + } + const found = await tx + .select({ id: routes.id }) + .from(routes) + .where(and(inArray(routes.id, routeIds), eq(routes.status, 'published'))); + const available = new Set(found.map((route) => route.id)); + const unavailable = routeIds.filter((routeId) => !available.has(routeId)); + if (unavailable.length > 0) { + throw httpError(409, 'Some routes cannot be chosen', 'conflict', { + reason: 'routes_unavailable', + routeIds: unavailable, + }); + } + const before = await tx + .select({ routeId: b2bClientRoutes.routeId }) + .from(b2bClientRoutes) + .where(eq(b2bClientRoutes.clientId, id)) + .orderBy(asc(b2bClientRoutes.position)); + await tx.delete(b2bClientRoutes).where(eq(b2bClientRoutes.clientId, id)); + await tx + .insert(b2bClientRoutes) + .values(routeIds.map((routeId, position) => ({ clientId: id, routeId, position }))); + await recordAdminAction(tx, { + actorId, + action: 'b2b_client.set_routes', + targetType: 'b2b_client', + targetId: id, + before: { routeIds: before.map((row) => row.routeId) }, + after: { routeIds }, + }); + return cardOf(tx, id); + }); +} diff --git a/apps/api/src/routes/admin-b2b.test.ts b/apps/api/src/routes/admin-b2b.test.ts index 591ca2e5..874236ff 100644 --- a/apps/api/src/routes/admin-b2b.test.ts +++ b/apps/api/src/routes/admin-b2b.test.ts @@ -1,4 +1,4 @@ -import { ApiError, B2bClient, type UserRole } from '@widoo/shared'; +import { ApiError, B2bClient, B2bClients, type UserRole } from '@widoo/shared'; import { and, eq, inArray, like } from 'drizzle-orm'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { buildApp } from '../app'; @@ -68,6 +68,8 @@ async function addPartner(headers: Headers, values: Record = {} return B2bClient.parse(response.json()); } +const publishedRoute = () => fixtures.copyDemoRoute({ status: 'published' }); + describe('who may read and write the partners', () => { it('is for editors and admins, not for a moderator, nor for someone signed out', async () => { const editor = await signIn('editor'); @@ -176,3 +178,67 @@ describe('PATCH /v1/admin/b2b-clients/:id', () => { expect((await send('GET', admin.headers, `/${uuidv7()}`)).statusCode).toBe(404); }); }); + +describe('PUT /v1/admin/b2b-clients/:id/routes', () => { + it('sets 3 to 10 published routes in order, replaces them, and lists them', async () => { + const editor = await signIn('editor'); + const partner = await addPartner(editor.headers); + const [a, b, c, d] = await Promise.all([1, 2, 3, 4].map(publishedRoute)); + if (!a || !b || !c || !d) throw new Error('Routes not created'); + + const set = await send('PUT', editor.headers, `/${partner.id}/routes`, { routeIds: [c, a, b] }); + + expect(set.statusCode).toBe(200); + const card = B2bClient.parse(set.json()); + expect(card.routes.map((route) => [route.id, route.position, route.isPublished])).toEqual([ + [c, 0, true], + [a, 1, true], + [b, 2, true], + ]); + expect(card.routeCount).toBe(3); + expect(card.cityNames.length).toBeGreaterThan(0); + + const replaced = await send('PUT', editor.headers, `/${partner.id}/routes`, { + routeIds: [d, b, a], + }); + expect(B2bClient.parse(replaced.json()).routes.map((route) => route.id)).toEqual([d, b, a]); + const listed = B2bClients.parse((await send('GET', editor.headers, '')).json()); + expect(listed.items.find((item) => item.id === partner.id)).toMatchObject({ routeCount: 3 }); + expect(await journalOf(partner.id)).toEqual([ + 'b2b_client.create', + 'b2b_client.set_routes', + 'b2b_client.set_routes', + ]); + }); + + it('refuses fewer than 3 routes, a route twice, and routes that are not published', async () => { + const editor = await signIn('editor'); + const partner = await addPartner(editor.headers); + const [a, b, c] = await Promise.all([1, 2, 3].map(publishedRoute)); + const draft = await fixtures.copyDemoRoute({ status: 'draft' }); + if (!a || !b || !c) throw new Error('Routes not created'); + const unknown = uuidv7(); + + expect( + (await send('PUT', editor.headers, `/${partner.id}/routes`, { routeIds: [a, b] })).statusCode, + ).toBe(422); + expect( + (await send('PUT', editor.headers, `/${partner.id}/routes`, { routeIds: [a, b, b] })) + .statusCode, + ).toBe(422); + const refused = await send('PUT', editor.headers, `/${partner.id}/routes`, { + routeIds: [a, draft, unknown], + }); + expect(refused.statusCode).toBe(409); + expect(ApiError.parse(refused.json()).details).toEqual({ + reason: 'routes_unavailable', + routeIds: [draft, unknown], + }); + expect( + (await send('PUT', editor.headers, `/${uuidv7()}/routes`, { routeIds: [a, b, c] })) + .statusCode, + ).toBe(404); + const card = B2bClient.parse((await send('GET', editor.headers, `/${partner.id}`)).json()); + expect(card.routes).toEqual([]); + }); +}); diff --git a/apps/api/src/routes/admin-b2b.ts b/apps/api/src/routes/admin-b2b.ts index a1658092..28095810 100644 --- a/apps/api/src/routes/admin-b2b.ts +++ b/apps/api/src/routes/admin-b2b.ts @@ -1,8 +1,20 @@ -import { B2bClient, B2bClientInput, B2bClientPatch, B2bClients } from '@widoo/shared'; +import { + B2bClient, + B2bClientInput, + B2bClientPatch, + B2bClientRoutesInput, + B2bClients, +} from '@widoo/shared'; import type { FastifyRequest } from 'fastify'; import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'; import { z } from 'zod'; -import { createClient, readClient, readClients, updateClient } from '../b2b/clients'; +import { + createClient, + readClient, + readClients, + setClientRoutes, + updateClient, +} from '../b2b/clients'; import { httpError } from '../errors'; const security = [{ firebaseIdToken: [] }]; @@ -90,4 +102,20 @@ export const adminB2bRoutes: FastifyPluginAsyncZod = async (app) => { }, async (request) => updateClient(app.db, actorIdOf(request), request.params.id, request.body), ); + + app.put( + '/admin/b2b-clients/:id/routes', + { + schema: { + tags, + summary: 'Choose the 3 to 10 published routes of a B2B partner, in order', + security, + params: ClientParams, + body: B2bClientRoutesInput, + response: { 200: B2bClient }, + }, + }, + async (request) => + setClientRoutes(app.db, actorIdOf(request), request.params.id, request.body.routeIds), + ); }; From 00f07486c31a146bfb803ff915377fb51c8f3075 Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Tue, 20 Oct 2026 13:47:47 +0700 Subject: [PATCH 06/14] feat(api): serve the page of a B2B partner to the site, without its contact or its price (A-10) GET /public/partners/:slug serves the name and the published routes of an active or prospect partner as cards, cached a minute. A suspended or ended partner is a 404, and no contact, price or note ever leaves. Co-Authored-By: Claude Sonnet 5.5 --- apps/api/src/app.ts | 2 + apps/api/src/b2b/public.ts | 38 +++++++ apps/api/src/routes/public-partners.test.ts | 114 ++++++++++++++++++++ apps/api/src/routes/public-partners.ts | 35 ++++++ 4 files changed, 189 insertions(+) create mode 100644 apps/api/src/b2b/public.ts create mode 100644 apps/api/src/routes/public-partners.test.ts create mode 100644 apps/api/src/routes/public-partners.ts diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 492abc2e..b7e90684 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -25,6 +25,7 @@ import { adminReportsRoutes } from './routes/admin-reports'; import { adminActionsRoutes } from './routes/admin-actions'; import { adminAffiliationRoutes } from './routes/admin-affiliation'; import { adminB2bRoutes } from './routes/admin-b2b'; +import { publicPartnersRoutes } from './routes/public-partners'; import { adminSettingsRoutes } from './routes/admin-settings'; import { adminUsersRoutes } from './routes/admin-users'; import { goRoutes } from './routes/go'; @@ -166,6 +167,7 @@ export async function buildApp(config: Config, options: BuildAppOptions = {}) { await app.register(adminActionsRoutes, { prefix: '/v1' }); await app.register(adminAffiliationRoutes, { prefix: '/v1' }); await app.register(adminB2bRoutes, { prefix: '/v1' }); + await app.register(publicPartnersRoutes, { prefix: '/v1' }); await app.register(goRoutes, { prefix: '/v1' }); await app.register(searchRoutesPlugin, { prefix: '/v1' }); await app.register(routeRatingsPlugin, { prefix: '/v1' }); diff --git a/apps/api/src/b2b/public.ts b/apps/api/src/b2b/public.ts new file mode 100644 index 00000000..055a4d18 --- /dev/null +++ b/apps/api/src/b2b/public.ts @@ -0,0 +1,38 @@ +import type { PublicPartner } from '@widoo/shared'; +import { eq, sql } from 'drizzle-orm'; +import { routeViewerOf } from '../access/route-viewer'; +import type { Db } from '../db/client'; +import { b2bClients } from '../db/schema'; +import { cardColumns, cardJoins, toCard, type CardRow } from '../search/repository'; + +/** + * The page of a partner for the site (`GET /public/partners/:slug`, ticket #347): its name and + * the published routes it chose, in order, as cards for an anonymous visitor. `undefined`, so a + * 404, for an unknown partner and for one that is suspended or ended. Nothing of its contact, + * price or notes leaves the admin. + */ +export async function readPublicPartner( + db: Db, + slug: string, + now = new Date(), +): Promise { + const [client] = await db.select().from(b2bClients).where(eq(b2bClients.slug, slug)); + if (!client || (client.status !== 'prospect' && client.status !== 'active')) { + return undefined; + } + const rows = await db.execute(sql` + select r.id, ${cardColumns} + from b2b_client_routes cr + join routes r on r.id = cr.route_id + ${cardJoins} + where cr.client_id = ${client.id} and r.status = 'published' + order by cr.position + `); + const viewer = await routeViewerOf(db, null, rows); + return { + slug: client.slug, + name: client.name, + isActive: client.status === 'active', + routes: rows.map((row) => toCard(row, viewer, now)), + }; +} diff --git a/apps/api/src/routes/public-partners.test.ts b/apps/api/src/routes/public-partners.test.ts new file mode 100644 index 00000000..10c2561e --- /dev/null +++ b/apps/api/src/routes/public-partners.test.ts @@ -0,0 +1,114 @@ +import { PublicPartner } from '@widoo/shared'; +import { eq, like } from 'drizzle-orm'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { buildApp } from '../app'; +import { TestTokenVerifier } from '../auth/test-verifier'; +import { b2bClientRoutes, b2bClients, routes } from '../db/schema'; +import { uuidv7 } from '../db/uuid'; +import { plannedFixtures } from '../planned/test-fixtures'; +import { testConfig } from '../test-config'; + +// A partner and its routes of their own: copies of the demo route, a marker in the slug. +const verifier = new TestTokenVerifier(); +const marker = `pubb2b${uuidv7().slice(-8)}`; +let slugCount = 0; + +let app: Awaited>; +const fixtures = plannedFixtures(() => app, verifier); +beforeAll(async () => { + app = await buildApp(testConfig(), { tokenVerifier: verifier }); +}); +afterAll(async () => { + await app.db.delete(b2bClients).where(like(b2bClients.slug, `${marker}-%`)); + await fixtures.cleanUp(); + await app.close(); +}); + +/** A partner in `status`, with the routes given in this order. */ +async function addPartner( + status: 'prospect' | 'active' | 'suspended' | 'ended', + routeIds: string[], +) { + slugCount += 1; + const [client] = await app.db + .insert(b2bClients) + .values({ + slug: `${marker}-${slugCount}`, + name: `Hôtel ${marker}`, + type: 'hotel', + status, + contactName: 'Camille Durand', + contactEmail: 'camille@hotel.example', + contactPhone: '0102030405', + monthlyPriceCents: 4900, + notes: 'Contrat signé le 2026-10-01', + }) + .returning(); + if (!client) throw new Error('Partner not created'); + if (routeIds.length > 0) { + await app.db + .insert(b2bClientRoutes) + .values(routeIds.map((routeId, position) => ({ clientId: client.id, routeId, position }))); + } + return client; +} + +const get = (slug: string) => app.inject({ url: `/v1/public/partners/${slug}` }); + +describe('GET /v1/public/partners/:slug', () => { + it('serves an active partner and its published routes in the order it chose', async () => { + const [a, b, c] = await Promise.all([1, 2, 3].map(() => fixtures.copyDemoRoute())); + if (!a || !b || !c) throw new Error('Routes not created'); + const partner = await addPartner('active', [c, a, b]); + + const response = await get(partner.slug); + + expect(response.statusCode).toBe(200); + const page = PublicPartner.parse(response.json()); + expect(page).toMatchObject({ slug: partner.slug, name: partner.name, isActive: true }); + expect(page.routes.map((route) => route.id)).toEqual([c, a, b]); + expect(response.headers['cache-control']).toBe('public, max-age=60'); + }); + + it('keeps out of the page a route that is no longer published', async () => { + const [a, b, c] = await Promise.all([1, 2, 3].map(() => fixtures.copyDemoRoute())); + if (!a || !b || !c) throw new Error('Routes not created'); + const partner = await addPartner('active', [a, b, c]); + await app.db.update(routes).set({ status: 'unpublished' }).where(eq(routes.id, b)); + + const page = PublicPartner.parse((await get(partner.slug)).json()); + + expect(page.routes.map((route) => route.id)).toEqual([a, c]); + }); + + it('serves a prospect as not active, for the site to keep it out of the search engines', async () => { + const partner = await addPartner('prospect', []); + + const response = await get(partner.slug); + + expect(response.statusCode).toBe(200); + expect(PublicPartner.parse(response.json())).toMatchObject({ isActive: false, routes: [] }); + }); + + it('answers 404 for a suspended or ended partner, and for one that does not exist', async () => { + const suspended = await addPartner('suspended', []); + const ended = await addPartner('ended', []); + + for (const slug of [suspended.slug, ended.slug, `${marker}-unknown`]) { + const response = await get(slug); + expect(response.statusCode).toBe(404); + expect(response.headers['cache-control'] ?? '').not.toContain('public'); + } + }); + + it('never serves the contact, the price or the notes of the partner', async () => { + const partner = await addPartner('active', []); + + const body = (await get(partner.slug)).body; + + for (const secret of ['Camille', 'camille@hotel.example', '0102030405', '4900', 'Contrat']) { + expect(body).not.toContain(secret); + } + expect(Object.keys(JSON.parse(body))).toEqual(['slug', 'name', 'isActive', 'routes']); + }); +}); diff --git a/apps/api/src/routes/public-partners.ts b/apps/api/src/routes/public-partners.ts new file mode 100644 index 00000000..c660b7d2 --- /dev/null +++ b/apps/api/src/routes/public-partners.ts @@ -0,0 +1,35 @@ +import { PublicPartner } from '@widoo/shared'; +import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'; +import { z } from 'zod'; +import { readPublicPartner } from '../b2b/public'; +import { httpError } from '../errors'; +import { cacheHook } from '../http-cache'; + +const PartnerParams = z.object({ slug: z.string().max(60) }); + +/** + * `GET /public/partners/:slug` (wiki API › Admin, publics liés à l'admin): the page of a B2B + * partner for the site, without any account. Public and cacheable a minute: it depends on no + * caller, and holds nothing private. + */ +export const publicPartnersRoutes: FastifyPluginAsyncZod = async (app) => { + app.get( + '/public/partners/:slug', + { + onSend: cacheHook(60), + schema: { + tags: ['public'], + summary: 'The page of a B2B partner: its name and the routes it chose, in order', + params: PartnerParams, + response: { 200: PublicPartner }, + }, + }, + async (request) => { + const partner = await readPublicPartner(app.db, request.params.slug); + if (!partner) { + throw httpError(404, 'Partner not found'); + } + return partner; + }, + ); +}; From e788ad27adf496c84931d7650706062618bea3b4 Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Tue, 20 Oct 2026 13:47:47 +0700 Subject: [PATCH 07/14] feat(api-client): call the B2B partners of the admin (A-10) getB2bClients, getB2bClient, createB2bClient, updateB2bClient and setB2bClientRoutes. Co-Authored-By: Claude Sonnet 5.5 --- packages/api-client/src/client.ts | 55 +++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/packages/api-client/src/client.ts b/packages/api-client/src/client.ts index 9aad9787..49be03eb 100644 --- a/packages/api-client/src/client.ts +++ b/packages/api-client/src/client.ts @@ -1,6 +1,8 @@ import { AffiliateProgram, AffiliatePrograms, + B2bClient, + B2bClients, BookableSteps, AdminActionFilters, AdminActions, @@ -59,6 +61,9 @@ import { type AdminActionsQuery, type AffiliateProgramInput, type AffiliateProgramPatch, + type B2bClientInput, + type B2bClientPatch, + type B2bClientRoutesInput, type BookableStepsQuery, type AdminSignalsQuery, type UpdateSettingBody, @@ -793,6 +798,56 @@ export function createApiClient(options: ApiClientOptions) { auth: true, }), + /** `GET /v1/admin/b2b-clients`: the B2B partners (A-10). */ + getB2bClients: (signal?: AbortSignal) => + request({ + method: 'GET', + path: '/v1/admin/b2b-clients', + schema: B2bClients, + auth: true, + signal, + }), + + /** `GET /v1/admin/b2b-clients/:id`: the card of a partner. */ + getB2bClient: (clientId: string, signal?: AbortSignal) => + request({ + method: 'GET', + path: `/v1/admin/b2b-clients/${clientId}`, + schema: B2bClient, + auth: true, + signal, + }), + + /** `POST /v1/admin/b2b-clients`: registers a partner. */ + createB2bClient: (client: B2bClientInput) => + request({ + method: 'POST', + path: '/v1/admin/b2b-clients', + body: client, + schema: B2bClient, + auth: true, + }), + + /** `PATCH /v1/admin/b2b-clients/:id`: changes the fields given of a partner, never its slug. */ + updateB2bClient: (clientId: string, patch: B2bClientPatch) => + request({ + method: 'PATCH', + path: `/v1/admin/b2b-clients/${clientId}`, + body: patch, + schema: B2bClient, + auth: true, + }), + + /** `PUT /v1/admin/b2b-clients/:id/routes`: the 3 to 10 published routes of a partner, in order. */ + setB2bClientRoutes: (clientId: string, body: B2bClientRoutesInput) => + request({ + method: 'PUT', + path: `/v1/admin/b2b-clients/${clientId}/routes`, + body, + schema: B2bClient, + auth: true, + }), + /** `GET /v1/admin/bookable-steps`: the steps that can be booked, with the link shown (A-09). */ getBookableSteps: (params: Partial, signal?: AbortSignal) => request({ From a985dd512a15322dfdc5ea2b0e33590814c7141e Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Tue, 20 Oct 2026 13:47:47 +0700 Subject: [PATCH 08/14] refactor(admin): share the card kept on screen between the lists with a panel The card that stays on screen while the next one loads moves to a hook shared by the users and the partners. Co-Authored-By: Claude Sonnet 5.5 --- apps/admin/src/containers/UsersContainer.tsx | 26 +++++++----------- apps/admin/src/hooks/useCardOnScreen.ts | 28 ++++++++++++++++++++ 2 files changed, 37 insertions(+), 17 deletions(-) create mode 100644 apps/admin/src/hooks/useCardOnScreen.ts diff --git a/apps/admin/src/containers/UsersContainer.tsx b/apps/admin/src/containers/UsersContainer.tsx index d2c42c16..f9f67c61 100644 --- a/apps/admin/src/containers/UsersContainer.tsx +++ b/apps/admin/src/containers/UsersContainer.tsx @@ -8,7 +8,7 @@ import { toUsersParams, usersListOptions } from '@/config/users-list'; import { catalogRoutePathOf } from '@/containers/CatalogRoutesContainer'; import { UserDecisionsContainer } from '@/containers/UserDecisionsContainer'; import { useDebouncedCallback } from '@/hooks/useDebouncedCallback'; -import { useIsSlow } from '@/hooks/useIsSlow'; +import { useCardOnScreen } from '@/hooks/useCardOnScreen'; import { useListQuery } from '@/hooks/useListQuery'; import { useRemoteData } from '@/hooks/useRemoteData'; import { getSessionClients } from '@/lib/auth/clients'; @@ -18,13 +18,10 @@ import { userNameOf } from '@/lib/user-display'; import { messages } from '@/messages/fr'; import type { AdminUserDetail } from '@widoo/shared'; import { Button, DetailPanel, DetailPanelSkeleton, Pagination, SearchInput } from '@widoo/ui'; -import { useState } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; const texts = messages.users; const searchDelayMs = 300; -/** A card that loads faster than this keeps the previous one on screen, without a gray panel. */ -const skeletonDelayMs = 300; /** Parameter of the address that holds the account whose card is open next to the list. */ const openUserParam = 'compte'; @@ -45,16 +42,11 @@ export function UsersContainer() { openId ? getSessionClients().api.getAdminUser(openId, signal) : Promise.resolve(null), ); const data = users.status === 'ready' ? users.data : undefined; - const openUser = card.status === 'ready' ? card.data : null; - // The card shown last, kept on screen while the next one loads, so that the panel does not - // disappear and the list does not widen and narrow again at every click. - const [lastUser, setLastUser] = useState(null); - if (openUser && openUser !== lastUser) { - setLastUser(openUser); - } else if (!openId && lastUser) { - setLastUser(null); - } - const isCardSlow = useIsSlow(openId !== null && card.status === 'loading', skeletonDelayMs); + const { + fresh: openUser, + isLoading: isCardLoading, + stale: staleUser, + } = useCardOnScreen(openId, card); /** The address of the list as it is, with the card of `userId` open, or none. */ const showUser = (userId: string | null) => { @@ -100,12 +92,12 @@ export function UsersContainer() { if (openUser) { return renderCard(openUser); } - if (openId === null || card.status !== 'loading') { + if (!isCardLoading) { return null; } - return lastUser && !isCardSlow ? ( + return staleUser ? (
- {renderCard(lastUser)} + {renderCard(staleUser)}
) : ( diff --git a/apps/admin/src/hooks/useCardOnScreen.ts b/apps/admin/src/hooks/useCardOnScreen.ts new file mode 100644 index 00000000..8f51fad0 --- /dev/null +++ b/apps/admin/src/hooks/useCardOnScreen.ts @@ -0,0 +1,28 @@ +import { useState } from 'react'; +import type { RemoteData } from './useRemoteData'; +import { useIsSlow } from './useIsSlow'; + +/** A card that loads faster than this keeps the previous one on screen, without a gray panel. */ +const skeletonDelayMs = 300; + +/** + * The card of the record the address names, open next to its list. The card shown last stays on + * screen while the next one loads (`stale`), so that the panel does not disappear and the list + * does not widen and narrow again at each click; only a wait longer than 300 ms gives it up + * (`stale` is null: show a gray panel). `card` is the read of the card of `openId`. + */ +export function useCardOnScreen( + openId: string | null, + card: RemoteData, +) { + const fresh = card.status === 'ready' ? card.data : null; + const [last, setLast] = useState(null); + if (fresh && fresh !== last) { + setLast(fresh); + } else if (!openId && last) { + setLast(null); + } + const isLoading = openId !== null && card.status === 'loading'; + const isSlow = useIsSlow(isLoading, skeletonDelayMs); + return { fresh, isLoading, stale: isSlow ? null : last }; +} From f1788a529271f17ca9ae003e897adf04a0315ef9 Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Tue, 20 Oct 2026 13:47:47 +0700 Subject: [PATCH 09/14] feat(admin): list the B2B partners and open the card of one (A-10) A-10 opens on the list of the partners and the card of the one the address names. Co-Authored-By: Claude Sonnet 5.5 --- .../src/app/(shell)/partenaires/page.tsx | 11 +++ .../components/b2b/PartnerPanelContent.tsx | 74 ++++++++++++++ .../src/components/b2b/PartnerStatusBadge.tsx | 15 +++ .../src/components/b2b/PartnersTable.tsx | 76 ++++++++++++++ .../src/containers/PartnersContainer.tsx | 99 +++++++++++++++++++ apps/admin/src/lib/partner-display.test.ts | 28 ++++++ apps/admin/src/lib/partner-display.ts | 17 ++++ apps/admin/src/messages/fr.ts | 47 +++++++++ 8 files changed, 367 insertions(+) create mode 100644 apps/admin/src/app/(shell)/partenaires/page.tsx create mode 100644 apps/admin/src/components/b2b/PartnerPanelContent.tsx create mode 100644 apps/admin/src/components/b2b/PartnerStatusBadge.tsx create mode 100644 apps/admin/src/components/b2b/PartnersTable.tsx create mode 100644 apps/admin/src/containers/PartnersContainer.tsx create mode 100644 apps/admin/src/lib/partner-display.test.ts create mode 100644 apps/admin/src/lib/partner-display.ts diff --git a/apps/admin/src/app/(shell)/partenaires/page.tsx b/apps/admin/src/app/(shell)/partenaires/page.tsx new file mode 100644 index 00000000..1943b94c --- /dev/null +++ b/apps/admin/src/app/(shell)/partenaires/page.tsx @@ -0,0 +1,11 @@ +import { PartnersContainer } from '@/containers/PartnersContainer'; +import { Suspense } from 'react'; + +/** A-10, the B2B partners. The open partner is read from the address, in the browser. */ +export default function PartnersPage() { + return ( + + + + ); +} diff --git a/apps/admin/src/components/b2b/PartnerPanelContent.tsx b/apps/admin/src/components/b2b/PartnerPanelContent.tsx new file mode 100644 index 00000000..d5d706dc --- /dev/null +++ b/apps/admin/src/components/b2b/PartnerPanelContent.tsx @@ -0,0 +1,74 @@ +import { dayLabelOf, monthlyPriceLabelOf } from '@/lib/partner-display'; +import { messages } from '@/messages/fr'; +import type { B2bClient } from '@widoo/shared'; +import { StatusBadge } from '@widoo/ui'; +import Link from 'next/link'; +import type { ReactNode } from 'react'; +import { PartnerStatusBadge } from './PartnerStatusBadge'; + +const texts = messages.b2b.panel; + +function Line({ label, children }: { label: string; children: ReactNode }) { + return ( +
+
{label}
+
{children}
+
+ ); +} + +const noValue = {messages.b2b.noValue}; + +type PartnerPanelContentProps = { + partner: B2bClient; + /** Address of the sheet of a route, to open it from the card. */ + routePathOf: (routeId: string) => string; +}; + +/** The card of a partner in the detail panel (A-10): contact, contract, notes and routes. */ +export function PartnerPanelContent({ partner, routePathOf }: PartnerPanelContentProps) { + return ( +
+
+ {messages.b2b.types[partner.type]} + + + + {partner.contactName ?? noValue} + {partner.contactEmail ?? noValue} + {partner.contactPhone ?? noValue} + {partner.address ?? noValue} + {monthlyPriceLabelOf(partner.monthlyPriceCents) ?? noValue} + {dayLabelOf(partner.startedAt) ?? noValue} + + {partner.source} + +
+ {partner.notes ? ( +
+

{texts.notes}

+

{partner.notes}

+
+ ) : null} +
+

{texts.routes}

+ {partner.routes.length === 0 ? ( +

{texts.noRoute}

+ ) : ( +
    + {partner.routes.map((route) => ( +
  1. + + {texts.routePosition(route.position + 1)} {route.title} + + {route.isPublished ? null : ( + {texts.notPublished} + )} +
  2. + ))} +
+ )} +
+
+ ); +} diff --git a/apps/admin/src/components/b2b/PartnerStatusBadge.tsx b/apps/admin/src/components/b2b/PartnerStatusBadge.tsx new file mode 100644 index 00000000..ed5cca43 --- /dev/null +++ b/apps/admin/src/components/b2b/PartnerStatusBadge.tsx @@ -0,0 +1,15 @@ +import { messages } from '@/messages/fr'; +import type { B2bClientStatus } from '@widoo/shared'; +import { StatusBadge } from '@widoo/ui'; + +const tones = { + prospect: 'info', + active: 'success', + suspended: 'alert', + ended: 'neutral', +} as const satisfies Record; + +/** Where a partner stands: prospect, active, suspended or ended. */ +export function PartnerStatusBadge({ status }: { status: B2bClientStatus }) { + return {messages.b2b.statuses[status]}; +} diff --git a/apps/admin/src/components/b2b/PartnersTable.tsx b/apps/admin/src/components/b2b/PartnersTable.tsx new file mode 100644 index 00000000..8d28d516 --- /dev/null +++ b/apps/admin/src/components/b2b/PartnersTable.tsx @@ -0,0 +1,76 @@ +import { citiesLabelOf, dayLabelOf, monthlyPriceLabelOf } from '@/lib/partner-display'; +import { messages } from '@/messages/fr'; +import type { B2bClientListItem } from '@widoo/shared'; +import { Table, TableCell, TableHeaderCell, TableRow, TableSkeleton } from '@widoo/ui'; +import { PartnerStatusBadge } from './PartnerStatusBadge'; + +const texts = messages.b2b; +const columnCount = 7; + +type PartnersTableProps = { + /** The partners; ignored while `isLoading`. */ + items: B2bClientListItem[]; + isLoading: boolean; + /** The partner whose card is open in the panel. */ + openId: string | null; + onOpen: (partner: B2bClientListItem) => void; +}; + +/** The B2B partners as a table (A-10); a row opens its card. */ +export function PartnersTable({ items, isLoading, openId, onOpen }: PartnersTableProps) { + return ( + + + + {texts.columns.partner} + {texts.columns.type} + {texts.columns.city} + {texts.columns.status} + {texts.columns.price} + {texts.columns.started} + {texts.columns.routes} + + + + {isLoading ? ( + + ) : ( + items.map((partner) => ( + onOpen(partner)} + isOpen={partner.id === openId} + > + + + {partner.name} + {partner.slug} + + + {texts.types[partner.type]} + + {citiesLabelOf(partner.cityNames) ?? ( + {texts.noValue} + )} + + + + + + {monthlyPriceLabelOf(partner.monthlyPriceCents) ?? ( + {texts.noValue} + )} + + + {dayLabelOf(partner.startedAt) ?? ( + {texts.noValue} + )} + + {partner.routeCount} + + )) + )} + +
+ ); +} diff --git a/apps/admin/src/containers/PartnersContainer.tsx b/apps/admin/src/containers/PartnersContainer.tsx new file mode 100644 index 00000000..69f7b590 --- /dev/null +++ b/apps/admin/src/containers/PartnersContainer.tsx @@ -0,0 +1,99 @@ +'use client'; + +import { PartnerPanelContent } from '@/components/b2b/PartnerPanelContent'; +import { PartnersTable } from '@/components/b2b/PartnersTable'; +import { PageMessage } from '@/components/states/PageMessage'; +import { sectionPaths } from '@/config/navigation'; +import { catalogRoutePathOf } from '@/containers/CatalogRoutesContainer'; +import { useCardOnScreen } from '@/hooks/useCardOnScreen'; +import { useRemoteData } from '@/hooks/useRemoteData'; +import { getSessionClients } from '@/lib/auth/clients'; +import { messages } from '@/messages/fr'; +import type { B2bClient } from '@widoo/shared'; +import { Button, DetailPanel, DetailPanelSkeleton } from '@widoo/ui'; +import { useRouter, useSearchParams } from 'next/navigation'; + +const texts = messages.b2b; +/** Parameter of the address that holds the partner whose card is open next to the list. */ +const openPartnerParam = 'partenaire'; + +/** A-10, the B2B partners: the list, and next to it the card of the one the address names. */ +export function PartnersContainer() { + const router = useRouter(); + const openId = useSearchParams().get(openPartnerParam); + const partners = useRemoteData('partners', (signal) => + getSessionClients().api.getB2bClients(signal), + ); + const card = useRemoteData(`card:${openId ?? ''}`, (signal) => + openId ? getSessionClients().api.getB2bClient(openId, signal) : Promise.resolve(null), + ); + const { fresh: openPartner, isLoading: isCardLoading, stale } = useCardOnScreen(openId, card); + const items = partners.status === 'ready' ? partners.data.items : undefined; + + const showPartner = (partnerId: string | null) => + router.replace( + partnerId + ? `${sectionPaths.b2bClients}?${new URLSearchParams({ [openPartnerParam]: partnerId }).toString()}` + : sectionPaths.b2bClients, + { scroll: false }, + ); + + const renderCard = (partner: B2bClient) => ( + showPartner(null)} + > + + + ); + + /** The card open next to the list: the fresh one, or the previous one while the next loads. */ + const renderPanel = () => { + if (openPartner) { + return renderCard(openPartner); + } + if (!isCardLoading) { + return null; + } + return stale ? ( +
+ {renderCard(stale)} +
+ ) : ( + + ); + }; + + if (partners.status === 'error') { + return ( + {messages.list.retry}} + /> + ); + } + + return ( +
+
+ {items ? ( +

{texts.total(items.length)}

+ ) : null} + {items?.length === 0 ? ( + + ) : ( + showPartner(partner.id)} + /> + )} +
+ {renderPanel()} +
+ ); +} diff --git a/apps/admin/src/lib/partner-display.test.ts b/apps/admin/src/lib/partner-display.test.ts new file mode 100644 index 00000000..12cfe5ba --- /dev/null +++ b/apps/admin/src/lib/partner-display.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { citiesLabelOf, dayLabelOf, monthlyPriceLabelOf } from './partner-display'; + +const plain = (text: string | null) => text?.replaceAll(/\s/g, ' ') ?? null; + +describe('monthlyPriceLabelOf', () => { + it('writes cents as euros excluding tax, and nothing while the price is unknown', () => { + expect(plain(monthlyPriceLabelOf(4900))).toBe('49,00 € HT'); + expect(plain(monthlyPriceLabelOf(123456))).toBe('1 234,56 € HT'); + expect(plain(monthlyPriceLabelOf(0))).toBe('0,00 € HT'); + expect(monthlyPriceLabelOf(null)).toBeNull(); + }); +}); + +describe('dayLabelOf', () => { + it('writes a day of the API, the same whatever the time zone', () => { + expect(dayLabelOf('2026-11-01')).toBe('1 nov. 2026'); + expect(dayLabelOf('2026-12-31')).toBe('31 déc. 2026'); + expect(dayLabelOf(null)).toBeNull(); + }); +}); + +describe('citiesLabelOf', () => { + it('lists the cities, or nothing', () => { + expect(citiesLabelOf(['Lyon', 'Paris'])).toBe('Lyon, Paris'); + expect(citiesLabelOf([])).toBeNull(); + }); +}); diff --git a/apps/admin/src/lib/partner-display.ts b/apps/admin/src/lib/partner-display.ts new file mode 100644 index 00000000..bff17ac8 --- /dev/null +++ b/apps/admin/src/lib/partner-display.ts @@ -0,0 +1,17 @@ +const price = new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }); +const day = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium', timeZone: 'UTC' }); + +/** A monthly price excluding tax as the admin writes it: « 49,00 € HT »; null while unknown. */ +export function monthlyPriceLabelOf(cents: number | null): string | null { + return cents === null ? null : `${price.format(cents / 100)} HT`; +} + +/** A day of the API (`2026-11-01`) as the admin writes it: « 1 nov. 2026 »; null when none. */ +export function dayLabelOf(isoDay: string | null): string | null { + return isoDay === null ? null : day.format(new Date(`${isoDay}T12:00:00Z`)); +} + +/** The cities of the routes of a partner, or null while it has none. */ +export function citiesLabelOf(cityNames: readonly string[]): string | null { + return cityNames.length === 0 ? null : cityNames.join(', '); +} diff --git a/apps/admin/src/messages/fr.ts b/apps/admin/src/messages/fr.ts index 206dd146..1682cdec 100644 --- a/apps/admin/src/messages/fr.ts +++ b/apps/admin/src/messages/fr.ts @@ -1181,6 +1181,53 @@ export const messages = { error: 'Les étapes ne se sont pas chargées.', }, }, + b2b: { + total: (count: number) => (count > 1 ? `${count} partenaires` : `${count} partenaire`), + columns: { + partner: 'Partenaire', + type: 'Type', + city: 'Ville', + status: 'Statut', + price: 'Prix mensuel', + started: 'Début', + routes: 'Parcours', + }, + types: { + hotel: 'Hôtel', + concierge: 'Conciergerie', + tourist_office: 'Office de tourisme', + other: 'Autre', + }, + statuses: { + prospect: 'Prospect', + active: 'Actif', + suspended: 'Suspendu', + ended: 'Résilié', + }, + noValue: 'Non renseigné', + empty: { + title: 'Aucun partenaire', + text: 'Les hôtels, conciergeries et offices de tourisme partenaires apparaissent ici.', + }, + error: 'Les partenaires ne se sont pas chargés.', + panel: { + loading: 'Chargement de la fiche', + type: 'Type', + status: 'Statut', + contactName: 'Contact', + contactEmail: 'E-mail', + contactPhone: 'Téléphone', + address: 'Adresse', + price: 'Prix mensuel', + started: 'Début du contrat', + source: 'Source de mesure', + notes: 'Notes', + routes: 'Parcours choisis', + noRoute: 'Aucun parcours choisi.', + routePosition: (position: number) => `${position}.`, + notPublished: 'Hors ligne', + }, + }, notFound: { title: 'Page introuvable', text: "Cette adresse n'existe pas dans l'admin.", From 908027ad251824b255f894a50afc4e36bf982c32 Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Tue, 20 Oct 2026 13:47:47 +0700 Subject: [PATCH 10/14] feat(admin): read the form of a B2B partner, its price and its slug (A-10) The price is typed in euros and sent in cents without a float, the slug follows the name until it is typed, and only the fields that changed are sent. Co-Authored-By: Claude Sonnet 5.5 --- apps/admin/src/lib/partner-form.test.ts | 82 +++++++++++++ apps/admin/src/lib/partner-form.ts | 150 ++++++++++++++++++++++++ 2 files changed, 232 insertions(+) create mode 100644 apps/admin/src/lib/partner-form.test.ts create mode 100644 apps/admin/src/lib/partner-form.ts diff --git a/apps/admin/src/lib/partner-form.test.ts b/apps/admin/src/lib/partner-form.test.ts new file mode 100644 index 00000000..b601b3d3 --- /dev/null +++ b/apps/admin/src/lib/partner-form.test.ts @@ -0,0 +1,82 @@ +import type { B2bClient } from '@widoo/shared'; +import { describe, expect, it } from 'vitest'; +import { + centsToEuros, + emptyPartnerForm, + eurosToCents, + partnerFormOf, + partnerInputOf, + partnerPatchOf, + slugFromName, +} from './partner-form'; + +const partner: B2bClient = { + id: '0198c1de-0000-7000-8000-000000000001', + name: 'Hôtel du Nord', + slug: 'hotel-du-nord', + type: 'hotel', + status: 'active', + monthlyPriceCents: 4950, + startedAt: '2026-11-01', + cityNames: ['Paris'], + routeCount: 3, + contactName: 'Camille Durand', + contactEmail: 'camille@hotel.example', + contactPhone: null, + address: null, + notes: null, + source: 'b2b-hotel-du-nord', + routes: [], + createdAt: '2026-10-20T09:00:00.000Z', +}; + +describe('slugFromName', () => { + it('writes lowercase words without accents, joined by hyphens', () => { + expect(slugFromName('Hôtel du Nord')).toBe('hotel-du-nord'); + expect(slugFromName(" L'Office de tourisme — Lyon ")).toBe('l-office-de-tourisme-lyon'); + expect(slugFromName('')).toBe(''); + expect(slugFromName('x'.repeat(80))).toHaveLength(60); + }); +}); + +describe('the price', () => { + it('goes from euros typed to cents and back, without a float', () => { + expect(eurosToCents('49')).toBe(4900); + expect(eurosToCents('49,5')).toBe(4950); + expect(eurosToCents('49.50')).toBe(4950); + expect(eurosToCents('0,07')).toBe(7); + expect(eurosToCents(' ')).toBeNull(); + for (const bad of ['abc', '-5', '49,555', '1 000', '49 €']) { + expect(eurosToCents(bad)).toBe('invalid'); + } + expect(centsToEuros(4900)).toBe('49'); + expect(centsToEuros(4950)).toBe('49,50'); + expect(centsToEuros(7)).toBe('0,07'); + }); +}); + +describe('partnerInputOf', () => { + it('reads the form, an empty text as none, and refuses a price it cannot read', () => { + const form = { ...emptyPartnerForm, name: ' Hôtel ', slug: 'hotel', contactName: ' ' }; + expect(partnerInputOf(form)).toMatchObject({ + name: 'Hôtel', + slug: 'hotel', + status: 'prospect', + contactName: null, + monthlyPriceCents: null, + }); + expect(partnerInputOf({ ...form, monthlyPrice: 'beaucoup' })).toBeNull(); + }); +}); + +describe('partnerPatchOf', () => { + it('holds the fields that changed, never the slug', () => { + const form = partnerFormOf(partner); + expect(partnerPatchOf(partner, form)).toEqual({}); + expect( + partnerPatchOf(partner, { ...form, status: 'suspended', monthlyPrice: '60', slug: 'autre' }), + ).toEqual({ status: 'suspended', monthlyPriceCents: 6000 }); + expect(partnerPatchOf(partner, { ...form, contactEmail: '' })).toEqual({ contactEmail: null }); + expect(partnerPatchOf(partner, { ...form, monthlyPrice: 'x' })).toBeNull(); + }); +}); diff --git a/apps/admin/src/lib/partner-form.ts b/apps/admin/src/lib/partner-form.ts new file mode 100644 index 00000000..e0bdf8b1 --- /dev/null +++ b/apps/admin/src/lib/partner-form.ts @@ -0,0 +1,150 @@ +import type { + B2bClient, + B2bClientInput, + B2bClientPatch, + B2bClientStatus, + B2bClientType, +} from '@widoo/shared'; + +/** A partner as the form holds it: everything is text until it is saved. */ +export type PartnerFormValues = { + name: string; + /** Typed at the creation only: the slug never changes. */ + slug: string; + type: B2bClientType; + status: B2bClientStatus; + contactName: string; + contactEmail: string; + contactPhone: string; + address: string; + /** Euros excluding tax, « 49 » or « 49,50 »; empty while unknown. */ + monthlyPrice: string; + /** A day, `2026-11-01`; empty until the contract is signed. */ + startedAt: string; + notes: string; +}; + +/** The form of a new partner: a prospect, a hotel. */ +export const emptyPartnerForm: PartnerFormValues = { + name: '', + slug: '', + type: 'hotel', + status: 'prospect', + contactName: '', + contactEmail: '', + contactPhone: '', + address: '', + monthlyPrice: '', + startedAt: '', + notes: '', +}; + +/** Pattern of the price field, as the browser checks it before the form is sent. */ +export const monthlyPricePattern = '\\d{1,6}([.,]\\d{1,2})?'; + +/** The form filled with an existing partner. */ +export function partnerFormOf(partner: B2bClient): PartnerFormValues { + return { + name: partner.name, + slug: partner.slug, + type: partner.type, + status: partner.status, + contactName: partner.contactName ?? '', + contactEmail: partner.contactEmail ?? '', + contactPhone: partner.contactPhone ?? '', + address: partner.address ?? '', + monthlyPrice: partner.monthlyPriceCents === null ? '' : centsToEuros(partner.monthlyPriceCents), + startedAt: partner.startedAt ?? '', + notes: partner.notes ?? '', + }; +} + +/** The slug a name suggests: lowercase words without accents, joined by hyphens. */ +export function slugFromName(name: string): string { + return name + .normalize('NFD') + .replaceAll(/\p{M}/gu, '') + .toLowerCase() + .replaceAll(/[^a-z0-9]+/g, '-') + .replaceAll(/^-+|-+$/g, '') + .slice(0, 60) + .replaceAll(/-+$/g, ''); +} + +/** Cents as euros typed with a comma: 4950 gives « 49,50 », 4900 gives « 49 ». */ +export function centsToEuros(cents: number): string { + const euros = Math.floor(cents / 100); + const rest = cents % 100; + return rest === 0 ? String(euros) : `${euros},${String(rest).padStart(2, '0')}`; +} + +/** + * The price typed as cents: « 49 » gives 4900, « 49,5 » 4950, « 49.50 » 4950. Null when nothing + * is typed, and `'invalid'` for anything else; the whole is read as text, never as a float. + */ +export function eurosToCents(text: string): number | null | 'invalid' { + const typed = text.trim(); + if (typed === '') { + return null; + } + const match = /^(\d{1,6})(?:[.,](\d{1,2}))?$/.exec(typed); + if (!match) { + return 'invalid'; + } + return Number(match[1]) * 100 + Number((match[2] ?? '').padEnd(2, '0')); +} + +const textOrNull = (text: string) => text.trim() || null; + +/** What to send to register a partner, or null while the price cannot be read. */ +export function partnerInputOf(values: PartnerFormValues): B2bClientInput | null { + const monthlyPriceCents = eurosToCents(values.monthlyPrice); + if (monthlyPriceCents === 'invalid') { + return null; + } + return { + slug: values.slug.trim(), + name: values.name.trim(), + type: values.type, + status: values.status, + contactName: textOrNull(values.contactName), + contactEmail: textOrNull(values.contactEmail), + contactPhone: textOrNull(values.contactPhone), + address: textOrNull(values.address), + monthlyPriceCents, + startedAt: textOrNull(values.startedAt), + notes: textOrNull(values.notes), + }; +} + +/** The fields of a partner that can change: every one but its slug. */ +const changeableFields = [ + 'name', + 'type', + 'status', + 'contactName', + 'contactEmail', + 'contactPhone', + 'address', + 'monthlyPriceCents', + 'startedAt', + 'notes', +] as const satisfies readonly (keyof B2bClientPatch)[]; + +/** The fields of the form that differ from the partner: only those are sent to change it. */ +export function partnerPatchOf( + partner: B2bClient, + values: PartnerFormValues, +): B2bClientPatch | null { + const input = partnerInputOf(values); + if (!input) { + return null; + } + const patch: B2bClientPatch = {}; + for (const key of changeableFields) { + if (input[key] !== partner[key]) { + Object.assign(patch, { [key]: input[key] }); + } + } + return patch; +} From 446730a671e93b7497196c12fff5a569dcd4f810 Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Tue, 20 Oct 2026 13:47:47 +0700 Subject: [PATCH 11/14] feat(admin): register and change a B2B partner from its card (A-10) The window that registers a partner or changes one, with its toasts. Co-Authored-By: Claude Sonnet 5.5 --- .../src/components/b2b/PartnerActions.tsx | 13 ++ .../src/components/b2b/PartnerFormDialog.tsx | 162 ++++++++++++++++++ .../src/containers/PartnerFormContainer.tsx | 76 ++++++++ .../src/containers/PartnersContainer.tsx | 30 +++- apps/admin/src/hooks/usePartnerActions.ts | 46 +++++ apps/admin/src/messages/fr.ts | 33 ++++ 6 files changed, 357 insertions(+), 3 deletions(-) create mode 100644 apps/admin/src/components/b2b/PartnerActions.tsx create mode 100644 apps/admin/src/components/b2b/PartnerFormDialog.tsx create mode 100644 apps/admin/src/containers/PartnerFormContainer.tsx create mode 100644 apps/admin/src/hooks/usePartnerActions.ts diff --git a/apps/admin/src/components/b2b/PartnerActions.tsx b/apps/admin/src/components/b2b/PartnerActions.tsx new file mode 100644 index 00000000..47bfb0e4 --- /dev/null +++ b/apps/admin/src/components/b2b/PartnerActions.tsx @@ -0,0 +1,13 @@ +import { messages } from '@/messages/fr'; +import { Button } from '@widoo/ui'; + +const texts = messages.b2b.panel; + +type PartnerActionsProps = { + onEdit: () => void; +}; + +/** The actions of the card of a partner (A-10): change it. */ +export function PartnerActions({ onEdit }: PartnerActionsProps) { + return ; +} diff --git a/apps/admin/src/components/b2b/PartnerFormDialog.tsx b/apps/admin/src/components/b2b/PartnerFormDialog.tsx new file mode 100644 index 00000000..8b8f7677 --- /dev/null +++ b/apps/admin/src/components/b2b/PartnerFormDialog.tsx @@ -0,0 +1,162 @@ +import { monthlyPricePattern, type PartnerFormValues } from '@/lib/partner-form'; +import { messages } from '@/messages/fr'; +import { b2bClientStatuses, b2bClientTypes } from '@widoo/shared'; +import { ConfirmDialog, Field, Input, Select, Textarea } from '@widoo/ui'; + +const texts = messages.b2b.form; + +type PartnerFormDialogProps = { + isOpen: boolean; + /** True when it changes a partner; false when it registers one. */ + isEdit: boolean; + values: PartnerFormValues; + onChange: (key: Key, value: PartnerFormValues[Key]) => void; + onCancel: () => void; + onConfirm: () => void; + isBusy: boolean; +}; + +/** « Ajouter un partenaire » or « Modifier le partenaire »: its contact, contract and notes. */ +export function PartnerFormDialog({ + isOpen, + isEdit, + values, + onChange, + onCancel, + onConfirm, + isBusy, +}: PartnerFormDialogProps) { + return ( + + + onChange('name', event.target.value)} + /> + + + onChange('slug', event.target.value)} + /> + + + + + + + + + onChange('contactName', event.target.value)} + /> + + + onChange('contactEmail', event.target.value)} + /> + + + onChange('contactPhone', event.target.value)} + /> + + + onChange('address', event.target.value)} + /> + + + onChange('monthlyPrice', event.target.value)} + /> + + + onChange('startedAt', event.target.value)} + /> + + +