From 3507a42c91655cd0e59f84def3c8794779f1489f Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Fri, 16 Oct 2026 17:51:25 +0700 Subject: [PATCH 01/11] feat(api): add the affiliate programs, the booking clicks and the partner link of a place (A-09) Migration 0018, additive: affiliate_programs (name, network, commission, cookie, active), booking_clicks (step, origin of the link; no foreign key to the step, as editing a route rewrites its steps and a cascade would erase the clicks), and places.partner_booking_url (https only) and partner_program_id (needs a link). user_id of a click stays null: the redirect opens in the browser, without a session. Co-Authored-By: Claude Sonnet 5.5 --- .../migrations/0018_add_affiliation.sql | 29 + .../migrations/meta/0018_snapshot.json | 3475 +++++++++++++++++ .../generated/migrations/meta/_journal.json | 7 + apps/api/src/db/schema.ts | 57 + apps/api/src/users/export.test.ts | 3 + packages/shared/src/booking-link.ts | 6 + packages/shared/src/index.ts | 1 + 7 files changed, 3578 insertions(+) create mode 100644 apps/api/src/db/generated/migrations/0018_add_affiliation.sql create mode 100644 apps/api/src/db/generated/migrations/meta/0018_snapshot.json create mode 100644 packages/shared/src/booking-link.ts diff --git a/apps/api/src/db/generated/migrations/0018_add_affiliation.sql b/apps/api/src/db/generated/migrations/0018_add_affiliation.sql new file mode 100644 index 00000000..6bd5bbd3 --- /dev/null +++ b/apps/api/src/db/generated/migrations/0018_add_affiliation.sql @@ -0,0 +1,29 @@ +CREATE TYPE "public"."link_origin" AS ENUM('creator', 'creator_affiliate', 'partner');--> statement-breakpoint +CREATE TABLE "affiliate_programs" ( + "id" uuid PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "network" text NOT NULL, + "commission_note" text, + "cookie_days" smallint NOT NULL, + "is_active" boolean DEFAULT true NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "affiliate_programs_name_unique" UNIQUE("name"), + CONSTRAINT "affiliate_programs_cookie_days_check" CHECK ("affiliate_programs"."cookie_days" between 0 and 365) +); +--> statement-breakpoint +CREATE TABLE "booking_clicks" ( + "id" uuid PRIMARY KEY NOT NULL, + "step_id" uuid NOT NULL, + "user_id" uuid, + "link_origin" "link_origin" NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "places" ADD COLUMN "partner_booking_url" text;--> statement-breakpoint +ALTER TABLE "places" ADD COLUMN "partner_program_id" uuid;--> statement-breakpoint +ALTER TABLE "booking_clicks" ADD CONSTRAINT "booking_clicks_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "booking_clicks_step_idx" ON "booking_clicks" USING btree ("step_id","created_at");--> statement-breakpoint +ALTER TABLE "places" ADD CONSTRAINT "places_partner_program_id_affiliate_programs_id_fk" FOREIGN KEY ("partner_program_id") REFERENCES "public"."affiliate_programs"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "places" ADD CONSTRAINT "places_partner_link_check" CHECK (("places"."partner_booking_url" is null or "places"."partner_booking_url" like 'https://%') + and ("places"."partner_program_id" is null or "places"."partner_booking_url" is not null)); \ No newline at end of file diff --git a/apps/api/src/db/generated/migrations/meta/0018_snapshot.json b/apps/api/src/db/generated/migrations/meta/0018_snapshot.json new file mode 100644 index 00000000..0e5251a9 --- /dev/null +++ b/apps/api/src/db/generated/migrations/meta/0018_snapshot.json @@ -0,0 +1,3475 @@ +{ + "id": "af5cf941-0cd5-4c17-8fb6-04627855d081", + "prevId": "b48574c0-3e10-4aa1-a2f3-f8f281ce856f", + "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.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_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 + }, + "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.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_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 fe3a5c8a..a5c8f9f8 100644 --- a/apps/api/src/db/generated/migrations/meta/_journal.json +++ b/apps/api/src/db/generated/migrations/meta/_journal.json @@ -127,6 +127,13 @@ "when": 1792123326260, "tag": "0017_add_plan_source_and_gifts", "breakpoints": true + }, + { + "idx": 18, + "version": "7", + "when": 1792142096667, + "tag": "0018_add_affiliation", + "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 82b75f68..0d8d69ce 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -7,6 +7,7 @@ import { directionsProfiles } from '@widoo/orchestration'; import { anecdoteAnswerMaxLength, appLocales, + linkOrigins, bioMaxLength, bookingNoteMaxLength, certifiedOverrides, @@ -87,6 +88,8 @@ export const directionsProfile = pgEnum('directions_profile', directionsProfiles export const certifiedOverride = pgEnum('certified_override', certifiedOverrides); /** Origin of a right to Premium: the store, or a gift of the team (A-06). */ export const planSource = pgEnum('plan_source', planSources); +/** Where the booking link of a step comes from (D-048); the admin names each origin. */ +export const linkOrigin = pgEnum('link_origin', linkOrigins); /** Status of a planned route (D-029, D-033, D-038); its labels belong to the app. */ export const plannedRouteStatus = pgEnum('planned_route_status', plannedRouteStatuses); /** Who closed a Go (D-033). */ @@ -238,6 +241,28 @@ export const devices = pgTable( ], ); +/** + * A program of affiliation Widoo is registered with (A-09, #212): the network, the commission and + * the cookie it keeps. An inactive program no longer lends its links to the places that name it. + */ +export const affiliatePrograms = pgTable( + 'affiliate_programs', + { + id: id(), + name: text('name').notNull().unique(), + /** The network that runs it: « Awin », « Impact »... */ + network: text('network').notNull(), + /** Free note on the commission: « 5 % du panier ». */ + commissionNote: text('commission_note'), + /** Days the program keeps its cookie. */ + cookieDays: smallint('cookie_days').notNull(), + isActive: boolean('is_active').notNull().default(true), + createdAt: createdAt(), + updatedAt: updatedAt(), + }, + (t) => [check('affiliate_programs_cookie_days_check', sql`${t.cookieDays} between 0 and 365`)], +); + export const places = pgTable( 'places', { @@ -255,6 +280,14 @@ export const places = pgTable( priceRange: priceRange('price_range'), websiteUrl: text('website_url'), phone: text('phone'), + /** + * Booking link of a partner program of Widoo (D-048): shown only when the creator of the step + * has no link of their own. https only. + */ + partnerBookingUrl: text('partner_booking_url'), + partnerProgramId: uuid('partner_program_id').references(() => affiliatePrograms.id, { + onDelete: 'set null', + }), /** Google Places, OpenStreetMap... identifiers, for later. */ externalRefs: jsonb('external_refs').notNull().default({}), /** @@ -279,6 +312,11 @@ export const places = pgTable( index('places_location_idx').using('gist', t.location), index('places_city_category_idx').on(t.cityId, t.category), index('places_name_trgm_idx').using('gin', t.name.op('gin_trgm_ops')), + check( + 'places_partner_link_check', + sql`(${t.partnerBookingUrl} is null or ${t.partnerBookingUrl} like 'https://%') + and (${t.partnerProgramId} is null or ${t.partnerBookingUrl} is not null)`, + ), ], ); @@ -892,3 +930,22 @@ export const supportMessages = pgTable( check('support_messages_locale_check', isOneOf(t.locale, appLocales)), ], ); + +/** + * A click on « Réserver », counted by `GET /go/:stepId` before it redirects (A-09). `step_id` is + * not a foreign key, like `planned_steps.step_id`: editing a route rewrites its steps, ids kept, + * and a cascade would erase the clicks. `user_id` stays null while the redirect is opened in the + * browser, which carries no session; the link is cut when an account is purged. + */ +export const bookingClicks = pgTable( + 'booking_clicks', + { + id: id(), + stepId: uuid('step_id').notNull(), + userId: uuid('user_id').references(() => users.id, { onDelete: 'set null' }), + /** Where the link that was served comes from. */ + linkOrigin: linkOrigin('link_origin').notNull(), + createdAt: createdAt(), + }, + (t) => [index('booking_clicks_step_idx').on(t.stepId, t.createdAt)], +); diff --git a/apps/api/src/users/export.test.ts b/apps/api/src/users/export.test.ts index 4b568b87..abc978ba 100644 --- a/apps/api/src/users/export.test.ts +++ b/apps/api/src/users/export.test.ts @@ -168,6 +168,9 @@ describe('exportUserData', () => { 'user_featured_routes.user_id', ]; const setAside = [ + // A click on « Réserver », a statistic of the link and not a record of the person: never + // written with an account while the redirect opens in the browser, cut at purge. + 'booking_clicks.user_id', // Journal of the writes of the team, kept when an account is purged (A-12): the work of a // member of the team, not data of the person. 'admin_actions.actor_id', diff --git a/packages/shared/src/booking-link.ts b/packages/shared/src/booking-link.ts new file mode 100644 index 00000000..132da4b3 --- /dev/null +++ b/packages/shared/src/booking-link.ts @@ -0,0 +1,6 @@ +/** + * Where the booking link of a step comes from (D-048): the creator's own link, the creator's + * affiliate link, or the link of a partner program of Widoo. Technical only: the admin names them. + */ +export const linkOrigins = ['creator', 'creator_affiliate', 'partner'] as const; +export type LinkOrigin = (typeof linkOrigins)[number]; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 739dd5de..359df9dc 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,4 +1,5 @@ export * from './admin-sections'; +export * from './booking-link'; export * from './analytics'; export * from './app-version'; export { arrondissementLabel } from './arrondissement'; From c6ef1320b24bbe6b3a7c108d23006ebd900a4bfe Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Fri, 16 Oct 2026 17:51:30 +0700 Subject: [PATCH 02/11] feat(shared): describe the affiliation of the admin, and the booking link a step shows (A-09) Schemas of the programs and of the bookable steps, and resolveBookingLink, the one place of the rule (D-048): the creator's link first, the partner link of Widoo only when the creator set none and unless its program is inactive. Co-Authored-By: Claude Sonnet 5.5 --- packages/shared/src/booking-link.test.ts | 54 ++++++++++++ packages/shared/src/booking-link.ts | 26 ++++++ packages/shared/src/index.ts | 1 + .../src/schemas/admin-affiliation.test.ts | 29 +++++++ .../shared/src/schemas/admin-affiliation.ts | 85 +++++++++++++++++++ 5 files changed, 195 insertions(+) create mode 100644 packages/shared/src/booking-link.test.ts create mode 100644 packages/shared/src/schemas/admin-affiliation.test.ts create mode 100644 packages/shared/src/schemas/admin-affiliation.ts diff --git a/packages/shared/src/booking-link.test.ts b/packages/shared/src/booking-link.test.ts new file mode 100644 index 00000000..d609cb39 --- /dev/null +++ b/packages/shared/src/booking-link.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import { resolveBookingLink } from './booking-link'; + +const noPartner = { partnerBookingUrl: null, partnerProgramIsActive: null }; +const partner = { partnerBookingUrl: 'https://partner.example/lieu', partnerProgramIsActive: true }; + +describe('resolveBookingLink', () => { + it('puts the link of the creator before the partner link', () => { + expect( + resolveBookingLink( + { bookingUrl: 'https://creator.example', bookingIsAffiliate: false }, + partner, + ), + ).toEqual({ url: 'https://creator.example', origin: 'creator' }); + }); + + it('says when the link of the creator is an affiliate link', () => { + expect( + resolveBookingLink( + { bookingUrl: 'https://creator.example', bookingIsAffiliate: true }, + noPartner, + ), + ).toEqual({ url: 'https://creator.example', origin: 'creator_affiliate' }); + }); + + it('falls back to the partner link when the creator set none', () => { + expect(resolveBookingLink({ bookingUrl: null, bookingIsAffiliate: false }, partner)).toEqual({ + url: 'https://partner.example/lieu', + origin: 'partner', + }); + }); + + it('uses a partner link that names no program, but not one whose program is inactive', () => { + const step = { bookingUrl: null, bookingIsAffiliate: false }; + expect( + resolveBookingLink(step, { + partnerBookingUrl: 'https://p.example', + partnerProgramIsActive: null, + }), + ).toMatchObject({ origin: 'partner' }); + expect( + resolveBookingLink(step, { + partnerBookingUrl: 'https://p.example', + partnerProgramIsActive: false, + }), + ).toBeNull(); + }); + + it('is null without any link', () => { + expect( + resolveBookingLink({ bookingUrl: null, bookingIsAffiliate: false }, noPartner), + ).toBeNull(); + }); +}); diff --git a/packages/shared/src/booking-link.ts b/packages/shared/src/booking-link.ts index 132da4b3..320ee6f8 100644 --- a/packages/shared/src/booking-link.ts +++ b/packages/shared/src/booking-link.ts @@ -4,3 +4,29 @@ */ export const linkOrigins = ['creator', 'creator_affiliate', 'partner'] as const; export type LinkOrigin = (typeof linkOrigins)[number]; + +/** The booking link a step shows, and where it comes from. */ +export type BookingLink = { url: string; origin: LinkOrigin }; + +/** + * The link « Réserver » opens (D-048, wiki Compte-et-Monetisation › Liens de réservation): the + * creator's own link first, as their pay and what makes them create; the partner link of Widoo + * only when the creator set none, and unless the program it belongs to is no longer active. Null + * when the step has no link at all. `partnerProgramIsActive` is null for a partner link that names + * no program. The one place of the rule: the route sheet and `GET /go/:stepId` both read it. + */ +export function resolveBookingLink( + step: { bookingUrl: string | null; bookingIsAffiliate: boolean }, + place: { partnerBookingUrl: string | null; partnerProgramIsActive: boolean | null }, +): BookingLink | null { + if (step.bookingUrl !== null) { + return { + url: step.bookingUrl, + origin: step.bookingIsAffiliate ? 'creator_affiliate' : 'creator', + }; + } + if (place.partnerBookingUrl !== null && place.partnerProgramIsActive !== false) { + return { url: place.partnerBookingUrl, origin: 'partner' }; + } + return null; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 359df9dc..7a850e8a 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -22,6 +22,7 @@ export { hasRole } from './roles'; export { zoneMinSpanM, zoneSearchBox } from './zone'; export * from './schemas/admin'; export * from './schemas/admin-actions'; +export * from './schemas/admin-affiliation'; export * from './schemas/admin-settings'; export * from './schemas/admin-reports'; export * from './schemas/admin-signals'; diff --git a/packages/shared/src/schemas/admin-affiliation.test.ts b/packages/shared/src/schemas/admin-affiliation.test.ts new file mode 100644 index 00000000..4a7bc89e --- /dev/null +++ b/packages/shared/src/schemas/admin-affiliation.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { AffiliateProgramInput, AffiliateProgramPatch } from './admin-affiliation'; + +const program = { name: 'Awin Paris', network: 'Awin', cookieDays: 30 }; + +describe('AffiliateProgramInput', () => { + it('takes a program, active by default, with an optional note', () => { + const parsed = AffiliateProgramInput.parse(program); + expect(parsed).toMatchObject({ isActive: true, commissionNote: null }); + expect( + AffiliateProgramInput.safeParse({ ...program, commissionNote: '5 % du panier' }).success, + ).toBe(true); + }); + + it('refuses a cookie out of bounds, markup in a name and an unknown field', () => { + expect(AffiliateProgramInput.safeParse({ ...program, cookieDays: 400 }).success).toBe(false); + expect(AffiliateProgramInput.safeParse({ ...program, cookieDays: -1 }).success).toBe(false); + expect(AffiliateProgramInput.safeParse({ ...program, name: 'x' }).success).toBe(false); + expect(AffiliateProgramInput.safeParse({ ...program, extra: 1 }).success).toBe(false); + }); +}); + +describe('AffiliateProgramPatch', () => { + it('takes some fields, not none', () => { + expect(AffiliateProgramPatch.safeParse({ isActive: false }).success).toBe(true); + expect(AffiliateProgramPatch.safeParse({}).success).toBe(false); + expect(AffiliateProgramPatch.parse({ network: 'Impact' })).toEqual({ network: 'Impact' }); + }); +}); diff --git a/packages/shared/src/schemas/admin-affiliation.ts b/packages/shared/src/schemas/admin-affiliation.ts new file mode 100644 index 00000000..12bbf821 --- /dev/null +++ b/packages/shared/src/schemas/admin-affiliation.ts @@ -0,0 +1,85 @@ +import { z } from 'zod'; +import { linkOrigins } from '../booking-link'; +import { HttpsUrl, plainText } from './common'; + +/** Rows of a page of the bookable steps of the admin. */ +export const adminBookableStepsPageSize = 50; + +/** Days a program may keep its cookie (the database holds the same bounds). */ +export const maxCookieDays = 365; + +/** A program of affiliation Widoo is registered with (A-09). */ +export const AffiliateProgram = z.object({ + id: z.uuid(), + name: z.string(), + /** The network that runs it: « Awin », « Impact »... */ + network: z.string(), + commissionNote: z.string().nullable(), + cookieDays: z.int().min(0).max(maxCookieDays), + isActive: z.boolean(), + /** Places whose partner link names this program. */ + placeCount: z.int().nonnegative(), + createdAt: z.iso.datetime(), +}); +export type AffiliateProgram = z.infer; + +/** Body of `GET /admin/affiliate-programs`. */ +export const AffiliatePrograms = z.object({ items: z.array(AffiliateProgram) }); +export type AffiliatePrograms = z.infer; + +const programFields = { + name: plainText(100), + network: plainText(100), + commissionNote: plainText(200).nullable(), + cookieDays: z.int().min(0).max(maxCookieDays), + isActive: z.boolean(), +}; + +/** Body of `POST /admin/affiliate-programs`: active and without a note unless said otherwise. */ +export const AffiliateProgramInput = z.strictObject({ + ...programFields, + commissionNote: programFields.commissionNote.default(null), + isActive: programFields.isActive.default(true), +}); +export type AffiliateProgramInput = z.infer; + +/** + * Body of `PATCH /admin/affiliate-programs/:id`: the fields to change, at least one. Built from + * the fields without their defaults, so that a field left out stays as it is. + */ +export const AffiliateProgramPatch = z + .strictObject(programFields) + .partial() + .refine((patch) => Object.keys(patch).length > 0, { message: 'Give at least one field' }); +export type AffiliateProgramPatch = z.infer; + +/** Query of `GET /admin/bookable-steps`: the steps that can be booked, newest routes first. */ +export const BookableStepsQuery = z.strictObject({ + /** Only the places without a partner link of Widoo: where to sign up first. */ + withoutPartnerLink: z.enum(['true']).optional(), + cursor: z.string().max(200).optional(), +}); +export type BookableStepsQuery = z.infer; + +/** A step that can be booked, with the link it shows and the clicks it got over 30 days. */ +export const BookableStep = z.object({ + stepId: z.uuid(), + position: z.int().nonnegative(), + route: z.object({ id: z.uuid(), title: z.string() }), + place: z.object({ id: z.uuid(), name: z.string(), hasPartnerLink: z.boolean() }), + /** The link shown and where it comes from; null when the step shows none. */ + link: z.object({ url: HttpsUrl, origin: z.enum(linkOrigins) }).nullable(), + clicks30Days: z.int().nonnegative(), +}); +export type BookableStep = z.infer; + +/** Body of `GET /admin/bookable-steps`. */ +export const BookableSteps = z.object({ + items: z.array(BookableStep), + total: z.int().nonnegative(), + /** Rows before this page: the first row shown is number `offset + 1`. */ + offset: z.int().nonnegative(), + previousCursor: z.string().nullable(), + nextCursor: z.string().nullable(), +}); +export type BookableSteps = z.infer; From f8bb868576cb20c72eb059d4bc670ee1b4fbd705 Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Fri, 16 Oct 2026 17:51:53 +0700 Subject: [PATCH 03/11] feat(api): register the affiliate programs, and change them (A-09) GET, POST and PATCH /admin/affiliate-programs, for editors and admins: name (unique, whatever the case), network, commission note, cookie days (0 to 365), active. A patch changes only the fields sent; each write is journaled with its before and after. An inactive program no longer lends its links. Co-Authored-By: Claude Sonnet 5.5 --- apps/api/src/affiliation/programs.ts | 118 ++++++++++++ apps/api/src/app.ts | 2 + apps/api/src/routes/admin-affiliation.test.ts | 176 ++++++++++++++++++ apps/api/src/routes/admin-affiliation.ts | 77 ++++++++ 4 files changed, 373 insertions(+) create mode 100644 apps/api/src/affiliation/programs.ts create mode 100644 apps/api/src/routes/admin-affiliation.test.ts create mode 100644 apps/api/src/routes/admin-affiliation.ts diff --git a/apps/api/src/affiliation/programs.ts b/apps/api/src/affiliation/programs.ts new file mode 100644 index 00000000..9f5064d8 --- /dev/null +++ b/apps/api/src/affiliation/programs.ts @@ -0,0 +1,118 @@ +import type { + AffiliateProgram, + AffiliateProgramInput, + AffiliateProgramPatch, + AffiliatePrograms, +} from '@widoo/shared'; +import { eq, sql } from 'drizzle-orm'; +import { recordAdminAction } from '../admin/actions'; +import type { Db, Tx } from '../db/client'; +import { affiliatePrograms } from '../db/schema'; +import { httpError } from '../errors'; + +const programOf = ( + row: typeof affiliatePrograms.$inferSelect, + placeCount: number, +): AffiliateProgram => ({ + id: row.id, + name: row.name, + network: row.network, + commissionNote: row.commissionNote, + cookieDays: row.cookieDays, + isActive: row.isActive, + placeCount, + createdAt: row.createdAt.toISOString(), +}); + +/** The programs, active first then by name, each with the places whose partner link names it. */ +export async function readPrograms(db: Db): Promise { + const rows = await db.execute<{ id: string; place_count: number }>( + sql`select ap.id, (select count(*)::int from places p where p.partner_program_id = ap.id) as place_count + from affiliate_programs ap`, + ); + const counts = new Map(rows.map((row) => [row.id, row.place_count])); + const programs = await db.select().from(affiliatePrograms); + return { + items: programs + .sort((a, b) => Number(b.isActive) - Number(a.isActive) || a.name.localeCompare(b.name, 'fr')) + .map((program) => programOf(program, counts.get(program.id) ?? 0)), + }; +} + +/** 409 when another program already has this name, whatever its case. */ +async function assertNameFree(tx: Tx, name: string, exceptId?: string): Promise { + const [taken] = await tx + .select({ id: affiliatePrograms.id }) + .from(affiliatePrograms) + .where(sql`lower(${affiliatePrograms.name}) = lower(${name})`); + if (taken && taken.id !== exceptId) { + throw httpError(409, 'A program already has this name', 'conflict', { reason: 'name_taken' }); + } +} + +/** Registers a program (A-09), with its journal line. */ +export async function createProgram( + db: Db, + actorId: string, + input: AffiliateProgramInput, +): Promise { + return db.transaction(async (tx) => { + await assertNameFree(tx, input.name); + const [row] = await tx.insert(affiliatePrograms).values(input).returning(); + if (!row) { + throw new Error('Program not created'); + } + await recordAdminAction(tx, { + actorId, + action: 'affiliate_program.create', + targetType: 'affiliate_program', + targetId: row.id, + after: input, + }); + return programOf(row, 0); + }); +} + +/** Changes the fields given of a program, with its journal line (before and after). 404 if unknown. */ +export async function updateProgram( + db: Db, + actorId: string, + id: string, + patch: AffiliateProgramPatch, + now = new Date(), +): Promise { + return db.transaction(async (tx) => { + const [current] = await tx + .select() + .from(affiliatePrograms) + .where(eq(affiliatePrograms.id, id)) + .for('update'); + if (!current) { + throw httpError(404, 'Program not found'); + } + if (patch.name !== undefined) { + await assertNameFree(tx, patch.name, id); + } + const [row] = await tx + .update(affiliatePrograms) + .set({ ...patch, updatedAt: now }) + .where(eq(affiliatePrograms.id, id)) + .returning(); + if (!row) { + throw httpError(404, 'Program not found'); + } + const changed = Object.keys(patch) as (keyof AffiliateProgramPatch)[]; + await recordAdminAction(tx, { + actorId, + action: 'affiliate_program.update', + targetType: 'affiliate_program', + targetId: id, + before: Object.fromEntries(changed.map((field) => [field, current[field]])), + after: patch, + }); + const [counted] = await tx.execute<{ place_count: number }>( + sql`select count(*)::int as place_count from places where partner_program_id = ${id}`, + ); + return programOf(row, counted?.place_count ?? 0); + }); +} diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 358520fe..873c54cc 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -21,6 +21,7 @@ import { adminCatalogRoutes } from './routes/admin-catalog'; 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 { adminSettingsRoutes } from './routes/admin-settings'; import { adminUsersRoutes } from './routes/admin-users'; import { configRoutes } from './routes/config'; @@ -133,6 +134,7 @@ export async function buildApp(config: Config, options: BuildAppOptions = {}) { await app.register(adminUsersRoutes, { prefix: '/v1' }); await app.register(adminSettingsRoutes, { prefix: '/v1' }); await app.register(adminActionsRoutes, { prefix: '/v1' }); + await app.register(adminAffiliationRoutes, { prefix: '/v1' }); await app.register(searchRoutesPlugin, { prefix: '/v1' }); await app.register(usersRoutes, { prefix: '/v1' }); await app.register(editorRoutes, { prefix: '/v1' }); diff --git a/apps/api/src/routes/admin-affiliation.test.ts b/apps/api/src/routes/admin-affiliation.test.ts new file mode 100644 index 00000000..1d65f4f0 --- /dev/null +++ b/apps/api/src/routes/admin-affiliation.test.ts @@ -0,0 +1,176 @@ +import { AffiliateProgram, AffiliatePrograms, ApiError, 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, affiliatePrograms, places, users } from '../db/schema'; +import { seed } from '../db/seed'; +import { uuidv7 } from '../db/uuid'; +import { testConfig } from '../test-config'; + +// Shared database, parallel files: the programs of this file carry a marker in their name, and +// only those are read back or deleted. +const verifier = new TestTokenVerifier(); +const createdUids: string[] = []; +const marker = `affitest${uuidv7().slice(-10)}`; + +let app: Awaited>; +let placeId: string; +beforeAll(async () => { + app = await buildApp(testConfig(), { tokenVerifier: verifier }); + await seed(app.db); + const [place] = await app.db.select({ id: places.id }).from(places).limit(1); + if (!place) throw new Error('No place in the seed'); + placeId = place.id; +}); +afterAll(async () => { + const ids = await app.db + .select({ id: affiliatePrograms.id }) + .from(affiliatePrograms) + .where(like(affiliatePrograms.name, `${marker}%`)); + const programIds = ids.map((row) => row.id); + if (programIds.length > 0) { + await app.db + .update(places) + .set({ partnerProgramId: null }) + .where(inArray(places.partnerProgramId, programIds)); + await app.db.delete(adminActions).where(inArray(adminActions.targetId, programIds)); + await app.db.delete(affiliatePrograms).where(inArray(affiliatePrograms.id, programIds)); + } + await app.db.delete(users).where(inArray(users.firebaseUid, createdUids)); + await app.close(); +}); + +async function signIn(role: UserRole) { + const { token, uid } = verifier.issue({ secondFactor: 'totp' }); + createdUids.push(uid); + const [user] = await app.db + .insert(users) + .values({ id: uuidv7(), firebaseUid: uid, role }) + .returning(); + if (!user) throw new Error('User not created'); + return { id: user.id, headers: { authorization: `Bearer ${token}` } }; +} + +const send = ( + method: 'GET' | 'POST' | 'PATCH', + headers: Record, + url: string, + payload?: object, +) => app.inject({ method, url: `/v1/admin${url}`, headers, payload }); +const program = (suffix: string, extra: object = {}) => ({ + name: `${marker} ${suffix}`, + network: 'Awin', + cookieDays: 30, + ...extra, +}); + +describe('/v1/admin/affiliate-programs', () => { + it('is for editors and admins, not for a moderator', async () => { + const moderator = await signIn('moderator'); + const response = await send('GET', moderator.headers, '/affiliate-programs'); + expect(response.statusCode).toBe(403); + expect(ApiError.parse(response.json()).code).toBe('forbidden'); + }); + + it('registers a program as an editor, active by default, and journals it', async () => { + const editor = await signIn('editor'); + const response = await send( + 'POST', + editor.headers, + '/affiliate-programs', + program('A', { commissionNote: '5 %' }), + ); + expect(response.statusCode).toBe(201); + const created = AffiliateProgram.parse(response.json()); + expect(created).toMatchObject({ + isActive: true, + commissionNote: '5 %', + placeCount: 0, + cookieDays: 30, + }); + const [entry] = await app.db + .select() + .from(adminActions) + .where(eq(adminActions.targetId, created.id)); + expect(entry).toMatchObject({ + action: 'affiliate_program.create', + targetType: 'affiliate_program', + actorId: editor.id, + }); + }); + + it('lists the programs, with the number of places that name each', async () => { + const admin = await signIn('admin'); + const created = AffiliateProgram.parse( + (await send('POST', admin.headers, '/affiliate-programs', program('B'))).json(), + ); + await app.db + .update(places) + .set({ partnerBookingUrl: 'https://partner.example/lieu', partnerProgramId: created.id }) + .where(eq(places.id, placeId)); + try { + const { items } = AffiliatePrograms.parse( + (await send('GET', admin.headers, '/affiliate-programs')).json(), + ); + expect(items.find((item) => item.id === created.id)).toMatchObject({ placeCount: 1 }); + } finally { + await app.db + .update(places) + .set({ partnerBookingUrl: null, partnerProgramId: null }) + .where(eq(places.id, placeId)); + } + }); + + it('refuses a name already taken, whatever its case', async () => { + const admin = await signIn('admin'); + await send('POST', admin.headers, '/affiliate-programs', program('C')); + const again = await send('POST', admin.headers, '/affiliate-programs', { + ...program('C'), + name: `${marker} c`, + }); + expect(again.statusCode).toBe(409); + expect(ApiError.parse(again.json()).details).toEqual({ reason: 'name_taken' }); + }); + + it('changes only the fields given, and journals the before and after', async () => { + const admin = await signIn('admin'); + const created = AffiliateProgram.parse( + (await send('POST', admin.headers, '/affiliate-programs', program('D'))).json(), + ); + const response = await send('PATCH', admin.headers, `/affiliate-programs/${created.id}`, { + isActive: false, + }); + expect(AffiliateProgram.parse(response.json())).toMatchObject({ + isActive: false, + network: 'Awin', + cookieDays: 30, + }); + const lines = await app.db + .select() + .from(adminActions) + .where(eq(adminActions.targetId, created.id)); + expect(lines.find((line) => line.action === 'affiliate_program.update')).toMatchObject({ + before: { isActive: true }, + after: { isActive: false }, + }); + }); + + it('refuses a cookie out of bounds, an empty change and an unknown program', async () => { + const admin = await signIn('admin'); + expect( + (await send('POST', admin.headers, '/affiliate-programs', program('E', { cookieDays: 999 }))) + .statusCode, + ).toBe(422); + const created = AffiliateProgram.parse( + (await send('POST', admin.headers, '/affiliate-programs', program('F'))).json(), + ); + expect( + (await send('PATCH', admin.headers, `/affiliate-programs/${created.id}`, {})).statusCode, + ).toBe(422); + expect( + (await send('PATCH', admin.headers, `/affiliate-programs/${uuidv7()}`, { isActive: false })) + .statusCode, + ).toBe(404); + }); +}); diff --git a/apps/api/src/routes/admin-affiliation.ts b/apps/api/src/routes/admin-affiliation.ts new file mode 100644 index 00000000..20890494 --- /dev/null +++ b/apps/api/src/routes/admin-affiliation.ts @@ -0,0 +1,77 @@ +import { + AffiliateProgram, + AffiliateProgramInput, + AffiliateProgramPatch, + AffiliatePrograms, +} from '@widoo/shared'; +import type { FastifyRequest } from 'fastify'; +import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'; +import { z } from 'zod'; +import { createProgram, readPrograms, updateProgram } from '../affiliation/programs'; +import { httpError } from '../errors'; + +const security = [{ firebaseIdToken: [] }]; +const tags = ['admin']; +const ProgramParams = 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; +} + +/** + * Affiliation of the admin (A-09, wiki API › Admin): the programs Widoo is registered with, for + * editors and admins. Each write is in the journal. Never cached. + */ +export const adminAffiliationRoutes: FastifyPluginAsyncZod = async (app) => { + app.addHook('onRequest', app.requireAdminSection('affiliation')); + app.addHook('onSend', async (_request, reply) => { + reply.header('cache-control', 'no-store'); + }); + + app.get( + '/admin/affiliate-programs', + { + schema: { + tags, + summary: 'The affiliate programs', + security, + response: { 200: AffiliatePrograms }, + }, + }, + async () => readPrograms(app.db), + ); + + app.post( + '/admin/affiliate-programs', + { + schema: { + tags, + summary: 'Register an affiliate program', + security, + body: AffiliateProgramInput, + response: { 201: AffiliateProgram }, + }, + }, + async (request, reply) => + reply.status(201).send(await createProgram(app.db, actorIdOf(request), request.body)), + ); + + app.patch( + '/admin/affiliate-programs/:id', + { + schema: { + tags, + summary: 'Change an affiliate program', + security, + params: ProgramParams, + body: AffiliateProgramPatch, + response: { 200: AffiliateProgram }, + }, + }, + async (request) => updateProgram(app.db, actorIdOf(request), request.params.id, request.body), + ); +}; From 3d0fb07606f42b305dcfa81f81b6985cd5549676 Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Fri, 16 Oct 2026 17:52:13 +0700 Subject: [PATCH 04/11] feat(api): set the partner link of Widoo on a place, with its program (A-09) PATCH /admin/places/:id takes partnerBookingUrl (https) and partnerProgramId, sent together or not at all: left out, the place keeps its link. A program needs a link, and must exist. The sheet of a place reads both, and the journal keeps them before and after. Co-Authored-By: Claude Sonnet 5.5 --- apps/admin/src/lib/place-form.test.ts | 2 + apps/api/src/catalog/place-writes.ts | 26 ++++++- apps/api/src/catalog/places.ts | 5 ++ .../src/routes/admin-catalog-places.test.ts | 75 ++++++++++++++++++- packages/shared/src/schemas/catalog-place.ts | 52 +++++++++---- 5 files changed, 143 insertions(+), 17 deletions(-) diff --git a/apps/admin/src/lib/place-form.test.ts b/apps/admin/src/lib/place-form.test.ts index c4583dba..4484acce 100644 --- a/apps/admin/src/lib/place-form.test.ts +++ b/apps/admin/src/lib/place-form.test.ts @@ -65,6 +65,8 @@ describe('placeFormOf', () => { priceRange: 'low' as const, websiteUrl: 'https://lomi.example', phone: '+33 1 00 00 00 00', + partnerBookingUrl: null, + partnerProgramId: null, hours: [{ weekday: 2, opens: '09:00', closes: '17:00', validFrom: null, validTo: null }], routes: [], }; diff --git a/apps/api/src/catalog/place-writes.ts b/apps/api/src/catalog/place-writes.ts index 374e1a51..9036aad7 100644 --- a/apps/api/src/catalog/place-writes.ts +++ b/apps/api/src/catalog/place-writes.ts @@ -2,7 +2,7 @@ import type { CatalogPlaceInput } from '@widoo/shared'; import { eq, sql } from 'drizzle-orm'; import { recordAdminAction } from '../admin/actions'; import type { Db } from '../db/client'; -import { placeHours, places } from '../db/schema'; +import { affiliatePrograms, placeHours, places } from '../db/schema'; import { httpError } from '../errors'; type Transaction = Parameters[0]>[0]; @@ -40,8 +40,29 @@ const fieldsOf = (input: CatalogPlaceInput) => ({ priceRange: input.priceRange, websiteUrl: input.websiteUrl, phone: input.phone, + // Left out, the partner link is left as it is (a creation or an import never sends it). + ...(input.partnerBookingUrl === undefined + ? {} + : { + partnerBookingUrl: input.partnerBookingUrl, + partnerProgramId: input.partnerProgramId ?? null, + }), }); +/** 400 when the program a partner link names does not exist. */ +async function assertProgramExists(tx: Transaction, programId: string | null | undefined) { + if (!programId) { + return; + } + const [program] = await tx + .select({ id: affiliatePrograms.id }) + .from(affiliatePrograms) + .where(eq(affiliatePrograms.id, programId)); + if (!program) { + throw httpError(400, 'Unknown affiliate program'); + } +} + async function replaceHours(tx: Transaction, placeId: string, hours: CatalogPlaceInput['hours']) { await tx.delete(placeHours).where(eq(placeHours.placeId, placeId)); if (hours.length > 0) { @@ -98,6 +119,7 @@ export async function updateCatalogPlace( } const hoursBefore = await tx.select().from(placeHours).where(eq(placeHours.placeId, placeId)); const cityId = await cityOf(tx, input.location); + await assertProgramExists(tx, input.partnerProgramId); await tx .update(places) .set({ ...fieldsOf(input), cityId }) @@ -117,6 +139,8 @@ export async function updateCatalogPlace( priceRange: before.priceRange, websiteUrl: before.websiteUrl, phone: before.phone, + partnerBookingUrl: before.partnerBookingUrl, + partnerProgramId: before.partnerProgramId, hours: hoursBefore.map(({ weekday, opens, closes, validFrom, validTo }) => ({ weekday, opens, diff --git a/apps/api/src/catalog/places.ts b/apps/api/src/catalog/places.ts index 412df66a..50dd949b 100644 --- a/apps/api/src/catalog/places.ts +++ b/apps/api/src/catalog/places.ts @@ -28,6 +28,8 @@ type DetailRow = PlaceRow & { price_range: CatalogPlaceDetail['priceRange']; website_url: string | null; phone: string | null; + partner_booking_url: string | null; + partner_program_id: string | null; }; type HoursRow = { @@ -113,6 +115,7 @@ export async function readCatalogPlace( ): Promise { const [place] = await db.execute(sql` select ${columns}, p.address, p.is_indoor, p.price_range, p.website_url, p.phone, + p.partner_booking_url, p.partner_program_id, ST_Y(p.location::geometry) as lat, ST_X(p.location::geometry) as lng ${from} where p.id = ${placeId}`); @@ -137,6 +140,8 @@ export async function readCatalogPlace( priceRange: place.price_range, websiteUrl: place.website_url, phone: place.phone, + partnerBookingUrl: place.partner_booking_url, + partnerProgramId: place.partner_program_id, hours: hours.map((slot) => ({ weekday: slot.weekday, opens: hourAndMinute(slot.opens), diff --git a/apps/api/src/routes/admin-catalog-places.test.ts b/apps/api/src/routes/admin-catalog-places.test.ts index d48cf886..71666630 100644 --- a/apps/api/src/routes/admin-catalog-places.test.ts +++ b/apps/api/src/routes/admin-catalog-places.test.ts @@ -8,7 +8,7 @@ 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, placeHours, places, users } from '../db/schema'; +import { adminActions, affiliatePrograms, placeHours, places, users } from '../db/schema'; import { seed } from '../db/seed'; import { uuidv7 } from '../db/uuid'; import { testConfig } from '../test-config'; @@ -143,6 +143,79 @@ describe('PATCH /v1/admin/places/:id', () => { expect(updateEntry?.after).toMatchObject({ category: 'restaurant', phone: null }); }); + it('sets the partner link of Widoo with its program, and leaves it alone when not sent', async () => { + const editor = await signIn('editor'); + const created = CatalogPlaceDetail.parse( + (await create(editor.headers, sheet('Partenaire'))).json(), + ); + expect(created).toMatchObject({ partnerBookingUrl: null, partnerProgramId: null }); + const [program] = await app.db + .insert(affiliatePrograms) + .values({ name: `${marker} programme`, network: 'Awin', cookieDays: 30 }) + .returning({ id: affiliatePrograms.id }); + if (!program) throw new Error('Program not created'); + try { + const withLink = sheet('Partenaire', { + partnerBookingUrl: 'https://partner.example/reserver', + partnerProgramId: program.id, + }); + const linked = CatalogPlaceDetail.parse( + (await update(editor.headers, created.id, withLink)).json(), + ); + expect(linked).toMatchObject({ + partnerBookingUrl: 'https://partner.example/reserver', + partnerProgramId: program.id, + }); + // A sheet without the two fields keeps them. + const kept = CatalogPlaceDetail.parse( + (await update(editor.headers, created.id, sheet('Partenaire', { phone: null }))).json(), + ); + expect(kept).toMatchObject({ partnerBookingUrl: 'https://partner.example/reserver' }); + const journal = await app.db + .select() + .from(adminActions) + .where(eq(adminActions.targetId, created.id)); + const setLink = journal.find( + (entry) => + (entry.after as Record | null)?.partnerBookingUrl === + 'https://partner.example/reserver', + ); + expect(setLink?.before).toMatchObject({ partnerBookingUrl: null, partnerProgramId: null }); + // Both null removes the link. + const removed = CatalogPlaceDetail.parse( + ( + await update(editor.headers, created.id, { + ...withLink, + partnerBookingUrl: null, + partnerProgramId: null, + }) + ).json(), + ); + expect(removed).toMatchObject({ partnerBookingUrl: null, partnerProgramId: null }); + } finally { + await app.db.update(places).set({ partnerProgramId: null }).where(eq(places.id, created.id)); + await app.db.delete(affiliatePrograms).where(eq(affiliatePrograms.id, program.id)); + } + }); + + it('refuses a program without a link, a link without its program field, a link that is not https and an unknown program', async () => { + const editor = await signIn('editor'); + const created = CatalogPlaceDetail.parse((await create(editor.headers, sheet('Refus'))).json()); + const bodies = [ + sheet('Refus', { partnerBookingUrl: null, partnerProgramId: uuidv7() }), + sheet('Refus', { partnerBookingUrl: 'https://partner.example' }), + sheet('Refus', { partnerBookingUrl: 'http://partner.example', partnerProgramId: null }), + ]; + for (const body of bodies) { + expect((await update(editor.headers, created.id, body)).statusCode).toBe(422); + } + const unknown = sheet('Refus', { + partnerBookingUrl: 'https://partner.example', + partnerProgramId: uuidv7(), + }); + expect((await update(editor.headers, created.id, unknown)).statusCode).toBe(400); + }); + it('answers 404 for a place that does not exist', async () => { const admin = await signIn('admin'); expect((await update(admin.headers, uuidv7(), sheet('Absent'))).statusCode).toBe(404); diff --git a/packages/shared/src/schemas/catalog-place.ts b/packages/shared/src/schemas/catalog-place.ts index 84df1304..5007a0d9 100644 --- a/packages/shared/src/schemas/catalog-place.ts +++ b/packages/shared/src/schemas/catalog-place.ts @@ -47,21 +47,40 @@ export type CatalogPlaces = z.infer; * sent, and its opening hours replace the previous ones. The city is never sent: it follows * from the position. The verification status has its own actions (A-03). */ -export const CatalogPlaceInput = z.strictObject({ - name: plainText(120), - category: z.enum(taxonomies.placeCategories), - address: plainText(240), - location: LatLng, - isIndoor: z.boolean(), - priceRange: z.enum(taxonomies.priceRanges).nullable(), - websiteUrl: HttpsUrl.nullable(), - phone: z - .string() - .trim() - .regex(/^\+?[0-9 .()-]{6,24}$/, 'Expected a phone number') - .nullable(), - hours: z.array(PlaceHours).max(60), -}); +export const CatalogPlaceInput = z + .strictObject({ + name: plainText(120), + category: z.enum(taxonomies.placeCategories), + address: plainText(240), + location: LatLng, + isIndoor: z.boolean(), + priceRange: z.enum(taxonomies.priceRanges).nullable(), + websiteUrl: HttpsUrl.nullable(), + phone: z + .string() + .trim() + .regex(/^\+?[0-9 .()-]{6,24}$/, 'Expected a phone number') + .nullable(), + hours: z.array(PlaceHours).max(60), + /** + * The booking link of a partner program of Widoo (A-09, D-048) and the program it belongs to. + * Both are sent together, or none: left out, the place keeps the link it has. Without a link + * there is no program. + */ + partnerBookingUrl: HttpsUrl.nullable().optional(), + partnerProgramId: z.uuid().nullable().optional(), + }) + .refine( + (place) => (place.partnerBookingUrl === undefined) === (place.partnerProgramId === undefined), + { + message: 'Send the partner link and its program together', + path: ['partnerProgramId'], + }, + ) + .refine((place) => place.partnerBookingUrl !== null || !place.partnerProgramId, { + message: 'A program needs a partner link', + path: ['partnerProgramId'], + }); export type CatalogPlaceInput = z.infer; /** Body of `GET /admin/places/:id`: the sheet of a place, and the routes that use it. */ @@ -72,6 +91,9 @@ export const CatalogPlaceDetail = CatalogPlace.extend({ priceRange: z.enum(taxonomies.priceRanges).nullable(), websiteUrl: z.string().nullable(), phone: z.string().nullable(), + /** The partner link of Widoo and its program (A-09). */ + partnerBookingUrl: z.string().nullable(), + partnerProgramId: z.uuid().nullable(), hours: z.array(PlaceHours), routes: z.array( z.object({ id: z.uuid(), title: z.string(), status: z.enum(taxonomies.routeStatuses) }), From 35b8b5ff01f274c129d4ef3dd8e0f5c005bb0f05 Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Fri, 16 Oct 2026 17:52:16 +0700 Subject: [PATCH 05/11] =?UTF-8?q?feat(api):=20count=20a=20click=20on=20?= =?UTF-8?q?=C2=AB=20R=C3=A9server=20=C2=BB=20and=20redirect=20to=20the=20l?= =?UTF-8?q?ink=20of=20the=20step=20(A-09)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /v1/go/:stepId, public: counts the click in booking_clicks, then redirects (302) to the link the step shows, the creator's before the partner's of Widoo. It only redirects to a link stored for a step of a published route (https), never to an address the caller gives; anything else is a 404 that counts nothing. Limited to 60 a minute and per address, never cached nor indexed. Co-Authored-By: Claude Sonnet 5.5 --- apps/api/src/affiliation/go.ts | 48 ++++++++++ apps/api/src/affiliation/test-fixtures.ts | 104 ++++++++++++++++++++++ apps/api/src/app.ts | 2 + apps/api/src/routes/go.test.ts | 97 ++++++++++++++++++++ apps/api/src/routes/go.ts | 44 +++++++++ 5 files changed, 295 insertions(+) create mode 100644 apps/api/src/affiliation/go.ts create mode 100644 apps/api/src/affiliation/test-fixtures.ts create mode 100644 apps/api/src/routes/go.test.ts create mode 100644 apps/api/src/routes/go.ts diff --git a/apps/api/src/affiliation/go.ts b/apps/api/src/affiliation/go.ts new file mode 100644 index 00000000..982d3357 --- /dev/null +++ b/apps/api/src/affiliation/go.ts @@ -0,0 +1,48 @@ +import { resolveBookingLink, type BookingLink } from '@widoo/shared'; +import { sql } from 'drizzle-orm'; +import type { Db } from '../db/client'; +import { bookingClicks } from '../db/schema'; + +type StepLinkRow = { + booking_url: string | null; + booking_is_affiliate: boolean; + partner_booking_url: string | null; + program_is_active: boolean | null; +}; + +/** + * The link « Réserver » opens for a step of a published route, with the rule of + * `resolveBookingLink` (creator first). Null for an unknown step, a route that is not online, a + * step without a link, or a link that is not https: nothing is ever redirected to anything else. + */ +export async function bookingLinkOfStep(db: Db, stepId: string): Promise { + const [row] = await db.execute(sql` + select s.booking_url, s.booking_is_affiliate, p.partner_booking_url, + ap.is_active as program_is_active + from steps s + join routes r on r.id = s.route_id + join places p on p.id = s.place_id + left join affiliate_programs ap on ap.id = p.partner_program_id + where s.id = ${stepId} and r.status = 'published'`); + if (!row) { + return null; + } + const link = resolveBookingLink( + { bookingUrl: row.booking_url, bookingIsAffiliate: row.booking_is_affiliate }, + { partnerBookingUrl: row.partner_booking_url, partnerProgramIsActive: row.program_is_active }, + ); + return link && link.url.startsWith('https://') ? link : null; +} + +/** + * Counts a click on « Réserver » and answers the link to open (A-09), or null when the step has + * none. The click is written before the answer, so the one that is counted is the one that is + * served. No account is recorded: the redirect opens in the browser, without a session. + */ +export async function countBookingClick(db: Db, stepId: string): Promise { + const link = await bookingLinkOfStep(db, stepId); + if (link) { + await db.insert(bookingClicks).values({ stepId, linkOrigin: link.origin }); + } + return link; +} diff --git a/apps/api/src/affiliation/test-fixtures.ts b/apps/api/src/affiliation/test-fixtures.ts new file mode 100644 index 00000000..e12f167b --- /dev/null +++ b/apps/api/src/affiliation/test-fixtures.ts @@ -0,0 +1,104 @@ +import { eq, inArray } from 'drizzle-orm'; +import type { Db } from '../db/client'; +import { affiliatePrograms, bookingClicks, places, routes, steps } from '../db/schema'; +import { seedId } from '../db/seed'; +import { uuidv7 } from '../db/uuid'; + +const templateRouteId = seedId('route:marais-gourmand'); +const farAway = { lat: 33, lng: 33 }; +const farAwayBounds = { west: 33, south: 33, east: 33.001, north: 33.001 }; + +export type StepSetup = { + creatorUrl?: string; + isAffiliate?: boolean; + bookingRequired?: boolean; + partnerUrl?: string; + /** Gives the partner link a program of its own, active or not. */ + programIsActive?: boolean; + routeStatus?: 'published' | 'draft'; +}; + +/** + * Steps for the tests of the booking links: each one on a route and a place of its own, copied + * from the seed far from every zone the other tests look at. The database is shared by the test + * files that run in parallel, so everything created here is deleted by `cleanUp`. + */ +export function createStepFixtures(db: Db) { + const placeIds: string[] = []; + const routeIds: string[] = []; + const programIds: string[] = []; + const stepIds: string[] = []; + + return { + async addStep(setup: StepSetup = {}): Promise { + const [routeTemplate] = await db.select().from(routes).where(eq(routes.id, templateRouteId)); + const [stepTemplate] = await db + .select() + .from(steps) + .where(eq(steps.routeId, templateRouteId)) + .limit(1); + if (!routeTemplate || !stepTemplate) throw new Error('Demo route missing from the seed'); + const [placeTemplate] = await db + .select() + .from(places) + .where(eq(places.id, stepTemplate.placeId)); + if (!placeTemplate) throw new Error('Demo place missing from the seed'); + + let programId: string | null = null; + if (setup.partnerUrl && setup.programIsActive !== undefined) { + programId = uuidv7(); + programIds.push(programId); + await db.insert(affiliatePrograms).values({ + id: programId, + name: `fixture-${programId}`, + network: 'Awin', + cookieDays: 30, + isActive: setup.programIsActive, + }); + } + const placeId = uuidv7(); + placeIds.push(placeId); + await db.insert(places).values({ + ...placeTemplate, + id: placeId, + name: `Lieu ${placeId}`, + location: farAway, + partnerBookingUrl: setup.partnerUrl ?? null, + partnerProgramId: programId, + }); + const routeId = uuidv7(); + routeIds.push(routeId); + await db.insert(routes).values({ + ...routeTemplate, + id: routeId, + authorId: null, + shareToken: null, + title: `Parcours ${routeId}`, + startLocation: farAway, + bounds: farAwayBounds, + status: setup.routeStatus ?? 'published', + }); + const stepId = uuidv7(); + stepIds.push(stepId); + await db.insert(steps).values({ + ...stepTemplate, + id: stepId, + routeId, + placeId, + bookingRequired: setup.bookingRequired ?? false, + bookingUrl: setup.creatorUrl ?? null, + bookingProvider: setup.creatorUrl ? 'restaurant' : null, + bookingIsAffiliate: setup.isAffiliate ?? false, + }); + return stepId; + }, + + async cleanUp(): Promise { + // Clicks hold no foreign key to their step: they are deleted by step. + await db.delete(bookingClicks).where(inArray(bookingClicks.stepId, stepIds)); + await db.delete(routes).where(inArray(routes.id, routeIds)); + await db.delete(places).where(inArray(places.id, placeIds)); + await db.delete(affiliatePrograms).where(inArray(affiliatePrograms.id, programIds)); + }, + }; +} diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 873c54cc..9dfd3f56 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -24,6 +24,7 @@ import { adminActionsRoutes } from './routes/admin-actions'; import { adminAffiliationRoutes } from './routes/admin-affiliation'; import { adminSettingsRoutes } from './routes/admin-settings'; import { adminUsersRoutes } from './routes/admin-users'; +import { goRoutes } from './routes/go'; import { configRoutes } from './routes/config'; import { editorRoutes } from './routes/editor'; import { geocodeRoutes } from './routes/geocode'; @@ -135,6 +136,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(goRoutes, { prefix: '/v1' }); await app.register(searchRoutesPlugin, { prefix: '/v1' }); await app.register(usersRoutes, { prefix: '/v1' }); await app.register(editorRoutes, { prefix: '/v1' }); diff --git a/apps/api/src/routes/go.test.ts b/apps/api/src/routes/go.test.ts new file mode 100644 index 00000000..9012ae06 --- /dev/null +++ b/apps/api/src/routes/go.test.ts @@ -0,0 +1,97 @@ +import { eq } from 'drizzle-orm'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { createStepFixtures } from '../affiliation/test-fixtures'; +import { buildApp } from '../app'; +import { bookingClicks } from '../db/schema'; +import { seed } from '../db/seed'; +import { uuidv7 } from '../db/uuid'; +import { testConfig } from '../test-config'; + +let app: Awaited>; +let fixtures: ReturnType; +beforeAll(async () => { + app = await buildApp(testConfig()); + await seed(app.db); + fixtures = createStepFixtures(app.db); +}); +afterAll(async () => { + await fixtures.cleanUp(); + await app.close(); +}); + +const go = (stepId: string) => app.inject({ url: `/v1/go/${stepId}` }); +const clicksOf = (stepId: string) => + app.db.select().from(bookingClicks).where(eq(bookingClicks.stepId, stepId)); + +describe('GET /v1/go/:stepId', () => { + it('counts the click then redirects to the link of the creator', async () => { + const stepId = await fixtures.addStep({ creatorUrl: 'https://creator.example/reserver' }); + const response = await go(stepId); + expect(response.statusCode).toBe(302); + expect(response.headers.location).toBe('https://creator.example/reserver'); + expect(response.headers['cache-control']).toBe('no-store'); + expect(await clicksOf(stepId)).toMatchObject([{ linkOrigin: 'creator', userId: null }]); + }); + + it('says when the link of the creator is an affiliate link', async () => { + const stepId = await fixtures.addStep({ + creatorUrl: 'https://creator.example/aff', + isAffiliate: true, + }); + await go(stepId); + expect(await clicksOf(stepId)).toMatchObject([{ linkOrigin: 'creator_affiliate' }]); + }); + + it('puts the link of the creator before the partner link of Widoo', async () => { + const stepId = await fixtures.addStep({ + creatorUrl: 'https://creator.example/reserver', + partnerUrl: 'https://partner.example/reserver', + programIsActive: true, + }); + const response = await go(stepId); + expect(response.headers.location).toBe('https://creator.example/reserver'); + expect(await clicksOf(stepId)).toMatchObject([{ linkOrigin: 'creator' }]); + }); + + it('falls back to the partner link when the creator set none, unless its program is inactive', async () => { + const active = await fixtures.addStep({ + partnerUrl: 'https://partner.example/ok', + programIsActive: true, + }); + const response = await go(active); + expect(response.headers.location).toBe('https://partner.example/ok'); + expect(await clicksOf(active)).toMatchObject([{ linkOrigin: 'partner' }]); + + const inactive = await fixtures.addStep({ + partnerUrl: 'https://partner.example/off', + programIsActive: false, + }); + expect((await go(inactive)).statusCode).toBe(404); + expect(await clicksOf(inactive)).toHaveLength(0); + }); + + it('answers 404, counts nothing and redirects nowhere for a step without link, off line or unknown', async () => { + const noLink = await fixtures.addStep({}); + const draft = await fixtures.addStep({ + creatorUrl: 'https://creator.example', + routeStatus: 'draft', + }); + for (const stepId of [noLink, draft, uuidv7()]) { + const response = await go(stepId); + expect(response.statusCode).toBe(404); + expect(response.headers.location).toBeUndefined(); + expect(await clicksOf(stepId)).toHaveLength(0); + } + expect((await app.inject({ url: '/v1/go/not-an-id' })).statusCode).toBe(400); + }); + + it('counts and redirects in less than 200 ms', async () => { + const stepId = await fixtures.addStep({ creatorUrl: 'https://creator.example/vite' }); + await go(stepId); + const startedAt = performance.now(); + const response = await go(stepId); + expect(response.statusCode).toBe(302); + expect(performance.now() - startedAt).toBeLessThan(200); + expect(await clicksOf(stepId)).toHaveLength(2); + }); +}); diff --git a/apps/api/src/routes/go.ts b/apps/api/src/routes/go.ts new file mode 100644 index 00000000..f806b204 --- /dev/null +++ b/apps/api/src/routes/go.ts @@ -0,0 +1,44 @@ +import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'; +import { z } from 'zod'; +import { countBookingClick } from '../affiliation/go'; +import { httpError } from '../errors'; + +/** + * Redirects per minute and per address. A click is one write: this keeps a script from filling + * `booking_clicks`, and is far above what a person taps. + */ +export const GO_RATE_LIMIT = 60; + +const GoParams = z.object({ stepId: z.uuid() }); + +/** + * `GET /go/:stepId` (A-09, D-048): public, counts the click on « Réserver » of a step and + * redirects (302) to the link it shows, the creator's before the partner's of Widoo. It only ever + * redirects to a link stored for the step: the caller gives no address. Never cached, never + * indexed. + */ +export const goRoutes: FastifyPluginAsyncZod = async (app) => { + app.get( + '/go/:stepId', + { + config: { + rateLimit: { max: GO_RATE_LIMIT, timeWindow: '1 minute', hook: 'preValidation' }, + }, + schema: { + tags: ['booking'], + summary: 'Count a click on « Réserver » and redirect to the booking link of the step', + params: GoParams, + }, + }, + async (request, reply) => { + const link = await countBookingClick(app.db, request.params.stepId); + if (!link) { + throw httpError(404, 'No booking link for this step'); + } + reply.header('cache-control', 'no-store'); + reply.header('x-robots-tag', 'noindex'); + reply.header('referrer-policy', 'no-referrer'); + return reply.redirect(link.url, 302); + }, + ); +}; From c2e357a542699a4ad5d26b4e671fb2323f34bae6 Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Fri, 16 Oct 2026 17:52:16 +0700 Subject: [PATCH 06/11] feat(api): list the steps that can be booked, with the link shown and the clicks of 30 days (A-09) GET /admin/bookable-steps, for editors and admins: the steps of published routes that ask for a booking or carry a link, most clicked first, each with the link it shows and where it comes from (creator, creator affiliate, partner of Widoo). withoutPartnerLink keeps the places with no partner link: the ones to sign up for first. Co-Authored-By: Claude Sonnet 5.5 --- apps/api/src/affiliation/bookable-steps.ts | 87 ++++++++++++++++ apps/api/src/routes/admin-affiliation.test.ts | 99 ++++++++++++++++++- apps/api/src/routes/admin-affiliation.ts | 17 ++++ 3 files changed, 201 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/affiliation/bookable-steps.ts diff --git a/apps/api/src/affiliation/bookable-steps.ts b/apps/api/src/affiliation/bookable-steps.ts new file mode 100644 index 00000000..d6698ce8 --- /dev/null +++ b/apps/api/src/affiliation/bookable-steps.ts @@ -0,0 +1,87 @@ +import { + adminBookableStepsPageSize, + resolveBookingLink, + type BookableSteps, + type BookableStepsQuery, +} from '@widoo/shared'; +import { sql } from 'drizzle-orm'; +import { neighbourCursors, offsetOfCursor } from '../admin/page-cursor'; +import type { Db } from '../db/client'; + +type Row = { + step_id: string; + position: number; + route_id: string; + route_title: string; + place_id: string; + place_name: string; + booking_url: string | null; + booking_is_affiliate: boolean; + partner_booking_url: string | null; + program_is_active: boolean | null; + clicks_30_days: number; +}; + +/** + * What makes a step bookable: its creator asks for a booking, or it carries a link, or its place + * has a partner link of Widoo. Only the steps of published routes count. + */ +const bookableSteps = sql`from steps s + join routes r on r.id = s.route_id and r.status = 'published' + join places p on p.id = s.place_id + left join affiliate_programs ap on ap.id = p.partner_program_id + where (s.booking_required or s.booking_url is not null or p.partner_booking_url is not null)`; + +/** + * A page of the bookable steps (A-09), the most clicked over the last 30 days first, with the link + * each one shows and where it comes from. `withoutPartnerLink` keeps the places with no partner + * link of Widoo: the ones to sign up for first. + */ +export async function readBookableSteps( + db: Db, + query: BookableStepsQuery, + now = new Date(), +): Promise { + const offset = offsetOfCursor(query.cursor); + const since = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + const where = query.withoutPartnerLink + ? sql`${bookableSteps} and p.partner_booking_url is null` + : bookableSteps; + + const rows = await db.execute(sql` + select s.id as step_id, s.position, r.id as route_id, r.title as route_title, + p.id as place_id, p.name as place_name, s.booking_url, s.booking_is_affiliate, + p.partner_booking_url, ap.is_active as program_is_active, + (select count(*)::int from booking_clicks c + where c.step_id = s.id and c.created_at >= ${since.toISOString()}::timestamptz) as clicks_30_days + ${where} + order by clicks_30_days desc, r.title, s.position, s.id + limit ${adminBookableStepsPageSize} offset ${offset}`); + const [counted] = await db.execute<{ total: number }>( + sql`select count(*)::int as total ${where}`, + ); + const total = counted?.total ?? 0; + return { + items: rows.map((row) => ({ + stepId: row.step_id, + position: row.position, + route: { id: row.route_id, title: row.route_title }, + place: { + id: row.place_id, + name: row.place_name, + hasPartnerLink: row.partner_booking_url !== null, + }, + link: resolveBookingLink( + { bookingUrl: row.booking_url, bookingIsAffiliate: row.booking_is_affiliate }, + { + partnerBookingUrl: row.partner_booking_url, + partnerProgramIsActive: row.program_is_active, + }, + ), + clicks30Days: row.clicks_30_days, + })), + total, + offset, + ...neighbourCursors(offset, total, adminBookableStepsPageSize), + }; +} diff --git a/apps/api/src/routes/admin-affiliation.test.ts b/apps/api/src/routes/admin-affiliation.test.ts index 1d65f4f0..ea13dbcc 100644 --- a/apps/api/src/routes/admin-affiliation.test.ts +++ b/apps/api/src/routes/admin-affiliation.test.ts @@ -1,9 +1,17 @@ -import { AffiliateProgram, AffiliatePrograms, ApiError, type UserRole } from '@widoo/shared'; +import { + AffiliateProgram, + AffiliatePrograms, + ApiError, + BookableSteps, + type BookableStep, + type UserRole, +} from '@widoo/shared'; import { eq, inArray, like } from 'drizzle-orm'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { createStepFixtures } from '../affiliation/test-fixtures'; import { buildApp } from '../app'; import { TestTokenVerifier } from '../auth/test-verifier'; -import { adminActions, affiliatePrograms, places, users } from '../db/schema'; +import { adminActions, affiliatePrograms, bookingClicks, places, users } from '../db/schema'; import { seed } from '../db/seed'; import { uuidv7 } from '../db/uuid'; import { testConfig } from '../test-config'; @@ -16,14 +24,17 @@ const marker = `affitest${uuidv7().slice(-10)}`; let app: Awaited>; let placeId: string; +let fixtures: ReturnType; beforeAll(async () => { app = await buildApp(testConfig(), { tokenVerifier: verifier }); await seed(app.db); const [place] = await app.db.select({ id: places.id }).from(places).limit(1); if (!place) throw new Error('No place in the seed'); placeId = place.id; + fixtures = createStepFixtures(app.db); }); afterAll(async () => { + await fixtures.cleanUp(); const ids = await app.db .select({ id: affiliatePrograms.id }) .from(affiliatePrograms) @@ -174,3 +185,87 @@ describe('/v1/admin/affiliate-programs', () => { ).toBe(404); }); }); + +/** Every page of the bookable steps the query gives, followed by its cursors. */ +async function bookableSteps(headers: Record, query = ''): Promise { + const items: BookableStep[] = []; + let cursor: string | null = null; + do { + const url: string = `/bookable-steps?${query}${cursor ? `&cursor=${cursor}` : ''}`; + const page = BookableSteps.parse((await send('GET', headers, url)).json()); + items.push(...page.items); + cursor = page.nextCursor; + } while (cursor); + return items; +} + +describe('GET /v1/admin/bookable-steps', () => { + it('is for editors and admins, not for a moderator', async () => { + const moderator = await signIn('moderator'); + expect((await send('GET', moderator.headers, '/bookable-steps')).statusCode).toBe(403); + }); + + it('lists the steps that can be booked, with the link shown and where it comes from', async () => { + const editor = await signIn('editor'); + const creator = await fixtures.addStep({ creatorUrl: 'https://creator.example/a' }); + const affiliate = await fixtures.addStep({ + creatorUrl: 'https://creator.example/b', + isAffiliate: true, + }); + const partner = await fixtures.addStep({ + partnerUrl: 'https://partner.example/c', + programIsActive: true, + }); + const required = await fixtures.addStep({ bookingRequired: true }); + const free = await fixtures.addStep({}); + const draft = await fixtures.addStep({ + creatorUrl: 'https://creator.example/d', + routeStatus: 'draft', + }); + + const items = await bookableSteps(editor.headers); + const byStep = new Map(items.map((item) => [item.stepId, item])); + expect(byStep.get(creator)?.link).toEqual({ + url: 'https://creator.example/a', + origin: 'creator', + }); + expect(byStep.get(affiliate)?.link?.origin).toBe('creator_affiliate'); + expect(byStep.get(partner)).toMatchObject({ + link: { url: 'https://partner.example/c', origin: 'partner' }, + place: { hasPartnerLink: true }, + }); + expect(byStep.get(required)).toMatchObject({ link: null, place: { hasPartnerLink: false } }); + expect(byStep.has(free)).toBe(false); + expect(byStep.has(draft)).toBe(false); + }); + + it('keeps the places without a partner link when asked', async () => { + const admin = await signIn('admin'); + const withoutLink = await fixtures.addStep({ creatorUrl: 'https://creator.example/e' }); + const withLink = await fixtures.addStep({ partnerUrl: 'https://partner.example/f' }); + const ids = (await bookableSteps(admin.headers, 'withoutPartnerLink=true')).map( + (item) => item.stepId, + ); + expect(ids).toContain(withoutLink); + expect(ids).not.toContain(withLink); + }); + + it('counts the clicks of the last 30 days, and puts the most clicked first', async () => { + const admin = await signIn('admin'); + const popular = await fixtures.addStep({ creatorUrl: 'https://creator.example/g' }); + const old = await fixtures.addStep({ creatorUrl: 'https://creator.example/h' }); + const now = Date.now(); + const day = 24 * 60 * 60 * 1000; + await app.db.insert(bookingClicks).values([ + { stepId: popular, linkOrigin: 'creator', createdAt: new Date(now - day) }, + { stepId: popular, linkOrigin: 'creator', createdAt: new Date(now - 29 * day) }, + { stepId: old, linkOrigin: 'creator', createdAt: new Date(now - 31 * day) }, + ]); + const items = await bookableSteps(admin.headers); + expect(items.find((item) => item.stepId === popular)?.clicks30Days).toBe(2); + expect(items.find((item) => item.stepId === old)?.clicks30Days).toBe(0); + expect(items.findIndex((item) => item.stepId === popular)).toBeLessThan( + items.findIndex((item) => item.stepId === old), + ); + }); +}); diff --git a/apps/api/src/routes/admin-affiliation.ts b/apps/api/src/routes/admin-affiliation.ts index 20890494..b25bb653 100644 --- a/apps/api/src/routes/admin-affiliation.ts +++ b/apps/api/src/routes/admin-affiliation.ts @@ -3,10 +3,13 @@ import { AffiliateProgramInput, AffiliateProgramPatch, AffiliatePrograms, + BookableSteps, + BookableStepsQuery, } from '@widoo/shared'; import type { FastifyRequest } from 'fastify'; import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'; import { z } from 'zod'; +import { readBookableSteps } from '../affiliation/bookable-steps'; import { createProgram, readPrograms, updateProgram } from '../affiliation/programs'; import { httpError } from '../errors'; @@ -74,4 +77,18 @@ export const adminAffiliationRoutes: FastifyPluginAsyncZod = async (app) => { }, async (request) => updateProgram(app.db, actorIdOf(request), request.params.id, request.body), ); + + app.get( + '/admin/bookable-steps', + { + schema: { + tags, + summary: 'The steps that can be booked, with the link shown and the clicks of 30 days', + security, + querystring: BookableStepsQuery, + response: { 200: BookableSteps }, + }, + }, + async (request) => readBookableSteps(app.db, request.query), + ); }; From 1655c64bf07b43cb7d972b7809d00747467e59bf Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Fri, 16 Oct 2026 17:52:16 +0700 Subject: [PATCH 07/11] feat(api-client): call the affiliation of the admin (A-09) Co-Authored-By: Claude Sonnet 5.5 --- packages/api-client/src/affiliation.test.ts | 14 ++++++ packages/api-client/src/affiliation.ts | 13 ++++++ packages/api-client/src/client.ts | 47 +++++++++++++++++++++ 3 files changed, 74 insertions(+) create mode 100644 packages/api-client/src/affiliation.test.ts create mode 100644 packages/api-client/src/affiliation.ts diff --git a/packages/api-client/src/affiliation.test.ts b/packages/api-client/src/affiliation.test.ts new file mode 100644 index 00000000..71edbcd2 --- /dev/null +++ b/packages/api-client/src/affiliation.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest'; +import { bookableStepsQueryString } from './affiliation'; + +describe('bookableStepsQueryString', () => { + it('keeps the filter and the page that are set', () => { + expect(bookableStepsQueryString({ withoutPartnerLink: 'true', cursor: 'abc' })).toBe( + 'withoutPartnerLink=true&cursor=abc', + ); + }); + + it('leaves out what is empty', () => { + expect(bookableStepsQueryString({ cursor: '' })).toBe(''); + }); +}); diff --git a/packages/api-client/src/affiliation.ts b/packages/api-client/src/affiliation.ts new file mode 100644 index 00000000..fd1232ee --- /dev/null +++ b/packages/api-client/src/affiliation.ts @@ -0,0 +1,13 @@ +import type { BookableStepsQuery } from '@widoo/shared'; + +/** Query string of the bookable steps, as the API reads it. */ +export function bookableStepsQueryString(params: Partial): string { + const query = new URLSearchParams(); + for (const key of ['withoutPartnerLink', 'cursor'] as const) { + const value = params[key]; + if (value !== undefined && value !== '') { + query.set(key, value); + } + } + return query.toString(); +} diff --git a/packages/api-client/src/client.ts b/packages/api-client/src/client.ts index ef9807ef..d483819a 100644 --- a/packages/api-client/src/client.ts +++ b/packages/api-client/src/client.ts @@ -1,4 +1,7 @@ import { + AffiliateProgram, + AffiliatePrograms, + BookableSteps, AdminActionFilters, AdminActions, AdminRatings, @@ -48,6 +51,9 @@ import { type AdminRatingsQuery, type AdminReportsQuery, type AdminActionsQuery, + type AffiliateProgramInput, + type AffiliateProgramPatch, + type BookableStepsQuery, type AdminSignalsQuery, type UpdateSettingBody, type AdminUsersQuery, @@ -74,6 +80,7 @@ import { createTransport, type ApiClientOptions } from './transport'; import { moderationQueryString, type ModerationQueueParams } from './moderation'; import { catalogPlacesQueryString, catalogQueryString } from './catalog'; import { actionsQueryString } from './actions'; +import { bookableStepsQueryString } from './affiliation'; import { usersQueryString } from './users'; import { ratingsQueryString, reportsQueryString, signalsQueryString } from './reports'; @@ -605,6 +612,46 @@ export function createApiClient(options: ApiClientOptions) { signal, }), + /** `GET /v1/admin/affiliate-programs`: the programs of affiliation (A-09). */ + getAffiliatePrograms: (signal?: AbortSignal) => + request({ + method: 'GET', + path: '/v1/admin/affiliate-programs', + schema: AffiliatePrograms, + auth: true, + signal, + }), + + /** `POST /v1/admin/affiliate-programs`: registers a program. */ + createAffiliateProgram: (program: AffiliateProgramInput) => + request({ + method: 'POST', + path: '/v1/admin/affiliate-programs', + body: program, + schema: AffiliateProgram, + auth: true, + }), + + /** `PATCH /v1/admin/affiliate-programs/:id`: changes the fields given of a program. */ + updateAffiliateProgram: (programId: string, patch: AffiliateProgramPatch) => + request({ + method: 'PATCH', + path: `/v1/admin/affiliate-programs/${programId}`, + body: patch, + schema: AffiliateProgram, + 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({ + method: 'GET', + path: `/v1/admin/bookable-steps?${bookableStepsQueryString(params)}`, + schema: BookableSteps, + auth: true, + signal, + }), + /** `GET /v1/admin/ratings`: the ratings with a comment, filtered by note and route (A-03). */ getAdminRatings: (params: Partial, signal?: AbortSignal) => request({ From b8e91ab4bb9942644201a186fcd93c8b26f43a26 Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Fri, 16 Oct 2026 17:52:53 +0700 Subject: [PATCH 08/11] feat(admin): list the affiliate programs and open one in a panel (A-09) Affiliation, for editors and admins: the programs as a table (network, commission, cookie, places that name it, status) and the detail of one next to it, with the warning that an inactive program no longer lends its links. Co-Authored-By: Claude Sonnet 5.5 --- .../src/app/(shell)/affiliation/page.tsx | 7 ++ .../(shell)/affiliation/programmes/page.tsx | 15 ++++ .../affiliation/AffiliationTabs.tsx | 25 ++++++ .../affiliation/ProgramPanelContent.tsx | 44 +++++++++++ .../components/affiliation/ProgramsTable.tsx | 61 +++++++++++++++ .../src/containers/ProgramsContainer.tsx | 76 +++++++++++++++++++ apps/admin/src/messages/fr.ts | 35 +++++++++ 7 files changed, 263 insertions(+) create mode 100644 apps/admin/src/app/(shell)/affiliation/page.tsx create mode 100644 apps/admin/src/app/(shell)/affiliation/programmes/page.tsx create mode 100644 apps/admin/src/components/affiliation/AffiliationTabs.tsx create mode 100644 apps/admin/src/components/affiliation/ProgramPanelContent.tsx create mode 100644 apps/admin/src/components/affiliation/ProgramsTable.tsx create mode 100644 apps/admin/src/containers/ProgramsContainer.tsx diff --git a/apps/admin/src/app/(shell)/affiliation/page.tsx b/apps/admin/src/app/(shell)/affiliation/page.tsx new file mode 100644 index 00000000..aaf2b1a9 --- /dev/null +++ b/apps/admin/src/app/(shell)/affiliation/page.tsx @@ -0,0 +1,7 @@ +import { affiliationPaths } from '@/components/affiliation/AffiliationTabs'; +import { redirect } from 'next/navigation'; + +/** A-09 opens on the programs. */ +export default function AffiliationPage() { + redirect(affiliationPaths.programs); +} diff --git a/apps/admin/src/app/(shell)/affiliation/programmes/page.tsx b/apps/admin/src/app/(shell)/affiliation/programmes/page.tsx new file mode 100644 index 00000000..f08957bf --- /dev/null +++ b/apps/admin/src/app/(shell)/affiliation/programmes/page.tsx @@ -0,0 +1,15 @@ +import { AffiliationTabs } from '@/components/affiliation/AffiliationTabs'; +import { ProgramsContainer } from '@/containers/ProgramsContainer'; +import { Suspense } from 'react'; + +/** A-09, the affiliate programs. The open program is read from the address, in the browser. */ +export default function ProgramsPage() { + return ( +
+ + + + +
+ ); +} diff --git a/apps/admin/src/components/affiliation/AffiliationTabs.tsx b/apps/admin/src/components/affiliation/AffiliationTabs.tsx new file mode 100644 index 00000000..f3ed440b --- /dev/null +++ b/apps/admin/src/components/affiliation/AffiliationTabs.tsx @@ -0,0 +1,25 @@ +import { SectionTabs } from '@/components/navigation/SectionTabs'; +import { sectionPaths } from '@/config/navigation'; +import { messages } from '@/messages/fr'; + +const texts = messages.affiliation.tabs; + +/** Addresses of the two lists of the affiliation (A-09). */ +export const affiliationPaths = { + programs: `${sectionPaths.affiliation}/programmes`, + steps: `${sectionPaths.affiliation}/etapes`, +} as const; + +/** The two lists of A-09 as tabs: the programs, then the bookable steps. */ +export function AffiliationTabs({ current }: { current: keyof typeof affiliationPaths }) { + return ( + + ); +} diff --git a/apps/admin/src/components/affiliation/ProgramPanelContent.tsx b/apps/admin/src/components/affiliation/ProgramPanelContent.tsx new file mode 100644 index 00000000..f09926f5 --- /dev/null +++ b/apps/admin/src/components/affiliation/ProgramPanelContent.tsx @@ -0,0 +1,44 @@ +import { formatFullDate } from '@/lib/dates'; +import { messages } from '@/messages/fr'; +import type { AffiliateProgram } from '@widoo/shared'; +import type { ReactNode } from 'react'; + +const texts = messages.affiliation.programs; + +function Line({ label, children }: { label: string; children: ReactNode }) { + return ( +
+
{label}
+
{children}
+
+ ); +} + +/** A program in the detail panel (A-09): its network, commission, cookie and the places it serves. */ +export function ProgramPanelContent({ program }: { program: AffiliateProgram }) { + return ( +
+ {program.isActive ? null : ( +

+ {texts.panel.inactiveHint} +

+ )} +
+ {program.network} + + {program.commissionNote ?? ( + {texts.noCommission} + )} + + {texts.cookieDays(program.cookieDays)} + {program.placeCount} + + {program.isActive ? texts.status.active : texts.status.inactive} + + + {formatFullDate(new Date(program.createdAt))} + +
+
+ ); +} diff --git a/apps/admin/src/components/affiliation/ProgramsTable.tsx b/apps/admin/src/components/affiliation/ProgramsTable.tsx new file mode 100644 index 00000000..250435ce --- /dev/null +++ b/apps/admin/src/components/affiliation/ProgramsTable.tsx @@ -0,0 +1,61 @@ +import { messages } from '@/messages/fr'; +import type { AffiliateProgram } from '@widoo/shared'; +import { StatusBadge, Table, TableCell, TableHeaderCell, TableRow, TableSkeleton } from '@widoo/ui'; + +const texts = messages.affiliation.programs; +const columnCount = 6; + +type ProgramsTableProps = { + /** The programs; ignored while `isLoading`. */ + items: AffiliateProgram[]; + isLoading: boolean; + /** The program whose detail is open in the panel. */ + openId: string | null; + onOpen: (program: AffiliateProgram) => void; +}; + +/** The affiliate programs as a table (A-09); a row opens its detail. */ +export function ProgramsTable({ items, isLoading, openId, onOpen }: ProgramsTableProps) { + return ( + + + + {texts.columns.program} + {texts.columns.network} + {texts.columns.commission} + {texts.columns.cookie} + {texts.columns.places} + {texts.columns.status} + + + + {isLoading ? ( + + ) : ( + items.map((program) => ( + onOpen(program)} + isOpen={program.id === openId} + > + {program.name} + {program.network} + + {program.commissionNote ?? ( + {texts.noCommission} + )} + + {texts.cookieDays(program.cookieDays)} + {program.placeCount} + + + {program.isActive ? texts.status.active : texts.status.inactive} + + + + )) + )} + +
+ ); +} diff --git a/apps/admin/src/containers/ProgramsContainer.tsx b/apps/admin/src/containers/ProgramsContainer.tsx new file mode 100644 index 00000000..a168ae3f --- /dev/null +++ b/apps/admin/src/containers/ProgramsContainer.tsx @@ -0,0 +1,76 @@ +'use client'; + +import { affiliationPaths } from '@/components/affiliation/AffiliationTabs'; +import { ProgramPanelContent } from '@/components/affiliation/ProgramPanelContent'; +import { ProgramsTable } from '@/components/affiliation/ProgramsTable'; +import { PageMessage } from '@/components/states/PageMessage'; +import { useRemoteData } from '@/hooks/useRemoteData'; +import { getSessionClients } from '@/lib/auth/clients'; +import { messages } from '@/messages/fr'; +import { Button, DetailPanel } from '@widoo/ui'; +import { useRouter, useSearchParams } from 'next/navigation'; + +const texts = messages.affiliation.programs; +/** Parameter of the address that holds the program whose detail is open next to the list. */ +const openProgramParam = 'programme'; + +/** A-09, the affiliate programs: the list, and next to it the detail of the one the address names. */ +export function ProgramsContainer() { + const router = useRouter(); + const openId = useSearchParams().get(openProgramParam); + const programs = useRemoteData('programs', (signal) => + getSessionClients().api.getAffiliatePrograms(signal), + ); + const items = programs.status === 'ready' ? programs.data.items : undefined; + const openProgram = items?.find((program) => program.id === openId); + + const showProgram = (programId: string | null) => + router.replace( + programId + ? `${affiliationPaths.programs}?${new URLSearchParams({ [openProgramParam]: programId }).toString()}` + : affiliationPaths.programs, + { scroll: false }, + ); + + if (programs.status === 'error') { + return ( + {messages.list.retry}} + /> + ); + } + + return ( +
+
+
+ {items ? ( +

{texts.total(items.length)}

+ ) : null} +
+ {items?.length === 0 ? ( + + ) : ( + showProgram(program.id)} + /> + )} +
+ {openProgram ? ( + showProgram(null)} + > + + + ) : null} +
+ ); +} diff --git a/apps/admin/src/messages/fr.ts b/apps/admin/src/messages/fr.ts index f68da325..c349284b 100644 --- a/apps/admin/src/messages/fr.ts +++ b/apps/admin/src/messages/fr.ts @@ -1068,6 +1068,41 @@ export const messages = { failed: "Le réglage n'a pas pu être enregistré.", }, }, + affiliation: { + tabs: { label: 'Affiliation', programs: 'Programmes', steps: 'Étapes réservables' }, + programs: { + add: 'Ajouter un programme', + total: (count: number) => (count > 1 ? `${count} programmes` : `${count} programme`), + columns: { + program: 'Programme', + network: 'Réseau', + commission: 'Commission', + cookie: 'Cookie', + places: 'Lieux', + status: 'Statut', + }, + cookieDays: (days: number) => `${days} j`, + noCommission: 'Non renseignée', + status: { active: 'Actif', inactive: 'Inactif' }, + empty: { + title: 'Aucun programme', + text: "Ajoutez les programmes d'affiliation auxquels Widoo est inscrit.", + }, + error: 'Les programmes ne se sont pas chargés.', + panel: { + network: 'Réseau', + commission: 'Commission', + cookie: 'Durée du cookie', + places: 'Lieux avec son lien', + status: 'Statut', + inactiveHint: + "Un programme inactif ne prête plus ses liens : un lieu qui le nomme n'affiche plus son lien partenaire.", + edit: 'Modifier', + deactivate: 'Désactiver', + activate: 'Activer', + }, + }, + }, notFound: { title: 'Page introuvable', text: "Cette adresse n'existe pas dans l'admin.", From 723dba96c378d44db1f4db55bb9aed1d11adbfbe Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Fri, 16 Oct 2026 17:53:01 +0700 Subject: [PATCH 09/11] feat(admin): add an affiliate program, change it, turn it off or on (A-09) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit « Ajouter un programme » and « Modifier » open a window with the name, network, commission note and cookie days; « Désactiver » and « Activer » switch a program. A name already taken, a refused field and any other failure are told apart in a toast. Co-Authored-By: Claude Sonnet 5.5 --- .../components/affiliation/ProgramActions.tsx | 30 +++++++ .../affiliation/ProgramFormDialog.tsx | 86 +++++++++++++++++++ .../src/containers/ProgramFormContainer.tsx | 67 +++++++++++++++ .../src/containers/ProgramsContainer.tsx | 25 ++++++ apps/admin/src/hooks/useProgramActions.ts | 47 ++++++++++ apps/admin/src/lib/program-form.test.ts | 49 +++++++++++ apps/admin/src/lib/program-form.ts | 68 +++++++++++++++ apps/admin/src/messages/fr.ts | 22 +++++ 8 files changed, 394 insertions(+) create mode 100644 apps/admin/src/components/affiliation/ProgramActions.tsx create mode 100644 apps/admin/src/components/affiliation/ProgramFormDialog.tsx create mode 100644 apps/admin/src/containers/ProgramFormContainer.tsx create mode 100644 apps/admin/src/hooks/useProgramActions.ts create mode 100644 apps/admin/src/lib/program-form.test.ts create mode 100644 apps/admin/src/lib/program-form.ts diff --git a/apps/admin/src/components/affiliation/ProgramActions.tsx b/apps/admin/src/components/affiliation/ProgramActions.tsx new file mode 100644 index 00000000..4aff8264 --- /dev/null +++ b/apps/admin/src/components/affiliation/ProgramActions.tsx @@ -0,0 +1,30 @@ +import { messages } from '@/messages/fr'; +import { Button } from '@widoo/ui'; + +const texts = messages.affiliation.programs.panel; + +type ProgramActionsProps = { + isActive: boolean; + onEdit: () => void; + /** Turns the program off, or back on. */ + onToggleActive: () => void; + isBusy: boolean; +}; + +/** The actions of the card of a program (A-09): change it, turn it off or on. */ +export function ProgramActions({ isActive, onEdit, onToggleActive, isBusy }: ProgramActionsProps) { + return ( + <> + + + + ); +} diff --git a/apps/admin/src/components/affiliation/ProgramFormDialog.tsx b/apps/admin/src/components/affiliation/ProgramFormDialog.tsx new file mode 100644 index 00000000..52da6c55 --- /dev/null +++ b/apps/admin/src/components/affiliation/ProgramFormDialog.tsx @@ -0,0 +1,86 @@ +import type { ProgramFormValues } from '@/lib/program-form'; +import { messages } from '@/messages/fr'; +import { Checkbox, ConfirmDialog, Field, Input } from '@widoo/ui'; + +const texts = messages.affiliation.programs.form; + +type ProgramFormDialogProps = { + isOpen: boolean; + /** True when it changes a program; false when it adds one. */ + isEdit: boolean; + values: ProgramFormValues; + onChange: (key: Key, value: ProgramFormValues[Key]) => void; + onCancel: () => void; + onConfirm: () => void; + isBusy: boolean; +}; + +/** « Ajouter un programme » or « Modifier le programme »: its name, network, commission and cookie. */ +export function ProgramFormDialog({ + isOpen, + isEdit, + values, + onChange, + onCancel, + onConfirm, + isBusy, +}: ProgramFormDialogProps) { + return ( + + + onChange('name', event.target.value)} + /> + + + onChange('network', event.target.value)} + /> + + + onChange('commissionNote', event.target.value)} + /> + + + onChange('cookieDays', event.target.value)} + /> + + onChange('isActive', event.target.checked)} + /> + + ); +} diff --git a/apps/admin/src/containers/ProgramFormContainer.tsx b/apps/admin/src/containers/ProgramFormContainer.tsx new file mode 100644 index 00000000..90772a0b --- /dev/null +++ b/apps/admin/src/containers/ProgramFormContainer.tsx @@ -0,0 +1,67 @@ +'use client'; + +import { ProgramFormDialog } from '@/components/affiliation/ProgramFormDialog'; +import { useProgramActions } from '@/hooks/useProgramActions'; +import { + emptyProgramForm, + programFormOf, + programInputOf, + programPatchOf, + type ProgramFormValues, +} from '@/lib/program-form'; +import type { AffiliateProgram } from '@widoo/shared'; +import { useState } from 'react'; + +type ProgramFormContainerProps = { + /** The program to change; null to add one. */ + program: AffiliateProgram | null; + onClose: () => void; + /** The program is saved: the list reads again. */ + onSaved: () => void; +}; + +/** + * The window that adds a program or changes one (A-09): the form, and what it sends. It is shown + * only while it is open, so that each opening starts from the program or from nothing. + */ +export function ProgramFormContainer({ program, onClose, onSaved }: ProgramFormContainerProps) { + const [values, setValues] = useState(() => + program ? programFormOf(program) : emptyProgramForm, + ); + const actions = useProgramActions(() => { + onClose(); + onSaved(); + }); + + const handleChange = ( + key: Key, + value: ProgramFormValues[Key], + ) => setValues((current) => ({ ...current, [key]: value })); + const handleConfirm = () => { + if (program) { + const patch = programPatchOf(program, values); + if (patch && Object.keys(patch).length > 0) { + void actions.update(program.id, patch); + } else { + onClose(); + } + return; + } + const input = programInputOf(values); + if (input) { + void actions.create(input); + } + }; + + return ( + + ); +} diff --git a/apps/admin/src/containers/ProgramsContainer.tsx b/apps/admin/src/containers/ProgramsContainer.tsx index a168ae3f..476fa0e9 100644 --- a/apps/admin/src/containers/ProgramsContainer.tsx +++ b/apps/admin/src/containers/ProgramsContainer.tsx @@ -1,14 +1,18 @@ 'use client'; import { affiliationPaths } from '@/components/affiliation/AffiliationTabs'; +import { ProgramActions } from '@/components/affiliation/ProgramActions'; import { ProgramPanelContent } from '@/components/affiliation/ProgramPanelContent'; import { ProgramsTable } from '@/components/affiliation/ProgramsTable'; import { PageMessage } from '@/components/states/PageMessage'; +import { ProgramFormContainer } from '@/containers/ProgramFormContainer'; +import { useProgramActions } from '@/hooks/useProgramActions'; import { useRemoteData } from '@/hooks/useRemoteData'; import { getSessionClients } from '@/lib/auth/clients'; import { messages } from '@/messages/fr'; import { Button, DetailPanel } from '@widoo/ui'; import { useRouter, useSearchParams } from 'next/navigation'; +import { useState } from 'react'; const texts = messages.affiliation.programs; /** Parameter of the address that holds the program whose detail is open next to the list. */ @@ -23,6 +27,9 @@ export function ProgramsContainer() { ); const items = programs.status === 'ready' ? programs.data.items : undefined; const openProgram = items?.find((program) => program.id === openId); + const [dialog, setDialog] = useState<'create' | 'edit' | null>(null); + const reload = programs.status === 'loading' ? undefined : programs.reload; + const toggle = useProgramActions(() => reload?.()); const showProgram = (programId: string | null) => router.replace( @@ -46,6 +53,7 @@ export function ProgramsContainer() {
+ {items ? (

{texts.total(items.length)}

) : null} @@ -67,10 +75,27 @@ export function ProgramsContainer() { title={openProgram.name} closeLabel={messages.confirm.close} onClose={() => showProgram(null)} + actions={ + setDialog('edit')} + onToggleActive={() => + void toggle.update(openProgram.id, { isActive: !openProgram.isActive }) + } + isBusy={toggle.isBusy} + /> + } > ) : null} + {dialog ? ( + setDialog(null)} + onSaved={() => reload?.()} + /> + ) : null}
); } diff --git a/apps/admin/src/hooks/useProgramActions.ts b/apps/admin/src/hooks/useProgramActions.ts new file mode 100644 index 00000000..21f73d23 --- /dev/null +++ b/apps/admin/src/hooks/useProgramActions.ts @@ -0,0 +1,47 @@ +import { getSessionClients } from '@/lib/auth/clients'; +import { messages } from '@/messages/fr'; +import { ApiRequestError } from '@widoo/api-client'; +import type { AffiliateProgramInput, AffiliateProgramPatch } from '@widoo/shared'; +import { showToast } from '@widoo/ui'; +import { useState } from 'react'; + +const texts = messages.affiliation.programs.toasts; + +/** The toast of a failed save: a name already taken, a refused field, or any other failure. */ +function failureTitleOf(error: unknown): string { + if (error instanceof ApiRequestError) { + if (error.status === 409) return texts.nameTaken; + if (error.status === 400 || error.status === 422) return texts.invalid; + } + return texts.failed; +} + +/** + * Adds or changes an affiliate program (A-09) and tells the result in a toast. `onSaved` is called + * once it is saved: the list is then read again. + */ +export function useProgramActions(onSaved: () => void) { + const [isBusy, setIsBusy] = useState(false); + const { api } = getSessionClients(); + + const send = async (action: () => Promise, successTitle: string) => { + setIsBusy(true); + try { + await action(); + showToast('success', { title: successTitle }); + onSaved(); + } catch (error) { + showToast('error', { title: failureTitleOf(error) }); + } finally { + setIsBusy(false); + } + }; + + return { + isBusy, + create: (program: AffiliateProgramInput) => + send(() => api.createAffiliateProgram(program), texts.created), + update: (programId: string, patch: AffiliateProgramPatch) => + send(() => api.updateAffiliateProgram(programId, patch), texts.updated), + }; +} diff --git a/apps/admin/src/lib/program-form.test.ts b/apps/admin/src/lib/program-form.test.ts new file mode 100644 index 00000000..0370d4e4 --- /dev/null +++ b/apps/admin/src/lib/program-form.test.ts @@ -0,0 +1,49 @@ +import type { AffiliateProgram } from '@widoo/shared'; +import { describe, expect, it } from 'vitest'; +import { emptyProgramForm, programFormOf, programInputOf, programPatchOf } from './program-form'; + +const program: AffiliateProgram = { + id: '0199d1ec-0000-7000-8000-000000000000', + name: 'Awin Paris', + network: 'Awin', + commissionNote: '5 %', + cookieDays: 30, + isActive: true, + placeCount: 2, + createdAt: '2026-10-16T10:00:00.000Z', +}; + +describe('programInputOf', () => { + it('trims the texts and turns an empty note into none', () => { + expect( + programInputOf({ ...emptyProgramForm, name: ' Awin ', network: 'Awin', commissionNote: ' ' }), + ).toEqual({ + name: 'Awin', + network: 'Awin', + commissionNote: null, + cookieDays: 30, + isActive: true, + }); + }); + + it('is null while the cookie is not a number of days', () => { + expect(programInputOf({ ...emptyProgramForm, cookieDays: '' })).toBeNull(); + expect(programInputOf({ ...emptyProgramForm, cookieDays: '3,5' })).toBeNull(); + expect(programInputOf({ ...emptyProgramForm, cookieDays: '-1' })).toBeNull(); + }); +}); + +describe('programPatchOf', () => { + it('has nothing to send for a form that is unchanged', () => { + expect(programPatchOf(program, programFormOf(program))).toEqual({}); + }); + + it('sends only the fields that changed', () => { + expect( + programPatchOf(program, { ...programFormOf(program), cookieDays: '45', isActive: false }), + ).toEqual({ cookieDays: 45, isActive: false }); + expect(programPatchOf(program, { ...programFormOf(program), commissionNote: '' })).toEqual({ + commissionNote: null, + }); + }); +}); diff --git a/apps/admin/src/lib/program-form.ts b/apps/admin/src/lib/program-form.ts new file mode 100644 index 00000000..66a50c47 --- /dev/null +++ b/apps/admin/src/lib/program-form.ts @@ -0,0 +1,68 @@ +import type { AffiliateProgram, AffiliateProgramInput, AffiliateProgramPatch } from '@widoo/shared'; + +/** A program as the form holds it: everything is text until it is saved. */ +export type ProgramFormValues = { + name: string; + network: string; + /** Empty for no note. */ + commissionNote: string; + cookieDays: string; + isActive: boolean; +}; + +/** The form of a new program: active, a month of cookie. */ +export const emptyProgramForm: ProgramFormValues = { + name: '', + network: '', + commissionNote: '', + cookieDays: '30', + isActive: true, +}; + +/** The form filled with an existing program. */ +export function programFormOf(program: AffiliateProgram): ProgramFormValues { + return { + name: program.name, + network: program.network, + commissionNote: program.commissionNote ?? '', + cookieDays: String(program.cookieDays), + isActive: program.isActive, + }; +} + +/** The whole number of days typed, or null for anything else. The API holds it to 0 to 365. */ +const daysOf = (text: string): number | null => + /^\d{1,3}$/.test(text.trim()) ? Number(text.trim()) : null; + +/** What to send to create a program, or null while the cookie cannot be read as days. */ +export function programInputOf(values: ProgramFormValues): AffiliateProgramInput | null { + const cookieDays = daysOf(values.cookieDays); + if (cookieDays === null) { + return null; + } + return { + name: values.name.trim(), + network: values.network.trim(), + commissionNote: values.commissionNote.trim() || null, + cookieDays, + isActive: values.isActive, + }; +} + +/** The fields of the form that differ from the program: only those are sent to change it. */ +export function programPatchOf( + program: AffiliateProgram, + values: ProgramFormValues, +): AffiliateProgramPatch | null { + const input = programInputOf(values); + if (!input) { + return null; + } + const patch: AffiliateProgramPatch = {}; + if (input.name !== program.name) patch.name = input.name; + if (input.network !== program.network) patch.network = input.network; + if (input.commissionNote !== program.commissionNote) patch.commissionNote = input.commissionNote; + if (input.cookieDays !== program.cookieDays) patch.cookieDays = input.cookieDays; + if (input.isActive !== program.isActive) patch.isActive = input.isActive; + return patch; +} diff --git a/apps/admin/src/messages/fr.ts b/apps/admin/src/messages/fr.ts index c349284b..6e044262 100644 --- a/apps/admin/src/messages/fr.ts +++ b/apps/admin/src/messages/fr.ts @@ -1101,6 +1101,28 @@ export const messages = { deactivate: 'Désactiver', activate: 'Activer', }, + form: { + createTitle: 'Ajouter un programme', + editTitle: 'Modifier le programme', + description: "Un programme d'affiliation auquel Widoo est inscrit.", + name: 'Nom', + network: 'Réseau', + networkHint: 'Le réseau qui le gère : Awin, Impact, Effiliation…', + commission: 'Commission', + commissionHint: 'Une note libre : « 5 % du panier ».', + cookieDays: 'Durée du cookie (jours)', + cookieHint: 'De 0 à 365 jours.', + isActive: 'Programme actif', + create: 'Ajouter', + save: 'Enregistrer', + }, + toasts: { + created: 'Programme ajouté', + updated: 'Programme modifié', + nameTaken: 'Un programme porte déjà ce nom.', + invalid: 'Le nom, le réseau, la note ou la durée sont refusés.', + failed: "Le programme n'a pas pu être enregistré.", + }, }, }, notFound: { From 932f45db947fe4d30af63fa36de03535907464c5 Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Fri, 16 Oct 2026 17:53:01 +0700 Subject: [PATCH 10/11] feat(admin): list the steps that can be booked, with the link shown and its clicks (A-09) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The second tab of Affiliation: each bookable step with the link it shows and where it comes from (creator, creator affiliate, partner of Widoo), its clicks of the last 30 days, and a link to the sheet of its place. « Lieux sans lien partenaire » keeps the places to sign up for first. Co-Authored-By: Claude Sonnet 5.5 --- .../app/(shell)/affiliation/etapes/page.tsx | 15 ++++ .../affiliation/BookableStepsTable.tsx | 79 ++++++++++++++++++ .../src/config/bookable-steps-list.test.ts | 31 +++++++ apps/admin/src/config/bookable-steps-list.ts | 25 ++++++ .../src/containers/BookableStepsContainer.tsx | 80 +++++++++++++++++++ apps/admin/src/messages/fr.ts | 20 +++++ 6 files changed, 250 insertions(+) create mode 100644 apps/admin/src/app/(shell)/affiliation/etapes/page.tsx create mode 100644 apps/admin/src/components/affiliation/BookableStepsTable.tsx create mode 100644 apps/admin/src/config/bookable-steps-list.test.ts create mode 100644 apps/admin/src/config/bookable-steps-list.ts create mode 100644 apps/admin/src/containers/BookableStepsContainer.tsx diff --git a/apps/admin/src/app/(shell)/affiliation/etapes/page.tsx b/apps/admin/src/app/(shell)/affiliation/etapes/page.tsx new file mode 100644 index 00000000..be05ad1c --- /dev/null +++ b/apps/admin/src/app/(shell)/affiliation/etapes/page.tsx @@ -0,0 +1,15 @@ +import { AffiliationTabs } from '@/components/affiliation/AffiliationTabs'; +import { BookableStepsContainer } from '@/containers/BookableStepsContainer'; +import { Suspense } from 'react'; + +/** A-09, the steps that can be booked. The filter and the page are read from the address. */ +export default function BookableStepsPage() { + return ( +
+ + + + +
+ ); +} diff --git a/apps/admin/src/components/affiliation/BookableStepsTable.tsx b/apps/admin/src/components/affiliation/BookableStepsTable.tsx new file mode 100644 index 00000000..61175765 --- /dev/null +++ b/apps/admin/src/components/affiliation/BookableStepsTable.tsx @@ -0,0 +1,79 @@ +import { catalogPaths } from '@/components/catalog/CatalogTabs'; +import { messages } from '@/messages/fr'; +import type { BookableStep } from '@widoo/shared'; +import { StatusBadge, Table, TableCell, TableHeaderCell, TableRow, TableSkeleton } from '@widoo/ui'; +import Link from 'next/link'; + +const texts = messages.affiliation.steps; +const columnCount = 4; + +type BookableStepsTableProps = { + /** Rows of the page; ignored while `isLoading`. */ + items: BookableStep[]; + isLoading: boolean; +}; + +/** The host of a link, to show where it leads without the whole address. */ +const hostOf = (url: string) => new URL(url).host; + +/** The steps that can be booked as a table (A-09): the link shown, its origin and its clicks. */ +export function BookableStepsTable({ items, isLoading }: BookableStepsTableProps) { + return ( + + + + {texts.columns.route} + {texts.columns.place} + {texts.columns.link} + {texts.columns.clicks} + + + + {isLoading ? ( + + ) : ( + items.map((step) => ( + + + + {step.route.title} + + {texts.stepOf(step.position)} + + + + + + + {step.place.name} + + + {step.place.hasPartnerLink ? texts.hasPartner : texts.addPartner} + + + + + {step.link ? ( + + + {texts.origins[step.link.origin]} + + + {hostOf(step.link.url)} + + + ) : ( + {texts.noLink} + )} + + {step.clicks30Days} + + )) + )} + +
+ ); +} diff --git a/apps/admin/src/config/bookable-steps-list.test.ts b/apps/admin/src/config/bookable-steps-list.test.ts new file mode 100644 index 00000000..2be1ab95 --- /dev/null +++ b/apps/admin/src/config/bookable-steps-list.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { toBookableStepsParams, type BookableStepsListQuery } from './bookable-steps-list'; + +const query = ( + filters: BookableStepsListQuery['filters'], + cursor: string | null = null, +): BookableStepsListQuery => ({ + search: '', + filters, + sort: { column: 'clics', direction: 'desc' }, + cursor, +}); + +describe('toBookableStepsParams', () => { + it('asks for the places without a partner link when the filter applies', () => { + expect(toBookableStepsParams(query({ 'sans-partenaire': 'oui' }, 'abc'))).toEqual({ + withoutPartnerLink: 'true', + cursor: 'abc', + }); + }); + + it('asks for everything otherwise, and ignores another value', () => { + expect(toBookableStepsParams(query({}))).toEqual({ + withoutPartnerLink: undefined, + cursor: undefined, + }); + expect( + toBookableStepsParams(query({ 'sans-partenaire': 'non' })).withoutPartnerLink, + ).toBeUndefined(); + }); +}); diff --git a/apps/admin/src/config/bookable-steps-list.ts b/apps/admin/src/config/bookable-steps-list.ts new file mode 100644 index 00000000..84b0233b --- /dev/null +++ b/apps/admin/src/config/bookable-steps-list.ts @@ -0,0 +1,25 @@ +import type { ListQuery, ListQueryOptions } from '@/lib/list-query'; +import type { BookableStepsQuery } from '@widoo/shared'; + +/** The steps are read most clicked first, with no column to sort by: only the one filter. */ +export type BookableStepFilter = 'sans-partenaire'; +export type BookableStepColumn = 'clics'; +export type BookableStepsListQuery = ListQuery; + +export const bookableStepsListOptions: ListQueryOptions = { + filters: ['sans-partenaire'], + sortableColumns: [], + defaultSort: { column: 'clics', direction: 'desc' }, +}; + +/** Value of the `sans-partenaire` filter when it applies. */ +export const withoutPartnerValue = 'oui'; + +/** What to ask the API for the list shown at an address. */ +export function toBookableStepsParams(query: BookableStepsListQuery): Partial { + return { + withoutPartnerLink: + query.filters['sans-partenaire'] === withoutPartnerValue ? 'true' : undefined, + cursor: query.cursor ?? undefined, + }; +} diff --git a/apps/admin/src/containers/BookableStepsContainer.tsx b/apps/admin/src/containers/BookableStepsContainer.tsx new file mode 100644 index 00000000..ceb2d3e4 --- /dev/null +++ b/apps/admin/src/containers/BookableStepsContainer.tsx @@ -0,0 +1,80 @@ +'use client'; + +import { BookableStepsTable } from '@/components/affiliation/BookableStepsTable'; +import { PageMessage } from '@/components/states/PageMessage'; +import { + bookableStepsListOptions, + toBookableStepsParams, + withoutPartnerValue, +} from '@/config/bookable-steps-list'; +import { useListQuery } from '@/hooks/useListQuery'; +import { useRemoteData } from '@/hooks/useRemoteData'; +import { getSessionClients } from '@/lib/auth/clients'; +import { serializeListQuery } from '@/lib/list-query'; +import { messages } from '@/messages/fr'; +import { Button, FilterChip, Pagination } from '@widoo/ui'; + +const texts = messages.affiliation.steps; + +/** + * A-09, the steps that can be booked: the link each one shows, where it comes from and its clicks + * of the last 30 days. « Lieux sans lien partenaire » keeps the places to sign up for first. + */ +export function BookableStepsContainer() { + const { query, handleFilter, handlePage } = useListQuery(bookableStepsListOptions); + const listAddress = serializeListQuery(query, bookableStepsListOptions); + const steps = useRemoteData(listAddress, (signal) => + getSessionClients().api.getBookableSteps(toBookableStepsParams(query), signal), + ); + const data = steps.status === 'ready' ? steps.data : undefined; + + const renderList = () => { + if (steps.status === 'error') { + return ( + {messages.list.retry}} + /> + ); + } + if (data?.items.length === 0) { + return ; + } + return ; + }; + + return ( +
+
+
+ handleFilter('sans-partenaire', withoutPartnerValue)} + /> +
+ {data ? ( +

{texts.total(data.total)}

+ ) : null} +
+ {renderList()} + {data && data.items.length > 0 ? ( +
+

+ {messages.list.range(data.offset + 1, data.offset + data.items.length, data.total)} +

+ handlePage(data.previousCursor) + } + onNext={data.nextCursor === null ? undefined : () => handlePage(data.nextCursor)} + /> +
+ ) : null} +
+ ); +} diff --git a/apps/admin/src/messages/fr.ts b/apps/admin/src/messages/fr.ts index 6e044262..48bf7104 100644 --- a/apps/admin/src/messages/fr.ts +++ b/apps/admin/src/messages/fr.ts @@ -1124,6 +1124,26 @@ export const messages = { failed: "Le programme n'a pas pu être enregistré.", }, }, + steps: { + filters: { label: 'Filtres des étapes', withoutPartner: 'Lieux sans lien partenaire' }, + total: (count: number) => (count > 1 ? `${count} étapes` : `${count} étape`), + columns: { route: 'Parcours', place: 'Lieu', link: 'Lien affiché', clicks: 'Clics sur 30 j' }, + stepOf: (position: number) => `Étape ${position + 1}`, + origins: { + creator: 'Créateur', + creator_affiliate: 'Créateur · lien affilié', + partner: 'Partenaire Widoo', + }, + noLink: 'Aucun lien', + hasPartner: 'Lien partenaire', + addPartner: 'Ajouter le lien partenaire', + editPlace: 'Modifier le lieu', + empty: { + title: 'Aucune étape réservable', + text: 'Les étapes avec un lien de réservation apparaissent ici.', + }, + error: 'Les étapes ne se sont pas chargées.', + }, }, notFound: { title: 'Page introuvable', From b166abd4f4a1d57e73ab7c0c6a20b43ac6589fbb Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Fri, 16 Oct 2026 17:53:14 +0700 Subject: [PATCH 11/11] feat(admin): set the partner link of Widoo in the form of a place (A-09) Two fields in the sheet of a place: the partner link (https, checked in the browser at once) and, once there is a link, the program it belongs to (an inactive one is marked). A field the API refuses (422) is now told as a refused field instead of a general failure, and the page scrolls to the error, as the button is at the bottom of a long form. Co-Authored-By: Claude Sonnet 5.5 --- .../src/components/catalog/PlaceForm.tsx | 30 ++++++++++++ .../src/containers/PlaceFormContainer.tsx | 31 +++++++++--- apps/admin/src/hooks/usePlaceForm.ts | 17 +------ apps/admin/src/lib/place-form.test.ts | 47 +++++++++++++++++++ apps/admin/src/lib/place-form.ts | 11 +++++ apps/admin/src/lib/place-save-failure.test.ts | 22 +++++++++ apps/admin/src/lib/place-save-failure.ts | 19 ++++++++ apps/admin/src/messages/fr.ts | 9 +++- 8 files changed, 163 insertions(+), 23 deletions(-) create mode 100644 apps/admin/src/lib/place-save-failure.test.ts create mode 100644 apps/admin/src/lib/place-save-failure.ts diff --git a/apps/admin/src/components/catalog/PlaceForm.tsx b/apps/admin/src/components/catalog/PlaceForm.tsx index fa4d3ad2..e4ea6028 100644 --- a/apps/admin/src/components/catalog/PlaceForm.tsx +++ b/apps/admin/src/components/catalog/PlaceForm.tsx @@ -12,6 +12,8 @@ type PlaceFormProps = { /** The map that shows and moves the position, under its two fields; nothing without one. */ positionMap: ReactNode; values: PlaceFormValues; + /** The affiliate programs the partner link may belong to (A-09). */ + programs: readonly { id: string; name: string; isActive: boolean }[]; /** Changes one field of the form. */ onChange: (key: Key, value: PlaceFormValues[Key]) => void; onSubmit: () => void; @@ -27,6 +29,7 @@ export function PlaceForm({ notice, positionMap, values, + programs, onChange, onSubmit, onCancel, @@ -142,6 +145,33 @@ export function PlaceForm({ onChange={(event) => onChange('phone', event.target.value)} /> + + onChange('partnerBookingUrl', event.target.value)} + /> + + {values.partnerBookingUrl.trim() ? ( + + + + ) : null}
{texts.hours}

diff --git a/apps/admin/src/containers/PlaceFormContainer.tsx b/apps/admin/src/containers/PlaceFormContainer.tsx index 14426c92..97ba020a 100644 --- a/apps/admin/src/containers/PlaceFormContainer.tsx +++ b/apps/admin/src/containers/PlaceFormContainer.tsx @@ -5,6 +5,8 @@ import { PlaceForm } from '@/components/catalog/PlaceForm'; import { PlacePositionMap } from '@/components/catalog/PlacePositionMap'; import { BackLink } from '@/components/states/BackLink'; import { usePlaceForm } from '@/hooks/usePlaceForm'; +import { useRemoteData } from '@/hooks/useRemoteData'; +import { getSessionClients } from '@/lib/auth/clients'; import { publicEnv } from '@/config/public-env'; import { coordinateText, emptyPlaceForm, placeFormOf, positionOfForm } from '@/lib/place-form'; import { messages } from '@/messages/fr'; @@ -12,6 +14,7 @@ import { WarningCircleIcon } from '@phosphor-icons/react/dist/ssr'; import type { CatalogPlaceDetail } from '@widoo/shared'; import { Notice, showToast } from '@widoo/ui'; import { useRouter } from 'next/navigation'; +import { useEffect, useRef } from 'react'; const texts = messages.catalog.places.form; const mapToken = publicEnv.NEXT_PUBLIC_MAPBOX_TOKEN; @@ -26,6 +29,10 @@ export const placePanelPathOf = (placeId: string) => */ export function PlaceFormContainer({ place }: { place: CatalogPlaceDetail | null }) { const router = useRouter(); + const noticeRef = useRef(null); + const programs = useRemoteData('affiliate-programs', (signal) => + getSessionClients().api.getAffiliatePrograms(signal), + ); const form = usePlaceForm({ initial: place ? placeFormOf(place) : emptyPlaceForm, placeId: place?.id ?? null, @@ -36,6 +43,13 @@ export function PlaceFormContainer({ place }: { place: CatalogPlaceDetail | null }, }); + // The form is long and its button is at the bottom: bring the error into view. + useEffect(() => { + if (form.failure) { + noticeRef.current?.scrollIntoView({ block: 'center', behavior: 'smooth' }); + } + }, [form.failure]); + return (

@@ -43,13 +57,15 @@ export function PlaceFormContainer({ place }: { place: CatalogPlaceDetail | null } - > -

{texts.errors[form.failure]}

- +
+ } + > +

{texts.errors[form.failure]}

+
+
) : null } positionMap={ @@ -65,6 +81,7 @@ export function PlaceFormContainer({ place }: { place: CatalogPlaceDetail | null ) : null } values={form.values} + programs={programs.status === 'ready' ? programs.data.items : []} onChange={form.handleChange} onSubmit={form.handleSubmit} onCancel={() => router.push(place ? placePanelPathOf(place.id) : catalogPaths.places)} diff --git a/apps/admin/src/hooks/usePlaceForm.ts b/apps/admin/src/hooks/usePlaceForm.ts index 1c5a97b5..1d9075ad 100644 --- a/apps/admin/src/hooks/usePlaceForm.ts +++ b/apps/admin/src/hooks/usePlaceForm.ts @@ -1,22 +1,9 @@ import { getSessionClients } from '@/lib/auth/clients'; -import { placeInputOf, type PlaceFormError, type PlaceFormValues } from '@/lib/place-form'; -import { ApiRequestError } from '@widoo/api-client'; +import { placeInputOf, type PlaceFormValues } from '@/lib/place-form'; +import { failureOf, type PlaceSaveFailure } from '@/lib/place-save-failure'; import type { CatalogPlaceDetail, PlaceHours } from '@widoo/shared'; import { useState } from 'react'; -/** Why a place was not saved, as the form tells it. */ -export type PlaceSaveFailure = PlaceFormError | 'outside' | 'refused' | 'failed'; - -// Message of the 400 the API sends for a position outside every city (place-writes.ts). -const outsideEveryCity = 'Position outside every city'; - -function failureOf(error: unknown): PlaceSaveFailure { - if (!(error instanceof ApiRequestError) || error.status !== 400) { - return 'failed'; - } - return error.body?.message === outsideEveryCity ? 'outside' : 'refused'; -} - /** * The form of a place (A-04): its values, and saving them as a new place or over `placeId`. * `previousHours` are the hours of the place being edited, whose seasonal slots are kept. diff --git a/apps/admin/src/lib/place-form.test.ts b/apps/admin/src/lib/place-form.test.ts index 4484acce..c22efe51 100644 --- a/apps/admin/src/lib/place-form.test.ts +++ b/apps/admin/src/lib/place-form.test.ts @@ -30,6 +30,8 @@ describe('placeInputOf', () => { priceRange: null, websiteUrl: null, phone: null, + partnerBookingUrl: null, + partnerProgramId: null, hours: [{ weekday: 0, opens: '08:00', closes: '18:00', validFrom: null, validTo: null }], }, }); @@ -79,6 +81,8 @@ describe('placeFormOf', () => { priceRange: 'low', websiteUrl: place.websiteUrl, phone: place.phone, + partnerBookingUrl: null, + partnerProgramId: null, hours: place.hours, }, }); @@ -105,3 +109,46 @@ describe('coordinateText', () => { }); }); }); + +describe('the partner link of Widoo', () => { + const place = { + id: 'p1', + name: 'Café Lomi', + category: 'cafe' as const, + cityName: 'Paris', + verificationStatus: 'verified' as const, + verifiedAt: null, + routeCount: 0, + address: '3 ter rue Marcadet, 75018 Paris', + location: { lat: 48.8905, lng: 2.3558 }, + isIndoor: true, + priceRange: null, + websiteUrl: null, + phone: null, + partnerBookingUrl: 'https://partner.example/lomi', + partnerProgramId: '0199d1ec-0000-7000-8000-000000000000', + hours: [], + routes: [], + }; + + it('is filled from the place and sent with its program', () => { + const values = placeFormOf(place); + expect(values).toMatchObject({ + partnerBookingUrl: 'https://partner.example/lomi', + partnerProgramId: place.partnerProgramId, + }); + expect(placeInputOf(values, [])).toMatchObject({ + input: { + partnerBookingUrl: 'https://partner.example/lomi', + partnerProgramId: place.partnerProgramId, + }, + }); + }); + + it('is sent as none, with no program, when the link is emptied', () => { + const values = { ...placeFormOf(place), partnerBookingUrl: ' ' }; + expect(placeInputOf(values, [])).toMatchObject({ + input: { partnerBookingUrl: null, partnerProgramId: null }, + }); + }); +}); diff --git a/apps/admin/src/lib/place-form.ts b/apps/admin/src/lib/place-form.ts index cd88aa32..70eb7b06 100644 --- a/apps/admin/src/lib/place-form.ts +++ b/apps/admin/src/lib/place-form.ts @@ -13,6 +13,9 @@ export type PlaceFormValues = { priceRange: string; websiteUrl: string; phone: string; + /** The partner link of Widoo (A-09), and the program it belongs to; empty for none. */ + partnerBookingUrl: string; + partnerProgramId: string; /** One line per day, Monday first. */ dayHours: string[]; }; @@ -30,6 +33,8 @@ export const emptyPlaceForm: PlaceFormValues = { priceRange: '', websiteUrl: '', phone: '', + partnerBookingUrl: '', + partnerProgramId: '', dayHours: ['', '', '', '', '', '', ''], }; @@ -45,6 +50,8 @@ export function placeFormOf(place: CatalogPlaceDetail): PlaceFormValues { priceRange: place.priceRange ?? '', websiteUrl: place.websiteUrl ?? '', phone: place.phone ?? '', + partnerBookingUrl: place.partnerBookingUrl ?? '', + partnerProgramId: place.partnerProgramId ?? '', dayHours: hoursToDayTexts(place.hours), }; } @@ -83,6 +90,8 @@ export function placeInputOf( if (!hours) { return { error: 'hours' }; } + // A program needs a link: without one, none is sent. The two always go together. + const partnerBookingUrl = values.partnerBookingUrl.trim() || null; return { input: { name: values.name.trim(), @@ -93,6 +102,8 @@ export function placeInputOf( priceRange: (values.priceRange || null) as CatalogPlaceInput['priceRange'], websiteUrl: values.websiteUrl.trim() || null, phone: values.phone.trim() || null, + partnerBookingUrl, + partnerProgramId: partnerBookingUrl ? values.partnerProgramId || null : null, hours, }, }; diff --git a/apps/admin/src/lib/place-save-failure.test.ts b/apps/admin/src/lib/place-save-failure.test.ts new file mode 100644 index 00000000..8c9f3699 --- /dev/null +++ b/apps/admin/src/lib/place-save-failure.test.ts @@ -0,0 +1,22 @@ +import { ApiRequestError } from '@widoo/api-client'; +import { describe, expect, it } from 'vitest'; +import { failureOf } from './place-save-failure'; + +const apiError = (status: number, message = 'x') => + new ApiRequestError('http', status, { code: 'validation_error', message }); + +describe('failureOf', () => { + it('tells a field the schema refuses (422) as a refused field', () => { + expect(failureOf(apiError(422))).toBe('refused'); + }); + + it('tells a position outside every city apart from a rule of the place', () => { + expect(failureOf(apiError(400, 'Position outside every city'))).toBe('outside'); + expect(failureOf(apiError(400, 'Unknown affiliate program'))).toBe('refused'); + }); + + it('keeps any other failure as a failure to save', () => { + expect(failureOf(apiError(500))).toBe('failed'); + expect(failureOf(new Error('offline'))).toBe('failed'); + }); +}); diff --git a/apps/admin/src/lib/place-save-failure.ts b/apps/admin/src/lib/place-save-failure.ts new file mode 100644 index 00000000..7f9d9041 --- /dev/null +++ b/apps/admin/src/lib/place-save-failure.ts @@ -0,0 +1,19 @@ +import { ApiRequestError } from '@widoo/api-client'; +import type { PlaceFormError } from './place-form'; + +/** Why a place was not saved, as the form tells it. */ +export type PlaceSaveFailure = PlaceFormError | 'outside' | 'refused' | 'failed'; + +// Message of the 400 the API sends for a position outside every city (place-writes.ts). +const outsideEveryCity = 'Position outside every city'; + +/** + * A 400 is a position outside every city or a rule of the place; a 422 is a field the schema + * refuses (a link that is not https, a phone): the form tells both as a refused field. + */ +export function failureOf(error: unknown): PlaceSaveFailure { + if (!(error instanceof ApiRequestError) || (error.status !== 400 && error.status !== 422)) { + return 'failed'; + } + return error.body?.message === outsideEveryCity ? 'outside' : 'refused'; +} diff --git a/apps/admin/src/messages/fr.ts b/apps/admin/src/messages/fr.ts index 48bf7104..b1ad644b 100644 --- a/apps/admin/src/messages/fr.ts +++ b/apps/admin/src/messages/fr.ts @@ -289,6 +289,12 @@ export const messages = { website: 'Site web', websiteHint: 'Une adresse en https.', phone: 'Téléphone', + partnerLink: 'Lien partenaire de Widoo', + partnerLinkHint: + "Une adresse en https. Elle ne s'affiche que si le créateur n'a pas mis son propre lien.", + partnerProgram: 'Programme du lien', + noProgram: 'Aucun programme', + inactiveProgram: (name: string) => `${name} (inactif)`, hours: 'Horaires', hoursHint: 'Par jour : « 9:00-12:30, 14:00-18:00 ». Laissez vide un jour de fermeture. Tout vide : horaires inconnus.', @@ -298,7 +304,8 @@ export const messages = { position: 'Saisissez une latitude et une longitude valides.', hours: 'Un horaire ne peut pas être lu. Écrivez par exemple « 9:00-18:00 ».', outside: "Cette position n'est dans aucune ville ouverte.", - refused: 'Un champ est refusé. Vérifiez le nom, le site web (https) et le téléphone.', + refused: + 'Un champ est refusé. Vérifiez le nom, le téléphone et les liens, qui doivent commencer par https.', failed: "Le lieu n'a pas pu être enregistré.", }, created: 'Lieu créé',