From 75269fe24cce6d95c0cdf9245545e48a75004554 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 15:04:40 +0000 Subject: [PATCH] =?UTF-8?q?Les=20parents=20sans=20compte=20peuvent=20annon?= =?UTF-8?q?cer=20leur=20venue=20=C3=A0=20une=20r=C3=A9union?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les réunions sont ouvertes aux familles depuis qu'elles paraissent sur le site, mais seuls les membres connectés pouvaient dire s'ils venaient : sur la page publique d'une réunion, un parent ne trouvait qu'un lien « poser une question ». Il peut maintenant répondre depuis cette page. Une seule table pour les deux populations. `meeting_attendance.user_id` devient facultatif et la ligne porte alors le nom, l'e-mail et le téléphone saisis ; une contrainte garantit qu'une réponse dit toujours qui vient. Deux tables auraient obligé chaque comptage à faire une union, et un oubli quelque part aurait fait disparaître des présents de la liste. Le formulaire public ne propose que « je serai là » et « peut-être » : un parent qui ne compte pas venir ferme la page, il ne remplit pas un formulaire pour le dire. Se décommander passe par le lien de l'e-mail de confirmation, qui rejoint la page d'annulation existante — le parent n'a qu'un lien au bas d'un message et ne sait pas de quelle table il relève. Revenir sur sa réponse marche aussi bien que la donner : un même e-mail met sa ligne à jour au lieu de buter sur « déjà répondu », car celui qui recoche n'a le plus souvent pas gardé l'e-mail de confirmation. Un membre connecté qui répond depuis la page publique est reconnu : sa réponse est rattachée à son compte plutôt qu'ajoutée en double sous son nom saisi. Rien ne change côté confidentialité : la liste des présents reste derrière une session, et la page publique enregistre une réponse sans en montrer aucune autre. Dans le tableau de bord, les parents venus par le lien public sont marqués « invité » pour qu'on ne les cherche pas dans l'annuaire. Au passage, les fonds des réponses sélectionnées passent deux crans plus foncés dans la palette : du blanc sur sea-500 tombait à 2,89:1 et sur sand-500 à 3,31:1, sous le seuil AA pour du texte de cette taille. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014SfQYBU4xXTeSEHKhHQXdD --- drizzle/0020_public_meeting_attendance.sql | 9 + drizzle/meta/0020_snapshot.json | 3580 ++++++++++++++++++++ drizzle/meta/_journal.json | 7 + src/app/annulation/[token]/page.tsx | 67 +- src/app/api/meetings/attendance/route.ts | 215 ++ src/app/api/signup/cancel/route.ts | 24 +- src/app/dashboard/events/[id]/page.tsx | 14 +- src/app/inscription/[token]/page.tsx | 60 +- src/components/cancel-signup.tsx | 15 +- src/components/meeting-attendance-form.tsx | 252 ++ src/components/meeting-attendance.tsx | 7 +- src/lib/data.ts | 24 +- src/lib/db/schema.ts | 52 +- src/lib/notifications/emails.ts | 45 + src/lib/validation.ts | 22 + 15 files changed, 4345 insertions(+), 48 deletions(-) create mode 100644 drizzle/0020_public_meeting_attendance.sql create mode 100644 drizzle/meta/0020_snapshot.json create mode 100644 src/app/api/meetings/attendance/route.ts create mode 100644 src/components/meeting-attendance-form.tsx diff --git a/drizzle/0020_public_meeting_attendance.sql b/drizzle/0020_public_meeting_attendance.sql new file mode 100644 index 0000000..df88aaf --- /dev/null +++ b/drizzle/0020_public_meeting_attendance.sql @@ -0,0 +1,9 @@ +ALTER TABLE "meeting_attendance" ALTER COLUMN "user_id" DROP NOT NULL;--> statement-breakpoint +ALTER TABLE "meeting_attendance" ADD COLUMN "name" text;--> statement-breakpoint +ALTER TABLE "meeting_attendance" ADD COLUMN "email" text;--> statement-breakpoint +ALTER TABLE "meeting_attendance" ADD COLUMN "phone" text;--> statement-breakpoint +ALTER TABLE "meeting_attendance" ADD COLUMN "cancel_token" text;--> statement-breakpoint +ALTER TABLE "meeting_attendance" ADD COLUMN "created_at" timestamp with time zone DEFAULT now() NOT NULL;--> statement-breakpoint +CREATE UNIQUE INDEX "meeting_attendance_event_email_unique" ON "meeting_attendance" USING btree ("event_id",lower("email")) WHERE "meeting_attendance"."user_id" is null and "meeting_attendance"."email" is not null;--> statement-breakpoint +CREATE UNIQUE INDEX "meeting_attendance_cancel_token_idx" ON "meeting_attendance" USING btree ("cancel_token");--> statement-breakpoint +ALTER TABLE "meeting_attendance" ADD CONSTRAINT "meeting_attendance_identite" CHECK ("meeting_attendance"."user_id" is not null or "meeting_attendance"."name" is not null); \ No newline at end of file diff --git a/drizzle/meta/0020_snapshot.json b/drizzle/meta/0020_snapshot.json new file mode 100644 index 0000000..501d6d1 --- /dev/null +++ b/drizzle/meta/0020_snapshot.json @@ -0,0 +1,3580 @@ +{ + "id": "d3c1d65e-8a29-43b1-938d-7cd6a77d722b", + "prevId": "c29c5c23-3a9a-4ba0-8b8d-ba2e093c644e", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounting_categories": { + "name": "accounting_categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "accounting_category_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "accounting_categories_type_active_idx": { + "name": "accounting_categories_type_active_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.accounting_entries": { + "name": "accounting_entries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "type": { + "name": "type", + "type": "accounting_entry_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "accounting_entry_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "category_id": { + "name": "category_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "event_id": { + "name": "event_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount_cents": { + "name": "amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "counterparty": { + "name": "counterparty", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payment_method": { + "name": "payment_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference": { + "name": "reference", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attachment_url": { + "name": "attachment_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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": { + "accounting_entries_occurred_at_idx": { + "name": "accounting_entries_occurred_at_idx", + "columns": [ + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "accounting_entries_status_occurred_at_idx": { + "name": "accounting_entries_status_occurred_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "accounting_entries_account_idx": { + "name": "accounting_entries_account_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "accounting_entries_category_idx": { + "name": "accounting_entries_category_idx", + "columns": [ + { + "expression": "category_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "accounting_entries_event_idx": { + "name": "accounting_entries_event_idx", + "columns": [ + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounting_entries_account_id_financial_accounts_id_fk": { + "name": "accounting_entries_account_id_financial_accounts_id_fk", + "tableFrom": "accounting_entries", + "tableTo": "financial_accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "accounting_entries_category_id_accounting_categories_id_fk": { + "name": "accounting_entries_category_id_accounting_categories_id_fk", + "tableFrom": "accounting_entries", + "tableTo": "accounting_categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "accounting_entries_event_id_events_id_fk": { + "name": "accounting_entries_event_id_events_id_fk", + "tableFrom": "accounting_entries", + "tableTo": "events", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "accounting_entries_created_by_users_id_fk": { + "name": "accounting_entries_created_by_users_id_fk", + "tableFrom": "accounting_entries", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "accounting_entries_amount_cents_check": { + "name": "accounting_entries_amount_cents_check", + "value": "\"accounting_entries\".\"amount_cents\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.association_documents": { + "name": "association_documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "type": { + "name": "type", + "type": "association_document_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "association_document_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_date": { + "name": "document_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "member_id": { + "name": "member_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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": { + "association_documents_type_status_date_idx": { + "name": "association_documents_type_status_date_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "association_documents_member_idx": { + "name": "association_documents_member_idx", + "columns": [ + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "association_documents_member_id_association_members_id_fk": { + "name": "association_documents_member_id_association_members_id_fk", + "tableFrom": "association_documents", + "tableTo": "association_members", + "columnsFrom": [ + "member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "association_documents_created_by_users_id_fk": { + "name": "association_documents_created_by_users_id_fk", + "tableFrom": "association_documents", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.association_members": { + "name": "association_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address_line_1": { + "name": "address_line_1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address_line_2": { + "name": "address_line_2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "postal_code": { + "name": "postal_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "city": { + "name": "city", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'France'" + }, + "status": { + "name": "status", + "type": "association_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "school_year": { + "name": "school_year", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "membership_fee_cents": { + "name": "membership_fee_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "fee_paid_at": { + "name": "fee_paid_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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": { + "association_members_user_idx": { + "name": "association_members_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"association_members\".\"user_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "association_members_status_school_year_idx": { + "name": "association_members_status_school_year_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "school_year", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "association_members_name_idx": { + "name": "association_members_name_idx", + "columns": [ + { + "expression": "last_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "first_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "association_members_user_id_users_id_fk": { + "name": "association_members_user_id_users_id_fk", + "tableFrom": "association_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "association_members_membership_fee_cents_check": { + "name": "association_members_membership_fee_cents_check", + "value": "\"association_members\".\"membership_fee_cents\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.association_settings": { + "name": "association_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "default": "'default'" + }, + "association_name": { + "name": "association_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'APEL Manager'" + }, + "school_name": { + "name": "school_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Votre établissement'" + }, + "contact_email": { + "name": "contact_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rna": { + "name": "rna", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "task_reminder_window_days": { + "name": "task_reminder_window_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "volunteer_reminder_window_days": { + "name": "volunteer_reminder_window_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2 + }, + "telegram_enabled": { + "name": "telegram_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "encrypted_telegram_bot_token": { + "name": "encrypted_telegram_bot_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telegram_token_last_four": { + "name": "telegram_token_last_four", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telegram_bot_username": { + "name": "telegram_bot_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telegram_token_verified_at": { + "name": "telegram_token_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "recaptcha_enabled": { + "name": "recaptcha_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "recaptcha_site_key": { + "name": "recaptcha_site_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_recaptcha_secret": { + "name": "encrypted_recaptcha_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "recaptcha_min_score": { + "name": "recaptcha_min_score", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 50 + }, + "whatsapp_group_url": { + "name": "whatsapp_group_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "push_vapid_public_key": { + "name": "push_vapid_public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_push_vapid_private_key": { + "name": "encrypted_push_vapid_private_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "association_settings_updated_by_users_id_fk": { + "name": "association_settings_updated_by_users_id_fk", + "tableFrom": "association_settings", + "tableTo": "users", + "columnsFrom": [ + "updated_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "association_settings_singleton_check": { + "name": "association_settings_singleton_check", + "value": "\"association_settings\".\"id\" = 'default'" + }, + "association_settings_task_reminder_window_check": { + "name": "association_settings_task_reminder_window_check", + "value": "\"association_settings\".\"task_reminder_window_days\" >= 0 and \"association_settings\".\"task_reminder_window_days\" <= 30" + }, + "association_settings_recaptcha_min_score_check": { + "name": "association_settings_recaptcha_min_score_check", + "value": "\"association_settings\".\"recaptcha_min_score\" >= 0 and \"association_settings\".\"recaptcha_min_score\" <= 100" + }, + "association_settings_volunteer_reminder_window_check": { + "name": "association_settings_volunteer_reminder_window_check", + "value": "\"association_settings\".\"volunteer_reminder_window_days\" >= 0 and \"association_settings\".\"volunteer_reminder_window_days\" <= 30" + } + }, + "isRLSEnabled": false + }, + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'web'" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_logs_entity_idx": { + "name": "audit_logs_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_logs_actor_created_idx": { + "name": "audit_logs_actor_created_idx", + "columns": [ + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_logs_client_created_idx": { + "name": "audit_logs_client_created_idx", + "columns": [ + { + "expression": "oauth_client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_logs_created_at_idx": { + "name": "audit_logs_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_logs_actor_user_id_users_id_fk": { + "name": "audit_logs_actor_user_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": [ + "actor_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_logs_oauth_client_id_oauth_clients_id_fk": { + "name": "audit_logs_oauth_client_id_oauth_clients_id_fk", + "tableFrom": "audit_logs", + "tableTo": "oauth_clients", + "columnsFrom": [ + "oauth_client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.checklist_templates": { + "name": "checklist_templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tasks": { + "name": "tasks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.event_attachments": { + "name": "event_attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "event_id": { + "name": "event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "event_attachments_event_idx": { + "name": "event_attachments_event_idx", + "columns": [ + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "event_attachments_event_id_events_id_fk": { + "name": "event_attachments_event_id_events_id_fk", + "tableFrom": "event_attachments", + "tableTo": "events", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "event_attachments_uploaded_by_users_id_fk": { + "name": "event_attachments_uploaded_by_users_id_fk", + "tableFrom": "event_attachments", + "tableTo": "users", + "columnsFrom": [ + "uploaded_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.events": { + "name": "events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "kind": { + "name": "kind", + "type": "event_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'event'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_description": { + "name": "public_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ticketing_url": { + "name": "ticketing_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start_at": { + "name": "start_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "end_at": { + "name": "end_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "event_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "share_token": { + "name": "share_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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()" + } + }, + "indexes": { + "events_status_start_idx": { + "name": "events_status_start_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "start_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "events_created_by_users_id_fk": { + "name": "events_created_by_users_id_fk", + "tableFrom": "events", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "events_share_token_unique": { + "name": "events_share_token_unique", + "nullsNotDistinct": false, + "columns": [ + "share_token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.financial_accounts": { + "name": "financial_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "financial_account_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "financial_accounts_type_active_idx": { + "name": "financial_accounts_type_active_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.meeting_attendance": { + "name": "meeting_attendance", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "event_id": { + "name": "event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "meeting_attendance_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cancel_token": { + "name": "cancel_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "meeting_attendance_event_user_unique": { + "name": "meeting_attendance_event_user_unique", + "columns": [ + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "meeting_attendance_event_email_unique": { + "name": "meeting_attendance_event_email_unique", + "columns": [ + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"meeting_attendance\".\"user_id\" is null and \"meeting_attendance\".\"email\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "meeting_attendance_cancel_token_idx": { + "name": "meeting_attendance_cancel_token_idx", + "columns": [ + { + "expression": "cancel_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "meeting_attendance_event_idx": { + "name": "meeting_attendance_event_idx", + "columns": [ + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "meeting_attendance_event_id_events_id_fk": { + "name": "meeting_attendance_event_id_events_id_fk", + "tableFrom": "meeting_attendance", + "tableTo": "events", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "meeting_attendance_user_id_users_id_fk": { + "name": "meeting_attendance_user_id_users_id_fk", + "tableFrom": "meeting_attendance", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "meeting_attendance_identite": { + "name": "meeting_attendance_identite", + "value": "\"meeting_attendance\".\"user_id\" is not null or \"meeting_attendance\".\"name\" is not null" + } + }, + "isRLSEnabled": false + }, + "public.notifications_log": { + "name": "notifications_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "task_id": { + "name": "task_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notifications_log_task_user_kind_idx": { + "name": "notifications_log_task_user_kind_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notifications_log_task_id_tasks_id_fk": { + "name": "notifications_log_task_id_tasks_id_fk", + "tableFrom": "notifications_log", + "tableTo": "tasks", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notifications_log_user_id_users_id_fk": { + "name": "notifications_log_user_id_users_id_fk", + "tableFrom": "notifications_log", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_authorization_codes": { + "name": "oauth_authorization_codes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "code_hash": { + "name": "code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "code_challenge": { + "name": "code_challenge", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code_challenge_method": { + "name": "code_challenge_method", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'S256'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_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": { + "oauth_authorization_codes_code_hash_idx": { + "name": "oauth_authorization_codes_code_hash_idx", + "columns": [ + { + "expression": "code_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_authorization_codes_client_expires_idx": { + "name": "oauth_authorization_codes_client_expires_idx", + "columns": [ + { + "expression": "oauth_client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_authorization_codes_user_idx": { + "name": "oauth_authorization_codes_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_authorization_codes_oauth_client_id_oauth_clients_id_fk": { + "name": "oauth_authorization_codes_oauth_client_id_oauth_clients_id_fk", + "tableFrom": "oauth_authorization_codes", + "tableTo": "oauth_clients", + "columnsFrom": [ + "oauth_client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_authorization_codes_user_id_users_id_fk": { + "name": "oauth_authorization_codes_user_id_users_id_fk", + "tableFrom": "oauth_authorization_codes", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_clients": { + "name": "oauth_clients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret_hash": { + "name": "client_secret_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "grant_types": { + "name": "grant_types", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[\"authorization_code\",\"refresh_token\"]'::jsonb" + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "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": { + "oauth_clients_client_id_idx": { + "name": "oauth_clients_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_clients_enabled_idx": { + "name": "oauth_clients_enabled_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_clients_created_by_users_id_fk": { + "name": "oauth_clients_created_by_users_id_fk", + "tableFrom": "oauth_clients", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_tokens": { + "name": "oauth_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "oauth_token_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "authorization_code_id": { + "name": "authorization_code_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_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": { + "oauth_tokens_token_hash_idx": { + "name": "oauth_tokens_token_hash_idx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_tokens_client_type_expires_idx": { + "name": "oauth_tokens_client_type_expires_idx", + "columns": [ + { + "expression": "oauth_client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_tokens_user_idx": { + "name": "oauth_tokens_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_tokens_authorization_code_idx": { + "name": "oauth_tokens_authorization_code_idx", + "columns": [ + { + "expression": "authorization_code_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_tokens_oauth_client_id_oauth_clients_id_fk": { + "name": "oauth_tokens_oauth_client_id_oauth_clients_id_fk", + "tableFrom": "oauth_tokens", + "tableTo": "oauth_clients", + "columnsFrom": [ + "oauth_client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_tokens_user_id_users_id_fk": { + "name": "oauth_tokens_user_id_users_id_fk", + "tableFrom": "oauth_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_tokens_authorization_code_id_oauth_authorization_codes_id_fk": { + "name": "oauth_tokens_authorization_code_id_oauth_authorization_codes_id_fk", + "tableFrom": "oauth_tokens", + "tableTo": "oauth_authorization_codes", + "columnsFrom": [ + "authorization_code_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbound_mail_settings": { + "name": "outbound_mail_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "default": "'default'" + }, + "provider": { + "name": "provider", + "type": "outbound_mail_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'resend'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reply_to": { + "name": "reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_last_four": { + "name": "key_last_four", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "smtp_host": { + "name": "smtp_host", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "smtp_port": { + "name": "smtp_port", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "smtp_secure": { + "name": "smtp_secure", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "smtp_username": { + "name": "smtp_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_smtp_password": { + "name": "encrypted_smtp_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_tested_at": { + "name": "last_tested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_test_status": { + "name": "last_test_status", + "type": "outbound_mail_test_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'untested'" + }, + "updated_by": { + "name": "updated_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "outbound_mail_settings_updated_by_users_id_fk": { + "name": "outbound_mail_settings_updated_by_users_id_fk", + "tableFrom": "outbound_mail_settings", + "tableTo": "users", + "columnsFrom": [ + "updated_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "outbound_mail_settings_singleton_check": { + "name": "outbound_mail_settings_singleton_check", + "value": "\"outbound_mail_settings\".\"id\" = 'default'" + }, + "outbound_mail_settings_smtp_port_check": { + "name": "outbound_mail_settings_smtp_port_check", + "value": "\"outbound_mail_settings\".\"smtp_port\" is null or (\"outbound_mail_settings\".\"smtp_port\" >= 1 and \"outbound_mail_settings\".\"smtp_port\" <= 65535)" + } + }, + "isRLSEnabled": false + }, + "public.password_reset_tokens": { + "name": "password_reset_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_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": {}, + "foreignKeys": { + "password_reset_tokens_user_id_users_id_fk": { + "name": "password_reset_tokens_user_id_users_id_fk", + "tableFrom": "password_reset_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.push_deliveries": { + "name": "push_deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "notification_id": { + "name": "notification_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "subscription_id": { + "name": "subscription_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "push_delivery_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "ack_token": { + "name": "ack_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "received_at": { + "name": "received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "opened_at": { + "name": "opened_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "push_deliveries_notification_idx": { + "name": "push_deliveries_notification_idx", + "columns": [ + { + "expression": "notification_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "push_deliveries_ack_token_idx": { + "name": "push_deliveries_ack_token_idx", + "columns": [ + { + "expression": "ack_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "push_deliveries_user_idx": { + "name": "push_deliveries_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "push_deliveries_notification_id_push_notifications_id_fk": { + "name": "push_deliveries_notification_id_push_notifications_id_fk", + "tableFrom": "push_deliveries", + "tableTo": "push_notifications", + "columnsFrom": [ + "notification_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "push_deliveries_user_id_users_id_fk": { + "name": "push_deliveries_user_id_users_id_fk", + "tableFrom": "push_deliveries", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "push_deliveries_subscription_id_push_subscriptions_id_fk": { + "name": "push_deliveries_subscription_id_push_subscriptions_id_fk", + "tableFrom": "push_deliveries", + "tableTo": "push_subscriptions", + "columnsFrom": [ + "subscription_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.push_notifications": { + "name": "push_notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "sender_id": { + "name": "sender_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "push_notifications_created_at_idx": { + "name": "push_notifications_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "push_notifications_sender_id_users_id_fk": { + "name": "push_notifications_sender_id_users_id_fk", + "tableFrom": "push_notifications", + "tableTo": "users", + "columnsFrom": [ + "sender_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.push_subscriptions": { + "name": "push_subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "p256dh": { + "name": "p256dh", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth": { + "name": "auth", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_label": { + "name": "device_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_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": { + "push_subscriptions_endpoint_idx": { + "name": "push_subscriptions_endpoint_idx", + "columns": [ + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "push_subscriptions_user_idx": { + "name": "push_subscriptions_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "push_subscriptions_user_id_users_id_fk": { + "name": "push_subscriptions_user_id_users_id_fk", + "tableFrom": "push_subscriptions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_assignees": { + "name": "task_assignees", + "schema": "", + "columns": { + "task_id": { + "name": "task_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "task_assignees_user_idx": { + "name": "task_assignees_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_assignees_task_id_tasks_id_fk": { + "name": "task_assignees_task_id_tasks_id_fk", + "tableFrom": "task_assignees", + "tableTo": "tasks", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_assignees_user_id_users_id_fk": { + "name": "task_assignees_user_id_users_id_fk", + "tableFrom": "task_assignees", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "task_assignees_task_id_user_id_pk": { + "name": "task_assignees_task_id_user_id_pk", + "columns": [ + "task_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tasks": { + "name": "tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "event_id": { + "name": "event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lead_time_days": { + "name": "lead_time_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 7 + }, + "lead_time_value": { + "name": "lead_time_value", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 7 + }, + "lead_time_unit": { + "name": "lead_time_unit", + "type": "task_lead_time_unit", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'days'" + }, + "due_at": { + "name": "due_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "task_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'todo'" + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "completed_at": { + "name": "completed_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": { + "tasks_event_idx": { + "name": "tasks_event_idx", + "columns": [ + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_status_due_idx": { + "name": "tasks_status_due_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "due_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tasks_event_id_events_id_fk": { + "name": "tasks_event_id_events_id_fk", + "tableFrom": "tasks", + "tableTo": "events", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "tasks_lead_time_value_check": { + "name": "tasks_lead_time_value_check", + "value": "\"tasks\".\"lead_time_value\" >= 0" + }, + "tasks_lead_time_consistency_check": { + "name": "tasks_lead_time_consistency_check", + "value": "\"tasks\".\"lead_time_days\" = \"tasks\".\"lead_time_value\" * case \"tasks\".\"lead_time_unit\" when 'days' then 1 when 'weeks' then 7 when 'months' then 30 end" + } + }, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_epoch": { + "name": "session_epoch", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "onboarding_seen_at": { + "name": "onboarding_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "push_enabled": { + "name": "push_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.volunteer_signups": { + "name": "volunteer_signups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slot_id": { + "name": "slot_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cancel_token": { + "name": "cancel_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reminded_at": { + "name": "reminded_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": { + "volunteer_signups_slot_idx": { + "name": "volunteer_signups_slot_idx", + "columns": [ + { + "expression": "slot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "volunteer_signups_user_idx": { + "name": "volunteer_signups_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "volunteer_signups_cancel_token_idx": { + "name": "volunteer_signups_cancel_token_idx", + "columns": [ + { + "expression": "cancel_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "volunteer_signups_slot_email_idx": { + "name": "volunteer_signups_slot_email_idx", + "columns": [ + { + "expression": "slot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"volunteer_signups\".\"email\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "volunteer_signups_slot_id_volunteer_slots_id_fk": { + "name": "volunteer_signups_slot_id_volunteer_slots_id_fk", + "tableFrom": "volunteer_signups", + "tableTo": "volunteer_slots", + "columnsFrom": [ + "slot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "volunteer_signups_user_id_users_id_fk": { + "name": "volunteer_signups_user_id_users_id_fk", + "tableFrom": "volunteer_signups", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.volunteer_slots": { + "name": "volunteer_slots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "event_id": { + "name": "event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start_at": { + "name": "start_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "end_at": { + "name": "end_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "capacity": { + "name": "capacity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "volunteer_slots_event_idx": { + "name": "volunteer_slots_event_idx", + "columns": [ + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "volunteer_slots_event_id_events_id_fk": { + "name": "volunteer_slots_event_id_events_id_fk", + "tableFrom": "volunteer_slots", + "tableTo": "events", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.accounting_category_type": { + "name": "accounting_category_type", + "schema": "public", + "values": [ + "income", + "expense" + ] + }, + "public.accounting_entry_status": { + "name": "accounting_entry_status", + "schema": "public", + "values": [ + "draft", + "posted" + ] + }, + "public.accounting_entry_type": { + "name": "accounting_entry_type", + "schema": "public", + "values": [ + "income", + "expense" + ] + }, + "public.association_document_status": { + "name": "association_document_status", + "schema": "public", + "values": [ + "draft", + "final", + "archived" + ] + }, + "public.association_document_type": { + "name": "association_document_type", + "schema": "public", + "values": [ + "ag_minutes", + "attestation", + "statutes", + "internal_rules", + "insurance", + "agreement", + "other" + ] + }, + "public.association_member_status": { + "name": "association_member_status", + "schema": "public", + "values": [ + "active", + "pending", + "inactive" + ] + }, + "public.event_kind": { + "name": "event_kind", + "schema": "public", + "values": [ + "event", + "meeting" + ] + }, + "public.event_status": { + "name": "event_status", + "schema": "public", + "values": [ + "draft", + "published", + "archived" + ] + }, + "public.financial_account_type": { + "name": "financial_account_type", + "schema": "public", + "values": [ + "bank", + "cash" + ] + }, + "public.meeting_attendance_status": { + "name": "meeting_attendance_status", + "schema": "public", + "values": [ + "yes", + "maybe", + "no" + ] + }, + "public.oauth_token_type": { + "name": "oauth_token_type", + "schema": "public", + "values": [ + "access", + "refresh" + ] + }, + "public.outbound_mail_provider": { + "name": "outbound_mail_provider", + "schema": "public", + "values": [ + "resend", + "smtp" + ] + }, + "public.outbound_mail_test_status": { + "name": "outbound_mail_test_status", + "schema": "public", + "values": [ + "untested", + "success", + "failed" + ] + }, + "public.push_delivery_status": { + "name": "push_delivery_status", + "schema": "public", + "values": [ + "queued", + "sent", + "failed", + "received", + "opened" + ] + }, + "public.role": { + "name": "role", + "schema": "public", + "values": [ + "admin", + "manager", + "member" + ] + }, + "public.task_lead_time_unit": { + "name": "task_lead_time_unit", + "schema": "public", + "values": [ + "days", + "weeks", + "months" + ] + }, + "public.task_status": { + "name": "task_status", + "schema": "public", + "values": [ + "todo", + "in_progress", + "done" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index a9c88a5..2ed11fd 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -141,6 +141,13 @@ "when": 1789044964091, "tag": "0019_meetings", "breakpoints": true + }, + { + "idx": 20, + "version": "7", + "when": 1789051948309, + "tag": "0020_public_meeting_attendance", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/app/annulation/[token]/page.tsx b/src/app/annulation/[token]/page.tsx index 2cac30f..d561532 100644 --- a/src/app/annulation/[token]/page.tsx +++ b/src/app/annulation/[token]/page.tsx @@ -3,16 +3,25 @@ import { CalendarDays, CircleAlert, Link2Off, + MapPin, } from "lucide-react"; import Link from "next/link"; import { CancelSignup } from "@/components/cancel-signup"; import { SiteHeader } from "@/components/site-header"; -import { getSignupByCancelToken } from "@/lib/data"; +import { + getMeetingAttendanceByCancelToken, + getSignupByCancelToken, +} from "@/lib/data"; import { formatDateTime } from "@/lib/dates"; export const dynamic = "force-dynamic"; +/** + * Retrait par lien public. Le même jeton peut désigner un créneau de bénévole + * ou une présence annoncée à une réunion : la page reconnaît les deux, parce + * que le parent n'a qu'un lien au bas d'un e-mail. + */ export default async function CancelPage({ params, }: { @@ -20,13 +29,14 @@ export default async function CancelPage({ }) { const { token } = await params; const signup = await getSignupByCancelToken(token); + const presence = signup ? null : await getMeetingAttendanceByCancelToken(token); return (
- {!signup ? ( + {!signup && !presence ? (
@@ -45,6 +55,53 @@ export default async function CancelPage({ Retour à l'accueil
+ ) : presence ? ( +
+ + + +

+ Gestion de ma réponse +

+

+ Retirer ma réponse +

+

+ Vous aviez annoncé votre venue à la réunion suivante. +

+ +
+

+ {presence.event.title} +

+

+ + {formatDateTime(presence.event.startAt)} +

+ {presence.event.location && ( +

+ + {presence.event.location} +

+ )} +
+ +
+

+ Votre nom sera retiré de la liste des présents. La réunion + vous reste ouverte : vous pouvez venir sans prévenir, et + répondre à nouveau depuis la page de la réunion. +

+
+ +
+ +
+
) : (
@@ -63,18 +120,18 @@ export default async function CancelPage({

- {signup.slot.event.title} + {signup!.slot.event.title}

- {formatDateTime(signup.slot.event.startAt)} + {formatDateTime(signup!.slot.event.startAt)}

Votre mission

- {signup.slot.title} + {signup!.slot.title}

diff --git a/src/app/api/meetings/attendance/route.ts b/src/app/api/meetings/attendance/route.ts new file mode 100644 index 0000000..eb69078 --- /dev/null +++ b/src/app/api/meetings/attendance/route.ts @@ -0,0 +1,215 @@ +import { and, eq, isNull, sql } from "drizzle-orm"; +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { handleApiError, HttpError } from "@/lib/auth/guards"; +import { getCurrentUser } from "@/lib/auth/session"; +import { getBaseUrl } from "@/lib/base-url"; +import { formatDateTime } from "@/lib/dates"; +import { db } from "@/lib/db"; +import { events, meetingAttendance } from "@/lib/db/schema"; +import { sendEmail } from "@/lib/notifications/email"; +import { meetingAttendanceConfirmationEmail } from "@/lib/notifications/emails"; +import { + getAssociationSettings, + getRecaptchaRuntimeConfig, +} from "@/lib/services/association-settings"; +import { verifyRecaptcha } from "@/lib/services/recaptcha"; +import { generateToken } from "@/lib/tokens"; +import { emptyToNull } from "@/lib/utils"; +import { publicMeetingAttendanceSchema } from "@/lib/validation"; + +/** + * « Je serai là » depuis la page publique d'une réunion. + * + * Les réunions sont ouvertes aux familles : on ne peut donc pas demander un + * compte pour annoncer sa venue. Ce point d'entrée est public et se protège + * comme les autres formulaires du site — pot de miel, reCAPTCHA, réunion + * retrouvée par son jeton de partage et non par son identifiant. + * + * Un membre connecté qui répond depuis cette page est reconnu : sa réponse est + * rattachée à son compte plutôt qu'ajoutée en double sous son nom saisi. + */ +const schemaPublic = publicMeetingAttendanceSchema + .extend({ + token: z.string().min(8, "Lien invalide"), + // Pot de miel anti-robot : champ caché qui doit rester vide. + website: z.string().optional(), + }) + .refine( + (d) => (d.email && d.email.length > 0) || (d.phone && d.phone.trim().length > 0), + { + message: "Indiquez au moins un e-mail ou un téléphone.", + path: ["email"], + }, + ); + +export async function POST(req: Request) { + try { + const data = schemaPublic.parse(await req.json()); + + // Robot détecté (pot de miel rempli) : on répond OK sans rien enregistrer. + if (data.website && data.website.trim().length > 0) { + return NextResponse.json({ ok: true }); + } + + const recaptcha = await getRecaptchaRuntimeConfig(); + if (recaptcha) { + await verifyRecaptcha({ + secret: recaptcha.secret, + token: data.recaptchaToken, + action: "inscription", + minScore: recaptcha.minScore, + ip: req.headers.get("x-forwarded-for")?.split(",")[0]?.trim(), + }); + } + + const [reunion] = await db + .select({ + id: events.id, + kind: events.kind, + status: events.status, + title: events.title, + startAt: events.startAt, + location: events.location, + }) + .from(events) + .where(eq(events.shareToken, data.token)) + .limit(1); + + if (!reunion || reunion.status !== "published") { + throw new HttpError(404, "Cette réunion n'accepte pas de réponses."); + } + if (reunion.kind !== "meeting") { + throw new HttpError( + 400, + "Ce rendez-vous n'est pas une réunion : pour un événement, ce sont les créneaux de bénévoles.", + ); + } + + const email = emptyToNull(data.email ?? null); + const phone = emptyToNull(data.phone ?? null); + const currentUser = await getCurrentUser(); + + // Un membre connecté répond sous son compte : c'est la même réponse que + // celle du tableau de bord, pas une seconde ligne au même nom. + if (currentUser) { + await db + .insert(meetingAttendance) + .values({ + eventId: reunion.id, + userId: currentUser.id, + status: data.status, + }) + .onConflictDoUpdate({ + target: [meetingAttendance.eventId, meetingAttendance.userId], + set: { status: data.status, updatedAt: new Date() }, + }); + return NextResponse.json({ ok: true }); + } + + const cancelToken = generateToken(18); + + // Revenir sur sa réponse doit marcher aussi bien que la donner : un même + // e-mail met sa ligne à jour au lieu de buter sur « déjà répondu », car le + // parent qui recoche n'a le plus souvent pas gardé l'e-mail de confirmation. + if (email) { + const [existant] = await db + .select({ id: meetingAttendance.id, cancelToken: meetingAttendance.cancelToken }) + .from(meetingAttendance) + .where( + and( + eq(meetingAttendance.eventId, reunion.id), + isNull(meetingAttendance.userId), + sql`lower(${meetingAttendance.email}) = ${email}`, + ), + ) + .limit(1); + + if (existant) { + await db + .update(meetingAttendance) + .set({ + status: data.status, + name: data.name, + phone, + cancelToken: existant.cancelToken ?? cancelToken, + updatedAt: new Date(), + }) + .where(eq(meetingAttendance.id, existant.id)); + await envoyerConfirmation({ + email, + nom: data.name, + statut: data.status, + reunion, + cancelToken: existant.cancelToken ?? cancelToken, + }); + return NextResponse.json({ ok: true }); + } + } + + try { + await db.insert(meetingAttendance).values({ + eventId: reunion.id, + status: data.status, + name: data.name, + email, + phone, + cancelToken, + }); + } catch (e) { + // Double soumission simultanée : l'index unique partiel a tranché. + if ((e as { code?: string })?.code === "23505") { + return NextResponse.json({ ok: true }); + } + throw e; + } + + if (email) { + await envoyerConfirmation({ + email, + nom: data.name, + statut: data.status, + reunion, + cancelToken, + }); + } + + return NextResponse.json({ ok: true }); + } catch (error) { + return handleApiError(error); + } +} + +async function envoyerConfirmation({ + email, + nom, + statut, + reunion, + cancelToken, +}: { + email: string; + nom: string; + statut: "yes" | "maybe" | "no"; + reunion: { title: string; startAt: Date; location: string | null }; + cancelToken: string; +}) { + const [baseUrl, association] = await Promise.all([ + getBaseUrl(), + getAssociationSettings(), + ]); + const mail = meetingAttendanceConfirmationEmail({ + name: nom, + eventTitle: reunion.title, + eventDate: formatDateTime(reunion.startAt), + location: reunion.location, + status: statut, + cancelUrl: `${baseUrl}/annulation/${cancelToken}`, + identity: { + associationName: association.associationName, + schoolName: association.schoolName, + rna: association.rna, + }, + }); + await sendEmail({ to: email, ...mail }); +} diff --git a/src/app/api/signup/cancel/route.ts b/src/app/api/signup/cancel/route.ts index 7702d4e..f24284b 100644 --- a/src/app/api/signup/cancel/route.ts +++ b/src/app/api/signup/cancel/route.ts @@ -4,21 +4,33 @@ import { z } from "zod"; import { handleApiError, HttpError } from "@/lib/auth/guards"; import { db } from "@/lib/db"; -import { volunteerSignups } from "@/lib/db/schema"; +import { meetingAttendance, volunteerSignups } from "@/lib/db/schema"; const schema = z.object({ token: z.string().min(8) }); +/** + * Retrait par lien public : créneau de bénévole ou présence annoncée à une + * réunion. Un seul point d'entrée pour les deux, parce que le parent, lui, n'a + * qu'un lien « me désinscrire » au bas d'un e-mail et ne sait pas de quelle + * table il relève. + */ export async function POST(req: Request) { try { const { token } = schema.parse(await req.json()); - const [deleted] = await db + + const [signup] = await db .delete(volunteerSignups) .where(eq(volunteerSignups.cancelToken, token)) .returning({ id: volunteerSignups.id }); - if (!deleted) { - throw new HttpError(404, "Inscription introuvable ou déjà annulée."); - } - return NextResponse.json({ ok: true }); + if (signup) return NextResponse.json({ ok: true }); + + const [presence] = await db + .delete(meetingAttendance) + .where(eq(meetingAttendance.cancelToken, token)) + .returning({ id: meetingAttendance.id }); + if (presence) return NextResponse.json({ ok: true }); + + throw new HttpError(404, "Inscription introuvable ou déjà annulée."); } catch (error) { return handleApiError(error); } diff --git a/src/app/dashboard/events/[id]/page.tsx b/src/app/dashboard/events/[id]/page.tsx index ed6bebd..5b1d82c 100644 --- a/src/app/dashboard/events/[id]/page.tsx +++ b/src/app/dashboard/events/[id]/page.tsx @@ -48,6 +48,7 @@ import { getChecklistTemplates, getEventWithDetails, getMeetingAttendance, + nomPresent, } from "@/lib/data"; import { formatDateTime, toDatetimeLocal } from "@/lib/dates"; import { formatEurosAbsolute } from "@/lib/money"; @@ -260,7 +261,8 @@ export default async function EventDetailPage({

Votre réponse est visible des autres membres. Recliquez dessus - pour la retirer. + pour la retirer. Les parents sans compte répondent depuis le lien + public : ils apparaissent ci-dessous, marqués « invité ».

@@ -286,7 +288,15 @@ export default async function EventDetailPage({
    {gens.map((r) => (
  • - {r.user.name} + {nomPresent(r)} + {/* Un parent sans compte a répondu par le lien public : + le dire évite de le chercher dans l'annuaire des + membres, et rappelle que la réunion est ouverte. */} + {!r.userId && ( + + invité + + )}
  • ))}
diff --git a/src/app/inscription/[token]/page.tsx b/src/app/inscription/[token]/page.tsx index c0cc3e9..558f935 100644 --- a/src/app/inscription/[token]/page.tsx +++ b/src/app/inscription/[token]/page.tsx @@ -12,6 +12,7 @@ import { import Link from "next/link"; import { FormattedText } from "@/components/formatted-text"; +import { MeetingAttendanceForm } from "@/components/meeting-attendance-form"; import { SiteFooter } from "@/components/site-footer"; import { SiteHeader } from "@/components/site-header"; import { getAssociationSettings } from "@/lib/services/association-settings"; @@ -37,9 +38,12 @@ export async function generateMetadata({ // Ce texte est l'aperçu affiché quand le lien circule dans un groupe de // classe : n'annoncer que le bénévolat ferait manquer la page aux familles // qui voulaient réserver. - const action = event.ticketingUrl - ? "réservez votre place ou donnez un coup de main." - : "proposez-vous comme bénévole."; + const action = + event.kind === "meeting" + ? "dites-nous si vous venez." + : event.ticketingUrl + ? "réservez votre place ou donnez un coup de main." + : "proposez-vous comme bénévole."; const desc = `${formatDateTime(event.startAt)}${ event.location ? ` · ${event.location}` : "" } — ${action}`; @@ -283,25 +287,43 @@ export default async function InscriptionPage({

Les réunions sont ouvertes aux parents

-

+

C’est là qu’on décide de ce que fait l’association. Venez - écouter, ou prendre la parole : les deux se valent. + écouter, ou prendre la parole : les deux se valent. Dites-nous + si vous venez, ça nous aide à prévoir la salle et les chaises.

{/* Aucune liste de présents ici : qui vient à une réunion est une - information réservée aux membres connectés. */} -
- - Poser une question - - - Rejoindre l’association - + information réservée aux membres connectés. Le formulaire + enregistre une réponse, il n'en montre aucune autre. */} + + +
+

+ Vous pouvez aussi venir sans avoir répondu : la porte reste + ouverte. +

+
+ + Poser une question + + + Rejoindre l’association + +
) : ( diff --git a/src/components/cancel-signup.tsx b/src/components/cancel-signup.tsx index 733e26f..dc31356 100644 --- a/src/components/cancel-signup.tsx +++ b/src/components/cancel-signup.tsx @@ -6,7 +6,16 @@ import { useState } from "react"; import { Button } from "@/components/ui"; import { api } from "@/lib/client"; -export function CancelSignup({ token }: { token: string }) { +export function CancelSignup({ + token, + label = "Confirmer ma désinscription", + confirmation = "Votre désinscription est enregistrée. Merci de nous avoir prévenus !", +}: { + token: string; + /** Libellé du bouton : on ne « se désinscrit » pas d'une réunion ouverte. */ + label?: string; + confirmation?: string; +}) { const [done, setDone] = useState(false); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); @@ -15,7 +24,7 @@ export function CancelSignup({ token }: { token: string }) { return (

- Votre désinscription est enregistrée. Merci de nous avoir prévenus ! + {confirmation}

); } @@ -39,7 +48,7 @@ export function CancelSignup({ token }: { token: string }) {

{error}

)}
); diff --git a/src/components/meeting-attendance-form.tsx b/src/components/meeting-attendance-form.tsx new file mode 100644 index 0000000..29a1cfa --- /dev/null +++ b/src/components/meeting-attendance-form.tsx @@ -0,0 +1,252 @@ +"use client"; + +import { Check, HelpCircle } from "lucide-react"; +import Link from "next/link"; +import { useState } from "react"; + +import { Button, Input, Label } from "@/components/ui"; +import { api } from "@/lib/client"; +import { celebrate } from "@/lib/confetti"; +import { useRecaptcha } from "@/lib/use-recaptcha"; + +/** + * « Je serai là » depuis la page publique d'une réunion. + * + * Deux réponses seulement, là où les membres en ont trois : un parent qui ne + * compte pas venir ferme la page, il ne remplit pas un formulaire pour le dire. + * Se décommander passe par le lien de l'e-mail de confirmation. + * + * La réponse est demandée avant les coordonnées, et « Je serai là » est + * présélectionné : c'est le geste attendu, il ne doit pas coûter un clic + * supplémentaire à celui qui a déjà décidé de venir. + * + * Les fonds des choix sélectionnés sont pris deux crans plus foncés que la + * teinte « naturelle » de la palette : du blanc sur sea-500 tombe à 2,89:1 et + * sur sand-500 à 3,31:1, sous le seuil AA pour du texte de cette taille. + */ + +const CHOIX = [ + { + valeur: "yes" as const, + titre: "Je serai là", + detail: "Comptez sur moi", + icone: Check, + actif: "border-sea-700 bg-sea-700 text-white", + detailActif: "text-sea-50", + }, + { + valeur: "maybe" as const, + titre: "Peut-être", + detail: "Je ne sais pas encore", + icone: HelpCircle, + actif: "border-sand-700 bg-sand-700 text-white", + detailActif: "text-sand-100", + }, +]; + +export function MeetingAttendanceForm({ + token, + defaultName = "", + defaultEmail = "", + recaptchaSiteKey = null, + whatsappGroupUrl = null, +}: { + /** Jeton de partage de la réunion. */ + token: string; + defaultName?: string; + defaultEmail?: string; + recaptchaSiteKey?: string | null; + whatsappGroupUrl?: string | null; +}) { + const executerRecaptcha = useRecaptcha(recaptchaSiteKey); + const [status, setStatus] = useState<"yes" | "maybe">("yes"); + const [done, setDone] = useState<"yes" | "maybe" | null>(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + if (done) { + return ( +
+
{done === "yes" ? "🎉" : "👍"}
+

+ {done === "yes" + ? "C’est noté, on vous attend !" + : "C’est noté : peut-être, et c’est déjà utile."} +

+

+ Vous recevrez un e-mail de confirmation, avec un lien pour vous + décommander si besoin. Venir sans avoir répondu reste possible. +

+ {whatsappGroupUrl && ( +

+ Les rappels et les changements de dernière minute passent par le{" "} + + groupe WhatsApp de l’association + (ouvre WhatsApp dans un nouvel onglet) + + . +

+ )} + +
+ ); + } + + async function onSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + setLoading(true); + const form = new FormData(e.currentTarget); + const choisi = status; + try { + await api("/api/meetings/attendance", { + body: { + token, + status: choisi, + name: form.get("name"), + email: form.get("email"), + phone: form.get("phone"), + consent: form.get("consent") === "on", + website: form.get("website"), // pot de miel + recaptchaToken: await executerRecaptcha("inscription"), + }, + }); + setDone(choisi); + if (choisi === "yes") void celebrate(); + } catch (err) { + setError((err as Error).message); + } finally { + setLoading(false); + } + } + + return ( +
+ {error && ( +

+ {error} +

+ )} + +
+ + Vous venez ? + +
+ {CHOIX.map(({ valeur, titre, detail, icone: Icone, actif, detailActif }) => { + const selectionne = status === valeur; + return ( + + ); + })} +
+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+

+ Indiquez au moins un e-mail ou un téléphone. L’e-mail permet de recevoir + la confirmation et d’être prévenu si la réunion est déplacée. +

+ + {/* Pot de miel anti-robot : invisible pour les humains. */} + + + + + +
+ ); +} diff --git a/src/components/meeting-attendance.tsx b/src/components/meeting-attendance.tsx index 5c3747a..d2d8677 100644 --- a/src/components/meeting-attendance.tsx +++ b/src/components/meeting-attendance.tsx @@ -13,6 +13,9 @@ import { api } from "@/lib/client"; * Trois réponses plutôt que deux, parce qu'un parent qui ne sait pas encore * n'est ni un présent ni un absent — et qu'une assemblée générale se prépare * avec des chiffres honnêtes. Recliquer sur sa réponse la retire. + * + * Les fonds sélectionnés sont deux crans plus foncés que la teinte naturelle + * de la palette : du blanc sur sea-500 tombe à 2,89:1, sous le seuil AA. */ export type MeetingReply = "yes" | "maybe" | "no"; @@ -29,14 +32,14 @@ const CHOIX: { label: "Je serai là", courte: "présent", icone: Check, - actif: "border-sea-500 bg-sea-500 text-white", + actif: "border-sea-700 bg-sea-700 text-white", }, { valeur: "maybe", label: "Peut-être", courte: "peut-être", icone: HelpCircle, - actif: "border-sand-500 bg-sand-500 text-white", + actif: "border-sand-700 bg-sand-700 text-white", }, { valeur: "no", diff --git a/src/lib/data.ts b/src/lib/data.ts index 9623c4a..ede2817 100644 --- a/src/lib/data.ts +++ b/src/lib/data.ts @@ -144,14 +144,36 @@ export async function getUpcomingMeetings() { }); } -/** Réponses de présence d'une réunion, avec le nom de chaque membre. */ +/** + * Réponses de présence d'une réunion, membres et parents venus par le lien + * public mêlés. Le nom affiché vient du compte quand il y en a un, du + * formulaire sinon — `nomPresent()` fait ce choix en un seul endroit pour que + * les trois colonnes de la page réunion ne divergent jamais. + */ export async function getMeetingAttendance(eventId: string) { return db.query.meetingAttendance.findMany({ where: eq(meetingAttendance.eventId, eventId), + orderBy: [asc(meetingAttendance.createdAt)], with: { user: { columns: { id: true, name: true } } }, }); } +/** Le nom sous lequel afficher une réponse de présence. */ +export function nomPresent(reponse: { + name: string | null; + user: { name: string } | null; +}): string { + return reponse.user?.name ?? reponse.name ?? "Sans nom"; +} + +/** Une présence annoncée publiquement, via son jeton de retrait. */ +export async function getMeetingAttendanceByCancelToken(token: string) { + return db.query.meetingAttendance.findFirst({ + where: eq(meetingAttendance.cancelToken, token), + with: { event: true }, + }); +} + /** Tâches assignées à un membre, avec leur événement. */ export async function getTasksForUser(userId: string) { const rows = await db.query.taskAssignees.findMany({ diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 7c7de82..f435558 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -22,9 +22,9 @@ import { export const roleEnum = pgEnum("role", ["admin", "manager", "member"]); /** - * Une réunion est un événement de l'association, pas une manifestation : elle - * ne sort jamais sur le site public (voir le filtre des requêtes publiques dans - * src/lib/data.ts) et se répond par une présence, pas par un créneau bénévole. + * Une réunion est un événement de l'association, pas une manifestation : une + * fois publiée elle paraît sur le site comme les autres rendez-vous, mais on y + * répond par une présence annoncée et non par un créneau de bénévolat. */ export const eventKindEnum = pgEnum("event_kind", ["event", "meeting"]); @@ -187,9 +187,17 @@ export const meetingAttendanceStatusEnum = pgEnum("meeting_attendance_status", [ ]); /** - * Présence annoncée à une réunion : une réponse par membre et par réunion, - * modifiable à volonté. Rien de public — la table n'est lue que par des - * requêtes derrière une session. + * Présence annoncée à une réunion. + * + * Deux populations dans la même table : les membres connectés (`userId` + * renseigné, le nom vient du compte) et les parents venus par le lien public + * (`userId` nul, coordonnées saisies dans le formulaire). Les réunions étant + * ouvertes aux familles, séparer les deux en deux tables aurait obligé chaque + * comptage à faire une union — et un oubli quelque part aurait fait disparaître + * des présents de la liste. + * + * La liste reste réservée aux membres : la table n'est lue que derrière une + * session, jamais par une page publique. */ export const meetingAttendance = pgTable( "meeting_attendance", @@ -198,23 +206,47 @@ export const meetingAttendance = pgTable( eventId: uuid("event_id") .notNull() .references(() => events.id, { onDelete: "cascade" }), - userId: uuid("user_id") - .notNull() - .references(() => users.id, { onDelete: "cascade" }), + /** Renseigné pour un membre connecté, nul pour une réponse publique. */ + userId: uuid("user_id").references(() => users.id, { onDelete: "cascade" }), status: meetingAttendanceStatusEnum("status").notNull(), + /** Coordonnées d'un parent non connecté ; nulles pour un membre. */ + name: text("name"), + email: text("email"), + phone: text("phone"), + /** Jeton de retrait autonome (lien public dans l'e-mail de confirmation). */ + cancelToken: text("cancel_token"), /** Mot laissé à l'équipe : « j'arriverai en retard », « je peux visio ». */ note: text("note"), updatedAt: timestamp("updated_at", { withTimezone: true }) .notNull() .defaultNow(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), }, (t) => ({ - // Une seule réponse par personne : changer d'avis met à jour la ligne. + // Une seule réponse par membre : changer d'avis met à jour la ligne. Les + // réponses publiques ont un user_id nul, que Postgres tient pour distinct + // les uns des autres : elles ne se marchent pas dessus ici. uniqueParPersonne: uniqueIndex("meeting_attendance_event_user_unique").on( t.eventId, t.userId, ), + // Anti-doublon des réponses publiques : un même e-mail ne répond qu'une + // fois par réunion — s'il revient, sa réponse est mise à jour. + eventEmailIdx: uniqueIndex("meeting_attendance_event_email_unique") + .on(t.eventId, sql`lower(${t.email})`) + .where(sql`${t.userId} is null and ${t.email} is not null`), + cancelTokenIdx: uniqueIndex("meeting_attendance_cancel_token_idx").on( + t.cancelToken, + ), eventIdx: index("meeting_attendance_event_idx").on(t.eventId), + // Une ligne dit toujours qui vient : soit un compte, soit un nom saisi. + // Sans cette garantie, la liste des présents afficherait des lignes vides. + identifiable: check( + "meeting_attendance_identite", + sql`${t.userId} is not null or ${t.name} is not null`, + ), }), ); diff --git a/src/lib/notifications/emails.ts b/src/lib/notifications/emails.ts index 2cf557e..84024e9 100644 --- a/src/lib/notifications/emails.ts +++ b/src/lib/notifications/emails.ts @@ -81,6 +81,51 @@ export function volunteerConfirmationEmail(ctx: VolunteerCtx): EmailContent { }; } +/** + * Accusé de réception d'une présence annoncée à une réunion. + * + * Le lien de retrait est le pendant de la promesse faite sur la page : on peut + * se décommander sans écrire à personne. « Peut-être » est repris tel quel dans + * le corps du message — recevoir « Votre présence est confirmée » après avoir + * coché « peut-être » ferait douter de ce qui a été enregistré. + */ +export function meetingAttendanceConfirmationEmail(ctx: { + name: string; + eventTitle: string; + eventDate: string; + location?: string | null; + status: "yes" | "maybe" | "no"; + cancelUrl: string; + identity?: NotificationIdentity; +}): EmailContent { + const reponse = { + yes: "vous serez là", + maybe: "vous viendrez peut-être", + no: "vous ne pourrez pas venir", + }[ctx.status]; + const titre = ctx.status === "no" ? "Merci de nous avoir prévenus" : "À bientôt !"; + const loc = ctx.location + ? `
  • Lieu : ${esc(ctx.location)}
  • ` + : ""; + return { + subject: `Votre réponse pour la réunion du ${ctx.eventDate}`, + html: layout( + titre, + `

    Bonjour ${esc(ctx.name)},

    +

    Nous avons noté que ${reponse} :

    +
      +
    • Réunion : ${esc(ctx.eventTitle)}
    • +
    • Date : ${ctx.eventDate}
    • + ${loc} +
    +

    Changement de programme ? Vous pouvez retirer votre réponse en un clic :

    +

    ${button(ctx.cancelUrl, "Retirer ma réponse")}

    `, + ctx.identity, + ), + text: `Bonjour ${ctx.name},\n\nNous avons noté que ${reponse} :\n- Réunion : ${ctx.eventTitle}\n- Date : ${ctx.eventDate}\n${ctx.location ? `- Lieu : ${ctx.location}\n` : ""}\nRetirer ma réponse : ${ctx.cancelUrl}`, + }; +} + export function volunteerReminderEmail(ctx: VolunteerCtx): EmailContent { return { subject: `Rappel — ${ctx.eventTitle}, c'est bientôt !`, diff --git a/src/lib/validation.ts b/src/lib/validation.ts index ebc4a67..803ecea 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -212,6 +212,28 @@ export const meetingAttendanceSchema = z.object({ note: z.string().trim().max(300).optional(), }); +/** + * Présence annoncée depuis la page publique d'une réunion, par un parent qui + * n'a pas de compte. Mêmes coordonnées qu'une inscription bénévole : un nom, + * et au moins un moyen de le joindre s'il faut prévenir d'un report. + */ +export const publicMeetingAttendanceSchema = z.object({ + status: z.enum(["yes", "maybe", "no"]), + name: z.string().trim().min(2, "Nom requis").max(120), + email: z + .string() + .trim() + .toLowerCase() + .email("Adresse e-mail invalide") + .optional() + .or(z.literal("")), + phone: z.string().trim().max(40).optional(), + consent: z.boolean().refine((v) => v === true, { + message: "Vous devez accepter la politique de confidentialité.", + }), + recaptchaToken: z.string().max(5000).optional(), +}); + export const forgotSchema = z.object({ email: z.string().trim().toLowerCase().email("Adresse e-mail invalide"), });