From 0d078f725f1dac7ad06725148145b31d00e2fef3 Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Fri, 16 Oct 2026 11:57:44 +0700 Subject: [PATCH 01/12] feat(api): record where a Premium right comes from, and allow a gifted route unlock (A-06) Add users.plan_source and route_unlocks.source (store or gift). A gift has no store transaction, so route_unlocks.store_transaction_id becomes nullable, required for the store. Additive migration 0017. Co-Authored-By: Claude Sonnet 5.5 --- .../0017_add_plan_source_and_gifts.sql | 5 + .../migrations/meta/0017_snapshot.json | 3278 +++++++++++++++++ .../generated/migrations/meta/_journal.json | 7 + apps/api/src/db/schema.ts | 18 +- packages/shared/src/schemas/profile.ts | 8 + 5 files changed, 3314 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/db/generated/migrations/0017_add_plan_source_and_gifts.sql create mode 100644 apps/api/src/db/generated/migrations/meta/0017_snapshot.json diff --git a/apps/api/src/db/generated/migrations/0017_add_plan_source_and_gifts.sql b/apps/api/src/db/generated/migrations/0017_add_plan_source_and_gifts.sql new file mode 100644 index 00000000..61d9effb --- /dev/null +++ b/apps/api/src/db/generated/migrations/0017_add_plan_source_and_gifts.sql @@ -0,0 +1,5 @@ +CREATE TYPE "public"."plan_source" AS ENUM('store', 'gift');--> statement-breakpoint +ALTER TABLE "route_unlocks" ALTER COLUMN "store_transaction_id" DROP NOT NULL;--> statement-breakpoint +ALTER TABLE "route_unlocks" ADD COLUMN "source" "plan_source" DEFAULT 'store' NOT NULL;--> statement-breakpoint +ALTER TABLE "users" ADD COLUMN "plan_source" "plan_source" DEFAULT 'store' NOT NULL;--> statement-breakpoint +ALTER TABLE "route_unlocks" ADD CONSTRAINT "route_unlocks_transaction_check" CHECK ("route_unlocks"."source" = 'gift' or "route_unlocks"."store_transaction_id" is not null); \ No newline at end of file diff --git a/apps/api/src/db/generated/migrations/meta/0017_snapshot.json b/apps/api/src/db/generated/migrations/meta/0017_snapshot.json new file mode 100644 index 00000000..802fdcf4 --- /dev/null +++ b/apps/api/src/db/generated/migrations/meta/0017_snapshot.json @@ -0,0 +1,3278 @@ +{ + "id": "b48574c0-3e10-4aa1-a2f3-f8f281ce856f", + "prevId": "13aae6fb-5275-4ed2-8be2-1deed87ac237", + "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.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.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 + }, + "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_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": {}, + "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.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 59758622..fe3a5c8a 100644 --- a/apps/api/src/db/generated/migrations/meta/_journal.json +++ b/apps/api/src/db/generated/migrations/meta/_journal.json @@ -120,6 +120,13 @@ "when": 1792121607808, "tag": "0016_add_moderation_actions", "breakpoints": true + }, + { + "idx": 17, + "version": "7", + "when": 1792123326260, + "tag": "0017_add_plan_source_and_gifts", + "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 25506dbb..82b75f68 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -15,6 +15,7 @@ import { plannedRouteClosers, notificationKinds, plannedRouteStatuses, + planSources, profileSlots, reportStatuses, routeTipsMax, @@ -84,6 +85,8 @@ export const mediaSource = pgEnum('media_source', mediaSources); export const directionsProfile = pgEnum('directions_profile', directionsProfiles); /** Choice of the team in the admin over the nightly computation of the badge (D-023). */ 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); /** 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). */ @@ -171,6 +174,8 @@ export const users = pgTable( /** Updated by the RevenueCat webhooks. */ plan: plan('plan').notNull().default('free'), planExpiresAt: timestamp('plan_expires_at', { withTimezone: true }), + /** `gift` when the team offered the Premium plan (A-06): the store webhooks leave it alone. */ + planSource: planSource('plan_source').notNull().default('store'), revenuecatAppUserId: text('revenuecat_app_user_id'), isPublic: boolean('is_public').notNull().default(true), bio: text('bio'), @@ -665,13 +670,22 @@ export const routeUnlocks = pgTable( routeId: uuid('route_id') .notNull() .references(() => routes.id, { onDelete: 'cascade' }), - /** One unlock per purchase: a webhook delivered twice writes nothing more. */ - storeTransactionId: text('store_transaction_id').notNull().unique(), + /** + * One unlock per purchase: a webhook delivered twice writes nothing more. Null for a gift, + * which has no transaction. + */ + storeTransactionId: text('store_transaction_id').unique(), + /** `gift`: a route offered by the team from A-06, without the store. */ + source: planSource('source').notNull().default('store'), createdAt: createdAt(), }, (t) => [ primaryKey({ name: 'route_unlocks_pkey', columns: [t.userId, t.routeId] }), index('route_unlocks_route_idx').on(t.routeId), + check( + 'route_unlocks_transaction_check', + sql`${t.source} = 'gift' or ${t.storeTransactionId} is not null`, + ), ], ); diff --git a/packages/shared/src/schemas/profile.ts b/packages/shared/src/schemas/profile.ts index 2ef404bd..aa40a0c5 100644 --- a/packages/shared/src/schemas/profile.ts +++ b/packages/shared/src/schemas/profile.ts @@ -58,6 +58,14 @@ export const certifiedOverrides = ['none', 'granted', 'revoked'] as const; export const CertifiedOverride = z.enum(certifiedOverrides); export type CertifiedOverride = z.infer; +/** + * Where a right to Premium comes from: the store (subscription, pass, route bought alone) or a gift + * of the team (A-06, support), which never goes through the store. Technical only. + */ +export const planSources = ['store', 'gift'] as const; +export const PlanSource = z.enum(planSources); +export type PlanSource = z.infer; + /** Key of the `settings` row of each criterion of the badge; a value is a JSON number. */ export const certificationSettingKeys = { minVerifiedRoutes: 'certification.min_verified_routes', From 4d2bd102b6997abb944c0abeea75a21979689fb7 Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Fri, 16 Oct 2026 11:57:44 +0700 Subject: [PATCH 02/12] feat(shared): describe the users of the admin and what each role may do to an account (A-06) Schemas of the list, the card and the actions, and allowedUserActions, the one place of the rule: the admin changes roles, offers Premium and deletes; a moderator suspends a user. Co-Authored-By: Claude Sonnet 5.5 --- packages/shared/src/index.ts | 1 + .../shared/src/schemas/admin-users.test.ts | 47 +++++ packages/shared/src/schemas/admin-users.ts | 165 ++++++++++++++++++ 3 files changed, 213 insertions(+) create mode 100644 packages/shared/src/schemas/admin-users.test.ts create mode 100644 packages/shared/src/schemas/admin-users.ts diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 6fd94e48..23478376 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -22,6 +22,7 @@ export { zoneMinSpanM, zoneSearchBox } from './zone'; export * from './schemas/admin'; export * from './schemas/admin-reports'; export * from './schemas/admin-signals'; +export * from './schemas/admin-users'; export * from './schemas/catalog'; export * from './schemas/catalog-place'; export * from './schemas/common'; diff --git a/packages/shared/src/schemas/admin-users.test.ts b/packages/shared/src/schemas/admin-users.test.ts new file mode 100644 index 00000000..1449fa3a --- /dev/null +++ b/packages/shared/src/schemas/admin-users.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { allowedUserActions, GiftBody } from './admin-users'; + +const admin = { id: 'a', role: 'admin' } as const; + +describe('allowedUserActions', () => { + it('gives an admin everything on a user, but nothing destructive on themselves', () => { + expect(allowedUserActions(admin, { id: 'u', role: 'user' })).toEqual([ + 'gift', + 'certification', + 'export', + 'role', + 'suspend', + 'delete', + ]); + expect(allowedUserActions(admin, admin)).toEqual(['gift', 'certification', 'export']); + }); + + it('asks an account of the team to be demoted before it is deleted', () => { + expect(allowedUserActions(admin, { id: 'e', role: 'editor' })).not.toContain('delete'); + }); + + it('lets a moderator suspend a user only', () => { + const moderator = { id: 'm', role: 'moderator' } as const; + expect(allowedUserActions(moderator, { id: 'u', role: 'user' })).toEqual(['suspend']); + expect(allowedUserActions(moderator, { id: 'e', role: 'editor' })).toEqual([]); + expect(allowedUserActions(moderator, { id: 'x', role: 'moderator' })).toEqual([]); + expect(allowedUserActions(moderator, admin)).toEqual([]); + expect(allowedUserActions(moderator, moderator)).toEqual([]); + }); + + it('gives nothing to an editor or a user', () => { + expect(allowedUserActions({ id: 'e', role: 'editor' }, { id: 'u', role: 'user' })).toEqual([]); + expect(allowedUserActions({ id: 'u', role: 'user' }, { id: 'v', role: 'user' })).toEqual([]); + }); +}); + +describe('GiftBody', () => { + it('takes a date or a route, never both nor none', () => { + const until = '2026-12-01T00:00:00.000Z'; + const routeId = '0199d1ec-0000-7000-8000-000000000000'; + expect(GiftBody.safeParse({ until }).success).toBe(true); + expect(GiftBody.safeParse({ routeId }).success).toBe(true); + expect(GiftBody.safeParse({ until, routeId }).success).toBe(false); + expect(GiftBody.safeParse({}).success).toBe(false); + }); +}); diff --git a/packages/shared/src/schemas/admin-users.ts b/packages/shared/src/schemas/admin-users.ts new file mode 100644 index 00000000..e3c729a4 --- /dev/null +++ b/packages/shared/src/schemas/admin-users.ts @@ -0,0 +1,165 @@ +import { z } from 'zod'; +import { reportStatuses, routeReportReasons } from '../reports'; +import { SupportTopic } from './support'; +import { taxonomies } from '../taxonomies'; +import type { UserRole } from '../taxonomies'; +import { plainText } from './common'; +import { CertifiedOverride, PlanSource } from './profile'; + +/** Rows of a page of the users of the admin. */ +export const adminUsersPageSize = 50; + +/** Query of `GET /admin/users` (A-06): a search by e-mail, first name or identifier. */ +export const AdminUsersQuery = z.strictObject({ + /** Part of an e-mail or of a first name, or a whole identifier. */ + q: z.string().trim().min(1).max(100).optional(), + cursor: z.string().max(200).optional(), +}); +export type AdminUsersQuery = z.infer; + +/** The plan of an account and where it comes from (store or gift of the team). */ +export const AdminUserPlan = z.object({ + plan: z.enum(taxonomies.plans), + source: PlanSource, + /** End of the Premium plan; null without an end (or while the plan is free). */ + expiresAt: z.iso.datetime().nullable(), +}); +export type AdminUserPlan = z.infer; + +/** A row of the list of users (A-06). */ +export const AdminUserListItem = z.object({ + id: z.uuid(), + firstName: z.string().nullable(), + /** Null with Sign in with Apple when the user hides the address. */ + email: z.string().nullable(), + role: z.enum(taxonomies.userRoles), + plan: AdminUserPlan, + createdRouteCount: z.number().int().nonnegative(), + createdAt: z.iso.datetime(), + /** Null for an active account. */ + suspendedAt: z.iso.datetime().nullable(), +}); +export type AdminUserListItem = z.infer; + +/** Body of `GET /admin/users`. */ +export const AdminUsers = z.object({ + items: z.array(AdminUserListItem), + total: z.number().int().nonnegative(), + /** Rows before this page: the first row shown is number `offset + 1`. */ + offset: z.number().int().nonnegative(), + previousCursor: z.string().nullable(), + nextCursor: z.string().nullable(), +}); +export type AdminUsers = z.infer; + +const RouteRef = z.object({ id: z.uuid(), title: z.string() }); + +/** A report seen from an account: made by it, or received on one of its routes. */ +export const AdminUserReport = z.object({ + id: z.uuid(), + reason: z.enum(routeReportReasons), + status: z.enum(reportStatuses), + route: RouteRef, + createdAt: z.iso.datetime(), +}); +export type AdminUserReport = z.infer; + +/** A rating seen from an account (`route_ratings`, #64: none is stored yet). */ +export const AdminUserRating = z.object({ + id: z.uuid(), + route: RouteRef, + score: z.number().int().min(1).max(5), + comment: z.string().nullable(), + createdAt: z.iso.datetime(), +}); +export type AdminUserRating = z.infer; + +/** The card of an account (A-06). */ +export const AdminUserDetail = AdminUserListItem.extend({ + suspensionReason: z.string().nullable(), + isPublic: z.boolean(), + /** The badge shown, and the two things it comes from (D-023). */ + certification: z.object({ + isCertified: z.boolean(), + computed: z.boolean(), + override: CertifiedOverride, + certifiedAt: z.iso.datetime().nullable(), + }), + unlockedRoutes: z.array(RouteRef.extend({ source: PlanSource, unlockedAt: z.iso.datetime() })), + createdRoutes: z.array(RouteRef.extend({ status: z.enum(taxonomies.routeStatuses) })), + reportsMade: z.array(AdminUserReport), + reportsReceived: z.array(AdminUserReport), + /** Empty until ratings are stored (#64). */ + ratingsGiven: z.array(AdminUserRating), + ratingsReceived: z.array(AdminUserRating), + supportMessages: z.array( + z.object({ + id: z.uuid(), + topic: SupportTopic, + createdAt: z.iso.datetime(), + handledAt: z.iso.datetime().nullable(), + }), + ), +}); +export type AdminUserDetail = z.infer; + +/** Body of `PATCH /admin/users/:id/role`. */ +export const ChangeUserRoleBody = z.strictObject({ role: z.enum(taxonomies.userRoles) }); +export type ChangeUserRoleBody = z.infer; + +/** Body of the suspension and of the reactivation: the reason stays in the journal of the admin. */ +export const UserReasonBody = z.strictObject({ reason: plainText(500) }); +export type UserReasonBody = z.infer; + +/** Body of `POST /admin/users/:id/gift`: Premium until a date, or one route, never both. */ +export const GiftBody = z + .strictObject({ until: z.iso.datetime().optional(), routeId: z.uuid().optional() }) + .refine((body) => (body.until === undefined) !== (body.routeId === undefined), { + message: 'Give exactly one of until and routeId', + }); +export type GiftBody = z.infer; + +/** Body of `POST /admin/users/:id/certification` (D-023). */ +export const CertificationBody = z.strictObject({ override: CertifiedOverride }); +export type CertificationBody = z.infer; + +/** Reasons of a 409 on an action on an account, in `details.reason`: the admin tells each one. */ +export const userConflictReasons = [ + 'already_suspended', + 'not_suspended', + 'active_store_plan', + 'free_route', + 'own_route', + 'route_already_unlocked', +] as const; +export type UserConflictReason = (typeof userConflictReasons)[number]; + +/** What an actor may do to an account (wiki Admin-Web › Rôles and A-06). */ +export type UserAction = 'role' | 'suspend' | 'gift' | 'certification' | 'export' | 'delete'; + +type Person = { id: string; role: UserRole }; + +/** + * The actions the API allows `actor` on `target`, the one place of the rule (the admin only hides + * what it refuses). Only an admin changes a role, offers Premium, awards the badge, exports or + * deletes; a moderator suspends and reactivates a `user` only (an account of the team is for an + * admin). Nobody suspends, deletes or changes the role of their own account, and an account of the team is demoted to `user` + * before it is deleted. + */ +export function allowedUserActions(actor: Person, target: Person): readonly UserAction[] { + const isSelf = actor.id === target.id; + if (actor.role === 'admin') { + const actions: UserAction[] = ['gift', 'certification', 'export']; + if (!isSelf) { + actions.push('role', 'suspend'); + if (target.role === 'user') { + actions.push('delete'); + } + } + return actions; + } + if (actor.role === 'moderator' && !isSelf && target.role === 'user') { + return ['suspend']; + } + return []; +} From 7420267c8557b45444df5738d0c6fa730b74edd8 Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Fri, 16 Oct 2026 11:59:03 +0700 Subject: [PATCH 03/12] feat(api): search the accounts and read the card of one (A-06) GET /admin/users and GET /admin/users/:id, for moderators and admins: search by part of an e-mail or first name or by whole identifier, newest first, with the plan and its origin, the routes unlocked and created, the reports made and received, and the support messages. The ratings are empty until route_ratings exists (#64). Co-Authored-By: Claude Sonnet 5.5 --- apps/api/src/admin/users-read.ts | 205 ++++++++++++++++++++++++ apps/api/src/app.ts | 2 + apps/api/src/routes/admin-users.test.ts | 98 +++++++++++ apps/api/src/routes/admin-users.ts | 55 +++++++ 4 files changed, 360 insertions(+) create mode 100644 apps/api/src/admin/users-read.ts create mode 100644 apps/api/src/routes/admin-users.test.ts create mode 100644 apps/api/src/routes/admin-users.ts diff --git a/apps/api/src/admin/users-read.ts b/apps/api/src/admin/users-read.ts new file mode 100644 index 00000000..ef23388c --- /dev/null +++ b/apps/api/src/admin/users-read.ts @@ -0,0 +1,205 @@ +import { + adminUsersPageSize, + isCertifiedWith, + type AdminUserDetail, + type AdminUserListItem, + type AdminUsers, + type AdminUsersQuery, +} from '@widoo/shared'; +import { and, desc, eq, isNull, sql } from 'drizzle-orm'; +import type { Db } from '../db/client'; +import { routeReports, routeUnlocks, routes, supportMessages, users } from '../db/schema'; +import { neighbourCursors, offsetOfCursor } from './page-cursor'; + +type UserRow = { + id: string; + first_name: string | null; + email: string | null; + role: AdminUserListItem['role']; + plan: AdminUserListItem['plan']['plan']; + plan_source: AdminUserListItem['plan']['source']; + plan_expires_at: Date | null; + created_at: Date; + suspended_at: Date | null; + created_route_count: number; +}; + +const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** A search text as a `like` pattern: the user's `%` and `_` search themselves. */ +const likePatternOf = (text: string) => `%${text.replace(/[\\%_]/g, '\\$&')}%`; + +const isoOrNull = (date: Date | null) => (date ? new Date(date).toISOString() : null); + +const toListItem = (row: UserRow): AdminUserListItem => ({ + id: row.id, + firstName: row.first_name, + email: row.email, + role: row.role, + plan: { + plan: row.plan, + source: row.plan_source, + expiresAt: isoOrNull(row.plan_expires_at), + }, + createdRouteCount: row.created_route_count, + createdAt: new Date(row.created_at).toISOString(), + suspendedAt: isoOrNull(row.suspended_at), +}); + +/** + * A page of the accounts (A-06), the newest first. Deleted accounts are left out: they have no + * data left to read. The search matches an e-mail or a first name by part, an identifier whole. + */ +export async function readUsers(db: Db, query: AdminUsersQuery): Promise { + const offset = offsetOfCursor(query.cursor); + const search = query.q; + const where = search + ? sql`u.deleted_at is null and (u.email ilike ${likePatternOf(search)} + or u.first_name ilike ${likePatternOf(search)} + ${uuidPattern.test(search) ? sql`or u.id = ${search.toLowerCase()}::uuid` : sql``})` + : sql`u.deleted_at is null`; + + const rows = await db.execute(sql` + select u.id, u.first_name, u.email, u.role, u.plan, u.plan_source, u.plan_expires_at, + u.created_at, u.suspended_at, + (select count(*)::int from routes r where r.author_id = u.id) as created_route_count + from users u + where ${where} + order by u.created_at desc, u.id + limit ${adminUsersPageSize} offset ${offset}`); + const [counted] = await db.execute<{ total: number }>( + sql`select count(*)::int as total from users u where ${where}`, + ); + const total = counted?.total ?? 0; + return { + items: rows.map(toListItem), + total, + offset, + ...neighbourCursors(offset, total, adminUsersPageSize), + }; +} + +/** Lists of a card are cut here: the team follows the rest in the section of each object. */ +const cardListLimit = 50; + +async function readReports( + db: Db, + by: { reporterId: string } | { authorId: string }, +): Promise { + const rows = await db + .select({ + id: routeReports.id, + reason: routeReports.reason, + status: routeReports.status, + createdAt: routeReports.createdAt, + routeId: routes.id, + routeTitle: routes.title, + }) + .from(routeReports) + .innerJoin(routes, eq(routes.id, routeReports.routeId)) + .where( + 'reporterId' in by + ? eq(routeReports.userId, by.reporterId) + : eq(routes.authorId, by.authorId), + ) + .orderBy(desc(routeReports.createdAt)) + .limit(cardListLimit); + return rows.map((row) => ({ + id: row.id, + reason: row.reason, + status: row.status, + route: { id: row.routeId, title: row.routeTitle }, + createdAt: row.createdAt.toISOString(), + })); +} + +/** + * The card of an account (A-06), or null when it does not exist or was deleted. The ratings are + * empty until `route_ratings` exists (#64): the single place to fill when it does. + */ +export async function readUserDetail(db: Db, userId: string): Promise { + const [user] = await db + .select() + .from(users) + .where(and(eq(users.id, userId), isNull(users.deletedAt))); + if (!user) { + return null; + } + const [unlocks, createdRoutes, reportsMade, reportsReceived, messages] = await Promise.all([ + db + .select({ + id: routes.id, + title: routes.title, + source: routeUnlocks.source, + unlockedAt: routeUnlocks.createdAt, + }) + .from(routeUnlocks) + .innerJoin(routes, eq(routes.id, routeUnlocks.routeId)) + .where(eq(routeUnlocks.userId, userId)) + .orderBy(desc(routeUnlocks.createdAt)) + .limit(cardListLimit), + db + .select({ id: routes.id, title: routes.title, status: routes.status }) + .from(routes) + .where(eq(routes.authorId, userId)) + .orderBy(desc(routes.createdAt)) + .limit(cardListLimit), + readReports(db, { reporterId: userId }), + readReports(db, { authorId: userId }), + db + .select({ + id: supportMessages.id, + topic: supportMessages.topic, + createdAt: supportMessages.createdAt, + handledAt: supportMessages.handledAt, + }) + .from(supportMessages) + .where(eq(supportMessages.userId, userId)) + .orderBy(desc(supportMessages.createdAt)) + .limit(cardListLimit), + ]); + const [created] = await db + .select({ count: sql`count(*)::int` }) + .from(routes) + .where(eq(routes.authorId, userId)); + + return { + ...toListItem({ + id: user.id, + first_name: user.firstName, + email: user.email, + role: user.role, + plan: user.plan, + plan_source: user.planSource, + plan_expires_at: user.planExpiresAt, + created_at: user.createdAt, + suspended_at: user.suspendedAt, + created_route_count: created?.count ?? 0, + }), + suspensionReason: user.suspensionReason, + isPublic: user.isPublic, + certification: { + isCertified: isCertifiedWith(user.isCertified, user.certifiedOverride), + computed: user.isCertified, + override: user.certifiedOverride, + certifiedAt: isoOrNull(user.certifiedAt), + }, + unlockedRoutes: unlocks.map((row) => ({ + id: row.id, + title: row.title, + source: row.source, + unlockedAt: row.unlockedAt.toISOString(), + })), + createdRoutes, + reportsMade, + reportsReceived, + ratingsGiven: [], + ratingsReceived: [], + supportMessages: messages.map((row) => ({ + id: row.id, + topic: row.topic, + createdAt: row.createdAt.toISOString(), + handledAt: isoOrNull(row.handledAt), + })), + }; +} diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 2335c695..d6ccaffb 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -20,6 +20,7 @@ import { adminRoutes } from './routes/admin'; import { adminCatalogRoutes } from './routes/admin-catalog'; import { adminModerationRoutes } from './routes/admin-moderation'; import { adminReportsRoutes } from './routes/admin-reports'; +import { adminUsersRoutes } from './routes/admin-users'; import { configRoutes } from './routes/config'; import { editorRoutes } from './routes/editor'; import { geocodeRoutes } from './routes/geocode'; @@ -127,6 +128,7 @@ export async function buildApp(config: Config, options: BuildAppOptions = {}) { await app.register(adminModerationRoutes, { prefix: '/v1' }); await app.register(adminCatalogRoutes, { prefix: '/v1' }); await app.register(adminReportsRoutes, { prefix: '/v1' }); + await app.register(adminUsersRoutes, { 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-users.test.ts b/apps/api/src/routes/admin-users.test.ts new file mode 100644 index 00000000..d65b96dc --- /dev/null +++ b/apps/api/src/routes/admin-users.test.ts @@ -0,0 +1,98 @@ +import { AdminUserDetail, AdminUsers, ApiError, type UserRole } from '@widoo/shared'; +import { inArray } from 'drizzle-orm'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { buildApp } from '../app'; +import { TestTokenVerifier } from '../auth/test-verifier'; +import { adminActions, routes, users } from '../db/schema'; +import { seed } from '../db/seed'; +import { uuidv7 } from '../db/uuid'; +import { testConfig } from '../test-config'; + +// Shared database, parallel files: unique users and routes, and only those are listed or deleted. +const verifier = new TestTokenVerifier(); +const createdUids: string[] = []; +const createdRouteIds: string[] = []; +const marker = `userstest${uuidv7().slice(-12)}`; + +let app: Awaited>; +beforeAll(async () => { + app = await buildApp(testConfig(), { tokenVerifier: verifier }); + await seed(app.db); +}); +afterAll(async () => { + const ids = await app.db + .select({ id: users.id }) + .from(users) + .where(inArray(users.firebaseUid, createdUids)); + await app.db.delete(adminActions).where( + inArray( + adminActions.targetId, + ids.map((row) => row.id), + ), + ); + await app.db.delete(routes).where(inArray(routes.id, createdRouteIds)); + await app.db.delete(users).where(inArray(users.firebaseUid, createdUids)); + await app.close(); +}); + +/** An account of this file, signed in with a second factor; `firstName` carries the marker. */ +async function signIn(role: UserRole, name = 'Membre') { + const { token, uid } = verifier.issue({ secondFactor: 'totp' }); + createdUids.push(uid); + const [user] = await app.db + .insert(users) + .values({ firebaseUid: uid, role, firstName: name, email: `${name}-${uid}@${marker}.test` }) + .returning(); + if (!user) throw new Error('User not created'); + return { id: user.id, headers: { authorization: `Bearer ${token}` } }; +} + +type Headers = Record; +const get = (headers: Headers, url: string) => app.inject({ url: `/v1/admin${url}`, headers }); + +describe('GET /v1/admin/users', () => { + it('is for moderators and admins, not for an editor', async () => { + const editor = await signIn('editor'); + const response = await get(editor.headers, '/users'); + expect(response.statusCode).toBe(403); + expect(ApiError.parse(response.json()).code).toBe('forbidden'); + }); + + it('finds an account by part of its e-mail or first name, and by identifier', async () => { + const moderator = await signIn('moderator'); + const target = await signIn('user', 'Zéphyrine'); + const byMail = AdminUsers.parse((await get(moderator.headers, `/users?q=${marker}`)).json()); + expect(byMail.items.map((item) => item.id)).toContain(target.id); + const byName = AdminUsers.parse( + (await get(moderator.headers, `/users?q=${encodeURIComponent('zéphyr')}`)).json(), + ); + expect(byName.items.map((item) => item.id)).toContain(target.id); + const byId = AdminUsers.parse((await get(moderator.headers, `/users?q=${target.id}`)).json()); + expect(byId.items.map((item) => item.id)).toEqual([target.id]); + expect(byId.items[0]).toMatchObject({ role: 'user', plan: { plan: 'free', source: 'store' } }); + }); + + it('does not take % or _ as jokers', async () => { + const moderator = await signIn('moderator'); + const response = AdminUsers.parse((await get(moderator.headers, '/users?q=%25')).json()); + expect(response.items.every((item) => `${item.email}${item.firstName}`.includes('%'))).toBe( + true, + ); + }); + + it('reads the card of an account, and answers 404 for an unknown one', async () => { + const moderator = await signIn('moderator'); + const target = await signIn('user'); + const card = AdminUserDetail.parse( + (await get(moderator.headers, `/users/${target.id}`)).json(), + ); + expect(card).toMatchObject({ + id: target.id, + suspendedAt: null, + certification: { isCertified: false, override: 'none' }, + ratingsGiven: [], + }); + const missing = await get(moderator.headers, `/users/${uuidv7()}`); + expect(missing.statusCode).toBe(404); + }); +}); diff --git a/apps/api/src/routes/admin-users.ts b/apps/api/src/routes/admin-users.ts new file mode 100644 index 00000000..3fe519df --- /dev/null +++ b/apps/api/src/routes/admin-users.ts @@ -0,0 +1,55 @@ +import { AdminUserDetail, AdminUsers, AdminUsersQuery } from '@widoo/shared'; +import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'; +import { z } from 'zod'; +import { readUserDetail, readUsers } from '../admin/users-read'; +import { httpError } from '../errors'; + +const security = [{ firebaseIdToken: [] }]; +const tags = ['admin']; +const UserParams = z.object({ id: z.uuid() }); + +/** + * Users and roles of the admin (A-06, wiki API › Admin). Moderators and admins open the section; + * what each may do to an account is the rule `allowedUserActions`, checked in the transaction of + * each write. Every write is in the journal of the admin. Never cached. + */ +export const adminUsersRoutes: FastifyPluginAsyncZod = async (app) => { + app.addHook('onRequest', app.requireAdminSection('users')); + app.addHook('onSend', async (_request, reply) => { + reply.header('cache-control', 'no-store'); + }); + + app.get( + '/admin/users', + { + schema: { + tags, + summary: 'Search the accounts', + security, + querystring: AdminUsersQuery, + response: { 200: AdminUsers }, + }, + }, + async (request) => readUsers(app.db, request.query), + ); + + app.get( + '/admin/users/:id', + { + schema: { + tags, + summary: 'The card of an account', + security, + params: UserParams, + response: { 200: AdminUserDetail }, + }, + }, + async (request) => { + const detail = await readUserDetail(app.db, request.params.id); + if (!detail) { + throw httpError(404, 'User not found'); + } + return detail; + }, + ); +}; From 323aff84fe731c209dde8322286f2964b0b3a0cf Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Fri, 16 Oct 2026 11:59:37 +0700 Subject: [PATCH 04/12] feat(api): change a role, suspend and reactivate an account (A-06) PATCH /admin/users/:id/role (admin only) and POST /admin/users/:id/suspend and /reactivate with a reason. The rule is allowedUserActions, checked on the locked row: nobody acts on their own account, a moderator suspends a user only. Each write is journaled in the transaction of its change. A suspended account keeps its published routes but its public profile is masked. Co-Authored-By: Claude Sonnet 5.5 --- apps/api/src/admin/users-actions.ts | 138 ++++++++++++++++++++++++ apps/api/src/routes/admin-users.test.ts | 92 +++++++++++++++- apps/api/src/routes/admin-users.ts | 66 +++++++++++- apps/api/src/users/public-profile.ts | 4 +- 4 files changed, 297 insertions(+), 3 deletions(-) create mode 100644 apps/api/src/admin/users-actions.ts diff --git a/apps/api/src/admin/users-actions.ts b/apps/api/src/admin/users-actions.ts new file mode 100644 index 00000000..e218f559 --- /dev/null +++ b/apps/api/src/admin/users-actions.ts @@ -0,0 +1,138 @@ +import { + allowedUserActions, + type AdminUserDetail, + type UserAction, + type UserRole, +} from '@widoo/shared'; +import { and, eq, isNull } from 'drizzle-orm'; +import type { Db, Tx } from '../db/client'; +import { users } from '../db/schema'; +import { httpError } from '../errors'; +import { recordAdminAction } from './actions'; +import { readUserDetail } from './users-read'; + +/** The member of the team who acts: the role comes from the database, read by the guard. */ +export type Actor = { id: string; role: UserRole }; + +type Target = typeof users.$inferSelect; + +/** + * Locks the account the action targets and checks the actor may do `action` to it (the rule of + * `allowedUserActions`). 404 for an account that does not exist or was deleted; 403 otherwise. + */ +async function lockTarget( + tx: Tx, + actor: Actor, + targetId: string, + action: UserAction, +): Promise { + const [target] = await tx + .select() + .from(users) + .where(and(eq(users.id, targetId), isNull(users.deletedAt))) + .for('update'); + if (!target) { + throw httpError(404, 'User not found'); + } + if (!allowedUserActions(actor, target).includes(action)) { + throw httpError(403, 'Insufficient role'); + } + return target; +} + +async function detailOf(db: Db, userId: string): Promise { + const detail = await readUserDetail(db, userId); + if (!detail) { + throw httpError(404, 'User not found'); + } + return detail; +} + +/** Changes the role of an account (admin only, never one's own). */ +export async function changeUserRole( + db: Db, + actor: Actor, + targetId: string, + role: UserRole, +): Promise { + await db.transaction(async (tx) => { + const target = await lockTarget(tx, actor, targetId, 'role'); + if (target.role === role) { + return; + } + await tx.update(users).set({ role }).where(eq(users.id, targetId)); + await recordAdminAction(tx, { + actorId: actor.id, + action: 'user.change_role', + targetType: 'user', + targetId, + before: { role: target.role }, + after: { role }, + }); + }); + return detailOf(db, targetId); +} + +/** + * Suspends an account: refused everywhere (403 `account_suspended`), its public profile hidden, + * its published routes kept. A suspended account answers 409. + */ +export async function suspendUser( + db: Db, + actor: Actor, + targetId: string, + reason: string, + now = new Date(), +): Promise { + await db.transaction(async (tx) => { + const target = await lockTarget(tx, actor, targetId, 'suspend'); + if (target.suspendedAt) { + throw httpError(409, 'Account already suspended', 'conflict', { + reason: 'already_suspended', + }); + } + await tx + .update(users) + .set({ suspendedAt: now, suspensionReason: reason }) + .where(eq(users.id, targetId)); + await recordAdminAction(tx, { + actorId: actor.id, + action: 'user.suspend', + targetType: 'user', + targetId, + before: { suspended: false }, + after: { suspended: true }, + reason, + }); + }); + return detailOf(db, targetId); +} + +/** Lifts a suspension. An account that is not suspended answers 409. */ +export async function reactivateUser( + db: Db, + actor: Actor, + targetId: string, + reason: string, +): Promise { + await db.transaction(async (tx) => { + const target = await lockTarget(tx, actor, targetId, 'suspend'); + if (!target.suspendedAt) { + throw httpError(409, 'Account is not suspended', 'conflict', { reason: 'not_suspended' }); + } + await tx + .update(users) + .set({ suspendedAt: null, suspensionReason: null }) + .where(eq(users.id, targetId)); + await recordAdminAction(tx, { + actorId: actor.id, + action: 'user.reactivate', + targetType: 'user', + targetId, + before: { suspended: true, suspensionReason: target.suspensionReason }, + after: { suspended: false }, + reason, + }); + }); + return detailOf(db, targetId); +} diff --git a/apps/api/src/routes/admin-users.test.ts b/apps/api/src/routes/admin-users.test.ts index d65b96dc..ad4711f4 100644 --- a/apps/api/src/routes/admin-users.test.ts +++ b/apps/api/src/routes/admin-users.test.ts @@ -1,5 +1,5 @@ import { AdminUserDetail, AdminUsers, ApiError, type UserRole } from '@widoo/shared'; -import { inArray } from 'drizzle-orm'; +import { eq, inArray } from 'drizzle-orm'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { buildApp } from '../app'; import { TestTokenVerifier } from '../auth/test-verifier'; @@ -49,6 +49,16 @@ async function signIn(role: UserRole, name = 'Membre') { type Headers = Record; const get = (headers: Headers, url: string) => app.inject({ url: `/v1/admin${url}`, headers }); +const send = ( + method: 'POST' | 'PATCH' | 'DELETE', + headers: Headers, + url: string, + payload?: object, +) => app.inject({ method, url: `/v1/admin${url}`, headers, payload }); +const journalOf = async (targetId: string) => + (await app.db.select().from(adminActions).where(eq(adminActions.targetId, targetId))).map( + (row) => row.action, + ); describe('GET /v1/admin/users', () => { it('is for moderators and admins, not for an editor', async () => { @@ -96,3 +106,83 @@ describe('GET /v1/admin/users', () => { expect(missing.statusCode).toBe(404); }); }); + +describe('role, suspension and the other actions by role', () => { + it('lets only an admin change a role, never their own', async () => { + const admin = await signIn('admin'); + const moderator = await signIn('moderator'); + const target = await signIn('user'); + const refused = await send('PATCH', moderator.headers, `/users/${target.id}/role`, { + role: 'editor', + }); + expect(refused.statusCode).toBe(403); + const own = await send('PATCH', admin.headers, `/users/${admin.id}/role`, { role: 'user' }); + expect(own.statusCode).toBe(403); + const changed = await send('PATCH', admin.headers, `/users/${target.id}/role`, { + role: 'editor', + }); + expect(changed.statusCode).toBe(200); + expect(AdminUserDetail.parse(changed.json()).role).toBe('editor'); + expect(await journalOf(target.id)).toEqual(['user.change_role']); + }); + + it('lets a moderator suspend a user, not an editor, a moderator, an admin or themselves', async () => { + const moderator = await signIn('moderator'); + const user = await signIn('user'); + const editor = await signIn('editor'); + const otherModerator = await signIn('moderator'); + const admin = await signIn('admin'); + for (const refused of [editor, otherModerator, admin, moderator]) { + const response = await send('POST', moderator.headers, `/users/${refused.id}/suspend`, { + reason: 'Test', + }); + expect(response.statusCode).toBe(403); + } + const response = await send('POST', moderator.headers, `/users/${user.id}/suspend`, { + reason: 'Propos injurieux', + }); + expect(response.statusCode).toBe(200); + }); + + it('refuses an admin to suspend themselves, and asks for a reason', async () => { + const admin = await signIn('admin'); + const own = await send('POST', admin.headers, `/users/${admin.id}/suspend`, { reason: 'Moi' }); + expect(own.statusCode).toBe(403); + const target = await signIn('user'); + const blank = await send('POST', admin.headers, `/users/${target.id}/suspend`, {}); + expect(blank.statusCode).toBe(422); + }); + + it('refuses a suspended account everywhere, hides its profile, and lets a reactivation undo it', async () => { + const admin = await signIn('admin'); + const target = await signIn('user', 'Visible'); + expect((await app.inject({ url: '/v1/me', headers: target.headers })).statusCode).toBe(200); + expect((await app.inject({ url: `/v1/users/${target.id}/profile` })).statusCode).toBe(200); + + const suspended = await send('POST', admin.headers, `/users/${target.id}/suspend`, { + reason: 'Spam', + }); + expect(suspended.statusCode).toBe(200); + expect(AdminUserDetail.parse(suspended.json())).toMatchObject({ suspensionReason: 'Spam' }); + const refused = await app.inject({ url: '/v1/me', headers: target.headers }); + expect(refused.statusCode).toBe(403); + expect(ApiError.parse(refused.json()).code).toBe('account_suspended'); + expect((await app.inject({ url: `/v1/users/${target.id}/profile` })).statusCode).toBe(404); + const again = await send('POST', admin.headers, `/users/${target.id}/suspend`, { reason: 'X' }); + expect(again.statusCode).toBe(409); + + const back = await send('POST', admin.headers, `/users/${target.id}/reactivate`, { + reason: 'Erreur', + }); + expect(AdminUserDetail.parse(back.json())).toMatchObject({ + suspendedAt: null, + suspensionReason: null, + }); + expect((await app.inject({ url: '/v1/me', headers: target.headers })).statusCode).toBe(200); + expect(await journalOf(target.id)).toEqual(['user.suspend', 'user.reactivate']); + const notSuspended = await send('POST', admin.headers, `/users/${target.id}/reactivate`, { + reason: 'Encore', + }); + expect(notSuspended.statusCode).toBe(409); + }); +}); diff --git a/apps/api/src/routes/admin-users.ts b/apps/api/src/routes/admin-users.ts index 3fe519df..6970923b 100644 --- a/apps/api/src/routes/admin-users.ts +++ b/apps/api/src/routes/admin-users.ts @@ -1,6 +1,14 @@ -import { AdminUserDetail, AdminUsers, AdminUsersQuery } from '@widoo/shared'; +import { + AdminUserDetail, + AdminUsers, + AdminUsersQuery, + ChangeUserRoleBody, + UserReasonBody, +} from '@widoo/shared'; +import type { FastifyRequest } from 'fastify'; import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'; import { z } from 'zod'; +import { changeUserRole, reactivateUser, suspendUser, type Actor } from '../admin/users-actions'; import { readUserDetail, readUsers } from '../admin/users-read'; import { httpError } from '../errors'; @@ -8,6 +16,14 @@ const security = [{ firebaseIdToken: [] }]; const tags = ['admin']; const UserParams = z.object({ id: z.uuid() }); +/** Set by the guard of the section, which every route of this plugin runs first. */ +function actorOf(request: FastifyRequest): Actor { + if (!request.user) { + throw httpError(401, 'Missing bearer token'); + } + return { id: request.user.id, role: request.user.role }; +} + /** * Users and roles of the admin (A-06, wiki API › Admin). Moderators and admins open the section; * what each may do to an account is the rule `allowedUserActions`, checked in the transaction of @@ -52,4 +68,52 @@ export const adminUsersRoutes: FastifyPluginAsyncZod = async (app) => { return detail; }, ); + + app.patch( + '/admin/users/:id/role', + { + schema: { + tags, + summary: 'Change the role of an account (admin only, never one’s own)', + security, + params: UserParams, + body: ChangeUserRoleBody, + response: { 200: AdminUserDetail }, + }, + }, + async (request) => + changeUserRole(app.db, actorOf(request), request.params.id, request.body.role), + ); + + app.post( + '/admin/users/:id/suspend', + { + schema: { + tags, + summary: 'Suspend an account', + security, + params: UserParams, + body: UserReasonBody, + response: { 200: AdminUserDetail }, + }, + }, + async (request) => + suspendUser(app.db, actorOf(request), request.params.id, request.body.reason), + ); + + app.post( + '/admin/users/:id/reactivate', + { + schema: { + tags, + summary: 'Lift the suspension of an account', + security, + params: UserParams, + body: UserReasonBody, + response: { 200: AdminUserDetail }, + }, + }, + async (request) => + reactivateUser(app.db, actorOf(request), request.params.id, request.body.reason), + ); }; diff --git a/apps/api/src/users/public-profile.ts b/apps/api/src/users/public-profile.ts index f17e7e1e..74be7e96 100644 --- a/apps/api/src/users/public-profile.ts +++ b/apps/api/src/users/public-profile.ts @@ -28,7 +28,7 @@ import { readAnecdotes, readCertificationValues } from './profile'; import type { User } from './repository'; /** - * The account of a public profile: active, public and with a first name, as the author of a card + * The account of a public profile: active, not suspended, public and with a first name, as the author of a card * (D-025). A masked profile, a deleted account and an unknown id are all `undefined`, so that the * answer never tells them apart. */ @@ -40,6 +40,8 @@ async function findPublicUser(db: Db, id: string): Promise { and( eq(users.id, id), isNull(users.deletedAt), + // A suspended account is masked; its published routes stay (A-06). + isNull(users.suspendedAt), eq(users.isPublic, true), isNotNull(users.firstName), ), From ffb834611473da43e270a1b234da6a1854980c47 Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Fri, 16 Oct 2026 11:59:52 +0700 Subject: [PATCH 05/12] feat(api): offer Premium until a date, or one Premium route (A-06) POST /admin/users/:id/gift, admin only: Premium until a future date (plan_source gift, which ends by itself through canAccessRoute) or one published Premium route (route_unlocks, source gift). Refused with a reason in details when the account has a running store plan, already has the route, is its author, or the route is free. Co-Authored-By: Claude Sonnet 5.5 --- apps/api/src/admin/users-actions.ts | 87 +++++++++++++++- apps/api/src/routes/admin-users.test.ts | 130 +++++++++++++++++++++++- apps/api/src/routes/admin-users.ts | 24 ++++- 3 files changed, 237 insertions(+), 4 deletions(-) diff --git a/apps/api/src/admin/users-actions.ts b/apps/api/src/admin/users-actions.ts index e218f559..d71e8d93 100644 --- a/apps/api/src/admin/users-actions.ts +++ b/apps/api/src/admin/users-actions.ts @@ -1,12 +1,13 @@ import { allowedUserActions, type AdminUserDetail, + type GiftBody, type UserAction, type UserRole, } from '@widoo/shared'; import { and, eq, isNull } from 'drizzle-orm'; import type { Db, Tx } from '../db/client'; -import { users } from '../db/schema'; +import { routeUnlocks, routes, users } from '../db/schema'; import { httpError } from '../errors'; import { recordAdminAction } from './actions'; import { readUserDetail } from './users-read'; @@ -136,3 +137,87 @@ export async function reactivateUser( }); return detailOf(db, targetId); } + +/** True while the right to Premium of an account comes from the store and has not ended. */ +const hasActiveStorePremium = (target: Target, now: Date) => + target.plan === 'premium' && + target.planSource === 'store' && + (target.planExpiresAt === null || target.planExpiresAt.getTime() > now.getTime()); + +/** + * Offers Premium until a date, or one Premium route (support, `source = gift`, without the + * store). An account whose store plan runs keeps it: the gift would shorten what it paid for (409). + * Past the date, the plan ends by itself (`canAccessRoute`). + */ +export async function giftUser( + db: Db, + actor: Actor, + targetId: string, + gift: GiftBody, + now = new Date(), +): Promise { + await db.transaction(async (tx) => { + const target = await lockTarget(tx, actor, targetId, 'gift'); + if (gift.until !== undefined) { + const until = new Date(gift.until); + if (until.getTime() <= now.getTime()) { + throw httpError(400, 'The end date must be in the future'); + } + if (hasActiveStorePremium(target, now)) { + throw httpError(409, 'The account has a running subscription or pass', 'conflict', { + reason: 'active_store_plan', + }); + } + await tx + .update(users) + .set({ plan: 'premium', planSource: 'gift', planExpiresAt: until }) + .where(eq(users.id, targetId)); + await recordAdminAction(tx, { + actorId: actor.id, + action: 'user.gift_premium', + targetType: 'user', + targetId, + before: { plan: target.plan, source: target.planSource, expiresAt: target.planExpiresAt }, + after: { plan: 'premium', source: 'gift', expiresAt: until }, + }); + return; + } + const routeId = gift.routeId; + if (routeId === undefined) { + throw httpError(400, 'Give exactly one of until and routeId'); + } + const [route] = await tx + .select({ access: routes.access, status: routes.status, authorId: routes.authorId }) + .from(routes) + .where(eq(routes.id, routeId)); + if (!route || route.status !== 'published') { + throw httpError(404, 'Route not found'); + } + if (route.authorId === targetId) { + throw httpError(409, 'A creator already has their own routes', 'conflict', { + reason: 'own_route', + }); + } + if (route.access !== 'premium') { + throw httpError(409, 'The route is free', 'conflict', { reason: 'free_route' }); + } + const [inserted] = await tx + .insert(routeUnlocks) + .values({ userId: targetId, routeId, source: 'gift', storeTransactionId: null }) + .onConflictDoNothing() + .returning({ routeId: routeUnlocks.routeId }); + if (!inserted) { + throw httpError(409, 'The route is already unlocked', 'conflict', { + reason: 'route_already_unlocked', + }); + } + await recordAdminAction(tx, { + actorId: actor.id, + action: 'user.gift_route', + targetType: 'user', + targetId, + after: { routeId }, + }); + }); + return detailOf(db, targetId); +} diff --git a/apps/api/src/routes/admin-users.test.ts b/apps/api/src/routes/admin-users.test.ts index ad4711f4..75543b3d 100644 --- a/apps/api/src/routes/admin-users.test.ts +++ b/apps/api/src/routes/admin-users.test.ts @@ -1,10 +1,11 @@ import { AdminUserDetail, AdminUsers, ApiError, type UserRole } from '@widoo/shared'; import { eq, inArray } from 'drizzle-orm'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { canAccessRoute } from '../access/route-access'; import { buildApp } from '../app'; import { TestTokenVerifier } from '../auth/test-verifier'; -import { adminActions, routes, users } from '../db/schema'; -import { seed } from '../db/seed'; +import { adminActions, routeUnlocks, routes, users } from '../db/schema'; +import { seed, seedId } from '../db/seed'; import { uuidv7 } from '../db/uuid'; import { testConfig } from '../test-config'; @@ -13,6 +14,10 @@ const verifier = new TestTokenVerifier(); const createdUids: string[] = []; const createdRouteIds: string[] = []; const marker = `userstest${uuidv7().slice(-12)}`; +const templateId = seedId('route:marais-gourmand'); +const farAway = { lat: 33, lng: 33 }; +const farAwayBounds = { west: 33, south: 33, east: 33.001, north: 33.001 }; +const day = 24 * 60 * 60 * 1000; let app: Awaited>; beforeAll(async () => { @@ -47,6 +52,25 @@ async function signIn(role: UserRole, name = 'Membre') { return { id: user.id, headers: { authorization: `Bearer ${token}` } }; } +async function addRoute(access: 'free' | 'premium', status: 'published' | 'draft' = 'published') { + const [template] = await app.db.select().from(routes).where(eq(routes.id, templateId)); + if (!template) throw new Error('Demo route missing from the seed'); + const id = uuidv7(); + createdRouteIds.push(id); + await app.db.insert(routes).values({ + ...template, + id, + authorId: null, + shareToken: null, + startLocation: farAway, + bounds: farAwayBounds, + status, + access, + title: `Parcours ${marker}`, + }); + return id; +} + type Headers = Record; const get = (headers: Headers, url: string) => app.inject({ url: `/v1/admin${url}`, headers }); const send = ( @@ -186,3 +210,105 @@ describe('role, suspension and the other actions by role', () => { expect(notSuspended.statusCode).toBe(409); }); }); + +describe('offering Premium', () => { + it('gives Premium until a date, then stops by itself', async () => { + const admin = await signIn('admin'); + const target = await signIn('user'); + const until = new Date(Date.now() + 7 * day); + const response = await send('POST', admin.headers, `/users/${target.id}/gift`, { + until: until.toISOString(), + }); + expect(response.statusCode).toBe(200); + expect(AdminUserDetail.parse(response.json()).plan).toEqual({ + plan: 'premium', + source: 'gift', + expiresAt: until.toISOString(), + }); + const [row] = await app.db.select().from(users).where(eq(users.id, target.id)); + if (!row) throw new Error('User missing'); + const premiumRoute = { id: uuidv7(), access: 'premium' as const }; + const viewer = { + plan: row.plan, + planExpiresAt: row.planExpiresAt, + unlockedRouteIds: new Set(), + }; + expect(canAccessRoute(premiumRoute, viewer, new Date())).toBe(true); + expect(canAccessRoute(premiumRoute, viewer, new Date(until.getTime() + 1000))).toBe(false); + expect(await journalOf(target.id)).toEqual(['user.gift_premium']); + }); + + it('refuses a past date, both fields, neither, and a running store plan', async () => { + const admin = await signIn('admin'); + const target = await signIn('user'); + const route = await addRoute('premium'); + const bodies = [ + { until: new Date(Date.now() - day).toISOString() }, + { until: new Date(Date.now() + day).toISOString(), routeId: route }, + {}, + ]; + const codes = [400, 422, 422]; + for (const [index, body] of bodies.entries()) { + const response = await send('POST', admin.headers, `/users/${target.id}/gift`, body); + expect(response.statusCode).toBe(codes[index]); + } + await app.db + .update(users) + .set({ plan: 'premium', planExpiresAt: new Date(Date.now() + 30 * day) }) + .where(eq(users.id, target.id)); + const running = await send('POST', admin.headers, `/users/${target.id}/gift`, { + until: new Date(Date.now() + day).toISOString(), + }); + expect(running.statusCode).toBe(409); + expect(ApiError.parse(running.json()).details).toEqual({ reason: 'active_store_plan' }); + }); + + it('replaces a store plan that has ended', async () => { + const admin = await signIn('admin'); + const target = await signIn('user'); + await app.db + .update(users) + .set({ plan: 'premium', planExpiresAt: new Date(Date.now() - day) }) + .where(eq(users.id, target.id)); + const response = await send('POST', admin.headers, `/users/${target.id}/gift`, { + until: new Date(Date.now() + day).toISOString(), + }); + expect(response.statusCode).toBe(200); + }); + + it('offers one Premium route, once', async () => { + const admin = await signIn('admin'); + const target = await signIn('user'); + const route = await addRoute('premium'); + const response = await send('POST', admin.headers, `/users/${target.id}/gift`, { + routeId: route, + }); + expect(response.statusCode).toBe(200); + expect(AdminUserDetail.parse(response.json()).unlockedRoutes).toMatchObject([ + { id: route, source: 'gift' }, + ]); + const [unlock] = await app.db + .select() + .from(routeUnlocks) + .where(eq(routeUnlocks.userId, target.id)); + expect(unlock).toMatchObject({ routeId: route, source: 'gift', storeTransactionId: null }); + const twice = await send('POST', admin.headers, `/users/${target.id}/gift`, { routeId: route }); + expect(twice.statusCode).toBe(409); + expect(ApiError.parse(twice.json()).details).toEqual({ reason: 'route_already_unlocked' }); + const free = await send('POST', admin.headers, `/users/${target.id}/gift`, { + routeId: await addRoute('free'), + }); + expect(free.statusCode).toBe(409); + const ownRoute = await addRoute('premium'); + await app.db.update(routes).set({ authorId: target.id }).where(eq(routes.id, ownRoute)); + const own = await send('POST', admin.headers, `/users/${target.id}/gift`, { + routeId: ownRoute, + }); + expect(own.statusCode).toBe(409); + expect(ApiError.parse(own.json()).details).toEqual({ reason: 'own_route' }); + const draft = await send('POST', admin.headers, `/users/${target.id}/gift`, { + routeId: await addRoute('premium', 'draft'), + }); + expect(draft.statusCode).toBe(404); + }); +}); diff --git a/apps/api/src/routes/admin-users.ts b/apps/api/src/routes/admin-users.ts index 6970923b..b1a94929 100644 --- a/apps/api/src/routes/admin-users.ts +++ b/apps/api/src/routes/admin-users.ts @@ -3,12 +3,19 @@ import { AdminUsers, AdminUsersQuery, ChangeUserRoleBody, + GiftBody, UserReasonBody, } from '@widoo/shared'; import type { FastifyRequest } from 'fastify'; import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'; import { z } from 'zod'; -import { changeUserRole, reactivateUser, suspendUser, type Actor } from '../admin/users-actions'; +import { + changeUserRole, + giftUser, + reactivateUser, + suspendUser, + type Actor, +} from '../admin/users-actions'; import { readUserDetail, readUsers } from '../admin/users-read'; import { httpError } from '../errors'; @@ -116,4 +123,19 @@ export const adminUsersRoutes: FastifyPluginAsyncZod = async (app) => { async (request) => reactivateUser(app.db, actorOf(request), request.params.id, request.body.reason), ); + + app.post( + '/admin/users/:id/gift', + { + schema: { + tags, + summary: 'Offer Premium until a date, or one Premium route', + security, + params: UserParams, + body: GiftBody, + response: { 200: AdminUserDetail }, + }, + }, + async (request) => giftUser(app.db, actorOf(request), request.params.id, request.body), + ); }; From 5ea1013736bc6fd5dc17c33fb988e372529fe796 Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Fri, 16 Oct 2026 11:59:57 +0700 Subject: [PATCH 06/12] feat(api): award the creator badge, export the data of an account and delete it (A-06) POST /admin/users/:id/certification (the team's choice wins over the nightly computation), GET /admin/users/:id/export (the file of the app, rate limited, journaled as it reads the data of someone else) and DELETE /admin/users/:id with the rule of the app (softDeleteUser). Admin only; an account of the team is demoted to user before it is deleted. Co-Authored-By: Claude Sonnet 5.5 --- apps/api/src/admin/users-actions.ts | 67 ++++++++++++++++++++ apps/api/src/routes/admin-users.test.ts | 67 +++++++++++++++++++- apps/api/src/routes/admin-users.ts | 81 +++++++++++++++++++++++++ 3 files changed, 214 insertions(+), 1 deletion(-) diff --git a/apps/api/src/admin/users-actions.ts b/apps/api/src/admin/users-actions.ts index d71e8d93..e0536a1d 100644 --- a/apps/api/src/admin/users-actions.ts +++ b/apps/api/src/admin/users-actions.ts @@ -1,6 +1,7 @@ import { allowedUserActions, type AdminUserDetail, + type CertificationBody, type GiftBody, type UserAction, type UserRole, @@ -9,6 +10,7 @@ import { and, eq, isNull } from 'drizzle-orm'; import type { Db, Tx } from '../db/client'; import { routeUnlocks, routes, users } from '../db/schema'; import { httpError } from '../errors'; +import { softDeleteUser } from '../users/repository'; import { recordAdminAction } from './actions'; import { readUserDetail } from './users-read'; @@ -221,3 +223,68 @@ export async function giftUser( }); return detailOf(db, targetId); } + +/** Awards or removes the Créateur certifié badge; the choice wins over the nightly computation. */ +export async function setCertification( + db: Db, + actor: Actor, + targetId: string, + body: CertificationBody, + now = new Date(), +): Promise { + await db.transaction(async (tx) => { + const target = await lockTarget(tx, actor, targetId, 'certification'); + if (target.certifiedOverride === body.override) { + return; + } + await tx + .update(users) + .set({ + certifiedOverride: body.override, + certifiedAt: body.override === 'granted' ? (target.certifiedAt ?? now) : target.certifiedAt, + }) + .where(eq(users.id, targetId)); + await recordAdminAction(tx, { + actorId: actor.id, + action: 'user.certification', + targetType: 'user', + targetId, + before: { override: target.certifiedOverride }, + after: { override: body.override }, + }); + }); + return detailOf(db, targetId); +} + +/** + * Deletes an account with the rule of the app (`softDeleteUser`, D-025, D-027): data erased now, + * routes online kept as « Membre Widoo », the others deleted. An account of the team is demoted to + * `user` first. + */ +export async function deleteUser(db: Db, actor: Actor, targetId: string): Promise { + await db.transaction(async (tx) => { + const target = await lockTarget(tx, actor, targetId, 'delete'); + await softDeleteUser(tx, targetId); + await recordAdminAction(tx, { + actorId: actor.id, + action: 'user.delete', + targetType: 'user', + targetId, + before: { role: target.role, plan: target.plan }, + }); + }); +} + +/** Refuses an export to whoever may not make it, before any data is read. */ +export async function assertCanExport(db: Db, actor: Actor, targetId: string): Promise { + const [target] = await db + .select({ id: users.id, role: users.role }) + .from(users) + .where(and(eq(users.id, targetId), isNull(users.deletedAt))); + if (!target) { + throw httpError(404, 'User not found'); + } + if (!allowedUserActions(actor, target).includes('export')) { + throw httpError(403, 'Insufficient role'); + } +} diff --git a/apps/api/src/routes/admin-users.test.ts b/apps/api/src/routes/admin-users.test.ts index 75543b3d..368fb8ab 100644 --- a/apps/api/src/routes/admin-users.test.ts +++ b/apps/api/src/routes/admin-users.test.ts @@ -1,4 +1,4 @@ -import { AdminUserDetail, AdminUsers, ApiError, type UserRole } from '@widoo/shared'; +import { AdminUserDetail, AdminUsers, ApiError, MyDataExport, type UserRole } from '@widoo/shared'; import { eq, inArray } from 'drizzle-orm'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { canAccessRoute } from '../access/route-access'; @@ -209,6 +209,24 @@ describe('role, suspension and the other actions by role', () => { }); expect(notSuspended.statusCode).toBe(409); }); + + it('keeps the other actions for the admin', async () => { + const moderator = await signIn('moderator'); + const target = await signIn('user'); + const route = await addRoute('premium'); + const attempts = [ + send('POST', moderator.headers, `/users/${target.id}/gift`, { + until: new Date(Date.now() + day).toISOString(), + }), + send('POST', moderator.headers, `/users/${target.id}/gift`, { routeId: route }), + send('POST', moderator.headers, `/users/${target.id}/certification`, { override: 'granted' }), + get(moderator.headers, `/users/${target.id}/export`), + send('DELETE', moderator.headers, `/users/${target.id}`), + ]; + for (const response of await Promise.all(attempts)) { + expect(response.statusCode).toBe(403); + } + }); }); describe('offering Premium', () => { @@ -312,3 +330,50 @@ describe('offering Premium', () => { expect(draft.statusCode).toBe(404); }); }); + +describe('certification, export and deletion', () => { + it('awards and removes the badge, which wins over the computation', async () => { + const admin = await signIn('admin'); + const target = await signIn('user'); + const granted = await send('POST', admin.headers, `/users/${target.id}/certification`, { + override: 'granted', + }); + expect(AdminUserDetail.parse(granted.json()).certification).toMatchObject({ + isCertified: true, + computed: false, + override: 'granted', + }); + const revoked = await send('POST', admin.headers, `/users/${target.id}/certification`, { + override: 'revoked', + }); + expect(AdminUserDetail.parse(revoked.json()).certification.isCertified).toBe(false); + expect(await journalOf(target.id)).toEqual(['user.certification', 'user.certification']); + }); + + it('exports the data of an account, and keeps a trace of it', async () => { + const admin = await signIn('admin'); + const target = await signIn('user', 'Exporté'); + const response = await get(admin.headers, `/users/${target.id}/export`); + expect(response.statusCode).toBe(200); + expect(response.headers['cache-control']).toBe('no-store'); + expect(response.headers['content-disposition']).toContain('widoo-donnees-'); + expect(MyDataExport.parse(response.json()).account.id).toBe(target.id); + expect(await journalOf(target.id)).toEqual(['user.export']); + }); + + it('deletes a user with the rule of the app, but not an account of the team nor their own', async () => { + const admin = await signIn('admin'); + const target = await signIn('user', 'Partante'); + const editor = await signIn('editor'); + expect((await send('DELETE', admin.headers, `/users/${editor.id}`)).statusCode).toBe(403); + expect((await send('DELETE', admin.headers, `/users/${admin.id}`)).statusCode).toBe(403); + const response = await send('DELETE', admin.headers, `/users/${target.id}`); + expect(response.statusCode).toBe(204); + const [row] = await app.db.select().from(users).where(eq(users.id, target.id)); + expect(row).toMatchObject({ firstName: null, email: null, isPublic: false }); + expect(row?.deletedAt).not.toBeNull(); + expect((await get(admin.headers, `/users/${target.id}`)).statusCode).toBe(404); + expect((await send('DELETE', admin.headers, `/users/${target.id}`)).statusCode).toBe(404); + expect(await journalOf(target.id)).toEqual(['user.delete']); + }); +}); diff --git a/apps/api/src/routes/admin-users.ts b/apps/api/src/routes/admin-users.ts index b1a94929..9fad03ca 100644 --- a/apps/api/src/routes/admin-users.ts +++ b/apps/api/src/routes/admin-users.ts @@ -2,22 +2,30 @@ import { AdminUserDetail, AdminUsers, AdminUsersQuery, + CertificationBody, ChangeUserRoleBody, GiftBody, + MyDataExport, UserReasonBody, } from '@widoo/shared'; import type { FastifyRequest } from 'fastify'; import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'; import { z } from 'zod'; +import { recordAdminAction } from '../admin/actions'; import { + assertCanExport, changeUserRole, + deleteUser, giftUser, reactivateUser, + setCertification, suspendUser, type Actor, } from '../admin/users-actions'; import { readUserDetail, readUsers } from '../admin/users-read'; import { httpError } from '../errors'; +import { exportUserData } from '../users/export'; +import { EXPORT_RATE_LIMIT, exportFileName } from './me-export'; const security = [{ firebaseIdToken: [] }]; const tags = ['admin']; @@ -138,4 +146,77 @@ export const adminUsersRoutes: FastifyPluginAsyncZod = async (app) => { }, async (request) => giftUser(app.db, actorOf(request), request.params.id, request.body), ); + + app.post( + '/admin/users/:id/certification', + { + schema: { + tags, + summary: 'Award or remove the Créateur certifié badge', + security, + params: UserParams, + body: CertificationBody, + response: { 200: AdminUserDetail }, + }, + }, + async (request) => setCertification(app.db, actorOf(request), request.params.id, request.body), + ); + + app.get( + '/admin/users/:id/export', + { + config: { + rateLimit: { + max: EXPORT_RATE_LIMIT, + timeWindow: '1 hour', + // After the guard: counted by member of the team, never by a shared address. + hook: 'preValidation', + keyGenerator: (request: FastifyRequest) => `admin-export:${request.user?.id ?? ''}`, + }, + }, + schema: { + tags, + summary: 'Export the data of an account, the same file as the app’s', + security, + params: UserParams, + response: { 200: MyDataExport }, + }, + }, + async (request, reply) => { + const actor = actorOf(request); + const targetId = request.params.id; + const now = new Date(); + await assertCanExport(app.db, actor, targetId); + const exported = await exportUserData(app.db, targetId, now); + if (!exported) { + throw httpError(404, 'User not found'); + } + // Reading the data of someone else leaves a trace, like any write of the admin. + await recordAdminAction(app.db, { + actorId: actor.id, + action: 'user.export', + targetType: 'user', + targetId, + }); + reply.header('content-disposition', `attachment; filename="${exportFileName(now)}"`); + return exported; + }, + ); + + app.delete( + '/admin/users/:id', + { + schema: { + tags, + summary: 'Delete an account, with the rule of the app', + security, + params: UserParams, + response: { 204: z.null() }, + }, + }, + async (request, reply) => { + await deleteUser(app.db, actorOf(request), request.params.id); + return reply.status(204).send(null); + }, + ); }; From 9995222c4b443aa305bf55224986637e0cf72b51 Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Fri, 16 Oct 2026 11:59:57 +0700 Subject: [PATCH 07/12] feat(api-client): call the users of the admin (A-06) Co-Authored-By: Claude Sonnet 5.5 --- packages/api-client/src/client.ts | 92 +++++++++++++++++++++++++++ packages/api-client/src/users.test.ts | 12 ++++ packages/api-client/src/users.ts | 13 ++++ 3 files changed, 117 insertions(+) create mode 100644 packages/api-client/src/users.test.ts create mode 100644 packages/api-client/src/users.ts diff --git a/packages/api-client/src/client.ts b/packages/api-client/src/client.ts index 2b9d9dae..9f42dfa2 100644 --- a/packages/api-client/src/client.ts +++ b/packages/api-client/src/client.ts @@ -1,5 +1,7 @@ import { AdminRatings, + AdminUserDetail, + AdminUsers, AdminRouteReports, AdminSignaledPlaces, PlaceActionResult, @@ -42,6 +44,11 @@ import { type AdminRatingsQuery, type AdminReportsQuery, type AdminSignalsQuery, + type AdminUsersQuery, + type CertificationBody, + type ChangeUserRoleBody, + type GiftBody, + type UserReasonBody, type CatalogPlaceInput, type ClosePlaceBody, type ResolveReportBody, @@ -60,6 +67,7 @@ import { removedOf, routePath, type GetRouteOptions, type RouteSheetAnswer } fro import { createTransport, type ApiClientOptions } from './transport'; import { moderationQueryString, type ModerationQueueParams } from './moderation'; import { catalogPlacesQueryString, catalogQueryString } from './catalog'; +import { usersQueryString } from './users'; import { ratingsQueryString, reportsQueryString, signalsQueryString } from './reports'; /** @@ -466,6 +474,90 @@ export function createApiClient(options: ApiClientOptions) { auth: true, }), + /** `GET /v1/admin/users`: the accounts, newest first, searched by e-mail, first name or id (A-06). */ + getAdminUsers: (params: Partial, signal?: AbortSignal) => + request({ + method: 'GET', + path: `/v1/admin/users?${usersQueryString(params)}`, + schema: AdminUsers, + auth: true, + signal, + }), + + /** `GET /v1/admin/users/:id`: the card of an account. */ + getAdminUser: (userId: string, signal?: AbortSignal) => + request({ + method: 'GET', + path: `/v1/admin/users/${userId}`, + schema: AdminUserDetail, + auth: true, + signal, + }), + + /** `PATCH /v1/admin/users/:id/role`: changes a role (admin only, never one's own). */ + changeUserRole: (userId: string, body: ChangeUserRoleBody) => + request({ + method: 'PATCH', + path: `/v1/admin/users/${userId}/role`, + body, + schema: AdminUserDetail, + auth: true, + }), + + /** `POST /v1/admin/users/:id/suspend`: suspends an account, with a reason. */ + suspendUser: (userId: string, body: UserReasonBody) => + request({ + method: 'POST', + path: `/v1/admin/users/${userId}/suspend`, + body, + schema: AdminUserDetail, + auth: true, + }), + + /** `POST /v1/admin/users/:id/reactivate`: lifts a suspension, with a reason. */ + reactivateUser: (userId: string, body: UserReasonBody) => + request({ + method: 'POST', + path: `/v1/admin/users/${userId}/reactivate`, + body, + schema: AdminUserDetail, + auth: true, + }), + + /** `POST /v1/admin/users/:id/gift`: offers Premium until a date, or one Premium route. */ + giftUser: (userId: string, body: GiftBody) => + request({ + method: 'POST', + path: `/v1/admin/users/${userId}/gift`, + body, + schema: AdminUserDetail, + auth: true, + }), + + /** `POST /v1/admin/users/:id/certification`: awards or removes the Créateur certifié badge. */ + setUserCertification: (userId: string, body: CertificationBody) => + request({ + method: 'POST', + path: `/v1/admin/users/${userId}/certification`, + body, + schema: AdminUserDetail, + auth: true, + }), + + /** `GET /v1/admin/users/:id/export`: the data of an account, the same file as the app's. */ + exportUserData: (userId: string, signal?: AbortSignal) => + request({ + method: 'GET', + path: `/v1/admin/users/${userId}/export`, + schema: MyDataExport, + auth: true, + signal, + }), + + /** `DELETE /v1/admin/users/:id`: deletes an account with the rule of the app. */ + deleteUser: (userId: string) => + requestEmpty({ method: 'DELETE', path: `/v1/admin/users/${userId}`, auth: true }), + /** `GET /v1/admin/ratings`: the ratings with a comment, filtered by note and route (A-03). */ getAdminRatings: (params: Partial, signal?: AbortSignal) => request({ diff --git a/packages/api-client/src/users.test.ts b/packages/api-client/src/users.test.ts new file mode 100644 index 00000000..e79666fc --- /dev/null +++ b/packages/api-client/src/users.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest'; +import { usersQueryString } from './users'; + +describe('usersQueryString', () => { + it('keeps the set values and encodes the search', () => { + expect(usersQueryString({ q: 'Léa & co', cursor: 'abc' })).toBe('q=L%C3%A9a+%26+co&cursor=abc'); + }); + + it('leaves out what is empty', () => { + expect(usersQueryString({ q: '', cursor: undefined })).toBe(''); + }); +}); diff --git a/packages/api-client/src/users.ts b/packages/api-client/src/users.ts new file mode 100644 index 00000000..5d679672 --- /dev/null +++ b/packages/api-client/src/users.ts @@ -0,0 +1,13 @@ +import type { AdminUsersQuery } from '@widoo/shared'; + +/** Query string of the list of users, as the API reads it. */ +export function usersQueryString(params: Partial): string { + const query = new URLSearchParams(); + for (const key of ['q', 'cursor'] as const) { + const value = params[key]; + if (value !== undefined && value !== '') { + query.set(key, value); + } + } + return query.toString(); +} From 250f1c213cf48f564480d8f7bb04643a263f785f Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Fri, 16 Oct 2026 11:59:57 +0700 Subject: [PATCH 08/12] fix(ui): keep the detail panel in view while the list scrolls The panel grew with the page, so scrolling a long card scrolled the list away. It now sticks to the top and scrolls inside its own height (max-h-detail-panel, to move to tokens.json). Co-Authored-By: Claude Sonnet 5.5 --- packages/ui/src/components/DetailPanel.tsx | 2 +- packages/ui/tailwind-preset.cjs | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/components/DetailPanel.tsx b/packages/ui/src/components/DetailPanel.tsx index 267e2dec..b7796f62 100644 --- a/packages/ui/src/components/DetailPanel.tsx +++ b/packages/ui/src/components/DetailPanel.tsx @@ -41,7 +41,7 @@ export function DetailPanel({ tabIndex={-1} aria-label={title} onKeyDown={(event) => event.key === 'Escape' && onClose()} - className="flex h-full w-detail-panel shrink-0 flex-col gap-16 border-l border-border p-24" + className="sticky top-0 flex max-h-detail-panel w-detail-panel shrink-0 flex-col gap-16 self-start border-l border-border p-24" >
diff --git a/packages/ui/tailwind-preset.cjs b/packages/ui/tailwind-preset.cjs index e8b69085..1efa14d6 100644 --- a/packages/ui/tailwind-preset.cjs +++ b/packages/ui/tailwind-preset.cjs @@ -95,6 +95,9 @@ module.exports = { decision: '320px', }, maxWidth: { 'sign-in': '440px', dialog: '520px' }, + // The detail panel stays in view while the list scrolls: the window less the header (64 px) and + // the padding of the page (2 × 24 px). + maxHeight: { 'detail-panel': 'calc(100dvh - 112px)' }, // A change of a revision on the moderation sheet: its subject, then what changed. // And a day of the week next to its opening hours, in the form of a place. gridTemplateColumns: { change: '150px 1fr', hours: '110px 1fr' }, From 12f5b9e9b80e13edb5cdcd0358d4018ca8edcbe8 Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Fri, 16 Oct 2026 12:00:38 +0700 Subject: [PATCH 09/12] feat(admin): show the accounts as a table, with their role, plan and status (A-06) The table of Utilisateurs, its badges and texts, how an account is named and its plan read, the search address of the list, and the id of the member of the session. Co-Authored-By: Claude Sonnet 5.5 --- .../src/components/users/UserRoleBadge.tsx | 18 ++++++ .../src/components/users/UserStatusBadge.tsx | 11 ++++ .../admin/src/components/users/UsersTable.tsx | 64 +++++++++++++++++++ apps/admin/src/config/users-list.test.ts | 22 +++++++ apps/admin/src/config/users-list.ts | 18 ++++++ apps/admin/src/lib/auth/session.test.ts | 4 +- apps/admin/src/lib/auth/session.ts | 13 +++- apps/admin/src/lib/user-display.test.ts | 35 ++++++++++ apps/admin/src/lib/user-display.ts | 28 ++++++++ apps/admin/src/messages/fr.ts | 64 +++++++++++++++++++ 10 files changed, 273 insertions(+), 4 deletions(-) create mode 100644 apps/admin/src/components/users/UserRoleBadge.tsx create mode 100644 apps/admin/src/components/users/UserStatusBadge.tsx create mode 100644 apps/admin/src/components/users/UsersTable.tsx create mode 100644 apps/admin/src/config/users-list.test.ts create mode 100644 apps/admin/src/config/users-list.ts create mode 100644 apps/admin/src/lib/user-display.test.ts create mode 100644 apps/admin/src/lib/user-display.ts diff --git a/apps/admin/src/components/users/UserRoleBadge.tsx b/apps/admin/src/components/users/UserRoleBadge.tsx new file mode 100644 index 00000000..e247250b --- /dev/null +++ b/apps/admin/src/components/users/UserRoleBadge.tsx @@ -0,0 +1,18 @@ +import { messages } from '@/messages/fr'; +import type { UserRole } from '@widoo/shared'; +import { StatusBadge } from '@widoo/ui'; +import type { ComponentProps } from 'react'; + +type Tone = ComponentProps['tone']; + +/** A member of the team stands out from the users: admin in strong, the others in blue. */ +const tones: Record = { + user: 'neutral', + editor: 'info', + moderator: 'info', + admin: 'strong', +}; + +export function UserRoleBadge({ role }: { role: UserRole }) { + return {messages.users.roles[role]}; +} diff --git a/apps/admin/src/components/users/UserStatusBadge.tsx b/apps/admin/src/components/users/UserStatusBadge.tsx new file mode 100644 index 00000000..315668b4 --- /dev/null +++ b/apps/admin/src/components/users/UserStatusBadge.tsx @@ -0,0 +1,11 @@ +import { messages } from '@/messages/fr'; +import { StatusBadge } from '@widoo/ui'; + +/** Active in green, suspended in red. */ +export function UserStatusBadge({ isSuspended }: { isSuspended: boolean }) { + return ( + + {isSuspended ? messages.users.status.suspended : messages.users.status.active} + + ); +} diff --git a/apps/admin/src/components/users/UsersTable.tsx b/apps/admin/src/components/users/UsersTable.tsx new file mode 100644 index 00000000..34cd85e5 --- /dev/null +++ b/apps/admin/src/components/users/UsersTable.tsx @@ -0,0 +1,64 @@ +import { planLabelOf, userNameOf } from '@/lib/user-display'; +import { formatRelativeDate } from '@/lib/dates'; +import { messages } from '@/messages/fr'; +import type { AdminUserListItem } from '@widoo/shared'; +import { Table, TableCell, TableHeaderCell, TableRow, TableSkeleton } from '@widoo/ui'; +import { UserRoleBadge } from './UserRoleBadge'; +import { UserStatusBadge } from './UserStatusBadge'; + +const texts = messages.users; +const columnCount = 6; + +type UsersTableProps = { + /** Rows of the page; ignored while `isLoading`. */ + items: AdminUserListItem[]; + isLoading: boolean; + /** The account whose card is open in the detail panel. */ + openId: string | null; + onOpen: (user: AdminUserListItem) => void; +}; + +/** The accounts as a table (A-06); a row opens the card of the account. */ +export function UsersTable({ items, isLoading, openId, onOpen }: UsersTableProps) { + return ( + + + + {texts.columns.user} + {texts.columns.role} + {texts.columns.plan} + {texts.columns.routes} + {texts.columns.joined} + {texts.columns.status} + + + + {isLoading ? ( + + ) : ( + items.map((user) => ( + onOpen(user)} isOpen={user.id === openId}> + + + {userNameOf(user)} + + {user.email ?? texts.noEmail} + + + + + + + {planLabelOf(user.plan)} + {user.createdRouteCount} + {formatRelativeDate(new Date(user.createdAt))} + + + + + )) + )} + +
+ ); +} diff --git a/apps/admin/src/config/users-list.test.ts b/apps/admin/src/config/users-list.test.ts new file mode 100644 index 00000000..7bd2e0fb --- /dev/null +++ b/apps/admin/src/config/users-list.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { toUsersParams } from './users-list'; + +const query = { + search: '', + filters: {}, + sort: { column: 'inscription', direction: 'desc' }, + cursor: null, +} as const; + +describe('toUsersParams', () => { + it('asks for the search and the page of the address', () => { + expect(toUsersParams({ ...query, search: 'léa', cursor: 'abc' })).toEqual({ + q: 'léa', + cursor: 'abc', + }); + }); + + it('leaves out an empty search and the first page', () => { + expect(toUsersParams(query)).toEqual({ q: undefined, cursor: undefined }); + }); +}); diff --git a/apps/admin/src/config/users-list.ts b/apps/admin/src/config/users-list.ts new file mode 100644 index 00000000..c5780b99 --- /dev/null +++ b/apps/admin/src/config/users-list.ts @@ -0,0 +1,18 @@ +import type { ListQuery, ListQueryOptions } from '@/lib/list-query'; +import type { AdminUsersQuery } from '@widoo/shared'; + +/** The accounts are read newest first, with no filter and no column to sort by: only the search. */ +export type UserFilter = never; +export type UserColumn = 'inscription'; +export type UsersListQuery = ListQuery; + +export const usersListOptions: ListQueryOptions = { + filters: [], + sortableColumns: [], + defaultSort: { column: 'inscription', direction: 'desc' }, +}; + +/** What to ask the API for the list shown at an address. */ +export function toUsersParams(query: UsersListQuery): Partial { + return { q: query.search || undefined, cursor: query.cursor ?? undefined }; +} diff --git a/apps/admin/src/lib/auth/session.test.ts b/apps/admin/src/lib/auth/session.test.ts index a64e80b5..ef195d1e 100644 --- a/apps/admin/src/lib/auth/session.test.ts +++ b/apps/admin/src/lib/auth/session.test.ts @@ -3,7 +3,7 @@ import { ApiRequestError } from '@widoo/api-client'; import type { Me } from '@widoo/shared'; import { readSession, requireSecondFactor, sessionOfAccount, type Session } from './session'; -const account = { firstName: 'Sarah', email: 'sarah@example.com' }; +const account = { id: '0', firstName: 'Sarah', email: 'sarah@example.com' }; describe('sessionOfAccount', () => { it.each(['editor', 'moderator', 'admin'] as const)('lets a %s in', (role) => { @@ -23,7 +23,7 @@ describe('sessionOfAccount', () => { describe('readSession', () => { const me = (role: Me['role']) => - ({ ...account, role, id: '0', avatarUrl: null, plan: 'free' }) as unknown as Me; + ({ ...account, role, avatarUrl: null, plan: 'free' }) as unknown as Me; it('lets in the member of the team the API describes', async () => { await expect(readSession({ getMe: async () => me('moderator') })).resolves.toMatchObject({ diff --git a/apps/admin/src/lib/auth/session.ts b/apps/admin/src/lib/auth/session.ts index 26383192..9e621a2f 100644 --- a/apps/admin/src/lib/auth/session.ts +++ b/apps/admin/src/lib/auth/session.ts @@ -3,6 +3,8 @@ import { isTeamRole, type Me, type TeamRole } from '@widoo/shared'; /** The member of the team who is signed in, as the shell shows it. */ export type TeamMember = { + /** To tell the account of the member apart in a list: nobody acts on their own. */ + id: string; firstName: string | null; email: string | null; role: TeamRole; @@ -32,13 +34,20 @@ export type Session = * other account is refused. The API checks the role again on every call: this only decides what * the admin shows. */ -export function sessionOfAccount(account: Pick): Session { +export function sessionOfAccount( + account: Pick, +): Session { if (!isTeamRole(account.role)) { return { status: 'signedOut', refusal: { reason: 'noAccess', email: account.email } }; } return { status: 'signedIn', - member: { firstName: account.firstName, email: account.email, role: account.role }, + member: { + id: account.id, + firstName: account.firstName, + email: account.email, + role: account.role, + }, }; } diff --git a/apps/admin/src/lib/user-display.test.ts b/apps/admin/src/lib/user-display.test.ts new file mode 100644 index 00000000..88d09963 --- /dev/null +++ b/apps/admin/src/lib/user-display.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { planLabelOf, userNameOf } from './user-display'; + +const now = new Date('2026-10-16T10:00:00Z'); + +describe('userNameOf', () => { + it('prefers the first name, then the address', () => { + expect(userNameOf({ firstName: 'Léa', email: 'lea@example.com' })).toBe('Léa'); + expect(userNameOf({ firstName: null, email: 'lea@example.com' })).toBe('lea@example.com'); + expect(userNameOf({ firstName: null, email: null })).toBe('Sans prénom'); + }); +}); + +describe('planLabelOf', () => { + it('names the free plan', () => { + expect(planLabelOf({ plan: 'free', source: 'store', expiresAt: null }, now)).toBe('Gratuit'); + }); + + it('says where Premium comes from and until when', () => { + const expiresAt = '2026-12-01T10:00:00.000Z'; + expect(planLabelOf({ plan: 'premium', source: 'gift', expiresAt }, now)).toBe( + "Offert, jusqu'au 1 décembre", + ); + expect(planLabelOf({ plan: 'premium', source: 'store', expiresAt: null }, now)).toBe( + 'Abonnement ou pass, sans fin', + ); + }); + + it('reads a past end as ended', () => { + const expiresAt = '2026-10-01T10:00:00.000Z'; + expect(planLabelOf({ plan: 'premium', source: 'gift', expiresAt }, now)).toBe( + 'Offert, terminé', + ); + }); +}); diff --git a/apps/admin/src/lib/user-display.ts b/apps/admin/src/lib/user-display.ts new file mode 100644 index 00000000..995c581e --- /dev/null +++ b/apps/admin/src/lib/user-display.ts @@ -0,0 +1,28 @@ +import { messages } from '@/messages/fr'; +import type { AdminUserListItem, AdminUserPlan } from '@widoo/shared'; +import { formatDayMonth } from './dates'; + +const texts = messages.users; + +/** How a row or a dialog names an account: first name, else the address, else a placeholder. */ +export function userNameOf(user: Pick): string { + return user.firstName ?? user.email ?? texts.noFirstName; +} + +/** + * The formula of an account as the list reads it: « Gratuit », « Abonnement ou pass, jusqu'au + * 2 octobre », « Offert, jusqu'au 2 octobre ». An end date in the past reads as ended. + */ +export function planLabelOf(plan: AdminUserPlan, now = new Date()): string { + if (plan.plan === 'free') { + return texts.plan.free; + } + const name = texts.plan[plan.source]; + if (plan.expiresAt === null) { + return `${name}, ${texts.plan.noEnd}`; + } + const end = new Date(plan.expiresAt); + return end.getTime() > now.getTime() + ? `${name}, ${texts.plan.until(formatDayMonth(end))}` + : `${name}, ${texts.plan.ended}`; +} diff --git a/apps/admin/src/messages/fr.ts b/apps/admin/src/messages/fr.ts index 740dc68e..e42fa7be 100644 --- a/apps/admin/src/messages/fr.ts +++ b/apps/admin/src/messages/fr.ts @@ -764,6 +764,70 @@ export const messages = { error: 'Les avis ne se sont pas chargés.', }, }, + users: { + search: 'Rechercher par e-mail, prénom ou identifiant', + total: (count: number) => (count > 1 ? `${count} comptes` : `${count} compte`), + columns: { + user: 'Compte', + role: 'Rôle', + plan: 'Formule', + routes: 'Parcours créés', + joined: 'Inscription', + status: 'Statut', + }, + noFirstName: 'Sans prénom', + noEmail: 'Adresse masquée', + roles: { user: 'Utilisateur', editor: 'Éditeur', moderator: 'Modérateur', admin: 'Admin' }, + status: { active: 'Actif', suspended: 'Suspendu' }, + plan: { + free: 'Gratuit', + store: 'Abonnement ou pass', + gift: 'Offert', + until: (date: string) => `jusqu'au ${date}`, + noEnd: 'sans fin', + ended: 'terminé', + }, + empty: { + title: 'Aucun compte trouvé', + text: "Essayez une autre partie de l'e-mail, du prénom ou de l'identifiant.", + }, + error: 'Les comptes ne se sont pas chargés.', + panel: { + id: 'Identifiant', + email: 'E-mail', + role: 'Rôle', + plan: 'Formule', + joined: 'Inscription', + profile: 'Profil public', + profilePublic: 'Visible', + profileMasked: 'Masqué', + suspension: 'Motif de suspension', + certification: 'Créateur certifié', + certified: 'Certifié', + notCertified: 'Non certifié', + certificationDetail: (computed: boolean, choice: string) => + `Calcul : ${computed ? 'oui' : 'non'} · choix de l'équipe : ${choice}`, + overrides: { none: 'aucun', granted: 'attribué', revoked: 'retiré' }, + unlocked: 'Parcours débloqués', + created: 'Parcours créés', + reportsMade: 'Signalements faits', + reportsReceived: 'Signalements reçus', + ratingsGiven: 'Avis donnés', + ratingsReceived: 'Avis reçus', + support: 'Messages de support', + none: 'Aucun', + gift: 'offert', + handled: 'traité', + unhandled: 'à traiter', + ratingsNotice: "Les avis ne sont pas encore enregistrés ; ils s'afficheront ici ensuite.", + }, + topics: { + question: 'Question', + problem: 'Problème', + data: 'Mes données', + other: 'Autre', + }, + }, notFound: { title: 'Page introuvable', text: "Cette adresse n'existe pas dans l'admin.", From eeab6db880b15b2e5d8edb207286d66112108ea2 Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Fri, 16 Oct 2026 12:00:38 +0700 Subject: [PATCH 10/12] feat(admin): open the card of an account in a panel next to the list (A-06) Utilisateurs for moderators and admins: a search by e-mail, first name or identifier, and the card of the account in the detail panel (plan and its origin, certification, routes unlocked and created, reports, support messages). Co-Authored-By: Claude Sonnet 5.5 --- .../src/app/(shell)/utilisateurs/page.tsx | 11 ++ .../src/components/users/UserPanelContent.tsx | 126 ++++++++++++++++++ apps/admin/src/containers/UsersContainer.tsx | 124 +++++++++++++++++ 3 files changed, 261 insertions(+) create mode 100644 apps/admin/src/app/(shell)/utilisateurs/page.tsx create mode 100644 apps/admin/src/components/users/UserPanelContent.tsx create mode 100644 apps/admin/src/containers/UsersContainer.tsx diff --git a/apps/admin/src/app/(shell)/utilisateurs/page.tsx b/apps/admin/src/app/(shell)/utilisateurs/page.tsx new file mode 100644 index 00000000..5243f2c5 --- /dev/null +++ b/apps/admin/src/app/(shell)/utilisateurs/page.tsx @@ -0,0 +1,11 @@ +import { UsersContainer } from '@/containers/UsersContainer'; +import { Suspense } from 'react'; + +/** A-06. The state of the list and the open account are read from the address, in the browser. */ +export default function UsersPage() { + return ( + + + + ); +} diff --git a/apps/admin/src/components/users/UserPanelContent.tsx b/apps/admin/src/components/users/UserPanelContent.tsx new file mode 100644 index 00000000..7fd51ba7 --- /dev/null +++ b/apps/admin/src/components/users/UserPanelContent.tsx @@ -0,0 +1,126 @@ +import { RouteStatusBadge } from '@/components/routes/RouteStatusBadge'; +import { formatRelativeDate } from '@/lib/dates'; +import { planLabelOf } from '@/lib/user-display'; +import { messages } from '@/messages/fr'; +import type { AdminUserDetail } from '@widoo/shared'; +import Link from 'next/link'; +import type { ReactNode } from 'react'; +import { UserRoleBadge } from './UserRoleBadge'; + +const texts = messages.users.panel; + +/** A line of the panel: what it is on the left, its value on the right. */ +function Line({ label, children }: { label: string; children: ReactNode }) { + return ( +
+
{label}
+
{children}
+
+ ); +} + +/** A titled list of objects of the account, or « Aucun ». */ +function Section({ title, children }: { title: string; children: ReactNode[] }) { + return ( +
+

{title}

+ {children.length === 0 ? ( +

{texts.none}

+ ) : ( +
    {children}
+ )} +
+ ); +} + +type UserPanelContentProps = { + user: AdminUserDetail; + /** Address of the sheet of a route, to open it from the card. */ + routePathOf: (routeId: string) => string; +}; + +/** The card of an account inside the detail panel (A-06): profile, plan, routes, reports. */ +export function UserPanelContent({ user, routePathOf }: UserPanelContentProps) { + const routeLink = (route: { id: string; title: string }) => ( + + {route.title} + + ); + const reportItem = (report: AdminUserDetail['reportsMade'][number]) => ( +
  • + {routeLink(report.route)} + + {messages.reports.reasons[report.reason]} ·{' '} + {messages.reports.routes.statuses[report.status]} ·{' '} + {formatRelativeDate(new Date(report.createdAt))} + +
  • + ); + return ( +
    +
    + {user.email ?? messages.users.noEmail} + + {user.id} + + + + + {planLabelOf(user.plan)} + {formatRelativeDate(new Date(user.createdAt))} + + {user.isPublic && user.suspendedAt === null ? texts.profilePublic : texts.profileMasked} + + {user.suspensionReason ? ( + {user.suspensionReason} + ) : null} + + + {user.certification.isCertified ? texts.certified : texts.notCertified} + + {texts.certificationDetail( + user.certification.computed, + texts.overrides[user.certification.override], + )} + + + +
    +
    + {user.unlockedRoutes.map((route) => ( +
  • + {routeLink(route)} + {route.source === 'gift' ? ( + · {texts.gift} + ) : null} +
  • + ))} +
    +
    + {user.createdRoutes.map((route) => ( +
  • + {routeLink(route)} + +
  • + ))} +
    +
    {user.reportsMade.map(reportItem)}
    +
    {user.reportsReceived.map(reportItem)}
    +
    {[]}
    +
    {[]}
    +

    {texts.ratingsNotice}

    +
    + {user.supportMessages.map((message) => ( +
  • + {messages.users.topics[message.topic]} ·{' '} + {formatRelativeDate(new Date(message.createdAt))} + + {' '} + · {message.handledAt ? texts.handled : texts.unhandled} + +
  • + ))} +
    +
    + ); +} diff --git a/apps/admin/src/containers/UsersContainer.tsx b/apps/admin/src/containers/UsersContainer.tsx new file mode 100644 index 00000000..94f6c69c --- /dev/null +++ b/apps/admin/src/containers/UsersContainer.tsx @@ -0,0 +1,124 @@ +'use client'; + +import { PageMessage } from '@/components/states/PageMessage'; +import { UserPanelContent } from '@/components/users/UserPanelContent'; +import { UserRoleBadge } from '@/components/users/UserRoleBadge'; +import { UsersTable } from '@/components/users/UsersTable'; +import { toUsersParams, usersListOptions } from '@/config/users-list'; +import { catalogRoutePathOf } from '@/containers/CatalogRoutesContainer'; +import { useDebouncedCallback } from '@/hooks/useDebouncedCallback'; +import { useListQuery } from '@/hooks/useListQuery'; +import { useRemoteData } from '@/hooks/useRemoteData'; +import { getSessionClients } from '@/lib/auth/clients'; +import { serializeListQuery } from '@/lib/list-query'; +import { sectionPaths } from '@/config/navigation'; +import { userNameOf } from '@/lib/user-display'; +import { messages } from '@/messages/fr'; +import { Button, DetailPanel, Pagination, SearchInput } from '@widoo/ui'; +import { useRouter, useSearchParams } from 'next/navigation'; + +const texts = messages.users; +const searchDelayMs = 300; +/** Parameter of the address that holds the account whose card is open next to the list. */ +const openUserParam = 'compte'; + +/** + * A-06, the accounts: the list the address asks for, searched by e-mail, first name or + * identifier, and next to it the card of the account the address names, with its actions. + */ +export function UsersContainer() { + const router = useRouter(); + const openId = useSearchParams().get(openUserParam); + const { query, handleSearch, handlePage } = useListQuery(usersListOptions); + const handleSearchTyped = useDebouncedCallback(handleSearch, searchDelayMs); + const listAddress = serializeListQuery(query, usersListOptions); + const users = useRemoteData(listAddress, (signal) => + getSessionClients().api.getAdminUsers(toUsersParams(query), signal), + ); + const card = useRemoteData(`card:${openId ?? ''}`, (signal) => + openId ? getSessionClients().api.getAdminUser(openId, signal) : Promise.resolve(null), + ); + const data = users.status === 'ready' ? users.data : undefined; + const openUser = card.status === 'ready' ? card.data : null; + + /** The address of the list as it is, with the card of `userId` open, or none. */ + const showUser = (userId: string | null) => { + const params = new URLSearchParams(listAddress); + if (userId) { + params.set(openUserParam, userId); + } + const address = params.toString(); + router.replace(address ? `${sectionPaths.users}?${address}` : sectionPaths.users, { + scroll: false, + }); + }; + + const renderList = () => { + if (users.status === 'error') { + return ( + {messages.list.retry}} + /> + ); + } + if (data?.items.length === 0) { + return ; + } + return ( + showUser(user.id)} + /> + ); + }; + + return ( +
    +
    +
    + handleSearchTyped(event.target.value)} + /> + {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} +
    + {openUser ? ( + } + closeLabel={messages.confirm.close} + onClose={() => showUser(null)} + > + + + ) : null} +
    + ); +} From 8a8cc951f9c2426fe7a7e80972c80635cbce92b5 Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Fri, 16 Oct 2026 12:00:45 +0700 Subject: [PATCH 11/12] feat(admin): act on an account: the actions it allows, a reason for a suspension, toasts (A-06) The buttons of the card (only what allowedUserActions gives the role), the window that asks a reason to suspend or reactivate, the texts of every action and of each refusal, and the hook that sends an action and tells its result in a toast. Co-Authored-By: Claude Sonnet 5.5 --- .../src/components/users/UserActions.tsx | 60 ++++++++++++++ .../src/components/users/UserReasonDialog.tsx | 55 ++++++++++++ apps/admin/src/hooks/useUserActions.ts | 80 ++++++++++++++++++ apps/admin/src/lib/gift-date.test.ts | 17 ++++ apps/admin/src/lib/gift-date.ts | 18 ++++ apps/admin/src/messages/fr.ts | 83 +++++++++++++++++++ 6 files changed, 313 insertions(+) create mode 100644 apps/admin/src/components/users/UserActions.tsx create mode 100644 apps/admin/src/components/users/UserReasonDialog.tsx create mode 100644 apps/admin/src/hooks/useUserActions.ts create mode 100644 apps/admin/src/lib/gift-date.test.ts create mode 100644 apps/admin/src/lib/gift-date.ts diff --git a/apps/admin/src/components/users/UserActions.tsx b/apps/admin/src/components/users/UserActions.tsx new file mode 100644 index 00000000..2aea4679 --- /dev/null +++ b/apps/admin/src/components/users/UserActions.tsx @@ -0,0 +1,60 @@ +import { messages } from '@/messages/fr'; +import type { UserAction } from '@widoo/shared'; +import { Button } from '@widoo/ui'; + +const texts = messages.users.actions; + +export type UserActionName = Exclude | 'suspend' | 'reactivate'; + +type UserActionsProps = { + /** What the role of the member allows on this account (`allowedUserActions`). */ + allowed: readonly UserAction[]; + isSuspended: boolean; + onAct: (action: UserActionName) => void; + isBusy: boolean; +}; + +/** The actions of the card of an account (A-06): only the ones the role of the member allows. */ +export function UserActions({ allowed, isSuspended, onAct, isBusy }: UserActionsProps) { + const has = (action: UserAction) => allowed.includes(action); + return ( + <> + {has('suspend') ? ( + isSuspended ? ( + + ) : ( + + ) + ) : null} + {has('role') ? ( + + ) : null} + {has('gift') ? ( + + ) : null} + {has('certification') ? ( + + ) : null} + {has('export') ? ( + + ) : null} + {has('delete') ? ( + + ) : null} + + ); +} diff --git a/apps/admin/src/components/users/UserReasonDialog.tsx b/apps/admin/src/components/users/UserReasonDialog.tsx new file mode 100644 index 00000000..dbacc625 --- /dev/null +++ b/apps/admin/src/components/users/UserReasonDialog.tsx @@ -0,0 +1,55 @@ +import { messages } from '@/messages/fr'; +import { ConfirmDialog, Field, Textarea } from '@widoo/ui'; + +const texts = messages.users.dialogs; + +type UserReasonDialogProps = { + isOpen: boolean; + /** What the window says: its question, the effect on the account, and its button. */ + dialog: { title: string; description: string; confirm: string }; + /** Red for a suspension, which refuses an account; blue for lifting it. */ + isDestructive: boolean; + reason: string; + onReasonChange: (reason: string) => void; + onCancel: () => void; + onConfirm: () => void; + isBusy: boolean; +}; + +/** Suspension or reactivation of an account, with the required reason kept in the journal. */ +export function UserReasonDialog({ + isOpen, + dialog, + isDestructive, + reason, + onReasonChange, + onCancel, + onConfirm, + isBusy, +}: UserReasonDialogProps) { + return ( + + +