diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 49d44fa..fd5d307 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,10 @@ concurrency: group: ci-${{ github.ref }} cancel-in-progress: true +env: + # Sans cela, npm annonce sa prochaine version majeure dans chaque journal. + NPM_CONFIG_UPDATE_NOTIFIER: false + jobs: checks: runs-on: ubuntu-latest @@ -25,7 +29,7 @@ jobs: - name: Préparer Node.js uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: npm - name: Installer les dépendances @@ -67,7 +71,7 @@ jobs: - name: Préparer Node.js uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: npm - name: Installer les dépendances diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 0fd68eb..2c14227 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -23,6 +23,8 @@ concurrency: env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} + # Sans cela, npm annonce sa prochaine version majeure dans chaque journal. + NPM_CONFIG_UPDATE_NOTIFIER: false jobs: # Les migrations s'appliquent-elles sur une base vierge ? C'est la seule @@ -55,7 +57,7 @@ jobs: - name: Préparer Node.js uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: npm - name: Installer les dépendances diff --git a/Dockerfile b/Dockerfile index 9491b7a..9dc7f26 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,16 @@ # syntax=docker/dockerfile:1.7 -FROM node:20-bookworm-slim AS base +# Node 22 : la branche 20 est sortie du support le 30 avril 2026, elle ne +# reçoit donc plus de correctif de sécurité. 22 est la LTS active. +FROM node:22-bookworm-slim AS base WORKDIR /app ENV NEXT_TELEMETRY_DISABLED=1 +# npm annonce ses nouvelles versions majeures dans chaque journal de +# construction. C'est du bruit dans un journal qu'on lit pour y chercher une +# panne, et la version de npm est celle que l'image apporte, pas un choix. +ENV NPM_CONFIG_UPDATE_NOTIFIER=false FROM base AS dependencies diff --git a/drizzle/0023_cotisation_affichee.sql b/drizzle/0023_cotisation_affichee.sql new file mode 100644 index 0000000..bd8a253 --- /dev/null +++ b/drizzle/0023_cotisation_affichee.sql @@ -0,0 +1,5 @@ +CREATE TYPE "public"."membership_fee_basis" AS ENUM('famille', 'enfant', 'non_precise');--> statement-breakpoint +ALTER TABLE "association_settings" ADD COLUMN "membership_fee_cents" integer;--> statement-breakpoint +ALTER TABLE "association_settings" ADD COLUMN "membership_fee_basis" "membership_fee_basis" DEFAULT 'non_precise' NOT NULL;--> statement-breakpoint +ALTER TABLE "association_settings" ADD COLUMN "membership_fee_note" text DEFAULT '' NOT NULL;--> statement-breakpoint +ALTER TABLE "association_settings" ADD CONSTRAINT "association_settings_membership_fee_check" CHECK ("association_settings"."membership_fee_cents" is null or "association_settings"."membership_fee_cents" >= 0); \ No newline at end of file diff --git a/drizzle/meta/0023_snapshot.json b/drizzle/meta/0023_snapshot.json new file mode 100644 index 0000000..8586368 --- /dev/null +++ b/drizzle/meta/0023_snapshot.json @@ -0,0 +1,3652 @@ +{ + "id": "ac37076c-cdd7-41d8-9a5a-7c2307806a0c", + "prevId": "8fe0922e-7a04-4af8-9c25-2ecc61e46165", + "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": "''" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "content_source": { + "name": "content_source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "signed_at": { + "name": "signed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "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": { + "association_documents_content_source_check": { + "name": "association_documents_content_source_check", + "value": "\"association_documents\".\"content_source\" in ('manual','payload')" + } + }, + "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": "''" + }, + "headquarters": { + "name": "headquarters", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "membership_fee_cents": { + "name": "membership_fee_cents", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "membership_fee_basis": { + "name": "membership_fee_basis", + "type": "membership_fee_basis", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'non_precise'" + }, + "membership_fee_note": { + "name": "membership_fee_note", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "statutory_rules": { + "name": "statutory_rules", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "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_membership_fee_check": { + "name": "association_settings_membership_fee_check", + "value": "\"association_settings\".\"membership_fee_cents\" is null or \"association_settings\".\"membership_fee_cents\" >= 0" + }, + "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.membership_fee_basis": { + "name": "membership_fee_basis", + "schema": "public", + "values": [ + "famille", + "enfant", + "non_precise" + ] + }, + "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 2a88b88..f628e9d 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -162,6 +162,13 @@ "when": 1789646856683, "tag": "0022_ag_minutes_payload", "breakpoints": true + }, + { + "idx": 23, + "version": "7", + "when": 1789718093996, + "tag": "0023_cotisation_affichee", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/app/api/join/route.ts b/src/app/api/join/route.ts index e5fbf4c..313c9fb 100644 --- a/src/app/api/join/route.ts +++ b/src/app/api/join/route.ts @@ -59,6 +59,7 @@ export async function POST(req: Request) { phone: data.phone, message: data.message, intention: data.intention, + feePublished: settings.membershipFeePublished, identity: { associationName: settings.associationName, schoolName: settings.schoolName, @@ -86,6 +87,7 @@ export async function POST(req: Request) { name: data.name, message: data.message, intention: data.intention, + feePublished: settings.membershipFeePublished, contactEmail: destination, identity: { associationName: settings.associationName, diff --git a/src/app/dashboard/adherents/page.tsx b/src/app/dashboard/adherents/page.tsx index 3657c65..df53c26 100644 --- a/src/app/dashboard/adherents/page.tsx +++ b/src/app/dashboard/adherents/page.tsx @@ -9,15 +9,22 @@ import { PageHeader } from "@/components/ui"; import { requireRole } from "@/lib/auth/rbac"; import { db } from "@/lib/db"; import { associationMembers } from "@/lib/db/schema"; +import { getAssociationSettings } from "@/lib/services/association-settings"; export const dynamic = "force-dynamic"; export default async function AdherentsPage() { await requireRole("admin"); - const members = await db - .select() - .from(associationMembers) - .orderBy(asc(associationMembers.lastName), asc(associationMembers.firstName)); + const [members, settings] = await Promise.all([ + db + .select() + .from(associationMembers) + .orderBy( + asc(associationMembers.lastName), + asc(associationMembers.firstName), + ), + getAssociationSettings(), + ]); const serialized: AdherentView[] = members.map((member) => ({ id: member.id, @@ -46,7 +53,10 @@ export default async function AdherentsPage() { description="Suivez les adhésions, les coordonnées et les cotisations de l'association." icon={ContactRound} /> - + ); } diff --git a/src/app/dashboard/settings/page.tsx b/src/app/dashboard/settings/page.tsx index 00ddaf5..30b8ccb 100644 --- a/src/app/dashboard/settings/page.tsx +++ b/src/app/dashboard/settings/page.tsx @@ -30,6 +30,9 @@ export default async function SettingsPage() { contactEmail: associationStatus.contactEmail, rna: associationStatus.rna, headquarters: associationStatus.headquarters, + membershipFeeCents: associationStatus.membershipFeeCents, + membershipFeeBasis: associationStatus.membershipFeeBasis, + membershipFeeNote: associationStatus.membershipFeeNote, logoUrl: associationStatus.logoUrl, taskReminderWindowDays: associationStatus.taskReminderWindowDays, volunteerReminderWindowDays: diff --git a/src/app/rejoindre/page.tsx b/src/app/rejoindre/page.tsx index 9265fb4..872bf97 100644 --- a/src/app/rejoindre/page.tsx +++ b/src/app/rejoindre/page.tsx @@ -24,6 +24,7 @@ import { getUpcomingPublishedEvents } from "@/lib/data"; import { formatDateTime, formatLongDateTime } from "@/lib/dates"; import { deEtablissement } from "@/lib/etablissement"; import { getAssociationSettings } from "@/lib/services/association-settings"; +import { MEMBERSHIP_FEE_BASIS_SUFFIX } from "@/lib/validation"; export const dynamic = "force-dynamic"; @@ -63,14 +64,32 @@ export async function generateMetadata(): Promise { }; } -/** Ce qu'adhérer veut dire, sans rien affirmer qui dépende des statuts. */ -const CE_QU_ADHERER_VEUT_DIRE = [ - "Régler la cotisation annuelle et devenir membre de l’association pour l’année scolaire.", - "Adhérer ne vous engage à aucune présence : ni réunion, ni permanence, ni stand.", - "La cotisation vaut pour la famille.", - "Les membres sont convoqués à l’assemblée générale, où l’on présente les comptes et ce qui est prévu.", - "Le montant et la façon de régler sont fixés par l’association pour l’année en cours : écrivez-nous, on vous le dit.", -]; +/** + * Ce qu'adhérer veut dire. Deux des cinq lignes dépendent des réglages, parce + * qu'elles dépendent des statuts : ce que couvre la cotisation, et son montant. + * Tant que le bureau ne les a pas renseignés, la page ne suppose rien et + * renvoie à lui — c'est moins pratique, mais ce n'est pas faux. + */ +function ceQuAdhererVeutDire(reglages: { + membershipFeePublished: boolean; + membershipFeeBasis: "famille" | "enfant" | "non_precise"; +}): string[] { + const couverture = { + famille: "Une seule cotisation par famille, quel que soit le nombre d’enfants scolarisés.", + enfant: "La cotisation se compte par enfant scolarisé.", + non_precise: null, + }[reglages.membershipFeeBasis]; + + return [ + "Régler la cotisation annuelle et devenir membre de l’association pour l’année scolaire.", + "Adhérer ne vous engage à aucune présence : ni réunion, ni permanence, ni stand.", + couverture, + "Les membres sont convoqués à l’assemblée générale, où l’on présente les comptes et ce qui est prévu.", + reglages.membershipFeePublished + ? null + : "Le montant et la façon de régler sont fixés par l’association pour l’année en cours : écrivez-nous, on vous le dit.", + ].filter((ligne): ligne is string => ligne !== null); +} const CE_QUE_LE_COUP_DE_MAIN_VEUT_DIRE = [ "Venir sur un rendez-vous et prendre une tâche, sans devenir membre.", @@ -220,6 +239,20 @@ export default async function RejoindrePage() { const contactEmail = settings.contactEmail?.trim() || null; const rna = settings.rna?.trim() || null; + // Le tarif affiché, quand le bureau a choisi de le publier. `null` n'est pas + // zéro : sans réglage, la page continue de renvoyer au bureau. + const cotisation = settings.membershipFeePublished + ? { + montant: (settings.membershipFeeCents! / 100).toLocaleString("fr-FR", { + style: "currency", + currency: "EUR", + }), + suffixe: MEMBERSHIP_FEE_BASIS_SUFFIX[settings.membershipFeeBasis], + note: settings.membershipFeeNote.trim(), + } + : null; + const puces = ceQuAdhererVeutDire(settings); + // Sans adresse de contact publiée, le formulaire n'existe pas et /api/join // répond 503 : aucun bouton ne doit alors promettre qu'on peut écrire. const ecritureOuverte = contactEmail !== null; @@ -303,16 +336,30 @@ export default async function RejoindrePage() { {/* La preuve de cette porte-ci. En face, la carte du coup de main affiche un rendez-vous daté ; sans cela, l'adhésion - serait la seule des deux à n'avancer que du texte. */} - {rna && ( + serait la seule des deux à n'avancer que du texte. + Le montant passe devant le RNA quand il est publié : c'est + la question que le parent se pose, le RNA ne lui dit rien. */} + {cotisation ? (

- Association déclarée + Cotisation annuelle - RNA {rna} + {cotisation.montant} + {cotisation.suffixe ? ` ${cotisation.suffixe}` : ""}

+ ) : ( + rna && ( +

+ + Association déclarée + + + RNA {rna} + +

+ ) )} @@ -449,7 +496,7 @@ export default async function RejoindrePage() { Devenir membre
    - {CE_QU_ADHERER_VEUT_DIRE.map((ligne) => ( + {puces.map((ligne) => (
+ {/* Le chiffre en grand, et la marche à suivre juste dessous : + c'est la première question d'un parent qui veut adhérer, et + la page y répondait par un aller-retour d'e-mail. */} + {cotisation && ( +
+

+ Cotisation annuelle +

+

+ {cotisation.montant} + {cotisation.suffixe ? ( + + {cotisation.suffixe} + + ) : null} +

+ {cotisation.note && ( +

+ {cotisation.note} +

+ )} +
+ )}

Vous ne savez pas si votre famille est déjà adhérente cette année ? Écrivez-nous, on vérifie. @@ -600,6 +670,7 @@ export default async function RejoindrePage() { : null } whatsappGroupUrl={settings.whatsappGroupUrl} + cotisationPubliee={settings.membershipFeePublished} /> diff --git a/src/components/adherents-manager.tsx b/src/components/adherents-manager.tsx index f8ce965..46eed6a 100644 --- a/src/components/adherents-manager.tsx +++ b/src/components/adherents-manager.tsx @@ -78,8 +78,15 @@ function dateInput(value: string | null) { export function AdherentsManager({ members, + cotisationParDefautCents = null, }: { members: AdherentView[]; + /** + * Le tarif publié par l'association, quand il y en a un : une adhésion + * nouvelle s'ouvre dessus plutôt que sur 0,00 €. On ne le force jamais — une + * famille peut régler autre chose — mais on évite de le retaper à chaque fois. + */ + cotisationParDefautCents?: number | null; }) { const router = useRouter(); const toast = useToast(); @@ -253,6 +260,7 @@ export function AdherentsManager({ loading={submitting} onSubmit={save} onCancel={() => setEditor(null)} + cotisationParDefautCents={cotisationParDefautCents} /> )} @@ -540,11 +548,13 @@ function AdherentForm({ loading, onSubmit, onCancel, + cotisationParDefautCents, }: { member: AdherentView | null; loading: boolean; onSubmit: (event: React.FormEvent) => void; onCancel: () => void; + cotisationParDefautCents: number | null; }) { return (

@@ -683,9 +693,11 @@ function AdherentForm({ min="0" step="0.01" inputMode="decimal" - defaultValue={ - member ? (member.membershipFeeCents / 100).toFixed(2) : "0.00" - } + defaultValue={( + (member + ? member.membershipFeeCents + : cotisationParDefautCents ?? 0) / 100 + ).toFixed(2)} /> diff --git a/src/components/association-settings-form.tsx b/src/components/association-settings-form.tsx index e08b15d..b162742 100644 --- a/src/components/association-settings-form.tsx +++ b/src/components/association-settings-form.tsx @@ -5,6 +5,7 @@ import { Bot, Building2, CircleAlert, + Coins, KeyRound, MessageCircle, Save, @@ -17,6 +18,10 @@ import { LogoUploadField } from "@/components/logo-upload-field"; import { useToast } from "@/components/toast"; import { Button, Card, Field, Input, Select } from "@/components/ui"; import { api } from "@/lib/client"; +import { + MEMBERSHIP_FEE_BASIS_SUFFIX, + type MembershipFeeBasis, +} from "@/lib/validation"; import { checkWhatsappUrl, estInvitationGroupe } from "@/lib/whatsapp"; export interface AssociationSettingsView { @@ -40,9 +45,28 @@ export interface AssociationSettingsView { recaptchaMinScore: number; recaptchaReady: boolean; whatsappGroupUrl: string | null; + membershipFeeCents: number | null; + membershipFeeBasis: MembershipFeeBasis; + membershipFeeNote: string; legacyEnvironment: boolean; } +/** + * Le tarif affiché circule en centimes et se saisit en euros. La conversion + * tient en deux fonctions plutôt qu’en une expression recopiée : c’est le genre + * d’arrondi qu’on n’a pas envie de déboguer sur une facture. + */ +function centsVersEuros(cents: number | null): string { + return cents === null ? "" : (cents / 100).toFixed(2); +} + +function eurosVersCents(saisie: string): number | null { + const propre = saisie.trim().replace(",", "."); + if (!propre) return null; + const montant = Number(propre); + return Number.isFinite(montant) ? Math.round(montant * 100) : null; +} + export function AssociationSettingsForm({ settings, }: { @@ -55,6 +79,16 @@ export function AssociationSettingsForm({ settings.whatsappGroupUrl ?? "", ); const [erreurWhatsapp, setErreurWhatsapp] = useState(null); + const [cotisation, setCotisation] = useState( + centsVersEuros(settings.membershipFeeCents), + ); + const [baseCotisation, setBaseCotisation] = useState( + settings.membershipFeeBasis, + ); + const [noteCotisation, setNoteCotisation] = useState( + settings.membershipFeeNote, + ); + const cotisationCents = eurosVersCents(cotisation); const verdictWhatsapp = checkWhatsappUrl(whatsappGroupUrl); // Prévient sans bloquer : un lien raccourci ou une redirection maison reste // acceptable, mais l'administrateur doit voir qu'il n'a pas collé l'invitation. @@ -91,6 +125,9 @@ export function AssociationSettingsForm({ contactEmail: form.get("contactEmail") || null, rna: form.get("rna"), headquarters: form.get("headquarters"), + membershipFeeCents: cotisationCents, + membershipFeeBasis: baseCotisation, + membershipFeeNote: noteCotisation, logoUrl: form.get("logoUrl") || null, taskReminderWindowDays: Number( form.get("taskReminderWindowDays"), @@ -239,6 +276,102 @@ export function AssociationSettingsForm({ +
+
+ + +
+

La cotisation

+

+ Ce qu’un parent lit sur la page « Rejoindre l’association » + avant d’écrire. +

+
+
+
+ + setCotisation(e.target.value)} + placeholder="18.00" + /> + + + + + + setNoteCotisation(e.target.value)} + maxLength={300} + placeholder="Par chèque à l’ordre de l’association, remis au secrétariat." + autoComplete="off" + /> + +
+ + {/* L’aperçu n’est pas un ornement : le seul moyen de vérifier qu’on + n’a pas saisi 1800 pour 18 €, c’est de lire la phrase publiée. */} +
+

+ Ce que verront les parents +

+ {cotisationCents === null ? ( +

+ « Le montant et la façon de régler sont fixés par + l’association pour l’année en cours : écrivez-nous, on vous le + dit. » +

+ ) : ( + <> +

+ {(cotisationCents / 100).toLocaleString("fr-FR", { + style: "currency", + currency: "EUR", + })}{" "} + {MEMBERSHIP_FEE_BASIS_SUFFIX[baseCotisation]} +

+ {noteCotisation.trim() && ( +

+ {noteCotisation.trim()} +

+ )} + + )} +
+
+
diff --git a/src/components/join-form.tsx b/src/components/join-form.tsx index 2dc5974..f550beb 100644 --- a/src/components/join-form.tsx +++ b/src/components/join-form.tsx @@ -98,11 +98,17 @@ export function JoinForm({ contactEmail, recaptchaSiteKey = null, whatsappGroupUrl = null, + cotisationPubliee = false, }: { contactEmail: string | null; recaptchaSiteKey?: string | null; /** Proposé pendant l'attente de la réponse : on suit sans avoir écrit. */ whatsappGroupUrl?: string | null; + /** + * Le montant figure déjà sur la page : promettre de l'annoncer par e-mail + * ferait passer l'association pour distraite. + */ + cotisationPubliee?: boolean; }) { const executerRecaptcha = useRecaptcha(recaptchaSiteKey); const [loading, setLoading] = useState(false); @@ -193,7 +199,9 @@ export function JoinForm({

{adhesion - ? "Un parent du bureau vous répond par e-mail avec le montant de la cotisation pour cette année scolaire et la marche à suivre pour régler." + ? cotisationPubliee + ? "Un parent du bureau vous répond par e-mail avec la marche à suivre pour régler la cotisation." + : "Un parent du bureau vous répond par e-mail avec le montant de la cotisation pour cette année scolaire et la marche à suivre pour régler." : "Un parent de l’équipe vous répondra par e-mail."}{" "} Comptez quelques jours ; sans nouvelles, n’hésitez pas à relancer {contactEmail ? ( diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index a538baf..0f5e57a 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -33,6 +33,18 @@ export const roleEnum = pgEnum("role", ["admin", "manager", "member"]); */ export const eventKindEnum = pgEnum("event_kind", ["event", "meeting"]); +/** + * Ce que couvre une cotisation : une famille entière, ou chaque enfant + * scolarisé. Cela relève des statuts et varie d'une association à l'autre — + * `non_precise` est la valeur de départ, et tant qu'elle tient, les pages + * publiques n'affirment rien sur ce point. + */ +export const membershipFeeBasisEnum = pgEnum("membership_fee_basis", [ + "famille", + "enfant", + "non_precise", +]); + export const eventStatusEnum = pgEnum("event_status", [ "draft", "published", @@ -687,6 +699,29 @@ export const associationSettings = pgTable( * — les documents le signalent plutôt que d'inventer. */ headquarters: text("headquarters").notNull().default(""), + /** + * Le montant de la cotisation annuelle, en centimes, tel que l'association + * choisit de le publier. + * + * `null` n'est pas zéro : c'est « non publié ». La distinction compte, car + * une association peut très bien afficher une cotisation libre à 0 €, et la + * page publique ne doit pas confondre les deux — elle renvoie au bureau + * dans le premier cas, elle annonce un montant dans le second. + * + * Sans rapport avec `associationMembers.membershipFeeCents`, qui enregistre + * ce qu'une famille a réellement réglé cette année-là : celui-ci est un + * tarif affiché, celui-là une recette constatée. + */ + membershipFeeCents: integer("membership_fee_cents"), + membershipFeeBasis: membershipFeeBasisEnum("membership_fee_basis") + .notNull() + .default("non_precise"), + /** + * La marche à suivre pour régler, en une phrase écrite par le bureau : + * c'est la seule façon honnête de couvrir des circuits qui vont du chèque + * remis en classe au prélèvement sur la facture de scolarité. + */ + membershipFeeNote: text("membership_fee_note").notNull().default(""), /** * Ce que prévoient les statuts : quorum, majorités, délai de convocation, * règle de voix. Lu une fois, réutilisé par toutes les assemblées. Vide, @@ -760,6 +795,10 @@ export const associationSettings = pgTable( "association_settings_task_reminder_window_check", sql`${t.taskReminderWindowDays} >= 0 and ${t.taskReminderWindowDays} <= 30`, ), + membershipFeeCheck: check( + "association_settings_membership_fee_check", + sql`${t.membershipFeeCents} is null or ${t.membershipFeeCents} >= 0`, + ), recaptchaMinScoreCheck: check( "association_settings_recaptcha_min_score_check", sql`${t.recaptchaMinScore} >= 0 and ${t.recaptchaMinScore} <= 100`, diff --git a/src/lib/mcp/register-association-tools.ts b/src/lib/mcp/register-association-tools.ts index 0244c8c..4f201c2 100644 --- a/src/lib/mcp/register-association-tools.ts +++ b/src/lib/mcp/register-association-tools.ts @@ -746,7 +746,7 @@ export function registerAssociationTools( { title: "Modifier les réglages de l’association", description: - "Met à jour l’identité, les fenêtres de rappel et l’activation de Telegram. Le token Telegram reste exclusivement configurable dans l’interface administrateur.", + "Met à jour l’identité, la cotisation affichée, les fenêtres de rappel et l’activation de Telegram. Le token Telegram reste exclusivement configurable dans l’interface administrateur.", inputSchema: z.object({ associationName: z.string().min(2).max(160).optional(), schoolName: z.string().min(2).max(200).optional(), @@ -769,6 +769,27 @@ export function registerAssociationTools( .describe( "Siège social déclaré en préfecture, repris sur les documents officiels.", ), + membershipFeeCents: z + .number() + .int() + .min(0) + .max(100_000) + .nullable() + .optional() + .describe( + "Cotisation annuelle affichée sur la page publique, en CENTIMES. null retire l'affichage : la page renvoie alors au bureau. Attention, null n'est pas 0 — 0 annoncerait une adhésion gratuite.", + ), + membershipFeeBasis: z + .enum(["famille", "enfant", "non_precise"]) + .optional() + .describe( + "Ce que couvre la cotisation. Relève des statuts ; sans précision la page n'affirme rien sur ce point.", + ), + membershipFeeNote: z + .string() + .max(300) + .optional() + .describe("La marche à suivre pour régler, en une phrase."), taskReminderWindowDays: z.number().int().min(0).max(30).optional(), volunteerReminderWindowDays: z .number() @@ -794,6 +815,17 @@ export function registerAssociationTools( : args.contactEmail, rna: args.rna ?? current.rna, headquarters: args.headquarters ?? current.headquarters, + // `?? current` ne conviendrait pas : null est une valeur délibérée + // ici — « retire le montant de la page » — et serait confondu avec + // « ne touche pas au montant ». + membershipFeeCents: + args.membershipFeeCents === undefined + ? current.membershipFeeCents + : args.membershipFeeCents, + membershipFeeBasis: + args.membershipFeeBasis ?? current.membershipFeeBasis, + membershipFeeNote: + args.membershipFeeNote ?? current.membershipFeeNote, taskReminderWindowDays: args.taskReminderWindowDays ?? current.taskReminderWindowDays, diff --git a/src/lib/notifications/emails.ts b/src/lib/notifications/emails.ts index b1e55e5..18dc1a2 100644 --- a/src/lib/notifications/emails.ts +++ b/src/lib/notifications/emails.ts @@ -276,9 +276,14 @@ export function joinRequestEmail(ctx: { phone?: string | null; message: string; intention?: "adherer" | "coup_de_main" | "les_deux" | "question"; + /** Le montant figure déjà sur la page publique : inutile de le redemander. */ + feePublished?: boolean; identity?: NotificationIdentity; }): EmailContent { const association = ctx.identity?.associationName || APP_NAME; + const aRepondreSurLaCotisation = ctx.feePublished + ? "la marche à suivre pour régler" + : "le montant de la cotisation de cette année et la marche à suivre pour régler"; // L'intention porte jusqu'à l'objet du message : le bureau lit sa boîte sur // un téléphone, et une demande d'adhésion n'attend pas la même réponse // qu'une proposition de coup de main. Quand elle manque — vieux client, appel @@ -287,8 +292,7 @@ export function joinRequestEmail(ctx: { adherer: { objet: "Demande d’adhésion", titre: "Une famille souhaite adhérer", - ligne: - "Cette personne veut devenir membre de l’association. Indiquez-lui le montant de la cotisation de cette année et la marche à suivre pour régler.", + ligne: `Cette personne veut devenir membre de l’association. Indiquez-lui ${aRepondreSurLaCotisation}.`, }, coup_de_main: { objet: "Proposition de coup de main", @@ -299,8 +303,7 @@ export function joinRequestEmail(ctx: { les_deux: { objet: "Adhésion + coup de main", titre: "Une famille souhaite adhérer et donner un coup de main", - ligne: - "Cette personne veut devenir membre et propose aussi de l’aide. Indiquez-lui le montant de la cotisation de cette année, la marche à suivre pour régler, et les créneaux où il manque des bras.", + ligne: `Cette personne veut devenir membre et propose aussi de l’aide. Indiquez-lui ${aRepondreSurLaCotisation}, et les créneaux où il manque des bras.`, }, question: { objet: "Question depuis le site", @@ -358,6 +361,8 @@ export function joinRequestAckEmail(ctx: { name: string; message: string; intention?: "adherer" | "coup_de_main" | "les_deux" | "question"; + /** Le montant figure déjà sur la page publique : ne pas promettre de l'annoncer. */ + feePublished?: boolean; contactEmail: string; identity?: NotificationIdentity; }): EmailContent { @@ -367,8 +372,10 @@ export function joinRequestAckEmail(ctx: { ? "Votre demande d’adhésion est bien arrivée" : "Votre message est bien arrivé"; const suite = adhesion - ? `Un parent du bureau vous répondra par e-mail avec le montant de la cotisation pour cette année scolaire et la marche à suivre pour régler.` - : `Un parent de l’équipe vous répondra par e-mail.`; + ? ctx.feePublished + ? "Un parent du bureau vous répondra par e-mail avec la marche à suivre pour régler la cotisation." + : "Un parent du bureau vous répondra par e-mail avec le montant de la cotisation pour cette année scolaire et la marche à suivre pour régler." + : "Un parent de l’équipe vous répondra par e-mail."; return { subject: `${titre} — ${association}`, diff --git a/src/lib/services/association-settings.ts b/src/lib/services/association-settings.ts index ddeb82b..1107379 100644 --- a/src/lib/services/association-settings.ts +++ b/src/lib/services/association-settings.ts @@ -73,6 +73,15 @@ export async function getAssociationSettings() { contactEmail: settings.contactEmail, rna: settings.rna, headquarters: settings.headquarters, + membershipFeeCents: settings.membershipFeeCents, + membershipFeeBasis: settings.membershipFeeBasis, + membershipFeeNote: settings.membershipFeeNote, + /** + * Seul état qui autorise une page publique à annoncer un tarif. `null` + * veut dire « non publié », pas « gratuit » : sans ce garde-fou, une + * installation neuve afficherait « 0 € » à tous les parents. + */ + membershipFeePublished: settings.membershipFeeCents !== null, statutoryRules: settings.statutoryRules ?? {}, logoUrl: settings.logoUrl, taskReminderWindowDays: settings.taskReminderWindowDays, @@ -112,6 +121,12 @@ export async function getAssociationSettings() { // installation d'avant les réglages en base, il reste vide et les // documents le signalent. headquarters: "", + // Aucune variable d'environnement n'a jamais porté de cotisation : sur une + // installation d'avant les réglages en base, rien n'est publié. + membershipFeeCents: null, + membershipFeeBasis: "non_precise" as const, + membershipFeeNote: "", + membershipFeePublished: false, statutoryRules: {}, logoUrl: null, taskReminderWindowDays: legacyReminderWindow("REMINDER_WINDOW_DAYS", 3), @@ -258,6 +273,9 @@ export async function saveAssociationSettings( contactEmail: emptyToNull(data.contactEmail), rna: data.rna, headquarters: data.headquarters, + membershipFeeCents: data.membershipFeeCents, + membershipFeeBasis: data.membershipFeeBasis, + membershipFeeNote: data.membershipFeeNote, logoUrl: emptyToNull(data.logoUrl), taskReminderWindowDays: data.taskReminderWindowDays, volunteerReminderWindowDays: data.volunteerReminderWindowDays, @@ -299,6 +317,10 @@ export async function saveAssociationSettings( // lien d'invitation. whatsappGroupChanged: (data.whatsappGroupUrl ?? null) !== (current?.whatsappGroupUrl ?? null), + // Le tarif affiché est public par destination : le consigner en clair ne + // divulgue rien, et savoir quand il a changé sert aux comptes. + membershipFeeCents: data.membershipFeeCents, + membershipFeeBasis: data.membershipFeeBasis, }, ); diff --git a/src/lib/validation.ts b/src/lib/validation.ts index 202ca79..d2cd107 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -479,6 +479,26 @@ export const eventAttachmentSchema = z.object({ ), }); +/** + * Ce que couvre la cotisation. Relève des statuts, donc varie : tant que le + * bureau n’a pas tranché, les pages publiques se taisent sur ce point plutôt + * que de supposer la règle la plus répandue. + */ +export const MEMBERSHIP_FEE_BASES = ["famille", "enfant", "non_precise"] as const; + +export type MembershipFeeBasis = (typeof MEMBERSHIP_FEE_BASES)[number]; + +/** Ce qui s’écrit à la suite d’un montant : « 18 € par famille ». */ +export const MEMBERSHIP_FEE_BASIS_SUFFIX: Record = { + famille: "par famille", + enfant: "par enfant", + non_precise: "", +}; + +const MEMBERSHIP_FEE_BASIS_SCHEMA = z + .enum(MEMBERSHIP_FEE_BASES) + .default("non_precise"); + export const associationSettingsSchema = z.object({ associationName: z.string().trim().min(2).max(160), schoolName: z.string().trim().min(2).max(200), @@ -496,6 +516,25 @@ export const associationSettingsSchema = z.object({ * l'empêcherait d'enregistrer le reste. */ headquarters: z.string().trim().max(300).default(""), + /** + * Cotisation annuelle affichée, en centimes. `null` vaut « non publiée » et + * non « gratuite » : la page publique renvoie alors au bureau au lieu + * d’annoncer un tarif. Le plafond à 1 000 € n’est pas une doctrine, c’est un + * garde-fou contre la saisie en centimes d’un montant en euros. + */ + membershipFeeCents: z.coerce + .number() + .int() + .min(0) + .max( + 100_000, + "Montant improbable : le réglage attend des euros, pas des centimes.", + ) + .nullable() + .default(null), + membershipFeeBasis: MEMBERSHIP_FEE_BASIS_SCHEMA, + /** La marche à suivre pour régler, en une phrase écrite par le bureau. */ + membershipFeeNote: z.string().trim().max(300).default(""), taskReminderWindowDays: z.coerce.number().int().min(0).max(30), volunteerReminderWindowDays: z.coerce.number().int().min(0).max(30), telegramEnabled: z.boolean().default(false),