diff --git a/drizzle/0020_public_meeting_attendance.sql b/drizzle/0020_public_meeting_attendance.sql new file mode 100644 index 0000000..df88aaf --- /dev/null +++ b/drizzle/0020_public_meeting_attendance.sql @@ -0,0 +1,9 @@ +ALTER TABLE "meeting_attendance" ALTER COLUMN "user_id" DROP NOT NULL;--> statement-breakpoint +ALTER TABLE "meeting_attendance" ADD COLUMN "name" text;--> statement-breakpoint +ALTER TABLE "meeting_attendance" ADD COLUMN "email" text;--> statement-breakpoint +ALTER TABLE "meeting_attendance" ADD COLUMN "phone" text;--> statement-breakpoint +ALTER TABLE "meeting_attendance" ADD COLUMN "cancel_token" text;--> statement-breakpoint +ALTER TABLE "meeting_attendance" ADD COLUMN "created_at" timestamp with time zone DEFAULT now() NOT NULL;--> statement-breakpoint +CREATE UNIQUE INDEX "meeting_attendance_event_email_unique" ON "meeting_attendance" USING btree ("event_id",lower("email")) WHERE "meeting_attendance"."user_id" is null and "meeting_attendance"."email" is not null;--> statement-breakpoint +CREATE UNIQUE INDEX "meeting_attendance_cancel_token_idx" ON "meeting_attendance" USING btree ("cancel_token");--> statement-breakpoint +ALTER TABLE "meeting_attendance" ADD CONSTRAINT "meeting_attendance_identite" CHECK ("meeting_attendance"."user_id" is not null or "meeting_attendance"."name" is not null); \ No newline at end of file diff --git a/drizzle/meta/0020_snapshot.json b/drizzle/meta/0020_snapshot.json new file mode 100644 index 0000000..501d6d1 --- /dev/null +++ b/drizzle/meta/0020_snapshot.json @@ -0,0 +1,3580 @@ +{ + "id": "d3c1d65e-8a29-43b1-938d-7cd6a77d722b", + "prevId": "c29c5c23-3a9a-4ba0-8b8d-ba2e093c644e", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounting_categories": { + "name": "accounting_categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "accounting_category_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "accounting_categories_type_active_idx": { + "name": "accounting_categories_type_active_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.accounting_entries": { + "name": "accounting_entries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "type": { + "name": "type", + "type": "accounting_entry_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "accounting_entry_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "category_id": { + "name": "category_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "event_id": { + "name": "event_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount_cents": { + "name": "amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "counterparty": { + "name": "counterparty", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payment_method": { + "name": "payment_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference": { + "name": "reference", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attachment_url": { + "name": "attachment_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "accounting_entries_occurred_at_idx": { + "name": "accounting_entries_occurred_at_idx", + "columns": [ + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "accounting_entries_status_occurred_at_idx": { + "name": "accounting_entries_status_occurred_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "accounting_entries_account_idx": { + "name": "accounting_entries_account_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "accounting_entries_category_idx": { + "name": "accounting_entries_category_idx", + "columns": [ + { + "expression": "category_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "accounting_entries_event_idx": { + "name": "accounting_entries_event_idx", + "columns": [ + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounting_entries_account_id_financial_accounts_id_fk": { + "name": "accounting_entries_account_id_financial_accounts_id_fk", + "tableFrom": "accounting_entries", + "tableTo": "financial_accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "accounting_entries_category_id_accounting_categories_id_fk": { + "name": "accounting_entries_category_id_accounting_categories_id_fk", + "tableFrom": "accounting_entries", + "tableTo": "accounting_categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "accounting_entries_event_id_events_id_fk": { + "name": "accounting_entries_event_id_events_id_fk", + "tableFrom": "accounting_entries", + "tableTo": "events", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "accounting_entries_created_by_users_id_fk": { + "name": "accounting_entries_created_by_users_id_fk", + "tableFrom": "accounting_entries", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "accounting_entries_amount_cents_check": { + "name": "accounting_entries_amount_cents_check", + "value": "\"accounting_entries\".\"amount_cents\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.association_documents": { + "name": "association_documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "type": { + "name": "type", + "type": "association_document_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "association_document_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_date": { + "name": "document_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "member_id": { + "name": "member_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "association_documents_type_status_date_idx": { + "name": "association_documents_type_status_date_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "association_documents_member_idx": { + "name": "association_documents_member_idx", + "columns": [ + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "association_documents_member_id_association_members_id_fk": { + "name": "association_documents_member_id_association_members_id_fk", + "tableFrom": "association_documents", + "tableTo": "association_members", + "columnsFrom": [ + "member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "association_documents_created_by_users_id_fk": { + "name": "association_documents_created_by_users_id_fk", + "tableFrom": "association_documents", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.association_members": { + "name": "association_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address_line_1": { + "name": "address_line_1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address_line_2": { + "name": "address_line_2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "postal_code": { + "name": "postal_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "city": { + "name": "city", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'France'" + }, + "status": { + "name": "status", + "type": "association_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "school_year": { + "name": "school_year", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "membership_fee_cents": { + "name": "membership_fee_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "fee_paid_at": { + "name": "fee_paid_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "association_members_user_idx": { + "name": "association_members_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"association_members\".\"user_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "association_members_status_school_year_idx": { + "name": "association_members_status_school_year_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "school_year", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "association_members_name_idx": { + "name": "association_members_name_idx", + "columns": [ + { + "expression": "last_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "first_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "association_members_user_id_users_id_fk": { + "name": "association_members_user_id_users_id_fk", + "tableFrom": "association_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "association_members_membership_fee_cents_check": { + "name": "association_members_membership_fee_cents_check", + "value": "\"association_members\".\"membership_fee_cents\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.association_settings": { + "name": "association_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "default": "'default'" + }, + "association_name": { + "name": "association_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'APEL Manager'" + }, + "school_name": { + "name": "school_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Votre établissement'" + }, + "contact_email": { + "name": "contact_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rna": { + "name": "rna", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "task_reminder_window_days": { + "name": "task_reminder_window_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "volunteer_reminder_window_days": { + "name": "volunteer_reminder_window_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2 + }, + "telegram_enabled": { + "name": "telegram_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "encrypted_telegram_bot_token": { + "name": "encrypted_telegram_bot_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telegram_token_last_four": { + "name": "telegram_token_last_four", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telegram_bot_username": { + "name": "telegram_bot_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telegram_token_verified_at": { + "name": "telegram_token_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "recaptcha_enabled": { + "name": "recaptcha_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "recaptcha_site_key": { + "name": "recaptcha_site_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_recaptcha_secret": { + "name": "encrypted_recaptcha_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "recaptcha_min_score": { + "name": "recaptcha_min_score", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 50 + }, + "whatsapp_group_url": { + "name": "whatsapp_group_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "push_vapid_public_key": { + "name": "push_vapid_public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_push_vapid_private_key": { + "name": "encrypted_push_vapid_private_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "association_settings_updated_by_users_id_fk": { + "name": "association_settings_updated_by_users_id_fk", + "tableFrom": "association_settings", + "tableTo": "users", + "columnsFrom": [ + "updated_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "association_settings_singleton_check": { + "name": "association_settings_singleton_check", + "value": "\"association_settings\".\"id\" = 'default'" + }, + "association_settings_task_reminder_window_check": { + "name": "association_settings_task_reminder_window_check", + "value": "\"association_settings\".\"task_reminder_window_days\" >= 0 and \"association_settings\".\"task_reminder_window_days\" <= 30" + }, + "association_settings_recaptcha_min_score_check": { + "name": "association_settings_recaptcha_min_score_check", + "value": "\"association_settings\".\"recaptcha_min_score\" >= 0 and \"association_settings\".\"recaptcha_min_score\" <= 100" + }, + "association_settings_volunteer_reminder_window_check": { + "name": "association_settings_volunteer_reminder_window_check", + "value": "\"association_settings\".\"volunteer_reminder_window_days\" >= 0 and \"association_settings\".\"volunteer_reminder_window_days\" <= 30" + } + }, + "isRLSEnabled": false + }, + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'web'" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_logs_entity_idx": { + "name": "audit_logs_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_logs_actor_created_idx": { + "name": "audit_logs_actor_created_idx", + "columns": [ + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_logs_client_created_idx": { + "name": "audit_logs_client_created_idx", + "columns": [ + { + "expression": "oauth_client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_logs_created_at_idx": { + "name": "audit_logs_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_logs_actor_user_id_users_id_fk": { + "name": "audit_logs_actor_user_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": [ + "actor_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_logs_oauth_client_id_oauth_clients_id_fk": { + "name": "audit_logs_oauth_client_id_oauth_clients_id_fk", + "tableFrom": "audit_logs", + "tableTo": "oauth_clients", + "columnsFrom": [ + "oauth_client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.checklist_templates": { + "name": "checklist_templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tasks": { + "name": "tasks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.event_attachments": { + "name": "event_attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "event_id": { + "name": "event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "event_attachments_event_idx": { + "name": "event_attachments_event_idx", + "columns": [ + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "event_attachments_event_id_events_id_fk": { + "name": "event_attachments_event_id_events_id_fk", + "tableFrom": "event_attachments", + "tableTo": "events", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "event_attachments_uploaded_by_users_id_fk": { + "name": "event_attachments_uploaded_by_users_id_fk", + "tableFrom": "event_attachments", + "tableTo": "users", + "columnsFrom": [ + "uploaded_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.events": { + "name": "events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "kind": { + "name": "kind", + "type": "event_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'event'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_description": { + "name": "public_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ticketing_url": { + "name": "ticketing_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start_at": { + "name": "start_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "end_at": { + "name": "end_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "event_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "share_token": { + "name": "share_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "events_status_start_idx": { + "name": "events_status_start_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "start_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "events_created_by_users_id_fk": { + "name": "events_created_by_users_id_fk", + "tableFrom": "events", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "events_share_token_unique": { + "name": "events_share_token_unique", + "nullsNotDistinct": false, + "columns": [ + "share_token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.financial_accounts": { + "name": "financial_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "financial_account_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "financial_accounts_type_active_idx": { + "name": "financial_accounts_type_active_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.meeting_attendance": { + "name": "meeting_attendance", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "event_id": { + "name": "event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "meeting_attendance_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cancel_token": { + "name": "cancel_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "meeting_attendance_event_user_unique": { + "name": "meeting_attendance_event_user_unique", + "columns": [ + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "meeting_attendance_event_email_unique": { + "name": "meeting_attendance_event_email_unique", + "columns": [ + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"meeting_attendance\".\"user_id\" is null and \"meeting_attendance\".\"email\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "meeting_attendance_cancel_token_idx": { + "name": "meeting_attendance_cancel_token_idx", + "columns": [ + { + "expression": "cancel_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "meeting_attendance_event_idx": { + "name": "meeting_attendance_event_idx", + "columns": [ + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "meeting_attendance_event_id_events_id_fk": { + "name": "meeting_attendance_event_id_events_id_fk", + "tableFrom": "meeting_attendance", + "tableTo": "events", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "meeting_attendance_user_id_users_id_fk": { + "name": "meeting_attendance_user_id_users_id_fk", + "tableFrom": "meeting_attendance", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "meeting_attendance_identite": { + "name": "meeting_attendance_identite", + "value": "\"meeting_attendance\".\"user_id\" is not null or \"meeting_attendance\".\"name\" is not null" + } + }, + "isRLSEnabled": false + }, + "public.notifications_log": { + "name": "notifications_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "task_id": { + "name": "task_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notifications_log_task_user_kind_idx": { + "name": "notifications_log_task_user_kind_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notifications_log_task_id_tasks_id_fk": { + "name": "notifications_log_task_id_tasks_id_fk", + "tableFrom": "notifications_log", + "tableTo": "tasks", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notifications_log_user_id_users_id_fk": { + "name": "notifications_log_user_id_users_id_fk", + "tableFrom": "notifications_log", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_authorization_codes": { + "name": "oauth_authorization_codes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "code_hash": { + "name": "code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "code_challenge": { + "name": "code_challenge", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code_challenge_method": { + "name": "code_challenge_method", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'S256'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "oauth_authorization_codes_code_hash_idx": { + "name": "oauth_authorization_codes_code_hash_idx", + "columns": [ + { + "expression": "code_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_authorization_codes_client_expires_idx": { + "name": "oauth_authorization_codes_client_expires_idx", + "columns": [ + { + "expression": "oauth_client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_authorization_codes_user_idx": { + "name": "oauth_authorization_codes_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_authorization_codes_oauth_client_id_oauth_clients_id_fk": { + "name": "oauth_authorization_codes_oauth_client_id_oauth_clients_id_fk", + "tableFrom": "oauth_authorization_codes", + "tableTo": "oauth_clients", + "columnsFrom": [ + "oauth_client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_authorization_codes_user_id_users_id_fk": { + "name": "oauth_authorization_codes_user_id_users_id_fk", + "tableFrom": "oauth_authorization_codes", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_clients": { + "name": "oauth_clients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret_hash": { + "name": "client_secret_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "grant_types": { + "name": "grant_types", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[\"authorization_code\",\"refresh_token\"]'::jsonb" + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "oauth_clients_client_id_idx": { + "name": "oauth_clients_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_clients_enabled_idx": { + "name": "oauth_clients_enabled_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_clients_created_by_users_id_fk": { + "name": "oauth_clients_created_by_users_id_fk", + "tableFrom": "oauth_clients", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_tokens": { + "name": "oauth_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "oauth_token_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "authorization_code_id": { + "name": "authorization_code_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "oauth_tokens_token_hash_idx": { + "name": "oauth_tokens_token_hash_idx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_tokens_client_type_expires_idx": { + "name": "oauth_tokens_client_type_expires_idx", + "columns": [ + { + "expression": "oauth_client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_tokens_user_idx": { + "name": "oauth_tokens_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_tokens_authorization_code_idx": { + "name": "oauth_tokens_authorization_code_idx", + "columns": [ + { + "expression": "authorization_code_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_tokens_oauth_client_id_oauth_clients_id_fk": { + "name": "oauth_tokens_oauth_client_id_oauth_clients_id_fk", + "tableFrom": "oauth_tokens", + "tableTo": "oauth_clients", + "columnsFrom": [ + "oauth_client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_tokens_user_id_users_id_fk": { + "name": "oauth_tokens_user_id_users_id_fk", + "tableFrom": "oauth_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_tokens_authorization_code_id_oauth_authorization_codes_id_fk": { + "name": "oauth_tokens_authorization_code_id_oauth_authorization_codes_id_fk", + "tableFrom": "oauth_tokens", + "tableTo": "oauth_authorization_codes", + "columnsFrom": [ + "authorization_code_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbound_mail_settings": { + "name": "outbound_mail_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "default": "'default'" + }, + "provider": { + "name": "provider", + "type": "outbound_mail_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'resend'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reply_to": { + "name": "reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_last_four": { + "name": "key_last_four", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "smtp_host": { + "name": "smtp_host", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "smtp_port": { + "name": "smtp_port", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "smtp_secure": { + "name": "smtp_secure", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "smtp_username": { + "name": "smtp_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_smtp_password": { + "name": "encrypted_smtp_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_tested_at": { + "name": "last_tested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_test_status": { + "name": "last_test_status", + "type": "outbound_mail_test_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'untested'" + }, + "updated_by": { + "name": "updated_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "outbound_mail_settings_updated_by_users_id_fk": { + "name": "outbound_mail_settings_updated_by_users_id_fk", + "tableFrom": "outbound_mail_settings", + "tableTo": "users", + "columnsFrom": [ + "updated_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "outbound_mail_settings_singleton_check": { + "name": "outbound_mail_settings_singleton_check", + "value": "\"outbound_mail_settings\".\"id\" = 'default'" + }, + "outbound_mail_settings_smtp_port_check": { + "name": "outbound_mail_settings_smtp_port_check", + "value": "\"outbound_mail_settings\".\"smtp_port\" is null or (\"outbound_mail_settings\".\"smtp_port\" >= 1 and \"outbound_mail_settings\".\"smtp_port\" <= 65535)" + } + }, + "isRLSEnabled": false + }, + "public.password_reset_tokens": { + "name": "password_reset_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "password_reset_tokens_user_id_users_id_fk": { + "name": "password_reset_tokens_user_id_users_id_fk", + "tableFrom": "password_reset_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.push_deliveries": { + "name": "push_deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "notification_id": { + "name": "notification_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "subscription_id": { + "name": "subscription_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "push_delivery_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "ack_token": { + "name": "ack_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "received_at": { + "name": "received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "opened_at": { + "name": "opened_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "push_deliveries_notification_idx": { + "name": "push_deliveries_notification_idx", + "columns": [ + { + "expression": "notification_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "push_deliveries_ack_token_idx": { + "name": "push_deliveries_ack_token_idx", + "columns": [ + { + "expression": "ack_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "push_deliveries_user_idx": { + "name": "push_deliveries_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "push_deliveries_notification_id_push_notifications_id_fk": { + "name": "push_deliveries_notification_id_push_notifications_id_fk", + "tableFrom": "push_deliveries", + "tableTo": "push_notifications", + "columnsFrom": [ + "notification_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "push_deliveries_user_id_users_id_fk": { + "name": "push_deliveries_user_id_users_id_fk", + "tableFrom": "push_deliveries", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "push_deliveries_subscription_id_push_subscriptions_id_fk": { + "name": "push_deliveries_subscription_id_push_subscriptions_id_fk", + "tableFrom": "push_deliveries", + "tableTo": "push_subscriptions", + "columnsFrom": [ + "subscription_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.push_notifications": { + "name": "push_notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "sender_id": { + "name": "sender_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "push_notifications_created_at_idx": { + "name": "push_notifications_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "push_notifications_sender_id_users_id_fk": { + "name": "push_notifications_sender_id_users_id_fk", + "tableFrom": "push_notifications", + "tableTo": "users", + "columnsFrom": [ + "sender_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.push_subscriptions": { + "name": "push_subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "p256dh": { + "name": "p256dh", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth": { + "name": "auth", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_label": { + "name": "device_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "push_subscriptions_endpoint_idx": { + "name": "push_subscriptions_endpoint_idx", + "columns": [ + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "push_subscriptions_user_idx": { + "name": "push_subscriptions_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "push_subscriptions_user_id_users_id_fk": { + "name": "push_subscriptions_user_id_users_id_fk", + "tableFrom": "push_subscriptions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_assignees": { + "name": "task_assignees", + "schema": "", + "columns": { + "task_id": { + "name": "task_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "task_assignees_user_idx": { + "name": "task_assignees_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_assignees_task_id_tasks_id_fk": { + "name": "task_assignees_task_id_tasks_id_fk", + "tableFrom": "task_assignees", + "tableTo": "tasks", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_assignees_user_id_users_id_fk": { + "name": "task_assignees_user_id_users_id_fk", + "tableFrom": "task_assignees", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "task_assignees_task_id_user_id_pk": { + "name": "task_assignees_task_id_user_id_pk", + "columns": [ + "task_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tasks": { + "name": "tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "event_id": { + "name": "event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lead_time_days": { + "name": "lead_time_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 7 + }, + "lead_time_value": { + "name": "lead_time_value", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 7 + }, + "lead_time_unit": { + "name": "lead_time_unit", + "type": "task_lead_time_unit", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'days'" + }, + "due_at": { + "name": "due_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "task_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'todo'" + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tasks_event_idx": { + "name": "tasks_event_idx", + "columns": [ + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_status_due_idx": { + "name": "tasks_status_due_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "due_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tasks_event_id_events_id_fk": { + "name": "tasks_event_id_events_id_fk", + "tableFrom": "tasks", + "tableTo": "events", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "tasks_lead_time_value_check": { + "name": "tasks_lead_time_value_check", + "value": "\"tasks\".\"lead_time_value\" >= 0" + }, + "tasks_lead_time_consistency_check": { + "name": "tasks_lead_time_consistency_check", + "value": "\"tasks\".\"lead_time_days\" = \"tasks\".\"lead_time_value\" * case \"tasks\".\"lead_time_unit\" when 'days' then 1 when 'weeks' then 7 when 'months' then 30 end" + } + }, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_epoch": { + "name": "session_epoch", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "onboarding_seen_at": { + "name": "onboarding_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "push_enabled": { + "name": "push_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.volunteer_signups": { + "name": "volunteer_signups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slot_id": { + "name": "slot_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cancel_token": { + "name": "cancel_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reminded_at": { + "name": "reminded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "volunteer_signups_slot_idx": { + "name": "volunteer_signups_slot_idx", + "columns": [ + { + "expression": "slot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "volunteer_signups_user_idx": { + "name": "volunteer_signups_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "volunteer_signups_cancel_token_idx": { + "name": "volunteer_signups_cancel_token_idx", + "columns": [ + { + "expression": "cancel_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "volunteer_signups_slot_email_idx": { + "name": "volunteer_signups_slot_email_idx", + "columns": [ + { + "expression": "slot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"volunteer_signups\".\"email\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "volunteer_signups_slot_id_volunteer_slots_id_fk": { + "name": "volunteer_signups_slot_id_volunteer_slots_id_fk", + "tableFrom": "volunteer_signups", + "tableTo": "volunteer_slots", + "columnsFrom": [ + "slot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "volunteer_signups_user_id_users_id_fk": { + "name": "volunteer_signups_user_id_users_id_fk", + "tableFrom": "volunteer_signups", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.volunteer_slots": { + "name": "volunteer_slots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "event_id": { + "name": "event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start_at": { + "name": "start_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "end_at": { + "name": "end_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "capacity": { + "name": "capacity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "volunteer_slots_event_idx": { + "name": "volunteer_slots_event_idx", + "columns": [ + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "volunteer_slots_event_id_events_id_fk": { + "name": "volunteer_slots_event_id_events_id_fk", + "tableFrom": "volunteer_slots", + "tableTo": "events", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.accounting_category_type": { + "name": "accounting_category_type", + "schema": "public", + "values": [ + "income", + "expense" + ] + }, + "public.accounting_entry_status": { + "name": "accounting_entry_status", + "schema": "public", + "values": [ + "draft", + "posted" + ] + }, + "public.accounting_entry_type": { + "name": "accounting_entry_type", + "schema": "public", + "values": [ + "income", + "expense" + ] + }, + "public.association_document_status": { + "name": "association_document_status", + "schema": "public", + "values": [ + "draft", + "final", + "archived" + ] + }, + "public.association_document_type": { + "name": "association_document_type", + "schema": "public", + "values": [ + "ag_minutes", + "attestation", + "statutes", + "internal_rules", + "insurance", + "agreement", + "other" + ] + }, + "public.association_member_status": { + "name": "association_member_status", + "schema": "public", + "values": [ + "active", + "pending", + "inactive" + ] + }, + "public.event_kind": { + "name": "event_kind", + "schema": "public", + "values": [ + "event", + "meeting" + ] + }, + "public.event_status": { + "name": "event_status", + "schema": "public", + "values": [ + "draft", + "published", + "archived" + ] + }, + "public.financial_account_type": { + "name": "financial_account_type", + "schema": "public", + "values": [ + "bank", + "cash" + ] + }, + "public.meeting_attendance_status": { + "name": "meeting_attendance_status", + "schema": "public", + "values": [ + "yes", + "maybe", + "no" + ] + }, + "public.oauth_token_type": { + "name": "oauth_token_type", + "schema": "public", + "values": [ + "access", + "refresh" + ] + }, + "public.outbound_mail_provider": { + "name": "outbound_mail_provider", + "schema": "public", + "values": [ + "resend", + "smtp" + ] + }, + "public.outbound_mail_test_status": { + "name": "outbound_mail_test_status", + "schema": "public", + "values": [ + "untested", + "success", + "failed" + ] + }, + "public.push_delivery_status": { + "name": "push_delivery_status", + "schema": "public", + "values": [ + "queued", + "sent", + "failed", + "received", + "opened" + ] + }, + "public.role": { + "name": "role", + "schema": "public", + "values": [ + "admin", + "manager", + "member" + ] + }, + "public.task_lead_time_unit": { + "name": "task_lead_time_unit", + "schema": "public", + "values": [ + "days", + "weeks", + "months" + ] + }, + "public.task_status": { + "name": "task_status", + "schema": "public", + "values": [ + "todo", + "in_progress", + "done" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index a9c88a5..2ed11fd 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -141,6 +141,13 @@ "when": 1789044964091, "tag": "0019_meetings", "breakpoints": true + }, + { + "idx": 20, + "version": "7", + "when": 1789051948309, + "tag": "0020_public_meeting_attendance", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/app/annulation/[token]/page.tsx b/src/app/annulation/[token]/page.tsx index 2cac30f..d561532 100644 --- a/src/app/annulation/[token]/page.tsx +++ b/src/app/annulation/[token]/page.tsx @@ -3,16 +3,25 @@ import { CalendarDays, CircleAlert, Link2Off, + MapPin, } from "lucide-react"; import Link from "next/link"; import { CancelSignup } from "@/components/cancel-signup"; import { SiteHeader } from "@/components/site-header"; -import { getSignupByCancelToken } from "@/lib/data"; +import { + getMeetingAttendanceByCancelToken, + getSignupByCancelToken, +} from "@/lib/data"; import { formatDateTime } from "@/lib/dates"; export const dynamic = "force-dynamic"; +/** + * Retrait par lien public. Le même jeton peut désigner un créneau de bénévole + * ou une présence annoncée à une réunion : la page reconnaît les deux, parce + * que le parent n'a qu'un lien au bas d'un e-mail. + */ export default async function CancelPage({ params, }: { @@ -20,13 +29,14 @@ export default async function CancelPage({ }) { const { token } = await params; const signup = await getSignupByCancelToken(token); + const presence = signup ? null : await getMeetingAttendanceByCancelToken(token); return (
- {!signup ? ( + {!signup && !presence ? (
@@ -45,6 +55,53 @@ export default async function CancelPage({ Retour à l'accueil
+ ) : presence ? ( +
+ + + +

+ Gestion de ma réponse +

+

+ Retirer ma réponse +

+

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

+ +
+

+ {presence.event.title} +

+

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

+ {presence.event.location && ( +

+ + {presence.event.location} +

+ )} +
+ +
+

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

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

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

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

Votre mission

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

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

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

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

Les réunions sont ouvertes aux parents

-

+

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

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

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

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

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

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

{error}

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

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

+

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

+ {whatsappGroupUrl && ( +

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

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

+ {error} +

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

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

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

    Bonjour ${esc(ctx.name)},

    +

    Nous avons noté que ${reponse} :

    + +

    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"), });