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}
)}
- Confirmer ma désinscription
+ {label}
);
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)
+
+ .
+
+ )}
+
setDone(null)}
+ className="mt-3 text-sm font-semibold text-brand-600 underline-offset-2 hover:underline"
+ >
+ Annoncer quelqu’un d’autre
+
+
+ );
+ }
+
+ 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 (
+
+ );
+}
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"),
});