From 50c242e20b6805cea80b1d54664b5ca2d4f046ad Mon Sep 17 00:00:00 2001
From: Claude
Date: Thu, 17 Sep 2026 12:33:05 +0000
Subject: [PATCH] =?UTF-8?q?La=20section=20Documents=20revue,=20et=20un=20p?=
=?UTF-8?q?roc=C3=A8s-verbal=20d'AG=20qui=20se=20r=C3=A9dige?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Deux demandes, une seule refonte : rendre la partie Documents plus vivante, et
faire qu'un procès-verbal d'assemblée générale se rédige au lieu de se
recopier. Jusqu'ici, un PV se saisissait dans un textarea vide : rien ne
guidait, et rien ne garantissait qu'il soit complet.
LE PRINCIPE QUI COMMANDE TOUT LE RESTE. La loi du 1ᵉʳ juillet 1901 n'impose ni
de tenir une assemblée générale, ni d'en rédiger un procès-verbal. Presque
tout vient des statuts, et une seule obligation légale naît de la séance :
déclarer sous trois mois les changements de dirigeants ou de statuts. Chaque
mention porte donc son niveau réel — exigé par la loi, prévu par vos statuts,
attendu par les tiers, simple usage — et quand la règle n'est pas connue,
l'application le dit au lieu d'inventer. Un outil qui ment sur son propre
fondement se fait remplacer par un fichier Word dès la deuxième année.
La section Documents s'ouvre sur l'état du classeur : statuts, assurance,
règlement, dernier PV. Ce sont les quatre pièces qu'on vous réclame et qu'il
faut retrouver vite. Le bandeau dit lesquelles manquent sans en faire une
liste de reproches — une association qui démarre n'a rien de tout cela.
Le procès-verbal se rédige en neuf sections sur une page continue, avec un
sommaire qui donne la position et la complétude. Une page plutôt qu'un
assistant : un secrétaire de séance relit ses notes en désordre et revient
trois fois sur les présences. Un bloc-notes recueille les notes de séance et
ne s'imprime jamais. Le brouillon existe en base avant la première frappe et
s'enregistre tout seul, en file d'attente à une seule requête — sans quoi la
frappe, le changement de section et la fermeture de l'onglet s'infligeraient
leurs propres conflits de version.
Les calculs sont faits, jamais devinés. Le quorum est constaté à partir de la
règle statutaire et de l'effectif ; la majorité est appréciée sur les
suffrages exprimés, abstentions exclues. Quand la règle manque, le verdict
n'est pas rendu : un quorum « atteint » affirmé au hasard est exactement ce
qu'un adhérent mécontent viendrait contester.
Douze textes types de résolutions, crochets voyants à compléter, avec les
élections et leurs candidats, et les modifications de statuts article par
article. Un avertissement les précède : ce sont des textes types, pas des
textes justes.
Les contrôles préviennent, ils ne bloquent presque jamais. Deux exceptions,
et ce sont des contradictions internes que personne ne peut vouloir : une
arithmétique de vote impossible, et un procès-verbal daté d'avant la séance
qu'il relate.
À l'impression, le procès-verbal sort section par section, avec les cases de
signature et un filigrane « PROJET » tant qu'il n'est pas finalisé. Le texte
composé reste stocké dans `content` : la recherche, les cartes et les outils
MCP continuent de fonctionner sans rien savoir du nouveau format.
Au passage, trois défauts du module existant :
— un procès-verbal finalisé était librement modifiable, ce qui retirait toute
valeur au statut. Le rouvrir reste possible, mais c'est un geste tracé.
— un `content` écrit par le serveur MCP était écrasé en silence à la
sauvegarde suivante ; une colonne dit désormais qui fait foi.
— les onglets n'étaient pilotables qu'à la souris, une carte au contenu long
faisait exploser la grille, et trois boutons de 44 px ne tenaient pas côte à
côte à 320 px.
Les procès-verbaux déjà saisis en texte libre gardent leur formulaire et leur
impression d'origine : on ne convertit pas d'office ce que quelqu'un a rédigé
autrement.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_014SfQYBU4xXTeSEHKhHQXdD
---
drizzle/0022_ag_minutes_payload.sql | 5 +
drizzle/meta/0022_snapshot.json | 3618 ++++++++++++++++++
drizzle/meta/_journal.json | 7 +
src/app/api/documents/[id]/route.ts | 25 +-
src/app/api/documents/regles/route.ts | 27 +
src/app/dashboard/documents/page.tsx | 3 +
src/app/dashboard/documents/pv/[id]/page.tsx | 68 +
src/app/dashboard/documents/regles/page.tsx | 11 +
src/components/documents-classeur.tsx | 204 +
src/components/documents-manager.tsx | 198 +-
src/components/documents-regles-form.tsx | 288 ++
src/components/pv/pv-champs.tsx | 425 ++
src/components/pv/pv-editeur.tsx | 1346 +++++++
src/components/pv/pv-resolutions.tsx | 548 +++
src/lib/db/schema.ts | 33 +
src/lib/documents/ag-calculs.ts | 348 ++
src/lib/documents/ag-controles.ts | 236 ++
src/lib/documents/ag-modeles.ts | 153 +
src/lib/documents/ag-rendu.ts | 411 ++
src/lib/documents/ag-types.ts | 384 ++
src/lib/documents/ag-validation.ts | 287 ++
src/lib/services/association-settings.ts | 2 +
src/lib/services/documents.ts | 139 +-
src/lib/validation.ts | 7 +
24 files changed, 8697 insertions(+), 76 deletions(-)
create mode 100644 drizzle/0022_ag_minutes_payload.sql
create mode 100644 drizzle/meta/0022_snapshot.json
create mode 100644 src/app/api/documents/regles/route.ts
create mode 100644 src/app/dashboard/documents/pv/[id]/page.tsx
create mode 100644 src/app/dashboard/documents/regles/page.tsx
create mode 100644 src/components/documents-classeur.tsx
create mode 100644 src/components/documents-regles-form.tsx
create mode 100644 src/components/pv/pv-champs.tsx
create mode 100644 src/components/pv/pv-editeur.tsx
create mode 100644 src/components/pv/pv-resolutions.tsx
create mode 100644 src/lib/documents/ag-calculs.ts
create mode 100644 src/lib/documents/ag-controles.ts
create mode 100644 src/lib/documents/ag-modeles.ts
create mode 100644 src/lib/documents/ag-rendu.ts
create mode 100644 src/lib/documents/ag-types.ts
create mode 100644 src/lib/documents/ag-validation.ts
diff --git a/drizzle/0022_ag_minutes_payload.sql b/drizzle/0022_ag_minutes_payload.sql
new file mode 100644
index 0000000..93dc688
--- /dev/null
+++ b/drizzle/0022_ag_minutes_payload.sql
@@ -0,0 +1,5 @@
+ALTER TABLE "association_documents" ADD COLUMN "payload" jsonb;--> statement-breakpoint
+ALTER TABLE "association_documents" ADD COLUMN "content_source" text DEFAULT 'manual' NOT NULL;--> statement-breakpoint
+ALTER TABLE "association_documents" ADD COLUMN "signed_at" timestamp with time zone;--> statement-breakpoint
+ALTER TABLE "association_settings" ADD COLUMN "statutory_rules" jsonb DEFAULT '{}'::jsonb NOT NULL;--> statement-breakpoint
+ALTER TABLE "association_documents" ADD CONSTRAINT "association_documents_content_source_check" CHECK ("association_documents"."content_source" in ('manual','payload'));
\ No newline at end of file
diff --git a/drizzle/meta/0022_snapshot.json b/drizzle/meta/0022_snapshot.json
new file mode 100644
index 0000000..31d5118
--- /dev/null
+++ b/drizzle/meta/0022_snapshot.json
@@ -0,0 +1,3618 @@
+{
+ "id": "8fe0922e-7a04-4af8-9c25-2ecc61e46165",
+ "prevId": "35531e8b-0540-44f3-99b4-438ae842e6ea",
+ "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": "''"
+ },
+ "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_recaptcha_min_score_check": {
+ "name": "association_settings_recaptcha_min_score_check",
+ "value": "\"association_settings\".\"recaptcha_min_score\" >= 0 and \"association_settings\".\"recaptcha_min_score\" <= 100"
+ },
+ "association_settings_volunteer_reminder_window_check": {
+ "name": "association_settings_volunteer_reminder_window_check",
+ "value": "\"association_settings\".\"volunteer_reminder_window_days\" >= 0 and \"association_settings\".\"volunteer_reminder_window_days\" <= 30"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.audit_logs": {
+ "name": "audit_logs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "actor_user_id": {
+ "name": "actor_user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "oauth_client_id": {
+ "name": "oauth_client_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "entity_type": {
+ "name": "entity_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "entity_id": {
+ "name": "entity_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'web'"
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "details": {
+ "name": "details",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "audit_logs_entity_idx": {
+ "name": "audit_logs_entity_idx",
+ "columns": [
+ {
+ "expression": "entity_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "entity_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "audit_logs_actor_created_idx": {
+ "name": "audit_logs_actor_created_idx",
+ "columns": [
+ {
+ "expression": "actor_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "audit_logs_client_created_idx": {
+ "name": "audit_logs_client_created_idx",
+ "columns": [
+ {
+ "expression": "oauth_client_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "audit_logs_created_at_idx": {
+ "name": "audit_logs_created_at_idx",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "audit_logs_actor_user_id_users_id_fk": {
+ "name": "audit_logs_actor_user_id_users_id_fk",
+ "tableFrom": "audit_logs",
+ "tableTo": "users",
+ "columnsFrom": [
+ "actor_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "audit_logs_oauth_client_id_oauth_clients_id_fk": {
+ "name": "audit_logs_oauth_client_id_oauth_clients_id_fk",
+ "tableFrom": "audit_logs",
+ "tableTo": "oauth_clients",
+ "columnsFrom": [
+ "oauth_client_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.checklist_templates": {
+ "name": "checklist_templates",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "tasks": {
+ "name": "tasks",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.event_attachments": {
+ "name": "event_attachments",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "event_id": {
+ "name": "event_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "file_url": {
+ "name": "file_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "uploaded_by": {
+ "name": "uploaded_by",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "event_attachments_event_idx": {
+ "name": "event_attachments_event_idx",
+ "columns": [
+ {
+ "expression": "event_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "event_attachments_event_id_events_id_fk": {
+ "name": "event_attachments_event_id_events_id_fk",
+ "tableFrom": "event_attachments",
+ "tableTo": "events",
+ "columnsFrom": [
+ "event_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "event_attachments_uploaded_by_users_id_fk": {
+ "name": "event_attachments_uploaded_by_users_id_fk",
+ "tableFrom": "event_attachments",
+ "tableTo": "users",
+ "columnsFrom": [
+ "uploaded_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.events": {
+ "name": "events",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "kind": {
+ "name": "kind",
+ "type": "event_kind",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'event'"
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "public_description": {
+ "name": "public_description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ticketing_url": {
+ "name": "ticketing_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "location": {
+ "name": "location",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "start_at": {
+ "name": "start_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "end_at": {
+ "name": "end_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "event_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'draft'"
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "share_token": {
+ "name": "share_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "events_status_start_idx": {
+ "name": "events_status_start_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "start_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "events_created_by_users_id_fk": {
+ "name": "events_created_by_users_id_fk",
+ "tableFrom": "events",
+ "tableTo": "users",
+ "columnsFrom": [
+ "created_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "events_share_token_unique": {
+ "name": "events_share_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "share_token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.financial_accounts": {
+ "name": "financial_accounts",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "financial_account_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "financial_accounts_type_active_idx": {
+ "name": "financial_accounts_type_active_idx",
+ "columns": [
+ {
+ "expression": "type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "is_active",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.meeting_attendance": {
+ "name": "meeting_attendance",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "event_id": {
+ "name": "event_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "meeting_attendance_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "phone": {
+ "name": "phone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cancel_token": {
+ "name": "cancel_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "note": {
+ "name": "note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "meeting_attendance_event_user_unique": {
+ "name": "meeting_attendance_event_user_unique",
+ "columns": [
+ {
+ "expression": "event_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "meeting_attendance_event_email_unique": {
+ "name": "meeting_attendance_event_email_unique",
+ "columns": [
+ {
+ "expression": "event_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "lower(\"email\")",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"meeting_attendance\".\"user_id\" is null and \"meeting_attendance\".\"email\" is not null",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "meeting_attendance_cancel_token_idx": {
+ "name": "meeting_attendance_cancel_token_idx",
+ "columns": [
+ {
+ "expression": "cancel_token",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "meeting_attendance_event_idx": {
+ "name": "meeting_attendance_event_idx",
+ "columns": [
+ {
+ "expression": "event_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "meeting_attendance_event_id_events_id_fk": {
+ "name": "meeting_attendance_event_id_events_id_fk",
+ "tableFrom": "meeting_attendance",
+ "tableTo": "events",
+ "columnsFrom": [
+ "event_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "meeting_attendance_user_id_users_id_fk": {
+ "name": "meeting_attendance_user_id_users_id_fk",
+ "tableFrom": "meeting_attendance",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "meeting_attendance_identite": {
+ "name": "meeting_attendance_identite",
+ "value": "\"meeting_attendance\".\"user_id\" is not null or \"meeting_attendance\".\"name\" is not null"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.notifications_log": {
+ "name": "notifications_log",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "task_id": {
+ "name": "task_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "sent_at": {
+ "name": "sent_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "notifications_log_task_user_kind_idx": {
+ "name": "notifications_log_task_user_kind_idx",
+ "columns": [
+ {
+ "expression": "task_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "notifications_log_task_id_tasks_id_fk": {
+ "name": "notifications_log_task_id_tasks_id_fk",
+ "tableFrom": "notifications_log",
+ "tableTo": "tasks",
+ "columnsFrom": [
+ "task_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "notifications_log_user_id_users_id_fk": {
+ "name": "notifications_log_user_id_users_id_fk",
+ "tableFrom": "notifications_log",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.oauth_authorization_codes": {
+ "name": "oauth_authorization_codes",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "code_hash": {
+ "name": "code_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "oauth_client_id": {
+ "name": "oauth_client_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "redirect_uri": {
+ "name": "redirect_uri",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "scopes": {
+ "name": "scopes",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "code_challenge": {
+ "name": "code_challenge",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "code_challenge_method": {
+ "name": "code_challenge_method",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'S256'"
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "used_at": {
+ "name": "used_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "oauth_authorization_codes_code_hash_idx": {
+ "name": "oauth_authorization_codes_code_hash_idx",
+ "columns": [
+ {
+ "expression": "code_hash",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "oauth_authorization_codes_client_expires_idx": {
+ "name": "oauth_authorization_codes_client_expires_idx",
+ "columns": [
+ {
+ "expression": "oauth_client_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "expires_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "oauth_authorization_codes_user_idx": {
+ "name": "oauth_authorization_codes_user_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "oauth_authorization_codes_oauth_client_id_oauth_clients_id_fk": {
+ "name": "oauth_authorization_codes_oauth_client_id_oauth_clients_id_fk",
+ "tableFrom": "oauth_authorization_codes",
+ "tableTo": "oauth_clients",
+ "columnsFrom": [
+ "oauth_client_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "oauth_authorization_codes_user_id_users_id_fk": {
+ "name": "oauth_authorization_codes_user_id_users_id_fk",
+ "tableFrom": "oauth_authorization_codes",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.oauth_clients": {
+ "name": "oauth_clients",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "client_id": {
+ "name": "client_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "client_secret_hash": {
+ "name": "client_secret_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "redirect_uris": {
+ "name": "redirect_uris",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "token_endpoint_auth_method": {
+ "name": "token_endpoint_auth_method",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'none'"
+ },
+ "grant_types": {
+ "name": "grant_types",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[\"authorization_code\",\"refresh_token\"]'::jsonb"
+ },
+ "scopes": {
+ "name": "scopes",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "oauth_clients_client_id_idx": {
+ "name": "oauth_clients_client_id_idx",
+ "columns": [
+ {
+ "expression": "client_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "oauth_clients_enabled_idx": {
+ "name": "oauth_clients_enabled_idx",
+ "columns": [
+ {
+ "expression": "enabled",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "oauth_clients_created_by_users_id_fk": {
+ "name": "oauth_clients_created_by_users_id_fk",
+ "tableFrom": "oauth_clients",
+ "tableTo": "users",
+ "columnsFrom": [
+ "created_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.oauth_tokens": {
+ "name": "oauth_tokens",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "token_hash": {
+ "name": "token_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "oauth_token_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "oauth_client_id": {
+ "name": "oauth_client_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "authorization_code_id": {
+ "name": "authorization_code_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scopes": {
+ "name": "scopes",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "oauth_tokens_token_hash_idx": {
+ "name": "oauth_tokens_token_hash_idx",
+ "columns": [
+ {
+ "expression": "token_hash",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "oauth_tokens_client_type_expires_idx": {
+ "name": "oauth_tokens_client_type_expires_idx",
+ "columns": [
+ {
+ "expression": "oauth_client_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "expires_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "oauth_tokens_user_idx": {
+ "name": "oauth_tokens_user_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "oauth_tokens_authorization_code_idx": {
+ "name": "oauth_tokens_authorization_code_idx",
+ "columns": [
+ {
+ "expression": "authorization_code_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "oauth_tokens_oauth_client_id_oauth_clients_id_fk": {
+ "name": "oauth_tokens_oauth_client_id_oauth_clients_id_fk",
+ "tableFrom": "oauth_tokens",
+ "tableTo": "oauth_clients",
+ "columnsFrom": [
+ "oauth_client_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "oauth_tokens_user_id_users_id_fk": {
+ "name": "oauth_tokens_user_id_users_id_fk",
+ "tableFrom": "oauth_tokens",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "oauth_tokens_authorization_code_id_oauth_authorization_codes_id_fk": {
+ "name": "oauth_tokens_authorization_code_id_oauth_authorization_codes_id_fk",
+ "tableFrom": "oauth_tokens",
+ "tableTo": "oauth_authorization_codes",
+ "columnsFrom": [
+ "authorization_code_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.outbound_mail_settings": {
+ "name": "outbound_mail_settings",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "'default'"
+ },
+ "provider": {
+ "name": "provider",
+ "type": "outbound_mail_provider",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'resend'"
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "from_name": {
+ "name": "from_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "from_email": {
+ "name": "from_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "reply_to": {
+ "name": "reply_to",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "domain": {
+ "name": "domain",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "encrypted_api_key": {
+ "name": "encrypted_api_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "key_last_four": {
+ "name": "key_last_four",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "smtp_host": {
+ "name": "smtp_host",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "smtp_port": {
+ "name": "smtp_port",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "smtp_secure": {
+ "name": "smtp_secure",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "smtp_username": {
+ "name": "smtp_username",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "encrypted_smtp_password": {
+ "name": "encrypted_smtp_password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_tested_at": {
+ "name": "last_tested_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_test_status": {
+ "name": "last_test_status",
+ "type": "outbound_mail_test_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'untested'"
+ },
+ "updated_by": {
+ "name": "updated_by",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "outbound_mail_settings_updated_by_users_id_fk": {
+ "name": "outbound_mail_settings_updated_by_users_id_fk",
+ "tableFrom": "outbound_mail_settings",
+ "tableTo": "users",
+ "columnsFrom": [
+ "updated_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "outbound_mail_settings_singleton_check": {
+ "name": "outbound_mail_settings_singleton_check",
+ "value": "\"outbound_mail_settings\".\"id\" = 'default'"
+ },
+ "outbound_mail_settings_smtp_port_check": {
+ "name": "outbound_mail_settings_smtp_port_check",
+ "value": "\"outbound_mail_settings\".\"smtp_port\" is null or (\"outbound_mail_settings\".\"smtp_port\" >= 1 and \"outbound_mail_settings\".\"smtp_port\" <= 65535)"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.password_reset_tokens": {
+ "name": "password_reset_tokens",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token_hash": {
+ "name": "token_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "used_at": {
+ "name": "used_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "password_reset_tokens_user_id_users_id_fk": {
+ "name": "password_reset_tokens_user_id_users_id_fk",
+ "tableFrom": "password_reset_tokens",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.push_deliveries": {
+ "name": "push_deliveries",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "notification_id": {
+ "name": "notification_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "subscription_id": {
+ "name": "subscription_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "push_delivery_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'queued'"
+ },
+ "ack_token": {
+ "name": "ack_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sent_at": {
+ "name": "sent_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "received_at": {
+ "name": "received_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "opened_at": {
+ "name": "opened_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "push_deliveries_notification_idx": {
+ "name": "push_deliveries_notification_idx",
+ "columns": [
+ {
+ "expression": "notification_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "push_deliveries_ack_token_idx": {
+ "name": "push_deliveries_ack_token_idx",
+ "columns": [
+ {
+ "expression": "ack_token",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "push_deliveries_user_idx": {
+ "name": "push_deliveries_user_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "push_deliveries_notification_id_push_notifications_id_fk": {
+ "name": "push_deliveries_notification_id_push_notifications_id_fk",
+ "tableFrom": "push_deliveries",
+ "tableTo": "push_notifications",
+ "columnsFrom": [
+ "notification_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "push_deliveries_user_id_users_id_fk": {
+ "name": "push_deliveries_user_id_users_id_fk",
+ "tableFrom": "push_deliveries",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "push_deliveries_subscription_id_push_subscriptions_id_fk": {
+ "name": "push_deliveries_subscription_id_push_subscriptions_id_fk",
+ "tableFrom": "push_deliveries",
+ "tableTo": "push_subscriptions",
+ "columnsFrom": [
+ "subscription_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.push_notifications": {
+ "name": "push_notifications",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "sender_id": {
+ "name": "sender_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "body": {
+ "name": "body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "push_notifications_created_at_idx": {
+ "name": "push_notifications_created_at_idx",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "push_notifications_sender_id_users_id_fk": {
+ "name": "push_notifications_sender_id_users_id_fk",
+ "tableFrom": "push_notifications",
+ "tableTo": "users",
+ "columnsFrom": [
+ "sender_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.push_subscriptions": {
+ "name": "push_subscriptions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "endpoint": {
+ "name": "endpoint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "p256dh": {
+ "name": "p256dh",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "auth": {
+ "name": "auth",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "device_label": {
+ "name": "device_label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_success_at": {
+ "name": "last_success_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "push_subscriptions_endpoint_idx": {
+ "name": "push_subscriptions_endpoint_idx",
+ "columns": [
+ {
+ "expression": "endpoint",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "push_subscriptions_user_idx": {
+ "name": "push_subscriptions_user_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "push_subscriptions_user_id_users_id_fk": {
+ "name": "push_subscriptions_user_id_users_id_fk",
+ "tableFrom": "push_subscriptions",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.task_assignees": {
+ "name": "task_assignees",
+ "schema": "",
+ "columns": {
+ "task_id": {
+ "name": "task_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "task_assignees_user_idx": {
+ "name": "task_assignees_user_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "task_assignees_task_id_tasks_id_fk": {
+ "name": "task_assignees_task_id_tasks_id_fk",
+ "tableFrom": "task_assignees",
+ "tableTo": "tasks",
+ "columnsFrom": [
+ "task_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "task_assignees_user_id_users_id_fk": {
+ "name": "task_assignees_user_id_users_id_fk",
+ "tableFrom": "task_assignees",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "task_assignees_task_id_user_id_pk": {
+ "name": "task_assignees_task_id_user_id_pk",
+ "columns": [
+ "task_id",
+ "user_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.tasks": {
+ "name": "tasks",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "event_id": {
+ "name": "event_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "lead_time_days": {
+ "name": "lead_time_days",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 7
+ },
+ "lead_time_value": {
+ "name": "lead_time_value",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 7
+ },
+ "lead_time_unit": {
+ "name": "lead_time_unit",
+ "type": "task_lead_time_unit",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'days'"
+ },
+ "due_at": {
+ "name": "due_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "task_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'todo'"
+ },
+ "position": {
+ "name": "position",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "tasks_event_idx": {
+ "name": "tasks_event_idx",
+ "columns": [
+ {
+ "expression": "event_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tasks_status_due_idx": {
+ "name": "tasks_status_due_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "due_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "tasks_event_id_events_id_fk": {
+ "name": "tasks_event_id_events_id_fk",
+ "tableFrom": "tasks",
+ "tableTo": "events",
+ "columnsFrom": [
+ "event_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "tasks_lead_time_value_check": {
+ "name": "tasks_lead_time_value_check",
+ "value": "\"tasks\".\"lead_time_value\" >= 0"
+ },
+ "tasks_lead_time_consistency_check": {
+ "name": "tasks_lead_time_consistency_check",
+ "value": "\"tasks\".\"lead_time_days\" = \"tasks\".\"lead_time_value\" * case \"tasks\".\"lead_time_unit\" when 'days' then 1 when 'weeks' then 7 when 'months' then 30 end"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.users": {
+ "name": "users",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "password_hash": {
+ "name": "password_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "role",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'member'"
+ },
+ "telegram_chat_id": {
+ "name": "telegram_chat_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "session_epoch": {
+ "name": "session_epoch",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "onboarding_seen_at": {
+ "name": "onboarding_seen_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "push_enabled": {
+ "name": "push_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "users_email_unique": {
+ "name": "users_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "email"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.volunteer_signups": {
+ "name": "volunteer_signups",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "slot_id": {
+ "name": "slot_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "phone": {
+ "name": "phone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cancel_token": {
+ "name": "cancel_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "reminded_at": {
+ "name": "reminded_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "volunteer_signups_slot_idx": {
+ "name": "volunteer_signups_slot_idx",
+ "columns": [
+ {
+ "expression": "slot_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "volunteer_signups_user_idx": {
+ "name": "volunteer_signups_user_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "volunteer_signups_cancel_token_idx": {
+ "name": "volunteer_signups_cancel_token_idx",
+ "columns": [
+ {
+ "expression": "cancel_token",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "volunteer_signups_slot_email_idx": {
+ "name": "volunteer_signups_slot_email_idx",
+ "columns": [
+ {
+ "expression": "slot_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "lower(\"email\")",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"volunteer_signups\".\"email\" is not null",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "volunteer_signups_slot_id_volunteer_slots_id_fk": {
+ "name": "volunteer_signups_slot_id_volunteer_slots_id_fk",
+ "tableFrom": "volunteer_signups",
+ "tableTo": "volunteer_slots",
+ "columnsFrom": [
+ "slot_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "volunteer_signups_user_id_users_id_fk": {
+ "name": "volunteer_signups_user_id_users_id_fk",
+ "tableFrom": "volunteer_signups",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.volunteer_slots": {
+ "name": "volunteer_slots",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "event_id": {
+ "name": "event_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "start_at": {
+ "name": "start_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "end_at": {
+ "name": "end_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "capacity": {
+ "name": "capacity",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "volunteer_slots_event_idx": {
+ "name": "volunteer_slots_event_idx",
+ "columns": [
+ {
+ "expression": "event_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "volunteer_slots_event_id_events_id_fk": {
+ "name": "volunteer_slots_event_id_events_id_fk",
+ "tableFrom": "volunteer_slots",
+ "tableTo": "events",
+ "columnsFrom": [
+ "event_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {
+ "public.accounting_category_type": {
+ "name": "accounting_category_type",
+ "schema": "public",
+ "values": [
+ "income",
+ "expense"
+ ]
+ },
+ "public.accounting_entry_status": {
+ "name": "accounting_entry_status",
+ "schema": "public",
+ "values": [
+ "draft",
+ "posted"
+ ]
+ },
+ "public.accounting_entry_type": {
+ "name": "accounting_entry_type",
+ "schema": "public",
+ "values": [
+ "income",
+ "expense"
+ ]
+ },
+ "public.association_document_status": {
+ "name": "association_document_status",
+ "schema": "public",
+ "values": [
+ "draft",
+ "final",
+ "archived"
+ ]
+ },
+ "public.association_document_type": {
+ "name": "association_document_type",
+ "schema": "public",
+ "values": [
+ "ag_minutes",
+ "attestation",
+ "statutes",
+ "internal_rules",
+ "insurance",
+ "agreement",
+ "other"
+ ]
+ },
+ "public.association_member_status": {
+ "name": "association_member_status",
+ "schema": "public",
+ "values": [
+ "active",
+ "pending",
+ "inactive"
+ ]
+ },
+ "public.event_kind": {
+ "name": "event_kind",
+ "schema": "public",
+ "values": [
+ "event",
+ "meeting"
+ ]
+ },
+ "public.event_status": {
+ "name": "event_status",
+ "schema": "public",
+ "values": [
+ "draft",
+ "published",
+ "archived"
+ ]
+ },
+ "public.financial_account_type": {
+ "name": "financial_account_type",
+ "schema": "public",
+ "values": [
+ "bank",
+ "cash"
+ ]
+ },
+ "public.meeting_attendance_status": {
+ "name": "meeting_attendance_status",
+ "schema": "public",
+ "values": [
+ "yes",
+ "maybe",
+ "no"
+ ]
+ },
+ "public.oauth_token_type": {
+ "name": "oauth_token_type",
+ "schema": "public",
+ "values": [
+ "access",
+ "refresh"
+ ]
+ },
+ "public.outbound_mail_provider": {
+ "name": "outbound_mail_provider",
+ "schema": "public",
+ "values": [
+ "resend",
+ "smtp"
+ ]
+ },
+ "public.outbound_mail_test_status": {
+ "name": "outbound_mail_test_status",
+ "schema": "public",
+ "values": [
+ "untested",
+ "success",
+ "failed"
+ ]
+ },
+ "public.push_delivery_status": {
+ "name": "push_delivery_status",
+ "schema": "public",
+ "values": [
+ "queued",
+ "sent",
+ "failed",
+ "received",
+ "opened"
+ ]
+ },
+ "public.role": {
+ "name": "role",
+ "schema": "public",
+ "values": [
+ "admin",
+ "manager",
+ "member"
+ ]
+ },
+ "public.task_lead_time_unit": {
+ "name": "task_lead_time_unit",
+ "schema": "public",
+ "values": [
+ "days",
+ "weeks",
+ "months"
+ ]
+ },
+ "public.task_status": {
+ "name": "task_status",
+ "schema": "public",
+ "values": [
+ "todo",
+ "in_progress",
+ "done"
+ ]
+ }
+ },
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json
index 73d81a2..2a88b88 100644
--- a/drizzle/meta/_journal.json
+++ b/drizzle/meta/_journal.json
@@ -155,6 +155,13 @@
"when": 1789629977428,
"tag": "0021_association_headquarters",
"breakpoints": true
+ },
+ {
+ "idx": 22,
+ "version": "7",
+ "when": 1789646856683,
+ "tag": "0022_ag_minutes_payload",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/src/app/api/documents/[id]/route.ts b/src/app/api/documents/[id]/route.ts
index 94027b8..94ab028 100644
--- a/src/app/api/documents/[id]/route.ts
+++ b/src/app/api/documents/[id]/route.ts
@@ -22,15 +22,24 @@ export async function GET(req: Request, { params }: Params) {
const { id } = await params;
const document = await getAssociationDocument(id);
if (!document) throw new HttpError(404, "Document introuvable.");
- if (new URL(req.url).searchParams.get("format") === "print") {
- return new NextResponse(await renderPrintableDocument(document), {
- headers: {
- "Content-Type": "text/html; charset=utf-8",
- "Content-Security-Policy":
- "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'",
- "X-Content-Type-Options": "nosniff",
+ const requete = new URL(req.url).searchParams;
+ if (requete.get("format") === "print") {
+ // `auto=0` sert l'aperçu affiché dans l'éditeur : sans lui, la boîte
+ // d'impression du navigateur s'ouvrirait à chaque rendu de l'iframe.
+ const auto = requete.get("auto") !== "0";
+ return new NextResponse(
+ await renderPrintableDocument(document, { auto }),
+ {
+ headers: {
+ "Content-Type": "text/html; charset=utf-8",
+ // `default-src 'none'` bloque les images en silence : l'erreur
+ // n'apparaît qu'en console, et le logo disparaît sans rien dire.
+ "Content-Security-Policy":
+ "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; script-src 'unsafe-inline'",
+ "X-Content-Type-Options": "nosniff",
+ },
},
- });
+ );
}
return NextResponse.json({ document });
} catch (error) {
diff --git a/src/app/api/documents/regles/route.ts b/src/app/api/documents/regles/route.ts
new file mode 100644
index 0000000..0665305
--- /dev/null
+++ b/src/app/api/documents/regles/route.ts
@@ -0,0 +1,27 @@
+import { NextResponse } from "next/server";
+
+import { handleApiError, requireApiRole } from "@/lib/auth/guards";
+import { webAuditActor } from "@/lib/services/audit";
+import { saveStatutoryRules } from "@/lib/services/documents";
+
+export const dynamic = "force-dynamic";
+
+/**
+ * Fiche statutaire : ce que prévoient les statuts en matière d'assemblée.
+ *
+ * Point d'entrée distinct des réglages généraux, parce que le schéma de
+ * ceux-ci remplace par leur valeur par défaut tous les champs absents : les y
+ * mêler ferait effacer la fiche à chaque enregistrement de la configuration.
+ */
+export async function PATCH(req: Request) {
+ try {
+ const user = await requireApiRole("admin");
+ const regles = await saveStatutoryRules(
+ await req.json(),
+ webAuditActor(user.id, req),
+ );
+ return NextResponse.json({ ok: true, regles });
+ } catch (error) {
+ return handleApiError(error);
+ }
+}
diff --git a/src/app/dashboard/documents/page.tsx b/src/app/dashboard/documents/page.tsx
index 595d2b3..1a261ce 100644
--- a/src/app/dashboard/documents/page.tsx
+++ b/src/app/dashboard/documents/page.tsx
@@ -41,6 +41,9 @@ export default async function DocumentsPage() {
content: document.content,
memberId: document.memberId,
fileUrl: document.fileUrl,
+ // Un booléen suffit à l'écran : le payload complet n'a rien à faire dans
+ // le flux envoyé au navigateur pour une simple liste.
+ payload: document.payload !== null,
version: document.version,
}));
diff --git a/src/app/dashboard/documents/pv/[id]/page.tsx b/src/app/dashboard/documents/pv/[id]/page.tsx
new file mode 100644
index 0000000..29004c3
--- /dev/null
+++ b/src/app/dashboard/documents/pv/[id]/page.tsx
@@ -0,0 +1,68 @@
+import { asc } from "drizzle-orm";
+import { notFound, redirect } from "next/navigation";
+
+import { PvEditeur, type PvDocumentView } from "@/components/pv/pv-editeur";
+import { requireRole } from "@/lib/auth/rbac";
+import { db } from "@/lib/db";
+import { associationMembers } from "@/lib/db/schema";
+import { payloadVide } from "@/lib/documents/ag-types";
+import { getAssociationDocument } from "@/lib/services/documents";
+import { getAssociationSettings } from "@/lib/services/association-settings";
+
+export const dynamic = "force-dynamic";
+
+/**
+ * Écran de rédaction d'un procès-verbal d'assemblée générale.
+ *
+ * Toute `Date` est convertie en chaîne avant la frontière serveur/client : la
+ * sérialisation est manuelle dans ce module, et un champ oublié n'arriverait
+ * jamais jusqu'à l'éditeur.
+ */
+export default async function PvPage({
+ params,
+}: {
+ params: Promise<{ id: string }>;
+}) {
+ await requireRole("manager");
+ const { id } = await params;
+
+ const [document, association, adherents] = await Promise.all([
+ getAssociationDocument(id),
+ getAssociationSettings(),
+ db
+ .select({
+ id: associationMembers.id,
+ firstName: associationMembers.firstName,
+ lastName: associationMembers.lastName,
+ })
+ .from(associationMembers)
+ .orderBy(asc(associationMembers.lastName), asc(associationMembers.firstName)),
+ ]);
+
+ if (!document) notFound();
+ // Un procès-verbal saisi en texte libre garde son formulaire d'origine : on
+ // ne convertit pas d'office ce que quelqu'un a déjà rédigé autrement.
+ if (document.type !== "ag_minutes" || !document.payload) {
+ redirect("/dashboard/documents");
+ }
+
+ const vue: PvDocumentView = {
+ id: document.id,
+ title: document.title,
+ status: document.status,
+ documentDate: document.documentDate.toISOString(),
+ version: document.version,
+ payload: { ...payloadVide(), ...document.payload },
+ };
+
+ return (
+ ({
+ id: a.id,
+ name: `${a.firstName} ${a.lastName}`,
+ }))}
+ />
+ );
+}
diff --git a/src/app/dashboard/documents/regles/page.tsx b/src/app/dashboard/documents/regles/page.tsx
new file mode 100644
index 0000000..9fabd32
--- /dev/null
+++ b/src/app/dashboard/documents/regles/page.tsx
@@ -0,0 +1,11 @@
+import { DocumentsReglesForm } from "@/components/documents-regles-form";
+import { requireRole } from "@/lib/auth/rbac";
+import { getAssociationSettings } from "@/lib/services/association-settings";
+
+export const dynamic = "force-dynamic";
+
+export default async function ReglesStatutairesPage() {
+ await requireRole("admin");
+ const association = await getAssociationSettings();
+ return ;
+}
diff --git a/src/components/documents-classeur.tsx b/src/components/documents-classeur.tsx
new file mode 100644
index 0000000..8ee7680
--- /dev/null
+++ b/src/components/documents-classeur.tsx
@@ -0,0 +1,204 @@
+"use client";
+
+import {
+ BadgeCheck,
+ Check,
+ FileText,
+ Landmark,
+ ScrollText,
+ ShieldCheck,
+} from "lucide-react";
+import Link from "next/link";
+import type { LucideIcon } from "lucide-react";
+
+import type { AssociationDocumentView } from "@/components/documents-manager";
+import { cn } from "@/lib/utils";
+
+/**
+ * L'état du classeur de l'association.
+ *
+ * Ce sont les quatre pièces qu'on vous réclame et qu'il faut retrouver vite :
+ * les statuts pour la banque, l'assurance pour la mairie, le règlement pour
+ * l'établissement, le dernier procès-verbal pour la préfecture. Le module les
+ * signale quand elles manquent, sans en faire une liste de reproches — une
+ * association qui démarre n'a rien de tout cela, et ce n'est pas une faute.
+ */
+
+interface Emplacement {
+ cle: string;
+ titre: string;
+ icone: LucideIcon;
+ present: boolean;
+ detail: string;
+}
+
+function annee(iso: string): number {
+ return new Date(iso).getFullYear();
+}
+
+export function etatDuClasseur(documents: AssociationDocumentView[]): Emplacement[] {
+ const vivants = documents.filter((d) => d.status !== "archived");
+ const dernier = (type: AssociationDocumentView["type"]) =>
+ vivants
+ .filter((d) => d.type === type)
+ .sort((a, b) => b.documentDate.localeCompare(a.documentDate))[0] ?? null;
+
+ const statuts = dernier("statutes");
+ const assurance = dernier("insurance");
+ const reglement = dernier("internal_rules");
+ const pv = dernier("ag_minutes");
+
+ // L'assurance se renouvelle chaque année scolaire : une attestation de
+ // l'an dernier ne prouve plus rien. On tolère l'année civile en cours et la
+ // précédente, l'année scolaire étant à cheval sur les deux.
+ const maintenant = new Date().getFullYear();
+ const assuranceAJour =
+ assurance !== null && annee(assurance.documentDate) >= maintenant - 1;
+
+ return [
+ {
+ cle: "statutes",
+ titre: "Statuts",
+ icone: ScrollText,
+ present: statuts !== null,
+ detail: statuts
+ ? `Déposés, version du ${new Date(statuts.documentDate).toLocaleDateString("fr-FR")}`
+ : "C’est la première pièce que réclame une banque ou une mairie.",
+ },
+ {
+ cle: "insurance",
+ titre: "Assurance",
+ icone: ShieldCheck,
+ present: assuranceAJour,
+ detail: assuranceAJour
+ ? `Attestation ${annee(assurance!.documentDate)}`
+ : assurance
+ ? `La dernière attestation date de ${annee(assurance.documentDate)}.`
+ : "Aucune attestation déposée.",
+ },
+ {
+ cle: "internal_rules",
+ titre: "Règlement intérieur",
+ icone: Landmark,
+ present: reglement !== null,
+ detail: reglement ? "Déposé" : "Facultatif, utile s’il existe.",
+ },
+ {
+ cle: "ag_minutes",
+ titre: "Dernier PV d’AG",
+ icone: FileText,
+ present: pv !== null && pv.status === "final",
+ detail: pv
+ ? pv.status === "final"
+ ? `Assemblée du ${new Date(pv.documentDate).toLocaleDateString("fr-FR")}`
+ : "Un procès-verbal est en brouillon."
+ : "Aucun procès-verbal d’assemblée générale.",
+ },
+ ];
+}
+
+export function ClasseurBandeau({
+ documents,
+ onVoir,
+}: {
+ documents: AssociationDocumentView[];
+ onVoir: (type: AssociationDocumentView["type"]) => void;
+}) {
+ const emplacements = etatDuClasseur(documents);
+ const complets = emplacements.filter((e) => e.present).length;
+ const total = emplacements.length;
+ const tout = complets === total;
+
+ return (
+
+
+
+
+ {tout ? : }
+
+
+
Le classeur de l’association
+
+ {tout
+ ? "Tout est là : les quatre pièces qu’on vous réclamera sont déposées."
+ : `${complets} pièce${complets > 1 ? "s" : ""} sur ${total}. Les autres se déposent quand vous les avez.`}
+
+
+
+
+ {complets}/{total}
+
+
+
+
+ {emplacements.map((emplacement) => (
+
+ onVoir(emplacement.cle as AssociationDocumentView["type"])}
+ className="flex h-full w-full items-start gap-3 bg-white p-4 text-left transition-colors hover:bg-brand-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand-500"
+ >
+
+
+
+
+
+ {emplacement.titre}
+ {emplacement.present && (
+
+ )}
+
+ {emplacement.present ? " — déposé" : " — manquant"}
+
+
+
+ {emplacement.detail}
+
+
+
+
+ ))}
+
+
+ {!tout && (
+
+ Une association qui démarre n’a rien de tout cela, et ce n’est pas une
+ faute. Ces repères servent le jour où l’on vous demande une pièce dans
+ la journée.{" "}
+
+ Renseigner ce que prévoient vos statuts
+
+
+ )}
+
+ );
+}
diff --git a/src/components/documents-manager.tsx b/src/components/documents-manager.tsx
index 621c680..1d3dec6 100644
--- a/src/components/documents-manager.tsx
+++ b/src/components/documents-manager.tsx
@@ -13,12 +13,15 @@ import {
Printer,
Search,
Trash2,
+ Sparkles,
UserRound,
X,
} from "lucide-react";
+import Link from "next/link";
import { useRouter } from "next/navigation";
-import { useMemo, useState } from "react";
+import { forwardRef, useMemo, useRef, useState } from "react";
+import { ClasseurBandeau } from "@/components/documents-classeur";
import { ConfirmDialog } from "@/components/confirm-dialog";
import { FileUploadField } from "@/components/file-upload-field";
import { ModuleStat } from "@/components/module-stat";
@@ -35,6 +38,7 @@ import {
} from "@/components/ui";
import { api } from "@/lib/client";
import { formatShortDate } from "@/lib/dates";
+import { payloadVide } from "@/lib/documents/ag-types";
import {
ASSOCIATION_DOCUMENT_TYPE_LABELS,
OFFICIAL_DOCUMENT_TYPES,
@@ -56,6 +60,8 @@ export interface AssociationDocumentView {
content: string;
memberId: string | null;
fileUrl: string | null;
+ /** Vrai pour un procès-verbal rédigé dans l'éditeur guidé. */
+ payload: boolean;
version: number;
}
@@ -104,6 +110,8 @@ export function DocumentsManager({
const [pendingDelete, setPendingDelete] =
useState(null);
const [deleting, setDeleting] = useState(false);
+ const [redaction, setRedaction] = useState(false);
+ const onglets = useRef<(HTMLButtonElement | null)[]>([]);
const permanentlyDeleting =
pendingDelete?.type === "ag_minutes" &&
pendingDelete.status === "archived";
@@ -210,6 +218,34 @@ export function DocumentsManager({
}
}
+ /**
+ * Le brouillon existe en base avant la première frappe : c'est ce qui fait
+ * que plus rien ne peut être perdu ensuite, et que fermer l'onglet par
+ * mégarde ne coûte rien.
+ */
+ async function redigerUnPv() {
+ setRedaction(true);
+ const aujourdhui = new Date();
+ const jour = aujourdhui.toISOString().slice(0, 10);
+ try {
+ const reponse = (await api("/api/documents", {
+ body: {
+ type: "ag_minutes",
+ status: "draft",
+ title: `Procès-verbal de l’assemblée générale du ${aujourdhui.toLocaleDateString("fr-FR")}`,
+ documentDate: jour,
+ payload: payloadVide({ date: jour }),
+ },
+ })) as { document?: { id?: string } };
+ const id = reponse?.document?.id;
+ if (!id) throw new Error("Le brouillon n’a pas pu être créé.");
+ router.push(`/dashboard/documents/pv/${id}`);
+ } catch (error) {
+ toast((error as Error).message, "error");
+ setRedaction(false);
+ }
+ }
+
function printDocument(document: AssociationDocumentView) {
const printWindow = window.open(
`/api/documents/${document.id}?format=print`,
@@ -231,9 +267,14 @@ export function DocumentsManager({
return (
+
changeTab(tabOf(type))}
+ />
+
-
{
+ const ordre: DocumentTab[] = ["ag_minutes", "attestation", "association"];
+ const index = ordre.indexOf(tab);
+ const suivant =
+ event.key === "ArrowRight"
+ ? (index + 1) % ordre.length
+ : event.key === "ArrowLeft"
+ ? (index - 1 + ordre.length) % ordre.length
+ : event.key === "Home"
+ ? 0
+ : event.key === "End"
+ ? ordre.length - 1
+ : -1;
+ if (suivant === -1) return;
+ event.preventDefault();
+ changeTab(ordre[suivant]);
+ onglets.current[suivant]?.focus();
+ }}
className="grid rounded-2xl border-2 border-slate-200 bg-slate-100 p-1 sm:w-fit sm:grid-cols-3"
>
{ onglets.current[0] = el; }}
+ id="onglet-ag_minutes"
active={tab === "ag_minutes"}
icon={FileText}
label="PV d'AG"
@@ -285,6 +339,8 @@ export function DocumentsManager({
onClick={() => changeTab("ag_minutes")}
/>
{ onglets.current[1] = el; }}
+ id="onglet-attestation"
active={tab === "attestation"}
icon={BadgeCheck}
label="Attestations"
@@ -292,6 +348,8 @@ export function DocumentsManager({
onClick={() => changeTab("attestation")}
/>
{ onglets.current[2] = el; }}
+ id="onglet-association"
active={tab === "association"}
icon={Landmark}
label="Documents de l’association"
@@ -380,21 +438,45 @@ export function DocumentsManager({
Finalisés
Archivés
- setEditor("new")}
- className="w-full lg:w-auto"
- >
- {tab === "ag_minutes"
- ? "Ajouter un PV"
- : tab === "attestation"
- ? "Créer une attestation"
- : "Ajouter un document"}
-
+ {tab === "ag_minutes" ? (
+
+ void redigerUnPv()}
+ >
+ Rédiger un PV d’AG
+
+ setEditor("new")}
+ >
+ Importer un PV rédigé
+
+
+ ) : (
+ setEditor("new")}
+ className="w-full lg:w-auto"
+ >
+ {tab === "attestation" ? "Créer une attestation" : "Ajouter un document"}
+
+ )}
+
{filtered.length === 0 ? (
>
)}
+
void;
-}) {
+const DocumentTabButton = forwardRef<
+ HTMLButtonElement,
+ {
+ active: boolean;
+ icon: typeof FileText;
+ label: string;
+ count: number;
+ onClick: () => void;
+ id: string;
+ }
+>(function DocumentTabButton({ active, icon: Icon, label, count, onClick, id }, ref) {
return (
);
-}
+});
function DocumentCard({
document,
@@ -603,7 +690,7 @@ function DocumentCard({
)}
{document.content && (
-
+
{document.content}
)}
@@ -619,16 +706,7 @@ function DocumentCard({
)}
-
+
{!isArchived && (
<>
-
- Modifier
-
+ {document.type === "ag_minutes" && document.payload ? (
+
+
+ Reprendre
+
+ ) : (
+
+ Modifier
+
+ )}
(regles);
+ const [occupe, setOccupe] = useState(false);
+
+ function maj(cle: K, valeur: ReglesStatutaires[K]) {
+ setValeurs((precedent) => ({ ...precedent, [cle]: valeur }));
+ }
+
+ async function enregistrer(event: React.FormEvent) {
+ event.preventDefault();
+ setOccupe(true);
+ try {
+ // Les champs laissés vides sont retirés : ils signifient « inconnu »,
+ // et non « zéro ».
+ const propres = Object.fromEntries(
+ Object.entries(valeurs).filter(([, v]) => v !== "" && v !== undefined && v !== null),
+ );
+ await api("/api/documents/regles", { method: "PATCH", body: propres });
+ toast("Fiche statutaire enregistrée.");
+ router.refresh();
+ } catch (error) {
+ toast((error as Error).message, "error");
+ } finally {
+ setOccupe(false);
+ }
+ }
+
+ return (
+
+ );
+}
diff --git a/src/components/pv/pv-champs.tsx b/src/components/pv/pv-champs.tsx
new file mode 100644
index 0000000..cbf1f19
--- /dev/null
+++ b/src/components/pv/pv-champs.tsx
@@ -0,0 +1,425 @@
+"use client";
+
+import { Plus, X } from "lucide-react";
+import { useId } from "react";
+
+import { Input, Select, Textarea } from "@/components/ui";
+import type { NiveauExigence, PvPersonne } from "@/lib/documents/ag-types";
+
+/**
+ * Les champs de l'éditeur de procès-verbal.
+ *
+ * Chacun porte son niveau d'exigence réel. C'est le point sur lequel toute la
+ * recherche a convergé : la tentation d'écrire « obligatoire » parce que c'est
+ * plus persuasif fait mentir l'outil sur son propre fondement, et un bénévole
+ * qui découvre l'exagération se remet à son fichier Word. La loi de 1901
+ * n'impose presque rien ; les statuts, beaucoup ; les tiers, le reste.
+ */
+
+const NIVEAUX: Record = {
+ legal: {
+ texte: "exigé par la loi",
+ classe: "bg-brand-50 text-brand-800 ring-brand-200",
+ },
+ statutaire: {
+ texte: "prévu par vos statuts",
+ classe: "bg-sea-50 text-sea-800 ring-sea-200",
+ },
+ tiers: {
+ texte: "attendu par les tiers",
+ classe: "bg-sand-100 text-sand-900 ring-sand-300",
+ },
+ usage: {
+ texte: "usage, non obligatoire",
+ classe: "bg-slate-100 text-slate-600 ring-slate-200",
+ },
+ facultatif: null,
+};
+
+/**
+ * Le niveau d'exigence fait partie du nom accessible du champ, et c'est voulu :
+ * savoir qu'une mention est exigée par la loi ou seulement attendue par une
+ * banque est utile à qui écoute la page autant qu'à qui la regarde. Le tiret
+ * invisible évite seulement que cela se lise « Présents attendu par les tiers ».
+ */
+export function Exigence({ niveau }: { niveau: NiveauExigence }) {
+ const n = NIVEAUX[niveau];
+ if (!n) return null;
+ return (
+ <>
+ —
+
+ {n.texte}
+
+ >
+ );
+}
+
+export function Champ({
+ label,
+ htmlFor,
+ aide,
+ niveau = "facultatif",
+ className = "",
+ children,
+}: {
+ label: string;
+ htmlFor?: string;
+ aide?: string;
+ niveau?: NiveauExigence;
+ className?: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+ {label}
+
+
+ {children}
+ {aide &&
{aide}
}
+
+ );
+}
+
+export function ChampTexte({
+ label,
+ valeur,
+ onChange,
+ aide,
+ niveau,
+ lignes,
+ placeholder,
+ className,
+ type = "text",
+}: {
+ label: string;
+ valeur: string;
+ onChange: (v: string) => void;
+ aide?: string;
+ niveau?: NiveauExigence;
+ lignes?: number;
+ placeholder?: string;
+ className?: string;
+ type?: "text" | "date" | "time";
+}) {
+ const id = useId();
+ return (
+
+ {lignes ? (
+
+ );
+}
+
+export function ChampNombre({
+ label,
+ valeur,
+ onChange,
+ aide,
+ niveau,
+ min = 0,
+ className,
+}: {
+ label: string;
+ valeur: number | null;
+ onChange: (v: number | null) => void;
+ aide?: string;
+ niveau?: NiveauExigence;
+ min?: number;
+ className?: string;
+}) {
+ const id = useId();
+ return (
+
+
+ onChange(e.target.value === "" ? null : Number(e.target.value))
+ }
+ />
+
+ );
+}
+
+export function ChampChoix({
+ label,
+ valeur,
+ onChange,
+ options,
+ aide,
+ niveau,
+ className,
+}: {
+ label: string;
+ valeur: T;
+ onChange: (v: T) => void;
+ options: { valeur: T; libelle: string }[];
+ aide?: string;
+ niveau?: NiveauExigence;
+ className?: string;
+}) {
+ const id = useId();
+ return (
+
+ onChange(e.target.value as T)}
+ >
+ {options.map((o) => (
+
+ {o.libelle}
+
+ ))}
+
+
+ );
+}
+
+export function ChampBoolean({
+ label,
+ valeur,
+ onChange,
+ aide,
+}: {
+ label: string;
+ valeur: boolean;
+ onChange: (v: boolean) => void;
+ aide?: string;
+}) {
+ return (
+
+ onChange(e.target.checked)}
+ className="mt-0.5 h-4 w-4 shrink-0 rounded border-slate-300 text-brand-600 focus:ring-brand-500"
+ />
+
+ {label}
+ {aide && {aide} }
+
+
+ );
+}
+
+/**
+ * Une personne : un adhérent choisi dans la liste, ou un nom libre. Le chef
+ * d'établissement et le représentant de la fédération ne sont pas adhérents, et
+ * ce sont pourtant eux qu'on invite le plus souvent.
+ */
+export function ChampPersonne({
+ label,
+ valeur,
+ onChange,
+ adherents,
+ aide,
+ niveau,
+ placeholderQualite = "Qualité (président, secrétaire…)",
+}: {
+ label: string;
+ valeur: PvPersonne;
+ onChange: (v: PvPersonne) => void;
+ adherents: { id: string; name: string }[];
+ aide?: string;
+ niveau?: NiveauExigence;
+ placeholderQualite?: string;
+}) {
+ const id = useId();
+ return (
+
+
+ {
+ const nom = e.target.value;
+ const trouve = adherents.find((a) => a.name === nom);
+ onChange({ ...valeur, nom, memberId: trouve?.id ?? null });
+ }}
+ />
+
+ {adherents.map((a) => (
+
+ ))}
+
+ onChange({ ...valeur, qualite: e.target.value })}
+ />
+
+
+ );
+}
+
+/** Liste de personnes qu'on allonge au besoin : scrutateurs, invités… */
+export function ListePersonnes({
+ label,
+ valeurs,
+ onChange,
+ adherents,
+ aide,
+ ajouterLabel,
+}: {
+ label: string;
+ valeurs: PvPersonne[];
+ onChange: (v: PvPersonne[]) => void;
+ adherents: { id: string; name: string }[];
+ aide?: string;
+ ajouterLabel: string;
+}) {
+ return (
+
+
{label}
+ {aide &&
{aide}
}
+
+ {valeurs.map((personne, index) => (
+
+ ))}
+
+
onChange([...valeurs, { memberId: null, nom: "", qualite: "" }])}
+ className="mt-2 inline-flex min-h-10 items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm font-bold text-brand-700 transition-colors hover:bg-brand-50 focus-visible:ring-2 focus-visible:ring-brand-500"
+ >
+
+ {ajouterLabel}
+
+
+ );
+}
+
+/** Liste de lignes de texte : ordre du jour, annexes, documents joints. */
+export function ListeTexte({
+ label,
+ valeurs,
+ onChange,
+ aide,
+ ajouterLabel,
+ placeholder,
+ numerotee = false,
+ niveau,
+}: {
+ label: string;
+ valeurs: string[];
+ onChange: (v: string[]) => void;
+ aide?: string;
+ ajouterLabel: string;
+ placeholder?: string;
+ numerotee?: boolean;
+ niveau?: NiveauExigence;
+}) {
+ return (
+
+
+ {label}
+ {niveau && }
+
+ {aide &&
{aide}
}
+
+ {valeurs.map((valeur, index) => (
+
+ {numerotee && (
+
+ {index + 1}
+
+ )}
+ {
+ const suite = [...valeurs];
+ suite[index] = e.target.value;
+ onChange(suite);
+ }}
+ />
+ onChange(valeurs.filter((_, i) => i !== index))}
+ aria-label={`Retirer la ligne ${index + 1}`}
+ className="grid h-9 w-9 shrink-0 place-items-center rounded-lg text-slate-400 transition-colors hover:bg-coral-50 hover:text-coral-700 focus-visible:ring-2 focus-visible:ring-brand-500"
+ >
+
+
+
+ ))}
+
+
onChange([...valeurs, ""])}
+ className="mt-2 inline-flex min-h-10 items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm font-bold text-brand-700 transition-colors hover:bg-brand-50 focus-visible:ring-2 focus-visible:ring-brand-500"
+ >
+
+ {ajouterLabel}
+
+
+ );
+}
diff --git a/src/components/pv/pv-editeur.tsx b/src/components/pv/pv-editeur.tsx
new file mode 100644
index 0000000..97e9a66
--- /dev/null
+++ b/src/components/pv/pv-editeur.tsx
@@ -0,0 +1,1346 @@
+"use client";
+
+import {
+ ArrowLeft,
+ Check,
+ CircleAlert,
+ CircleDashed,
+ FileCheck2,
+ Loader2,
+ Lock,
+ NotebookPen,
+ Printer,
+ TriangleAlert,
+ Unlock,
+} from "lucide-react";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+
+import {
+ ChampBoolean,
+ ChampChoix,
+ ChampNombre,
+ ChampPersonne,
+ ChampTexte,
+ ListePersonnes,
+ ListeTexte,
+} from "@/components/pv/pv-champs";
+import { PvResolutions } from "@/components/pv/pv-resolutions";
+import { ConfirmDialog } from "@/components/confirm-dialog";
+import { useToast } from "@/components/toast";
+import { Badge, Button, Card, Textarea } from "@/components/ui";
+import { api } from "@/lib/client";
+import {
+ completudeSection,
+ resumeSection,
+ verdictQuorum,
+ votantsTotaux,
+} from "@/lib/documents/ag-calculs";
+import { blocages, controlerPv } from "@/lib/documents/ag-controles";
+import { ORDRE_DU_JOUR_TYPE } from "@/lib/documents/ag-modeles";
+import {
+ AG_SECTIONS,
+ type AgMinutesPayload,
+ type AgSectionCle,
+ type ReglesStatutaires,
+} from "@/lib/documents/ag-types";
+import { cn } from "@/lib/utils";
+
+/**
+ * L'éditeur de procès-verbal.
+ *
+ * Une page continue plutôt qu'un assistant en sept écrans : un secrétaire de
+ * séance relit ses notes en désordre, revient trois fois sur les présences, et
+ * un assistant l'oblige à avancer dans un ordre qu'il ne suit pas. Le sommaire
+ * de gauche donne la position et la complétude ; tout le reste est là, sous les
+ * yeux, et s'enregistre tout seul.
+ *
+ * L'enregistrement est en file d'attente à une seule requête : la frappe, le
+ * changement de section et la fermeture de l'onglet déclenchent tous une
+ * sauvegarde, et sans cette file le module s'infligerait ses propres conflits
+ * de version.
+ */
+
+const DELAI_AUTOSAVE_MS = 1200;
+
+export interface PvDocumentView {
+ id: string;
+ title: string;
+ status: "draft" | "final" | "archived";
+ documentDate: string;
+ version: number;
+ payload: AgMinutesPayload;
+}
+
+export function PvEditeur({
+ document: initial,
+ regles,
+ adherents,
+}: {
+ document: PvDocumentView;
+ regles: ReglesStatutaires;
+ adherents: { id: string; name: string }[];
+}) {
+ const router = useRouter();
+ const toast = useToast();
+ const [payload, setPayload] = useState(initial.payload);
+ const [titre, setTitre] = useState(initial.title);
+ const [statut, setStatut] = useState(initial.status);
+ const [enregistrement, setEnregistrement] = useState<"repos" | "encours" | "erreur">(
+ "repos",
+ );
+ const [sectionActive, setSectionActive] = useState(
+ (initial.payload.redaction.derniereSection as AgSectionCle) || "seance",
+ );
+ const [confirmation, setConfirmation] = useState(null);
+ const [occupe, setOccupe] = useState(false);
+
+ const version = useRef(initial.version);
+ const enVol = useRef(false);
+ const enAttente = useRef<{ payload: AgMinutesPayload; titre: string } | null>(null);
+ const minuteur = useRef | null>(null);
+
+ const verrouille = statut !== "draft";
+
+ /** Une seule requête en vol ; la suivante attend son tour et fusionne. */
+ const envoyer = useCallback(
+ async (corps: { payload: AgMinutesPayload; titre: string }) => {
+ if (enVol.current) {
+ enAttente.current = corps;
+ return;
+ }
+ enVol.current = true;
+ setEnregistrement("encours");
+ try {
+ const reponse = (await api(`/api/documents/${initial.id}`, {
+ method: "PATCH",
+ body: {
+ title: corps.titre,
+ payload: corps.payload,
+ version: version.current,
+ },
+ })) as { document?: { version?: number } };
+ if (typeof reponse?.document?.version === "number") {
+ version.current = reponse.document.version;
+ }
+ setEnregistrement("repos");
+ } catch (error) {
+ setEnregistrement("erreur");
+ toast((error as Error).message, "error");
+ } finally {
+ enVol.current = false;
+ const suivant = enAttente.current;
+ enAttente.current = null;
+ if (suivant) void envoyer(suivant);
+ }
+ },
+ [initial.id, toast],
+ );
+
+ const planifier = useCallback(
+ (suite: AgMinutesPayload, titreSuite = titre) => {
+ if (minuteur.current) clearTimeout(minuteur.current);
+ minuteur.current = setTimeout(
+ () => void envoyer({ payload: suite, titre: titreSuite }),
+ DELAI_AUTOSAVE_MS,
+ );
+ },
+ [envoyer, titre],
+ );
+
+ const majPayload = useCallback(
+ (transformation: (p: AgMinutesPayload) => AgMinutesPayload) => {
+ setPayload((precedent) => {
+ const suite = transformation(precedent);
+ planifier(suite);
+ return suite;
+ });
+ },
+ [planifier],
+ );
+
+ // Fermer l'onglet ne doit pas coûter les deux dernières minutes de saisie.
+ useEffect(() => {
+ function avantFermeture() {
+ if (minuteur.current) {
+ clearTimeout(minuteur.current);
+ void envoyer({ payload, titre });
+ }
+ }
+ window.addEventListener("pagehide", avantFermeture);
+ return () => window.removeEventListener("pagehide", avantFermeture);
+ }, [envoyer, payload, titre]);
+
+ const controles = useMemo(() => controlerPv(payload, regles), [payload, regles]);
+ const empechements = blocages(controles);
+
+ async function finaliser() {
+ setConfirmation(null);
+ setOccupe(true);
+ try {
+ if (minuteur.current) clearTimeout(minuteur.current);
+ await envoyer({ payload, titre });
+ await api(`/api/documents/${initial.id}`, {
+ method: "PATCH",
+ body: { status: "final", version: version.current },
+ });
+ setStatut("final");
+ toast("Procès-verbal finalisé.");
+ router.refresh();
+ } catch (error) {
+ toast((error as Error).message, "error");
+ } finally {
+ setOccupe(false);
+ }
+ }
+
+ async function rouvrir() {
+ setConfirmation(null);
+ setOccupe(true);
+ try {
+ const reponse = (await api(`/api/documents/${initial.id}`, {
+ method: "PATCH",
+ body: { status: "draft", version: version.current },
+ })) as { document?: { version?: number } };
+ if (typeof reponse?.document?.version === "number") {
+ version.current = reponse.document.version;
+ }
+ setStatut("draft");
+ toast("Procès-verbal rouvert en brouillon.");
+ router.refresh();
+ } catch (error) {
+ toast((error as Error).message, "error");
+ } finally {
+ setOccupe(false);
+ }
+ }
+
+ function allerA(cle: AgSectionCle) {
+ setSectionActive(cle);
+ majPayload((p) => ({ ...p, redaction: { ...p.redaction, derniereSection: cle } }));
+ window.document.getElementById(`section-${cle}`)?.scrollIntoView({
+ behavior: "smooth",
+ block: "start",
+ });
+ }
+
+ return (
+
+
+
+
+ Tous les documents
+
+
+
+
+
+ Aperçu
+
+ {verrouille ? (
+
setConfirmation("rouvrir")}
+ >
+ Rouvrir
+
+ ) : (
+
setConfirmation("finaliser")}
+ >
+ Finaliser
+
+ )}
+
+
+
+ {verrouille && (
+
+
+
+ Ce procès-verbal est finalisé : c’est la version qu’on signe et qu’on
+ diffuse. Pour le corriger, rouvrez-le en brouillon — le geste est
+ tracé dans le journal.
+
+
+ )}
+
+
+
+
+
+
+ majPayload((p) => ({ ...p, redaction: { ...p.redaction, notesBrutes } }))
+ }
+ />
+
+ {
+ setTitre(t);
+ planifier(payload, t);
+ }}
+ onChange={majPayload}
+ />
+
+
+
+
+
+
c.message).join(" ")} Corrigez-les avant de finaliser : elles se contredisent entre elles, personne ne peut les vouloir.`
+ : "Le procès-verbal passera en version finale : il ne sera plus modifiable sans être rouvert, et le filigrane « projet » disparaîtra de l’impression. Vos notes de séance ne sont jamais imprimées."
+ }
+ confirmLabel={empechements.length ? "J’ai compris" : "Finaliser"}
+ loading={occupe}
+ onConfirm={empechements.length ? () => setConfirmation(null) : finaliser}
+ onCancel={() => setConfirmation(null)}
+ />
+ setConfirmation(null)}
+ />
+
+ );
+}
+
+function EtatEnregistrement({
+ etat,
+ verrouille,
+}: {
+ etat: "repos" | "encours" | "erreur";
+ verrouille: boolean;
+}) {
+ if (verrouille) return Finalisé ;
+ if (etat === "encours") {
+ return (
+
+
+ Enregistrement…
+
+ );
+ }
+ if (etat === "erreur") {
+ return (
+
+
+ Non enregistré
+
+ );
+ }
+ return (
+
+
+ Brouillon enregistré
+
+ );
+}
+
+function Sommaire({
+ payload,
+ active,
+ onAller,
+}: {
+ payload: AgMinutesPayload;
+ active: AgSectionCle;
+ onAller: (c: AgSectionCle) => void;
+}) {
+ return (
+
+
+ {AG_SECTIONS.map((section, index) => {
+ const etat = completudeSection(payload, section.cle);
+ const resume = resumeSection(payload, section.cle);
+ const courante = active === section.cle;
+ return (
+
+ onAller(section.cle)}
+ className={cn(
+ "grid w-full min-h-11 grid-cols-[1.25rem_minmax(0,1fr)] items-start gap-2.5 rounded-xl px-3 py-2.5 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500",
+ courante ? "bg-brand-50 text-brand-900" : "hover:bg-slate-50",
+ )}
+ >
+
+ {etat === "complete" ? (
+
+ ) : etat === "entamee" ? (
+
+ ) : (
+
+ )}
+
+
+
+ {index + 1}
+
+ {index + 1}. {section.titre}
+
+ — {section.titre}
+
+
+ {etat === "complete"
+ ? " — complète"
+ : etat === "entamee"
+ ? " — incomplète"
+ : " — vierge"}
+
+ {resume && (
+
+ {resume}
+
+ )}
+
+
+
+ );
+ })}
+
+
+ );
+}
+
+function BlocNotes({
+ valeur,
+ onChange,
+}: {
+ valeur: string;
+ onChange: (v: string) => void;
+}) {
+ return (
+ 0} className="group">
+
+
+ Mes notes de séance
+
+ {valeur.trim() ? "remplies" : "vides"}
+
+
+
+
+ Collez ici ce que vous avez noté sur votre carnet ou votre téléphone.
+ Ce texte ne sera jamais imprimé : il n’est là que pour vous aider à
+ rédiger.
+
+
+
+ );
+}
+
+function Controles({
+ controles,
+ onAller,
+}: {
+ controles: ReturnType;
+ onAller: (c: AgSectionCle) => void;
+}) {
+ if (controles.length === 0) {
+ return (
+
+
+
+ Rien à signaler : les mentions attendues sont présentes et les
+ décomptes sont cohérents.
+
+
+ );
+ }
+ return (
+
+
+
+ Ce qu’il reste à vérifier ({controles.length})
+
+
+ Des remarques, pas des refus : seules les contradictions internes
+ empêchent de finaliser.
+
+
+
+ {controles.map((controle) => (
+
+ {controle.severite === "blocage" ? (
+
+ ) : (
+
+ )}
+
+ {controle.message}
+
+ onAller(controle.section as AgSectionCle)}
+ className="shrink-0 rounded-lg px-2 py-1 text-xs font-bold text-brand-700 transition-colors hover:bg-brand-50 focus-visible:ring-2 focus-visible:ring-brand-500"
+ >
+ Corriger
+
+
+ ))}
+
+
+ );
+}
+
+function Bloc({
+ cle,
+ index,
+ titre,
+ children,
+}: {
+ cle: AgSectionCle;
+ index: number;
+ titre: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+
+ {index}. {titre}
+
+
+ {children}
+
+ );
+}
+
+function Sections({
+ payload,
+ titre,
+ regles,
+ adherents,
+ onTitre,
+ onChange,
+}: {
+ payload: AgMinutesPayload;
+ titre: string;
+ regles: ReglesStatutaires;
+ adherents: { id: string; name: string }[];
+ onTitre: (t: string) => void;
+ onChange: (f: (p: AgMinutesPayload) => AgMinutesPayload) => void;
+}) {
+ const quorum = verdictQuorum(payload);
+ const votants = votantsTotaux(payload);
+
+ return (
+ <>
+
+
+
+ onChange((p) => ({ ...p, natureAssemblee }))}
+ options={[
+ { valeur: "AGO", libelle: "Ordinaire" },
+ { valeur: "AGE", libelle: "Extraordinaire" },
+ { valeur: "mixte", libelle: "Ordinaire et extraordinaire" },
+ { valeur: "constitutive", libelle: "Constitutive" },
+ ]}
+ aide="Modifier les statuts, transférer le siège ou dissoudre relève en principe d’une assemblée extraordinaire."
+ />
+
+ onChange((p) => ({ ...p, seance: { ...p.seance, date: date || null } }))
+ }
+ niveau="tiers"
+ />
+
+ onChange((p) => ({ ...p, seance: { ...p.seance, heureOuverture } }))
+ }
+ />
+
+ onChange((p) => ({ ...p, seance: { ...p.seance, heureCloture } }))
+ }
+ />
+
+ onChange((p) => ({ ...p, seance: { ...p.seance, lieu } }))}
+ placeholder="Salle polyvalente de l’école, 12 rue de l’École"
+ niveau="tiers"
+ />
+
+ onChange((p) => ({ ...p, entete: { ...p.entete, affiliationApel } }))
+ }
+ placeholder="APEL du Finistère"
+ />
+
+
+ Participation à distance, seconde convocation, statuts applicables
+
+
+
+ onChange((p) => ({
+ ...p,
+ seance: { ...p.seance, distanciel: { ...p.seance.distanciel, actif } },
+ }))
+ }
+ aide="Vérifiez que vos statuts la prévoient : à défaut, une délibération prise à distance peut être discutée."
+ />
+ {payload.seance.distanciel.actif && (
+
+ onChange((p) => ({
+ ...p,
+ seance: { ...p.seance, distanciel: { ...p.seance.distanciel, outil } },
+ }))
+ }
+ />
+ )}
+
+ onChange((p) => ({
+ ...p,
+ seance: {
+ ...p.seance,
+ secondeConvocation: { ...p.seance.secondeConvocation, actif },
+ },
+ }))
+ }
+ />
+ {payload.seance.secondeConvocation.actif && (
+
+ onChange((p) => ({
+ ...p,
+ seance: {
+ ...p.seance,
+ secondeConvocation: {
+ ...p.seance.secondeConvocation,
+ premiereSeanceDate: d || null,
+ },
+ },
+ }))
+ }
+ />
+ )}
+
+ onChange((p) => ({
+ ...p,
+ entete: { ...p.entete, statutsVersionDate: d || null },
+ }))
+ }
+ />
+
+
+
+
+
+
+
+ onChange((p) => ({ ...p, convocation: { ...p.convocation, auteur } }))
+ }
+ placeholder={regles.auteurConvocation || "Le président"}
+ niveau="statutaire"
+ />
+
+ onChange((p) => ({
+ ...p,
+ convocation: { ...p.convocation, dateEnvoi: d || null },
+ }))
+ }
+ aide={
+ regles.delaiConvocationJours
+ ? `Vos statuts prévoient un délai de ${regles.delaiConvocationJours} jours.`
+ : "Le délai figure dans vos statuts."
+ }
+ niveau="statutaire"
+ />
+
+ onChange((p) => ({ ...p, convocation: { ...p.convocation, mode } }))
+ }
+ placeholder="courrier électronique et affichage à l’école"
+ />
+
+ onChange((p) => ({
+ ...p,
+ convocation: { ...p.convocation, nombreDestinataires },
+ }))
+ }
+ />
+
+
+
+ o.intitule)}
+ onChange={(intitules) =>
+ onChange((p) => ({
+ ...p,
+ ordreDuJour: intitules.map((intitule, i) => ({
+ id: p.ordreDuJour[i]?.id ?? `odj-${i}-${intitule.length}`,
+ intitule,
+ })),
+ }))
+ }
+ ajouterLabel="Ajouter un point"
+ placeholder="Approbation des comptes de l’exercice clos"
+ numerotee
+ niveau="tiers"
+ aide="Une décision prise hors de l’ordre du jour est le premier reproche fait à une délibération contestée."
+ />
+ {payload.ordreDuJour.length === 0 && (
+
+ onChange((p) => ({
+ ...p,
+ ordreDuJour: ORDRE_DU_JOUR_TYPE.map((intitule, i) => ({
+ id: `odj-type-${i}`,
+ intitule,
+ })),
+ }))
+ }
+ >
+ Reprendre l’ordre du jour type d’une AG ordinaire
+
+ )}
+
+
+
+ onChange((p) => ({ ...p, convocation: { ...p.convocation, incidents } }))
+ }
+ lignes={2}
+ aide="Retard, adresse erronée, réclamation d’un adhérent : le dire ici vaut mieux que de le taire."
+ />
+
+
+
+
+ onChange((p) => ({ ...p, bureauSeance: { ...p.bureauSeance, president } }))
+ }
+ adherents={adherents}
+ niveau="tiers"
+ aide="C’est lui qui signe le procès-verbal."
+ />
+
+ onChange((p) => ({ ...p, bureauSeance: { ...p.bureauSeance, secretaire } }))
+ }
+ adherents={adherents}
+ niveau="tiers"
+ />
+
+ onChange((p) => ({ ...p, bureauSeance: { ...p.bureauSeance, scrutateurs } }))
+ }
+ adherents={adherents}
+ ajouterLabel="Ajouter un scrutateur"
+ aide="Usage emprunté au droit des sociétés : utile pour un scrutin disputé, jamais obligatoire ici."
+ />
+
+ onChange((p) => ({ ...p, bureauSeance: { ...p.bureauSeance, invites } }))
+ }
+ adherents={adherents}
+ ajouterLabel="Ajouter un invité"
+ aide="Chef d’établissement, représentant de la fédération… Le procès-verbal précisera qu’ils ne comptent ni dans le quorum, ni dans les votes."
+ />
+
+
+
+
+
+ onChange((p) => ({ ...p, presences: { ...p.presences, regleVoix } }))
+ }
+ options={[
+ { valeur: "non_precise", libelle: "Non précisée" },
+ { valeur: "famille", libelle: "Une voix par famille adhérente" },
+ { valeur: "personne", libelle: "Une voix par adhérent" },
+ ]}
+ niveau="statutaire"
+ />
+
+ onChange((p) => ({
+ ...p,
+ presences: { ...p.presences, dateReference: d || null },
+ }))
+ }
+ />
+
+ onChange((p) => ({ ...p, presences: { ...p.presences, effectifVotants } }))
+ }
+ />
+
+ onChange((p) => ({ ...p, presences: { ...p.presences, pouvoirsEcartes } }))
+ }
+ />
+
+ onChange((p) => ({ ...p, presences: { ...p.presences, presents } }))
+ }
+ niveau="tiers"
+ />
+
+ onChange((p) => ({ ...p, presences: { ...p.presences, representes } }))
+ }
+ />
+
+
+
+ onChange((p) => ({
+ ...p,
+ presences: { ...p.presences, quorum: { ...p.presences.quorum, type } },
+ }))
+ }
+ options={[
+ { valeur: "inconnu", libelle: "Je ne sais pas — à lire dans nos statuts" },
+ { valeur: "aucun", libelle: "Nos statuts ne prévoient aucun quorum" },
+ { valeur: "fraction", libelle: "Une fraction des membres" },
+ { valeur: "nombre", libelle: "Un nombre de membres" },
+ ]}
+ niveau="statutaire"
+ aide="Sans cette règle, le procès-verbal n’affirmera rien sur le quorum — ce qui vaut mieux que d’affirmer au hasard."
+ />
+ {(payload.presences.quorum.type === "fraction" ||
+ payload.presences.quorum.type === "nombre") && (
+
+ onChange((p) => ({
+ ...p,
+ presences: { ...p.presences, quorum: { ...p.presences.quorum, valeur } },
+ }))
+ }
+ />
+ )}
+
+
+ {quorum.phrase ||
+ (votants === null
+ ? "Saisissez les présents et les représentés pour obtenir le total des voix."
+ : `${votants} voix réunies. Le quorum ne sera pas mentionné, faute de règle connue.`)}
+
+
+
+ onChange((p) => ({
+ ...p,
+ presences: { ...p.presences, feuilleEmargementAnnexee },
+ }))
+ }
+ aide="L’annexer plutôt que d’y recopier les noms garde le procès-verbal diffusable."
+ />
+
+ onChange((p) => ({ ...p, presences: { ...p.presences, incidentsSeance } }))
+ }
+ lignes={2}
+ aide="Arrivée en cours de séance, départ avant un vote, contestation : ce qui change un décompte se note."
+ />
+
+
+
+
+
+ onChange((p) => ({
+ ...p,
+ rapports: {
+ ...p.rapports,
+ exercice: { ...p.rapports.exercice, debut: d || null },
+ },
+ }))
+ }
+ />
+
+ onChange((p) => ({
+ ...p,
+ rapports: {
+ ...p.rapports,
+ exercice: { ...p.rapports.exercice, fin: d || null },
+ },
+ }))
+ }
+ aide={regles.clotureExercice ? `Vos statuts : ${regles.clotureExercice}.` : undefined}
+ />
+
+
+ onChange((p) => ({ ...p, rapports: { ...p.rapports, moral } }))
+ }
+ lignes={6}
+ aide="Ce qui a été fait cette année. Quelques lignes suffisent : c’est la trace, pas un discours."
+ />
+
+ onChange((p) => ({
+ ...p,
+ rapports: { ...p.rapports, financier: { ...p.rapports.financier, texte } },
+ }))
+ }
+ lignes={4}
+ />
+
+
+ onChange((p) => ({
+ ...p,
+ rapports: {
+ ...p.rapports,
+ financier: { ...p.rapports.financier, produitsCents },
+ },
+ }))
+ }
+ />
+
+ onChange((p) => ({
+ ...p,
+ rapports: {
+ ...p.rapports,
+ financier: { ...p.rapports.financier, chargesCents },
+ },
+ }))
+ }
+ />
+
+ onChange((p) => ({
+ ...p,
+ rapports: {
+ ...p.rapports,
+ financier: { ...p.rapports.financier, resultatCents },
+ },
+ }))
+ }
+ />
+
+ onChange((p) => ({
+ ...p,
+ rapports: {
+ ...p.rapports,
+ financier: { ...p.rapports.financier, tresorerieCents },
+ },
+ }))
+ }
+ />
+
+
+ onChange((p) => ({ ...p, rapports: { ...p.rapports, verificateur } }))
+ }
+ lignes={2}
+ aide="Si vos statuts en prévoient un, son avis se mentionne ici."
+ />
+
+
+
+ onChange((p) => ({ ...p, resolutions }))}
+ />
+
+
+
+
+ onChange((p) => ({ ...p, instances: { ...p.instances, bureauEluPar } }))
+ }
+ options={[
+ { valeur: "non_precise", libelle: "Non précisé" },
+ { valeur: "AG", libelle: "L’assemblée générale" },
+ { valeur: "CA", libelle: "Le conseil d’administration" },
+ ]}
+ niveau="statutaire"
+ />
+ ({
+ memberId: m.memberId,
+ nom: m.nom,
+ qualite: m.fonction,
+ }))}
+ onChange={(personnes) =>
+ onChange((p) => ({
+ ...p,
+ instances: {
+ ...p.instances,
+ compositionApres: personnes.map((x) => ({
+ nom: x.nom,
+ fonction: x.qualite,
+ memberId: x.memberId,
+ })),
+ },
+ }))
+ }
+ adherents={adherents}
+ ajouterLabel="Ajouter un membre élu"
+ aide="C’est cette liste que la préfecture attend pour enregistrer le changement de dirigeants."
+ />
+
+ onChange((p) => ({ ...p, instances: { ...p.instances, dureeEtEffetMandats } }))
+ }
+ lignes={2}
+ placeholder={
+ regles.dureeMandatAnnees
+ ? `Mandats de ${regles.dureeMandatAnnees} an(s), à effet immédiat.`
+ : "Mandats à effet immédiat."
+ }
+ niveau="statutaire"
+ />
+
+ onChange((p) => ({ ...p, instances: { ...p.instances, siegesVacants } }))
+ }
+ lignes={2}
+ />
+
+
+ Projets, manifestations et vie de l’association
+
+
+
+ onChange((p) => ({ ...p, vieApel: { ...p.vieApel, engagements } }))
+ }
+ lignes={3}
+ />
+
+ onChange((p) => ({ ...p, vieApel: { ...p.vieApel, manifestations } }))
+ }
+ lignes={3}
+ />
+
+ onChange((p) => ({ ...p, vieApel: { ...p.vieApel, representants } }))
+ }
+ lignes={2}
+ />
+
+
+
+
+
+
+ onChange((p) => ({ ...p, cloture: { ...p.cloture, questionsDiverses } }))
+ }
+ lignes={4}
+ />
+
+
+ onChange((p) => ({
+ ...p,
+ cloture: { ...p.cloture, dateRedaction: d || null },
+ }))
+ }
+ niveau="tiers"
+ />
+
+ onChange((p) => ({ ...p, cloture: { ...p.cloture, prochaineAG: d || null } }))
+ }
+ />
+
+
+ onChange((p) => ({ ...p, cloture: { ...p.cloture, signataires } }))
+ }
+ adherents={adherents}
+ ajouterLabel="Ajouter un signataire"
+ aide="Le président et le secrétaire de séance, en pratique. L’impression réserve une case pour chacun."
+ />
+
+ onChange((p) => ({ ...p, cloture: { ...p.cloture, mentionCertifieConforme } }))
+ }
+ aide="Les banques la réclament souvent ; elle n’ajoute aucune valeur juridique au document."
+ />
+ onChange((p) => ({ ...p, cloture: { ...p.cloture, annexes } }))}
+ ajouterLabel="Ajouter une annexe"
+ placeholder="Feuille d’émargement, comptes de l’exercice…"
+ />
+
+
+
+
+ onChange((p) => ({
+ ...p,
+ formalites: { ...p.formalites, echeanceDeclaration: d || null },
+ }))
+ }
+ niveau="legal"
+ aide="Changement de dirigeants ou de statuts : trois mois, article 5 de la loi du 1ᵉʳ juillet 1901. C’est la seule obligation légale née de cette séance."
+ />
+
+ onChange((p) => ({ ...p, formalites: { ...p.formalites, pouvoirsBancaires } }))
+ }
+ lignes={2}
+ />
+
+ onChange((p) => ({
+ ...p,
+ formalites: { ...p.formalites, mandataireFormalites },
+ }))
+ }
+ lignes={2}
+ />
+
+ onChange((p) => ({
+ ...p,
+ formalites: { ...p.formalites, transmissionFederation },
+ }))
+ }
+ lignes={2}
+ />
+
+ onChange((p) => ({
+ ...p,
+ diffusion: { ...p.diffusion, controleDonneesSensibles },
+ }))
+ }
+ aide="Santé, situation familiale, difficultés d’un élève : rien de tout cela n’a sa place dans un document qui circule."
+ />
+
+ onChange((p) => ({ ...p, diffusion: { ...p.diffusion, perimetre } }))
+ }
+ placeholder="Adhérents à jour de cotisation, chef d’établissement, fédération"
+ />
+
+ >
+ );
+}
+
+/** Saisie en euros, stockée en centimes : pas d'arrondi surprise. */
+function ChampMontant({
+ label,
+ valeur,
+ onChange,
+}: {
+ label: string;
+ valeur: number | null;
+ onChange: (v: number | null) => void;
+}) {
+ return (
+ onChange(euros === null ? null : Math.round(euros * 100))}
+ min={-1_000_000}
+ />
+ );
+}
diff --git a/src/components/pv/pv-resolutions.tsx b/src/components/pv/pv-resolutions.tsx
new file mode 100644
index 0000000..0edbbca
--- /dev/null
+++ b/src/components/pv/pv-resolutions.tsx
@@ -0,0 +1,548 @@
+"use client";
+
+import { ChevronDown, Plus, TriangleAlert, X } from "lucide-react";
+import { useState } from "react";
+
+import { ChampChoix, ChampNombre, ChampTexte, ListeTexte } from "@/components/pv/pv-champs";
+import { Badge, Button, Input, Textarea } from "@/components/ui";
+import { resumeResolution, verdictVote } from "@/lib/documents/ag-calculs";
+import { MODELES_RESOLUTIONS } from "@/lib/documents/ag-modeles";
+import type {
+ AgMinutesPayload,
+ PvResolution,
+ RegleMajorite,
+} from "@/lib/documents/ag-types";
+import { voteVide } from "@/lib/documents/ag-types";
+import { cn } from "@/lib/utils";
+
+/**
+ * Les résolutions et leurs votes : le cœur du procès-verbal, et la seule
+ * section que personne ne peut escamoter. C'est le texte soumis au vote qui
+ * sera exécuté, pas son intitulé — d'où la place donnée au texte, et le
+ * décompte des voix montré en toutes lettres plutôt qu'en barre décorative.
+ */
+
+function identifiant(prefixe: string, index: number): string {
+ return `${prefixe}-${index}-${String(index * 7919 + prefixe.length).slice(-5)}`;
+}
+
+const MAJORITES: { valeur: RegleMajorite; libelle: string }[] = [
+ { valeur: "non_precise", libelle: "Règle de la séance" },
+ { valeur: "simple", libelle: "Majorité des suffrages exprimés" },
+ { valeur: "absolue", libelle: "Majorité absolue" },
+ { valeur: "deux_tiers", libelle: "Majorité des deux tiers" },
+ { valeur: "unanimite", libelle: "Unanimité" },
+];
+
+export function PvResolutions({
+ payload,
+ onChange,
+}: {
+ payload: AgMinutesPayload;
+ onChange: (suite: PvResolution[]) => void;
+}) {
+ const [ouverte, setOuverte] = useState(null);
+
+ function ajouter(modele: (typeof MODELES_RESOLUTIONS)[number]) {
+ const id = identifiant("res", payload.resolutions.length + 1);
+ const resolution: PvResolution = {
+ id,
+ nature: modele.nature,
+ intitule: modele.intitule,
+ texte: modele.texte,
+ vote: { ...voteVide(), regleMajorite: modele.majorite ?? "non_precise" },
+ mentionsNominatives: [],
+ conflitsInterets: [],
+ candidats: [],
+ textesStatuts: [],
+ };
+ onChange([...payload.resolutions, resolution]);
+ setOuverte(id);
+ }
+
+ function modifier(id: string, transformation: (r: PvResolution) => PvResolution) {
+ onChange(payload.resolutions.map((r) => (r.id === id ? transformation(r) : r)));
+ }
+
+ const extraordinaire =
+ payload.natureAssemblee === "AGE" || payload.natureAssemblee === "mixte";
+
+ return (
+
+
+
+
+ Textes types, à relire et à adapter à vos statuts. Les passages entre
+ crochets restent à compléter.
+
+
+
+
+
+ Ajouter une résolution
+
+
+ {MODELES_RESOLUTIONS.filter(
+ (m) => !m.extraordinaire || extraordinaire,
+ ).map((modele) => (
+
ajouter(modele)}
+ className="inline-flex min-h-10 items-center gap-1.5 rounded-xl border-2 border-slate-200 bg-white px-3 py-1.5 text-sm font-bold text-slate-700 transition-colors hover:border-brand-300 hover:bg-brand-50 hover:text-brand-800 focus-visible:ring-2 focus-visible:ring-brand-500"
+ >
+
+ {modele.libelle}
+
+ ))}
+
+
+
+ {payload.resolutions.length === 0 ? (
+
+ Aucune résolution pour l’instant. Choisissez un modèle ci-dessus : le
+ texte s’écrit tout seul, il ne reste qu’à compléter les crochets et à
+ saisir les voix.
+
+ ) : (
+
+ {payload.resolutions.map((resolution, index) => {
+ const estOuverte = ouverte === resolution.id;
+ const v = verdictVote(resolution.vote, payload.reglesVote);
+ return (
+
+
+ setOuverte(estOuverte ? null : resolution.id)}
+ aria-expanded={estOuverte}
+ className="flex min-w-0 flex-1 items-start gap-3 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500"
+ >
+
+ {index + 1}
+
+
+
+ {resolution.intitule || "Résolution sans intitulé"}
+
+
+ {resumeResolution(resolution, payload.reglesVote)}
+
+
+
+
+
+ onChange(payload.resolutions.filter((r) => r.id !== resolution.id))
+ }
+ aria-label={`Supprimer la résolution n° ${index + 1}`}
+ className="grid h-9 w-9 shrink-0 place-items-center rounded-lg text-slate-400 transition-colors hover:bg-coral-50 hover:text-coral-700 focus-visible:ring-2 focus-visible:ring-brand-500"
+ >
+
+
+
+
+ {estOuverte && (
+
+
+ modifier(resolution.id, (r) => ({ ...r, intitule }))
+ }
+ />
+
+
+ Texte soumis au vote
+
+
+
+
+ modifier(resolution.id, (r) => ({
+ ...r,
+ vote: { ...r.vote, nonSoumiseAuVote },
+ }))
+ }
+ />
+
+ {!resolution.vote.nonSoumiseAuVote && (
+ <>
+
+
+ modifier(resolution.id, (r) => ({
+ ...r,
+ vote: { ...r.vote, modeScrutin },
+ }))
+ }
+ options={[
+ { valeur: "main_levee", libelle: "À main levée" },
+ { valeur: "bulletin_secret", libelle: "À bulletin secret" },
+ { valeur: "electronique", libelle: "Vote électronique" },
+ { valeur: "non_precise", libelle: "Non précisé" },
+ ]}
+ />
+
+ modifier(resolution.id, (r) => ({
+ ...r,
+ vote: { ...r.vote, regleMajorite },
+ }))
+ }
+ options={MAJORITES}
+ niveau="statutaire"
+ />
+
+
+
+
+ modifier(resolution.id, (r) => ({ ...r, vote: { ...r.vote, pour } }))
+ }
+ />
+
+ modifier(resolution.id, (r) => ({ ...r, vote: { ...r.vote, contre } }))
+ }
+ />
+
+ modifier(resolution.id, (r) => ({
+ ...r,
+ vote: { ...r.vote, abstentions },
+ }))
+ }
+ />
+
+ modifier(resolution.id, (r) => ({
+ ...r,
+ vote: { ...r.vote, votants },
+ }))
+ }
+ aide="Laissez vide pour reprendre le total de la séance."
+ />
+
+
+ {/* Le verdict est annoncé, pas seulement dessiné : une
+ barre colorée ne dit rien à qui écoute la page. */}
+
+ {v.incoherence ?? (v.phrase || "Saisissez les voix pour obtenir le résultat.")}
+
+ >
+ )}
+
+ {resolution.nature === "election" && (
+
+ modifier(resolution.id, (r) => ({ ...r, candidats }))
+ }
+ />
+ )}
+
+ {resolution.nature === "modification_statuts" && (
+
+ modifier(resolution.id, (r) => ({ ...r, textesStatuts }))
+ }
+ />
+ )}
+
+
+ modifier(resolution.id, (r) => ({ ...r, conflitsInterets }))
+ }
+ ajouterLabel="Ajouter une personne"
+ placeholder="Nom de la personne concernée"
+ aide="Conflit d’intérêts : le noter protège la délibération."
+ />
+
+ )}
+
+ );
+ })}
+
+ )}
+
+ );
+}
+
+function ChampBooleanLigne({
+ label,
+ valeur,
+ onChange,
+}: {
+ label: string;
+ valeur: boolean;
+ onChange: (v: boolean) => void;
+}) {
+ return (
+
+ onChange(e.target.checked)}
+ className="h-4 w-4 rounded border-slate-300 text-brand-600 focus:ring-brand-500"
+ />
+ {label}
+
+ );
+}
+
+function Candidats({
+ resolution,
+ onChange,
+}: {
+ resolution: PvResolution;
+ onChange: (c: PvResolution["candidats"]) => void;
+}) {
+ return (
+
+
Candidats et voix
+
+ L’acceptation des fonctions est ce que vérifie la préfecture : cochez-la
+ pour chaque élu présent.
+
+
+ {resolution.candidats.map((candidat, index) => (
+
+ ))}
+
+
+ onChange([
+ ...resolution.candidats,
+ {
+ id: identifiant("cand", resolution.candidats.length + 1),
+ memberId: null,
+ nom: "",
+ fonction: "",
+ voix: null,
+ accepte: true,
+ },
+ ])
+ }
+ >
+ Ajouter un candidat
+
+
+ );
+}
+
+function ArticlesStatuts({
+ resolution,
+ onChange,
+}: {
+ resolution: PvResolution;
+ onChange: (t: PvResolution["textesStatuts"]) => void;
+}) {
+ return (
+
+
+ Articles modifiés
+
+
+ L’ancienne et la nouvelle rédaction, côte à côte : c’est ce que réclame
+ la préfecture pour enregistrer la modification.
+
+
+ {resolution.textesStatuts.map((article, index) => (
+
+
+ {
+ const suite = [...resolution.textesStatuts];
+ suite[index] = { ...article, article: e.target.value };
+ onChange(suite);
+ }}
+ />
+ onChange(resolution.textesStatuts.filter((_, i) => i !== index))}
+ aria-label={`Retirer l’article ${index + 1}`}
+ className="grid h-9 w-9 shrink-0 place-items-center rounded-lg text-slate-400 hover:bg-coral-50 hover:text-coral-700 focus-visible:ring-2 focus-visible:ring-brand-500"
+ >
+
+
+
+
+
+
+ ))}
+
+
+ onChange([...resolution.textesStatuts, { article: "", ancienne: "", nouvelle: "" }])
+ }
+ >
+ Ajouter un article
+
+
+ );
+}
+
+export function BadgeNature({ nature }: { nature: AgMinutesPayload["natureAssemblee"] }) {
+ const libelles = {
+ AGO: "Assemblée ordinaire",
+ AGE: "Assemblée extraordinaire",
+ mixte: "Ordinaire et extraordinaire",
+ constitutive: "Assemblée constitutive",
+ };
+ return {libelles[nature]} ;
+}
diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts
index 3a5cd7a..a538baf 100644
--- a/src/lib/db/schema.ts
+++ b/src/lib/db/schema.ts
@@ -1,4 +1,9 @@
import { relations, sql } from "drizzle-orm";
+
+import type {
+ AgMinutesPayload,
+ ReglesStatutaires,
+} from "@/lib/documents/ag-types";
import {
boolean,
check,
@@ -597,6 +602,21 @@ export const associationDocuments = pgTable(
title: text("title").notNull(),
documentDate: timestamp("document_date", { withTimezone: true }).notNull(),
content: text("content").notNull().default(""),
+ /**
+ * Procès-verbal d'assemblée générale rédigé section par section. Nul pour
+ * tous les autres documents, et pour les PV saisis en texte libre avant
+ * l'éditeur guidé — qui continuent de fonctionner tels quels.
+ */
+ payload: jsonb("payload").$type(),
+ /**
+ * Qui fait foi de `content` : la saisie directe, ou la composition à partir
+ * du payload. Sans ce témoin, un `update_association_document` passé par le
+ * serveur MCP écrivait `content`, et l'enregistrement suivant de l'éditeur
+ * l'écrasait en silence.
+ */
+ contentSource: text("content_source").notNull().default("manual"),
+ /** Finalisé n'est pas signé : le classeur mentirait sans cette date. */
+ signedAt: timestamp("signed_at", { withTimezone: true }),
memberId: uuid("member_id").references(() => associationMembers.id, {
onDelete: "set null",
}),
@@ -617,6 +637,10 @@ export const associationDocuments = pgTable(
"association_documents_type_status_date_idx",
).on(t.type, t.status, t.documentDate),
memberIdx: index("association_documents_member_idx").on(t.memberId),
+ contentSourceCheck: check(
+ "association_documents_content_source_check",
+ sql`${t.contentSource} in ('manual','payload')`,
+ ),
}),
);
@@ -663,6 +687,15 @@ export const associationSettings = pgTable(
* — les documents le signalent plutôt que d'inventer.
*/
headquarters: text("headquarters").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,
+ * l'application s'abstient de tout verdict plutôt que d'en inventer un.
+ */
+ statutoryRules: jsonb("statutory_rules")
+ .$type()
+ .notNull()
+ .default(sql`'{}'::jsonb`),
/**
* Logo affiché sur le site et dans l'espace de travail. Chemin d'un fichier
* du scope `branding` ; vide, l'application utilise le logo neutre livré
diff --git a/src/lib/documents/ag-calculs.ts b/src/lib/documents/ag-calculs.ts
new file mode 100644
index 0000000..8a4a3c7
--- /dev/null
+++ b/src/lib/documents/ag-calculs.ts
@@ -0,0 +1,348 @@
+import type {
+ AgMinutesPayload,
+ AgSectionCle,
+ PvResolution,
+ PvVote,
+ RegleQuorum,
+} from "./ag-types";
+
+/**
+ * Ce que l'application peut calculer seule à partir du procès-verbal, et ce
+ * qu'elle refuse de calculer faute de règle connue.
+ *
+ * La règle de conduite est unique : quand une donnée manque, on rend `null` et
+ * l'écran se tait. Un quorum « atteint » affirmé sans connaître la règle des
+ * statuts serait pire que pas de verdict du tout — c'est exactement ce qu'un
+ * adhérent mécontent viendrait contester.
+ */
+
+export interface VerdictQuorum {
+ /** Nombre de voix exigé, quand la règle et l'effectif sont connus. */
+ exige: number | null;
+ votants: number | null;
+ atteint: boolean | null;
+ /** Phrase destinée au procès-verbal imprimé, vide s'il n'y a rien à dire. */
+ phrase: string;
+}
+
+/** Nombre de voix exigé par une règle de quorum, pour un effectif donné. */
+export function quorumRequis(
+ quorum: RegleQuorum,
+ effectif: number | null,
+): number | null {
+ if (quorum.type === "aucun") return 0;
+ if (quorum.type === "nombre") return quorum.valeur ?? null;
+ if (quorum.type === "fraction") {
+ if (!quorum.valeur || quorum.valeur <= 0) return null;
+ if (effectif === null || effectif < 0) return null;
+ // Le quart de 214 fait 53,5 : il faut 54 voix, jamais 53.
+ return Math.ceil(effectif / quorum.valeur);
+ }
+ return null;
+}
+
+/**
+ * Forme introduite par « à » : la phrase du verdict s'écrit « correspond à la
+ * moitié », « correspond à un quart ». Les formes en « le » donneraient
+ * « de le cinquième », et ce procès-verbal-là part à la préfecture.
+ */
+const FRACTIONS: Record = {
+ 2: "la moitié",
+ 3: "un tiers",
+ 4: "un quart",
+ 5: "un cinquième",
+ 10: "un dixième",
+};
+
+export function libelleQuorum(quorum: RegleQuorum): string {
+ if (quorum.type === "aucun") return "aucun quorum exigé par les statuts";
+ if (quorum.type === "nombre" && quorum.valeur) {
+ return `${quorum.valeur} voix`;
+ }
+ if (quorum.type === "fraction" && quorum.valeur) {
+ return FRACTIONS[quorum.valeur] ?? `un ${quorum.valeur}ᵉ`;
+ }
+ return "règle inconnue";
+}
+
+export function votantsTotaux(payload: AgMinutesPayload): number | null {
+ const { presents, representes } = payload.presences;
+ if (presents === null && representes === null) return null;
+ return (presents ?? 0) + (representes ?? 0);
+}
+
+export function verdictQuorum(payload: AgMinutesPayload): VerdictQuorum {
+ const votants = votantsTotaux(payload);
+ const { quorum, effectifVotants } = payload.presences;
+
+ if (quorum.type === "inconnu") {
+ return { exige: null, votants, atteint: null, phrase: "" };
+ }
+ if (quorum.type === "aucun") {
+ return {
+ exige: 0,
+ votants,
+ atteint: true,
+ phrase:
+ "Les statuts ne fixent pas de quorum ; l’assemblée a délibéré sans condition de quorum.",
+ };
+ }
+
+ const exige = quorumRequis(quorum, effectifVotants);
+ if (exige === null || votants === null) {
+ return { exige, votants, atteint: null, phrase: "" };
+ }
+
+ const atteint = votants >= exige;
+ const base =
+ quorum.type === "fraction" && effectifVotants !== null
+ ? `Le quorum statutaire correspond à ${libelleQuorum(quorum)} des ${effectifVotants} membres à jour, soit ${exige} voix.`
+ : `Le quorum statutaire est de ${exige} voix.`;
+ return {
+ exige,
+ votants,
+ atteint,
+ phrase: `${base} ${votants} voix étant réunies, le quorum ${
+ atteint ? "est atteint : l’assemblée peut délibérer" : "n’est pas atteint"
+ }.`,
+ };
+}
+
+export interface VerdictVote {
+ exprimes: number | null;
+ base: number | null;
+ requis: number | null;
+ adoptee: boolean | null;
+ phrase: string;
+ /** Incohérence arithmétique franche : le seul motif de blocage assumé. */
+ incoherence: string | null;
+}
+
+function seuil(regle: PvVote["regleMajorite"], base: number): number | null {
+ if (regle === "simple" || regle === "absolue") return Math.floor(base / 2) + 1;
+ if (regle === "deux_tiers") return Math.ceil((base * 2) / 3);
+ if (regle === "unanimite") return base;
+ return null;
+}
+
+const MAJORITES: Record = {
+ simple: "à la majorité des suffrages exprimés",
+ absolue: "à la majorité absolue",
+ deux_tiers: "à la majorité des deux tiers",
+ unanimite: "à l’unanimité",
+};
+
+export function verdictVote(
+ vote: PvVote,
+ reglesSeance: AgMinutesPayload["reglesVote"],
+): VerdictVote {
+ if (vote.nonSoumiseAuVote) {
+ return {
+ exprimes: null,
+ base: null,
+ requis: null,
+ adoptee: null,
+ phrase: "Point exposé, non soumis au vote.",
+ incoherence: null,
+ };
+ }
+
+ const pour = vote.pour ?? 0;
+ const contre = vote.contre ?? 0;
+ const abstentions = vote.abstentions ?? 0;
+ const blancsNuls = vote.blancsNuls ?? 0;
+ const retraits = vote.nePrennentPasPart ?? 0;
+ const rien =
+ vote.pour === null && vote.contre === null && vote.abstentions === null;
+ if (rien) {
+ return {
+ exprimes: null,
+ base: null,
+ requis: null,
+ adoptee: null,
+ phrase: "",
+ incoherence: null,
+ };
+ }
+
+ // Abstentions et blancs ne sont pas des suffrages exprimés : c'est la règle
+ // que retiennent presque tous les statuts, et celle qui change le résultat.
+ const exprimes = pour + contre;
+ const total = exprimes + abstentions + blancsNuls + retraits;
+
+ let incoherence: string | null = null;
+ if (vote.votants !== null && total > vote.votants) {
+ incoherence = `Le décompte atteint ${total} voix pour ${vote.votants} votants annoncés.`;
+ }
+
+ const regle =
+ vote.regleMajorite !== "non_precise"
+ ? vote.regleMajorite
+ : reglesSeance.regleMajorite;
+ const baseChoisie =
+ vote.baseMajorite !== "non_precise"
+ ? vote.baseMajorite
+ : reglesSeance.baseMajorite;
+ const base =
+ baseChoisie === "presents_representes"
+ ? vote.votants ?? total
+ : exprimes;
+
+ if (regle === "non_precise") {
+ return {
+ exprimes,
+ base,
+ requis: null,
+ adoptee: null,
+ phrase: `${pour} pour, ${contre} contre, ${abstentions} abstention${
+ abstentions > 1 ? "s" : ""
+ }.`,
+ incoherence,
+ };
+ }
+
+ const requis = seuil(regle, base);
+ const adoptee = requis === null ? null : pour >= requis;
+ const detail = `${pour} pour, ${contre} contre, ${abstentions} abstention${
+ abstentions > 1 ? "s" : ""
+ }`;
+ return {
+ exprimes,
+ base,
+ requis,
+ adoptee,
+ phrase:
+ adoptee === null
+ ? `${detail}.`
+ : `La résolution est ${adoptee ? "adoptée" : "rejetée"} ${
+ MAJORITES[regle] ?? ""
+ } : ${detail}.`,
+ incoherence,
+ };
+}
+
+/** Intitulé court affiché sur une carte repliée. */
+export function resumeResolution(
+ resolution: PvResolution,
+ reglesSeance: AgMinutesPayload["reglesVote"],
+): string {
+ const v = verdictVote(resolution.vote, reglesSeance);
+ if (resolution.vote.nonSoumiseAuVote) return "Non soumise au vote";
+ if (v.adoptee === null) return v.phrase || "Vote à renseigner";
+ return `${v.adoptee ? "Adoptée" : "Rejetée"} · ${resolution.vote.pour ?? 0}–${
+ resolution.vote.contre ?? 0
+ }–${resolution.vote.abstentions ?? 0}`;
+}
+
+export type Completude = "vierge" | "entamee" | "complete";
+
+function etat(valeurs: unknown[]): Completude {
+ const remplies = valeurs.filter((v) => {
+ if (v === null || v === undefined) return false;
+ if (typeof v === "string") return v.trim().length > 0;
+ if (Array.isArray(v)) return v.length > 0;
+ if (typeof v === "boolean") return v;
+ return true;
+ }).length;
+ if (remplies === 0) return "vierge";
+ return remplies === valeurs.length ? "complete" : "entamee";
+}
+
+/** Ce que le sommaire affiche : où l'on en est, section par section. */
+export function completudeSection(
+ payload: AgMinutesPayload,
+ cle: AgSectionCle,
+): Completude {
+ const p = payload;
+ switch (cle) {
+ case "seance":
+ return etat([p.seance.date, p.seance.lieu, p.seance.heureOuverture]);
+ case "convocation":
+ return etat([
+ p.convocation.auteur,
+ p.convocation.dateEnvoi,
+ p.convocation.mode,
+ p.ordreDuJour,
+ ]);
+ case "bureau":
+ return etat([p.bureauSeance.president.nom, p.bureauSeance.secretaire.nom]);
+ case "presences":
+ return etat([
+ p.presences.presents,
+ p.presences.effectifVotants,
+ p.presences.quorum.type !== "inconnu" ? true : null,
+ ]);
+ case "rapports":
+ return etat([
+ p.rapports.moral,
+ p.rapports.exercice.debut,
+ p.rapports.financier.resultatCents,
+ ]);
+ case "resolutions":
+ return p.resolutions.length === 0
+ ? "vierge"
+ : p.resolutions.every(
+ (r) => r.texte.trim() && (r.vote.nonSoumiseAuVote || r.vote.pour !== null),
+ )
+ ? "complete"
+ : "entamee";
+ case "instances":
+ return etat([p.instances.compositionApres, p.instances.dureeEtEffetMandats]);
+ case "cloture":
+ return etat([
+ p.seance.heureCloture,
+ p.cloture.dateRedaction,
+ p.cloture.signataires,
+ ]);
+ case "suites":
+ return etat([p.diffusion.perimetre, p.formalites.mandataireFormalites]);
+ default:
+ return "vierge";
+ }
+}
+
+/** Résumé d'une ligne affiché sous le titre de section, quand il y a de quoi. */
+export function resumeSection(
+ payload: AgMinutesPayload,
+ cle: AgSectionCle,
+): string {
+ const p = payload;
+ switch (cle) {
+ case "seance":
+ return p.seance.lieu || "";
+ case "convocation":
+ return p.ordreDuJour.length
+ ? `${p.ordreDuJour.length} point${p.ordreDuJour.length > 1 ? "s" : ""} à l’ordre du jour`
+ : "";
+ case "bureau":
+ return p.bureauSeance.president.nom
+ ? `Présidée par ${p.bureauSeance.president.nom}`
+ : "";
+ case "presences": {
+ const votants = votantsTotaux(p);
+ if (votants === null) return "";
+ const q = verdictQuorum(p);
+ return `${votants} votant${votants > 1 ? "s" : ""}${
+ q.atteint === null ? "" : q.atteint ? " · quorum atteint" : " · quorum non atteint"
+ }`;
+ }
+ case "rapports":
+ return p.rapports.financier.resultatCents === null
+ ? ""
+ : `Résultat ${(p.rapports.financier.resultatCents / 100).toLocaleString("fr-FR", { style: "currency", currency: "EUR" })}`;
+ case "resolutions":
+ return p.resolutions.length
+ ? `${p.resolutions.length} résolution${p.resolutions.length > 1 ? "s" : ""}`
+ : "";
+ case "instances":
+ return p.instances.compositionApres.length
+ ? `${p.instances.compositionApres.length} membre${p.instances.compositionApres.length > 1 ? "s" : ""} élu${p.instances.compositionApres.length > 1 ? "s" : ""}`
+ : "";
+ case "cloture":
+ return p.cloture.signataires.length
+ ? `${p.cloture.signataires.length} signataire${p.cloture.signataires.length > 1 ? "s" : ""}`
+ : "";
+ default:
+ return "";
+ }
+}
diff --git a/src/lib/documents/ag-controles.ts b/src/lib/documents/ag-controles.ts
new file mode 100644
index 0000000..53efb57
--- /dev/null
+++ b/src/lib/documents/ag-controles.ts
@@ -0,0 +1,236 @@
+import { verdictQuorum, verdictVote, votantsTotaux } from "./ag-calculs";
+import type { AgMinutesPayload, ReglesStatutaires } from "./ag-types";
+
+/**
+ * Ce que l'application vérifie avant de laisser finaliser un procès-verbal.
+ *
+ * Elle prévient, elle ne bloque presque jamais : le secrétaire de séance est un
+ * parent bénévole qui rédige le soir, et un logiciel qui refuse d'enregistrer
+ * se fait remplacer par un fichier Word. Deux exceptions seulement, et toutes
+ * deux sont des contradictions internes que personne ne peut vouloir : une
+ * arithmétique de vote impossible, et une date de rédaction antérieure à la
+ * séance. Enregistrer un brouillon, lui, n'est jamais empêché.
+ */
+
+export type SeveriteControle = "blocage" | "avertissement";
+
+export interface Controle {
+ cle: string;
+ message: string;
+ severite: SeveriteControle;
+ /** Section à ouvrir pour corriger. */
+ section: string;
+}
+
+function jours(a: string, b: string): number | null {
+ const d1 = new Date(a).getTime();
+ const d2 = new Date(b).getTime();
+ if (Number.isNaN(d1) || Number.isNaN(d2)) return null;
+ return Math.round((d1 - d2) / 86_400_000);
+}
+
+export function controlerPv(
+ payload: AgMinutesPayload,
+ regles: ReglesStatutaires,
+): Controle[] {
+ const liste: Controle[] = [];
+ const ajouter = (
+ cle: string,
+ message: string,
+ section: string,
+ severite: SeveriteControle = "avertissement",
+ ) => liste.push({ cle, message, severite, section });
+
+ // — Contradictions internes : les deux seuls blocages —
+ payload.resolutions.forEach((resolution, index) => {
+ const v = verdictVote(resolution.vote, payload.reglesVote);
+ if (v.incoherence) {
+ ajouter(
+ `vote_incoherent_${resolution.id}`,
+ `Résolution n° ${index + 1} : ${v.incoherence}`,
+ "resolutions",
+ "blocage",
+ );
+ }
+ });
+
+ if (payload.cloture.dateRedaction && payload.seance.date) {
+ const ecart = jours(payload.cloture.dateRedaction, payload.seance.date);
+ if (ecart !== null && ecart < 0) {
+ ajouter(
+ "redaction_avant_seance",
+ "Le procès-verbal est daté d’avant la séance qu’il relate.",
+ "cloture",
+ "blocage",
+ );
+ } else if (ecart !== null && ecart > 90) {
+ ajouter(
+ "redaction_tardive",
+ `Le procès-verbal est rédigé ${ecart} jours après la séance. Un délai long affaiblit sa force de conviction s’il est un jour discuté.`,
+ "cloture",
+ );
+ }
+ }
+
+ // — Ce qui manque et qu'on regrette plus tard —
+ if (!payload.seance.date) {
+ ajouter("date_absente", "La date de la séance n’est pas renseignée.", "seance");
+ }
+ if (!payload.seance.lieu.trim()) {
+ ajouter("lieu_absent", "Le lieu de la séance n’est pas renseigné.", "seance");
+ }
+ if (!payload.bureauSeance.president.nom.trim()) {
+ ajouter(
+ "president_absent",
+ "Le président de séance n’est pas nommé : c’est lui qui signe le procès-verbal.",
+ "bureau",
+ );
+ }
+ if (!payload.bureauSeance.secretaire.nom.trim()) {
+ ajouter(
+ "secretaire_absent",
+ "Le secrétaire de séance n’est pas nommé.",
+ "bureau",
+ );
+ }
+ if (payload.ordreDuJour.length === 0) {
+ ajouter(
+ "ordre_du_jour_vide",
+ "Aucun point n’est inscrit à l’ordre du jour. C’est le premier reproche fait à une délibération contestée.",
+ "convocation",
+ );
+ }
+ if (payload.cloture.signataires.length === 0) {
+ ajouter(
+ "signataires_absents",
+ "Aucun signataire n’est indiqué. Un procès-verbal non signé se défend mal.",
+ "cloture",
+ );
+ }
+
+ // — Convocation —
+ if (
+ regles.delaiConvocationJours &&
+ payload.convocation.dateEnvoi &&
+ payload.seance.date
+ ) {
+ const delai = jours(payload.seance.date, payload.convocation.dateEnvoi);
+ if (delai !== null && delai < regles.delaiConvocationJours) {
+ ajouter(
+ "delai_convocation",
+ `La convocation a été envoyée ${delai} jour${delai > 1 ? "s" : ""} avant la séance, alors que vos statuts en prévoient ${regles.delaiConvocationJours}.`,
+ "convocation",
+ );
+ }
+ }
+
+ // — Quorum —
+ const quorum = verdictQuorum(payload);
+ if (payload.presences.quorum.type === "inconnu") {
+ ajouter(
+ "quorum_inconnu",
+ "La règle de quorum n’est pas renseignée : le procès-verbal n’affirmera rien à ce sujet. Elle se lit dans vos statuts, et se saisit une fois pour toutes.",
+ "presences",
+ );
+ } else if (quorum.atteint === false) {
+ ajouter(
+ "quorum_non_atteint",
+ "Le quorum n’est pas atteint. Les délibérations prises dans ces conditions pourront être contestées ; envisagez une seconde convocation.",
+ "presences",
+ );
+ }
+
+ if (
+ regles.plafondPouvoirs &&
+ payload.presences.representes !== null &&
+ payload.presences.presents !== null &&
+ payload.presences.presents > 0 &&
+ payload.presences.representes >
+ payload.presences.presents * regles.plafondPouvoirs
+ ) {
+ ajouter(
+ "pouvoirs_au_dela_du_plafond",
+ `Vos statuts limitent à ${regles.plafondPouvoirs} pouvoir${regles.plafondPouvoirs > 1 ? "s" : ""} par personne présente : ${payload.presences.representes} pouvoirs pour ${payload.presences.presents} présents dépassent ce plafond.`,
+ "presences",
+ );
+ }
+
+ const votants = votantsTotaux(payload);
+ payload.resolutions.forEach((resolution, index) => {
+ if (resolution.vote.nonSoumiseAuVote) return;
+ if (
+ votants !== null &&
+ resolution.vote.votants !== null &&
+ resolution.vote.votants > votants
+ ) {
+ ajouter(
+ `votants_superieurs_${resolution.id}`,
+ `Résolution n° ${index + 1} : ${resolution.vote.votants} votants, alors que l’assemblée en compte ${votants}.`,
+ "resolutions",
+ );
+ }
+ if (!resolution.texte.trim()) {
+ ajouter(
+ `texte_manquant_${resolution.id}`,
+ `Résolution n° ${index + 1} : le texte soumis au vote est vide. C’est lui qui sera exécuté, pas son intitulé.`,
+ "resolutions",
+ );
+ }
+ });
+
+ // — Assemblée extraordinaire —
+ const estAGE =
+ payload.natureAssemblee === "AGE" || payload.natureAssemblee === "mixte";
+ const toucheAuxStatuts = payload.resolutions.some((r) =>
+ ["modification_statuts", "transfert_siege", "dissolution"].includes(r.nature),
+ );
+ if (toucheAuxStatuts && !estAGE) {
+ ajouter(
+ "statuts_hors_age",
+ "Une modification des statuts, un transfert de siège ou une dissolution relèvent en principe d’une assemblée extraordinaire. Vérifiez vos statuts.",
+ "seance",
+ );
+ }
+ if (estAGE && regles.majoriteAGE && regles.majoriteAGE !== "non_precise") {
+ const insuffisantes = payload.resolutions.filter(
+ (r) =>
+ !r.vote.nonSoumiseAuVote &&
+ r.vote.regleMajorite !== "non_precise" &&
+ r.vote.regleMajorite !== regles.majoriteAGE,
+ );
+ if (insuffisantes.length > 0) {
+ ajouter(
+ "majorite_age",
+ `Vos statuts exigent une majorité particulière en assemblée extraordinaire ; ${insuffisantes.length} résolution${insuffisantes.length > 1 ? "s en retiennent" : " en retient"} une autre.`,
+ "resolutions",
+ );
+ }
+ }
+
+ // — La seule obligation légale née de la séance —
+ const changeLesInstances =
+ payload.instances.compositionApres.length > 0 ||
+ payload.resolutions.some((r) => r.nature === "election");
+ if ((changeLesInstances || toucheAuxStatuts) && !payload.formalites.echeanceDeclaration) {
+ ajouter(
+ "declaration_prefecture",
+ "Cette assemblée change les dirigeants ou les statuts : la déclaration en préfecture est due dans les trois mois (art. 5 de la loi de 1901). Notez l’échéance pour ne pas la manquer.",
+ "suites",
+ );
+ }
+
+ if (!payload.diffusion.controleDonneesSensibles && payload.resolutions.some((r) => r.mentionsNominatives.length > 0)) {
+ ajouter(
+ "donnees_nominatives",
+ "Des personnes sont nommées dans les débats. Vérifiez qu’elles l’ont demandé avant de diffuser le procès-verbal.",
+ "suites",
+ );
+ }
+
+ return liste;
+}
+
+/** Ce qui empêche la finalisation, par opposition à ce qui la commente. */
+export function blocages(controles: Controle[]): Controle[] {
+ return controles.filter((c) => c.severite === "blocage");
+}
diff --git a/src/lib/documents/ag-modeles.ts b/src/lib/documents/ag-modeles.ts
new file mode 100644
index 0000000..51d7bd3
--- /dev/null
+++ b/src/lib/documents/ag-modeles.ts
@@ -0,0 +1,153 @@
+import type { NatureResolution, RegleMajorite } from "./ag-types";
+
+/**
+ * Textes types de résolutions.
+ *
+ * Ils font gagner l'essentiel du temps : devant une page blanche, un secrétaire
+ * de séance recopie le procès-verbal de l'an dernier, ou renonce. Les crochets
+ * marquent ce qui reste à compléter — ils sont volontairement voyants, pour
+ * qu'un oubli saute aux yeux à la relecture plutôt qu'à la préfecture.
+ *
+ * Ce sont des textes types, pas des textes justes : l'écran le dit au-dessus de
+ * la liste, et rien ici ne remplace une lecture des statuts.
+ */
+
+export interface ModeleResolution {
+ nature: NatureResolution;
+ libelle: string;
+ intitule: string;
+ texte: string;
+ /** Majorité usuelle, proposée et jamais imposée. */
+ majorite?: RegleMajorite;
+ /** Réservé aux assemblées extraordinaires. */
+ extraordinaire?: boolean;
+}
+
+export const MODELES_RESOLUTIONS: ModeleResolution[] = [
+ {
+ nature: "approbation_pv_precedent",
+ libelle: "PV précédent",
+ intitule: "Approbation du procès-verbal de l’assemblée précédente",
+ texte:
+ "L’assemblée générale, après lecture du procès-verbal de l’assemblée générale du [date de la séance précédente], l’approuve sans réserve.",
+ majorite: "simple",
+ },
+ {
+ nature: "rapport_moral",
+ libelle: "Rapport moral",
+ intitule: "Approbation du rapport moral et d’activité",
+ texte:
+ "L’assemblée générale, après avoir entendu la lecture du rapport moral et d’activité présenté par [nom du président] au titre de l’exercice clos le [date de clôture], l’approuve.",
+ majorite: "simple",
+ },
+ {
+ nature: "approbation_comptes",
+ libelle: "Comptes de l’exercice",
+ intitule: "Approbation des comptes de l’exercice clos",
+ texte:
+ "L’assemblée générale, après avoir entendu la lecture du rapport financier présenté par [nom du trésorier], approuve les comptes de l’exercice clos le [date de clôture], lesquels font apparaître un total de produits de [produits] €, un total de charges de [charges] € et un résultat de [résultat] €.",
+ majorite: "simple",
+ },
+ {
+ nature: "affectation_resultat",
+ libelle: "Affectation du résultat",
+ intitule: "Affectation du résultat de l’exercice",
+ texte:
+ "L’assemblée générale décide d’affecter le résultat de l’exercice, soit [résultat] €, au report à nouveau de l’association.",
+ majorite: "simple",
+ },
+ {
+ nature: "quitus",
+ libelle: "Quitus",
+ intitule: "Quitus aux membres du bureau",
+ texte:
+ "L’assemblée générale donne quitus entier et sans réserve aux membres du bureau pour leur gestion au titre de l’exercice clos le [date de clôture].",
+ majorite: "simple",
+ },
+ {
+ nature: "budget",
+ libelle: "Budget prévisionnel",
+ intitule: "Approbation du budget prévisionnel",
+ texte:
+ "L’assemblée générale approuve le budget prévisionnel de l’exercice [exercice], équilibré en produits et en charges à la somme de [montant] €.",
+ majorite: "simple",
+ },
+ {
+ nature: "cotisation",
+ libelle: "Cotisation",
+ intitule: "Montant de la cotisation",
+ texte:
+ "L’assemblée générale fixe le montant de la cotisation annuelle à [montant] € par famille pour l’année scolaire [année scolaire], part reversée à la fédération APEL comprise.",
+ majorite: "simple",
+ },
+ {
+ nature: "election",
+ libelle: "Élection",
+ intitule: "Élection des membres du conseil d’administration",
+ texte:
+ "L’assemblée générale procède à l’élection des membres du conseil d’administration. Après dépouillement, sont élus pour une durée de [durée] an(s) les candidats dont les noms et les voix figurent ci-dessous. Chacun des élus présents a accepté ses fonctions.",
+ majorite: "simple",
+ },
+ {
+ nature: "pouvoirs_bancaires",
+ libelle: "Pouvoirs bancaires",
+ intitule: "Pouvoirs bancaires",
+ texte:
+ "L’assemblée générale donne pouvoir à [nom et qualité] pour faire fonctionner, sous sa seule signature, les comptes ouverts au nom de l’association auprès de [établissement bancaire], et met fin aux pouvoirs antérieurement consentis à [nom du prédécesseur].",
+ majorite: "simple",
+ },
+ {
+ nature: "modification_statuts",
+ libelle: "Modification des statuts",
+ intitule: "Modification des statuts",
+ texte:
+ "L’assemblée générale extraordinaire décide de modifier l’article [numéro] des statuts, dont la rédaction est désormais la suivante : « [nouvelle rédaction] ». Les statuts ainsi modifiés sont annexés au présent procès-verbal.",
+ majorite: "deux_tiers",
+ extraordinaire: true,
+ },
+ {
+ nature: "transfert_siege",
+ libelle: "Transfert du siège",
+ intitule: "Transfert du siège social",
+ texte:
+ "L’assemblée générale extraordinaire décide de transférer le siège social de l’association à l’adresse suivante : [nouvelle adresse], à compter du [date d’effet].",
+ majorite: "deux_tiers",
+ extraordinaire: true,
+ },
+ {
+ nature: "dissolution",
+ libelle: "Dissolution",
+ intitule: "Dissolution de l’association",
+ texte:
+ "L’assemblée générale extraordinaire prononce la dissolution de l’association à compter du [date d’effet] et désigne [nom du liquidateur] en qualité de liquidateur, avec les pouvoirs les plus étendus pour réaliser l’actif et acquitter le passif.",
+ majorite: "deux_tiers",
+ extraordinaire: true,
+ },
+ {
+ nature: "devolution",
+ libelle: "Dévolution de l’actif",
+ intitule: "Dévolution de l’actif net",
+ texte:
+ "L’assemblée générale extraordinaire décide que l’actif net subsistant après liquidation sera dévolu à [nom de l’association bénéficiaire], association poursuivant un but analogue. En aucun cas les membres de l’association ne peuvent se voir attribuer une part quelconque des biens.",
+ majorite: "deux_tiers",
+ extraordinaire: true,
+ },
+ {
+ nature: "libre",
+ libelle: "Résolution libre",
+ intitule: "",
+ texte: "L’assemblée générale ",
+ },
+];
+
+/** Ordre du jour type d'une assemblée générale ordinaire d'APEL. */
+export const ORDRE_DU_JOUR_TYPE = [
+ "Approbation du procès-verbal de l’assemblée générale précédente",
+ "Rapport moral et rapport d’activité de l’année écoulée",
+ "Rapport financier et présentation des comptes de l’exercice clos",
+ "Approbation des comptes et quitus au bureau",
+ "Budget prévisionnel et montant de la cotisation",
+ "Renouvellement des membres du conseil d’administration",
+ "Projets et manifestations de l’année à venir",
+ "Questions diverses",
+];
diff --git a/src/lib/documents/ag-rendu.ts b/src/lib/documents/ag-rendu.ts
new file mode 100644
index 0000000..7a6d314
--- /dev/null
+++ b/src/lib/documents/ag-rendu.ts
@@ -0,0 +1,411 @@
+import { verdictQuorum, verdictVote, votantsTotaux } from "./ag-calculs";
+import type { AgMinutesPayload, PvPersonne } from "./ag-types";
+
+/**
+ * Le procès-verbal, rendu en texte et en HTML imprimable.
+ *
+ * Les deux sortent du même parcours de sections : deux parcours divergeraient à
+ * la première évolution, et c'est la version imprimée — celle qu'on signe et
+ * qu'on porte à la préfecture — qui perdrait une mention.
+ *
+ * Une règle tient tout le fichier : rien n'est écrit sur ce qui n'a pas été
+ * saisi. Un procès-verbal muet sur le quorum vaut mieux qu'un procès-verbal qui
+ * en affirme un au hasard. Les notes de rédaction, elles, ne sortent jamais.
+ */
+
+export interface IdentiteAssociation {
+ associationName: string;
+ schoolName: string;
+ rna: string;
+ headquarters: string;
+}
+
+export type VarianteRendu = "integral" | "diffusable";
+
+function esc(valeur: string): string {
+ return valeur
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+}
+
+function dateLongue(iso: string | null): string {
+ if (!iso) return "";
+ const d = new Date(iso);
+ if (Number.isNaN(d.getTime())) return "";
+ return d.toLocaleDateString("fr-FR", {
+ day: "numeric",
+ month: "long",
+ year: "numeric",
+ });
+}
+
+function euros(cents: number | null): string {
+ if (cents === null) return "";
+ return (cents / 100).toLocaleString("fr-FR", {
+ style: "currency",
+ currency: "EUR",
+ });
+}
+
+function personne(p: PvPersonne): string {
+ if (!p.nom.trim()) return "";
+ return p.qualite.trim() ? `${p.nom} (${p.qualite})` : p.nom;
+}
+
+const NATURES: Record = {
+ AGO: "assemblée générale ordinaire",
+ AGE: "assemblée générale extraordinaire",
+ mixte: "assemblée générale ordinaire et extraordinaire",
+ constitutive: "assemblée générale constitutive",
+};
+
+const SCRUTINS: Record = {
+ main_levee: "à main levée",
+ bulletin_secret: "à bulletin secret",
+ electronique: "par vote électronique",
+ non_precise: "",
+};
+
+/** Un bloc du procès-verbal : un titre, des paragraphes déjà composés. */
+interface Bloc {
+ titre: string;
+ paragraphes: string[];
+ /** Tableaux rendus en HTML, listés en texte. */
+ tableaux?: { entetes: string[]; lignes: string[][] }[];
+}
+
+/**
+ * Compose les blocs du procès-verbal. C'est ici que vit la totalité de la
+ * rédaction ; les deux rendus ne font ensuite que l'habiller.
+ */
+function composer(
+ payload: AgMinutesPayload,
+ identite: IdentiteAssociation,
+ variante: VarianteRendu,
+): Bloc[] {
+ const p = payload;
+ const blocs: Bloc[] = [];
+ const ajouter = (titre: string, paragraphes: (string | null | false)[], tableaux?: Bloc["tableaux"]) => {
+ const propres = paragraphes.filter((x): x is string => Boolean(x && x.trim()));
+ if (propres.length === 0 && !tableaux?.length) return;
+ blocs.push({ titre, paragraphes: propres, tableaux });
+ };
+
+ // 1. L'association et la séance
+ const identification = [
+ identite.associationName,
+ identite.headquarters.trim() ? `dont le siège social est situé ${identite.headquarters.trim()}` : null,
+ identite.rna.trim() ? `déclarée sous le numéro RNA ${identite.rna.trim()}` : null,
+ ]
+ .filter(Boolean)
+ .join(", ");
+ const lieuEtHeure = [
+ p.seance.date ? `le ${dateLongue(p.seance.date)}` : null,
+ p.seance.heureOuverture ? `à ${p.seance.heureOuverture}` : null,
+ p.seance.lieu.trim() ? `à ${p.seance.lieu.trim()}` : null,
+ ]
+ .filter(Boolean)
+ .join(" ");
+ ajouter("Identification et ouverture de la séance", [
+ identification
+ ? `Les membres de l’association ${identification} se sont réunis en ${NATURES[p.natureAssemblee]}${lieuEtHeure ? ` ${lieuEtHeure}` : ""}.`
+ : null,
+ p.entete.affiliationApel.trim()
+ ? `L’association est affiliée à ${p.entete.affiliationApel.trim()}.`
+ : null,
+ p.entete.statutsVersionDate
+ ? `Les statuts applicables sont ceux adoptés le ${dateLongue(p.entete.statutsVersionDate)}.`
+ : null,
+ p.seance.distanciel.actif
+ ? `La participation à distance a été ouverte${p.seance.distanciel.outil.trim() ? ` par ${p.seance.distanciel.outil.trim()}` : ""}.${p.seance.distanciel.modalites.trim() ? ` ${p.seance.distanciel.modalites.trim()}` : ""}`
+ : null,
+ p.seance.secondeConvocation.actif
+ ? `L’assemblée est réunie sur seconde convocation, la séance du ${dateLongue(p.seance.secondeConvocation.premiereSeanceDate)} n’ayant pu délibérer faute de quorum.`
+ : null,
+ ]);
+
+ // 2. La convocation
+ const convocation = [
+ p.convocation.auteur.trim() ? `par ${p.convocation.auteur.trim()}` : null,
+ p.convocation.dateEnvoi ? `le ${dateLongue(p.convocation.dateEnvoi)}` : null,
+ p.convocation.mode.trim() ? `par ${p.convocation.mode.trim()}` : null,
+ ].filter(Boolean);
+ ajouter("Convocation et ordre du jour", [
+ convocation.length
+ ? `La convocation a été adressée ${convocation.join(", ")}${p.convocation.nombreDestinataires !== null ? ` à ${p.convocation.nombreDestinataires} destinataires` : ""}.`
+ : null,
+ p.convocation.incidents.trim() || null,
+ p.ordreDuJour.length
+ ? `L’ordre du jour était le suivant :\n${p.ordreDuJour.map((point, i) => `${i + 1}. ${point.intitule}`).join("\n")}`
+ : null,
+ p.convocation.documentsJoints.length
+ ? `Documents joints à la convocation : ${p.convocation.documentsJoints.join(", ")}.`
+ : null,
+ ]);
+
+ // 3. Le bureau de séance
+ ajouter("Bureau de séance", [
+ personne(p.bureauSeance.president)
+ ? `La séance est présidée par ${personne(p.bureauSeance.president)}.`
+ : null,
+ personne(p.bureauSeance.secretaire)
+ ? `${personne(p.bureauSeance.secretaire)} assure le secrétariat de séance.`
+ : null,
+ p.bureauSeance.scrutateurs.length
+ ? `Scrutateurs : ${p.bureauSeance.scrutateurs.map(personne).filter(Boolean).join(", ")}.`
+ : null,
+ p.bureauSeance.invites.length
+ ? `Assistaient également à la séance, à titre d’invités et sans voix délibérative : ${p.bureauSeance.invites.map(personne).filter(Boolean).join(", ")}. Ils ne sont comptés ni dans le quorum, ni dans les votes.`
+ : null,
+ ]);
+
+ // 4. Présences et quorum
+ const votants = votantsTotaux(p);
+ const quorum = verdictQuorum(p);
+ ajouter("Présences, pouvoirs et quorum", [
+ p.presences.effectifVotants !== null
+ ? `L’association comptait ${p.presences.effectifVotants} membre${p.presences.effectifVotants > 1 ? "s" : ""} disposant du droit de vote${p.presences.dateReference ? ` au ${dateLongue(p.presences.dateReference)}` : ""}${p.presences.regleVoix === "famille" ? ", à raison d’une voix par famille adhérente" : p.presences.regleVoix === "personne" ? ", à raison d’une voix par adhérent" : ""}.`
+ : null,
+ votants !== null
+ ? `${p.presences.presents ?? 0} membre${(p.presences.presents ?? 0) > 1 ? "s étaient présents" : " était présent"} et ${p.presences.representes ?? 0} représenté${(p.presences.representes ?? 0) > 1 ? "s" : ""}, soit ${votants} voix.`
+ : null,
+ p.presences.pouvoirsEcartes
+ ? `${p.presences.pouvoirsEcartes} pouvoir${p.presences.pouvoirsEcartes > 1 ? "s ont été écartés" : " a été écarté"}.`
+ : null,
+ quorum.phrase || null,
+ p.presences.incidentsSeance.trim() || null,
+ p.presences.feuilleEmargementAnnexee
+ ? "La feuille d’émargement, signée par les membres présents, est annexée au présent procès-verbal."
+ : null,
+ ]);
+
+ // 5. Rapports et comptes
+ const f = p.rapports.financier;
+ ajouter("Rapports et comptes de l’exercice", [
+ p.rapports.exercice.debut || p.rapports.exercice.fin
+ ? `Les comptes présentés portent sur l’exercice ${p.rapports.exercice.debut ? `ouvert le ${dateLongue(p.rapports.exercice.debut)}` : ""}${p.rapports.exercice.fin ? ` et clos le ${dateLongue(p.rapports.exercice.fin)}` : ""}.`
+ : null,
+ p.rapports.moral.trim() || null,
+ f.texte.trim() || null,
+ f.produitsCents !== null || f.chargesCents !== null || f.resultatCents !== null
+ ? `Les comptes font apparaître ${[
+ f.produitsCents !== null ? `un total de produits de ${euros(f.produitsCents)}` : null,
+ f.chargesCents !== null ? `un total de charges de ${euros(f.chargesCents)}` : null,
+ f.resultatCents !== null ? `un résultat de ${euros(f.resultatCents)}` : null,
+ f.tresorerieCents !== null ? `une trésorerie de ${euros(f.tresorerieCents)}` : null,
+ ]
+ .filter(Boolean)
+ .join(", ")}.`
+ : null,
+ p.rapports.verificateur.trim() || null,
+ ]);
+
+ // 6. Résolutions
+ if (p.resolutions.length) {
+ const paragraphes: string[] = [];
+ p.resolutions.forEach((resolution, index) => {
+ const v = verdictVote(resolution.vote, p.reglesVote);
+ const scrutin = SCRUTINS[resolution.vote.modeScrutin] || "";
+ const entete = `Résolution n° ${index + 1} — ${resolution.intitule || "sans intitulé"}`;
+ const corps = resolution.texte.trim();
+ const conflits = resolution.conflitsInterets.length
+ ? `${resolution.conflitsInterets.join(", ")} n’${resolution.conflitsInterets.length > 1 ? "ont" : "a"} pas pris part au vote.`
+ : "";
+ const resultat = resolution.vote.nonSoumiseAuVote
+ ? "Ce point a été exposé sans être soumis au vote."
+ : v.phrase
+ ? `Mise aux voix${scrutin ? ` ${scrutin}` : ""}, ${v.phrase.charAt(0).toLowerCase()}${v.phrase.slice(1)}`
+ : "";
+ const candidats = resolution.candidats.length
+ ? `Résultats par candidat : ${resolution.candidats
+ .map(
+ (c) =>
+ `${c.nom}${c.fonction ? ` (${c.fonction})` : ""}${c.voix !== null ? ` — ${c.voix} voix` : ""}${c.accepte ? ", qui a accepté ses fonctions" : ""}`,
+ )
+ .join(" ; ")}.`
+ : "";
+ const statuts = resolution.textesStatuts.length
+ ? resolution.textesStatuts
+ .map(
+ (t) =>
+ `Article ${t.article} — ancienne rédaction : « ${t.ancienne} ». Nouvelle rédaction : « ${t.nouvelle} ».`,
+ )
+ .join("\n")
+ : "";
+ const mentions =
+ variante === "integral" && resolution.mentionsNominatives.length
+ ? `Mentions portées à la demande des intéressés : ${resolution.mentionsNominatives.join(" ; ")}.`
+ : "";
+ paragraphes.push(
+ [entete, corps, statuts, conflits, resultat, candidats, mentions]
+ .filter((x) => x && x.trim())
+ .join("\n"),
+ );
+ });
+ ajouter("Résolutions et votes", paragraphes);
+ }
+
+ // 7. Instances
+ ajouter("Composition des instances", [
+ p.instances.bureauEluPar === "CA"
+ ? "Le bureau est élu par le conseil d’administration ; sa composition fera l’objet d’un procès-verbal distinct."
+ : p.instances.bureauEluPar === "AG"
+ ? "Le bureau est élu directement par l’assemblée générale."
+ : null,
+ p.instances.compositionApres.length
+ ? `À l’issue de la séance, les instances sont ainsi composées :\n${p.instances.compositionApres
+ .map((m) => `— ${m.nom}${m.fonction ? `, ${m.fonction}` : ""}`)
+ .join("\n")}`
+ : null,
+ p.instances.dureeEtEffetMandats.trim() || null,
+ p.instances.siegesVacants.trim() || null,
+ ]);
+
+ // 8. Vie de l'association
+ ajouter("Projets et vie de l’association", [
+ p.vieApel.engagements.trim() || null,
+ p.vieApel.manifestations.trim() || null,
+ p.vieApel.representants.trim() || null,
+ ]);
+
+ // 9. Assemblée extraordinaire
+ ajouter("Décisions relevant de l’assemblée extraordinaire", [
+ p.age.transfertSiege.trim() || null,
+ p.age.dissolution.trim() || null,
+ p.age.devolution.trim() || null,
+ ]);
+
+ // 10. Clôture
+ ajouter("Questions diverses et clôture", [
+ p.cloture.questionsDiverses.trim() || null,
+ p.seance.heureCloture
+ ? `L’ordre du jour étant épuisé, la séance est levée à ${p.seance.heureCloture}.`
+ : "L’ordre du jour étant épuisé, la séance est levée.",
+ p.cloture.prochaineAG
+ ? `La prochaine assemblée générale est envisagée le ${dateLongue(p.cloture.prochaineAG)}.`
+ : null,
+ ]);
+
+ // 11. Suites
+ ajouter("Formalités et suites à donner", [
+ p.formalites.pouvoirsBancaires.trim() || null,
+ p.formalites.mandataireFormalites.trim() || null,
+ p.formalites.echeanceDeclaration
+ ? `Les modifications décidées ce jour seront déclarées en préfecture au plus tard le ${dateLongue(p.formalites.echeanceDeclaration)}, conformément à l’article 5 de la loi du 1ᵉʳ juillet 1901.`
+ : null,
+ p.formalites.transmissionFederation.trim() || null,
+ ]);
+
+ return blocs;
+}
+
+/** Signature de fin, commune aux deux rendus. */
+function mentionsFinales(payload: AgMinutesPayload): string[] {
+ const p = payload;
+ const lignes: string[] = [];
+ if (p.cloture.dateRedaction) {
+ lignes.push(`Procès-verbal établi le ${dateLongue(p.cloture.dateRedaction)}.`);
+ }
+ if (p.cloture.mentionCertifieConforme) {
+ lignes.push(
+ "Certifié conforme. Cette mention est réclamée par certains organismes ; elle n’ajoute aucune valeur juridique au document.",
+ );
+ }
+ if (p.cloture.annexes.length) {
+ lignes.push(`Annexes : ${p.cloture.annexes.join(", ")}.`);
+ }
+ return lignes;
+}
+
+/** Version texte, conservée dans `content` : recherche, aperçu, repli. */
+export function pvEnTexte(
+ payload: AgMinutesPayload,
+ identite: IdentiteAssociation,
+): string {
+ const blocs = composer(payload, identite, "integral");
+ const corps = blocs
+ .map((b) => `${b.titre.toUpperCase()}\n\n${b.paragraphes.join("\n\n")}`)
+ .join("\n\n");
+ const fin = mentionsFinales(payload);
+ const signatures = payload.cloture.signataires
+ .map(personne)
+ .filter(Boolean)
+ .join("\n");
+ return [corps, fin.join("\n"), signatures].filter((x) => x && x.trim()).join("\n\n");
+}
+
+/**
+ * Corps du document imprimable. L'en-tête de l'association et le pied de page
+ * restent produits par `renderPrintableDocument`, commun à tous les documents.
+ */
+export function pvEnHtml(
+ payload: AgMinutesPayload,
+ identite: IdentiteAssociation,
+ options: { variante?: VarianteRendu; projet?: boolean } = {},
+): string {
+ const variante = options.variante ?? "integral";
+ const blocs = composer(payload, identite, variante);
+
+ const sections = blocs
+ .map(
+ (b) => `
+ ${esc(b.titre)}
+ ${b.paragraphes
+ .map((texte) => `${esc(texte).replace(/\n/g, " ")}
`)
+ .join("\n")}
+ `,
+ )
+ .join("\n");
+
+ const fin = mentionsFinales(payload)
+ .map((ligne) => `${esc(ligne)}
`)
+ .join("\n");
+
+ // Les cases de signature sont ce pour quoi le document est imprimé : elles
+ // doivent tenir sur la même page que la mention qui les introduit.
+ const signatures = payload.cloture.signataires.length
+ ? `
+ Le présent procès-verbal est signé par :
+
+ ${payload.cloture.signataires
+ .map(
+ (s) => `
+
${esc(s.nom)}
+
${esc(s.qualite)}
+
+
`,
+ )
+ .join("\n")}
+
+ `
+ : "";
+
+ const filigrane = options.projet
+ ? `PROJET
`
+ : "";
+
+ return `${filigrane}
+
+ ${sections}
+ ${fin}
+ ${signatures}`;
+}
diff --git a/src/lib/documents/ag-types.ts b/src/lib/documents/ag-types.ts
new file mode 100644
index 0000000..89e07b3
--- /dev/null
+++ b/src/lib/documents/ag-types.ts
@@ -0,0 +1,384 @@
+/**
+ * Formes du procès-verbal d'assemblée générale.
+ *
+ * Fichier de types purs, sans aucun import : il est référencé par le schéma
+ * Drizzle, qui est lui-même importé par la validation. Le moindre import ici
+ * refermerait le cycle.
+ *
+ * LE PRINCIPE QUI COMMANDE TOUT LE RESTE. La loi du 1er juillet 1901 n'impose
+ * ni de tenir une assemblée générale, ni d'en rédiger un procès-verbal. Presque
+ * tout vient des statuts de l'association — et une seule obligation légale naît
+ * de la séance : déclarer sous trois mois les changements de dirigeants ou de
+ * statuts. Chaque mention affichée porte donc son niveau réel, et quand la règle
+ * n'est pas connue, l'application le dit plutôt que d'inventer. Un outil qui
+ * ment sur son propre fondement se fait remplacer par un fichier Word dès la
+ * deuxième année.
+ */
+
+export type NiveauExigence =
+ /** Découle d'un texte : art. 5 de la loi de 1901, RGPD. */
+ | "legal"
+ /** Découle des statuts de l'association — donc vérifiable, pas supposé. */
+ | "statutaire"
+ /** Réclamé en pratique par une banque, une mairie, la fédération. */
+ | "tiers"
+ /** Emprunt au droit des sociétés : utile, jamais obligatoire ici. */
+ | "usage"
+ | "facultatif";
+
+/**
+ * Ce que les statuts de l'association prévoient, lu une fois et réutilisé par
+ * toutes les assemblées. Tout est facultatif : un champ vide signifie « règle
+ * inconnue », et l'application s'abstient alors de tout verdict.
+ */
+export interface ReglesStatutaires {
+ articleAG?: string;
+ delaiConvocationJours?: number;
+ auteurConvocation?: string;
+ quorumAGO?: RegleQuorum;
+ quorumAGE?: RegleQuorum;
+ majoriteAGO?: RegleMajorite;
+ majoriteAGE?: RegleMajorite;
+ baseMajorite?: BaseMajorite;
+ representationAutorisee?: boolean;
+ plafondPouvoirs?: number;
+ /** Beaucoup d'APEL comptent une voix par famille adhérente, pas par personne. */
+ regleVoix?: "famille" | "personne";
+ dureeMandatAnnees?: number;
+ /** Date de clôture de l'exercice, telle qu'écrite dans les statuts. */
+ clotureExercice?: string;
+}
+
+export type RegleMajorite =
+ | "simple"
+ | "absolue"
+ | "deux_tiers"
+ | "unanimite"
+ | "non_precise";
+
+export type BaseMajorite =
+ | "suffrages_exprimes"
+ | "presents_representes"
+ | "non_precise";
+
+export interface RegleQuorum {
+ type: "fraction" | "nombre" | "aucun" | "inconnu";
+ /** Dénominateur d'une fraction (4 pour « le quart »), ou nombre exigé. */
+ valeur?: number | null;
+ /** La phrase des statuts, recopiée telle quelle. */
+ texte?: string;
+}
+
+/**
+ * Une personne citée au procès-verbal. `memberId` la rattache à un adhérent
+ * quand elle en est un — le chef d'établissement ou un représentant de la
+ * fédération n'en sont pas, d'où la saisie libre toujours possible.
+ */
+export interface PvPersonne {
+ memberId: string | null;
+ nom: string;
+ qualite: string;
+}
+
+export type ModeScrutin =
+ | "main_levee"
+ | "bulletin_secret"
+ | "electronique"
+ | "non_precise";
+
+export interface PvVote {
+ modeScrutin: ModeScrutin;
+ baseMajorite: BaseMajorite;
+ regleMajorite: RegleMajorite;
+ votants: number | null;
+ pour: number | null;
+ contre: number | null;
+ abstentions: number | null;
+ blancsNuls: number | null;
+ /** Conflit d'intérêts : qui s'est retiré du vote. */
+ nePrennentPasPart: number | null;
+ /** Certains points sont exposés sans être mis aux voix. */
+ nonSoumiseAuVote: boolean;
+}
+
+export type NatureResolution =
+ | "approbation_pv_precedent"
+ | "rapport_moral"
+ | "approbation_comptes"
+ | "affectation_resultat"
+ | "quitus"
+ | "budget"
+ | "cotisation"
+ | "election"
+ | "modification_statuts"
+ | "transfert_siege"
+ | "dissolution"
+ | "devolution"
+ | "pouvoirs_bancaires"
+ | "libre";
+
+export interface PvCandidat {
+ id: string;
+ memberId: string | null;
+ nom: string;
+ fonction: string;
+ voix: number | null;
+ /** L'acceptation des fonctions est ce que la préfecture vérifie. */
+ accepte: boolean;
+}
+
+export interface PvResolution {
+ id: string;
+ nature: NatureResolution;
+ intitule: string;
+ texte: string;
+ vote: PvVote;
+ /** Nommer quelqu'un dans un débat : seulement à sa demande. */
+ mentionsNominatives: string[];
+ conflitsInterets: string[];
+ candidats: PvCandidat[];
+ textesStatuts: { article: string; ancienne: string; nouvelle: string }[];
+}
+
+export interface AgMinutesPayload {
+ schema: 1;
+ /** « L'AG a déjà eu lieu » est le cas courant, donc le défaut. */
+ mode: "preparation" | "transcription";
+ natureAssemblee: "AGO" | "AGE" | "mixte" | "constitutive";
+ entete: {
+ statutsVersionDate: string | null;
+ affiliationApel: string;
+ };
+ seance: {
+ date: string | null;
+ heureOuverture: string;
+ heureCloture: string;
+ lieu: string;
+ distanciel: { actif: boolean; outil: string; modalites: string };
+ secondeConvocation: { actif: boolean; premiereSeanceDate: string | null };
+ };
+ convocation: {
+ auteur: string;
+ dateEnvoi: string | null;
+ mode: string;
+ nombreDestinataires: number | null;
+ incidents: string;
+ documentsJoints: string[];
+ };
+ ordreDuJour: { id: string; intitule: string }[];
+ bureauSeance: {
+ president: PvPersonne;
+ secretaire: PvPersonne;
+ scrutateurs: PvPersonne[];
+ /** Sans voix délibérative : ni dans le quorum, ni dans les votes. */
+ invites: PvPersonne[];
+ };
+ presences: {
+ regleVoix: "famille" | "personne" | "non_precise";
+ dateReference: string | null;
+ effectifVotants: number | null;
+ presents: number | null;
+ representes: number | null;
+ pouvoirsEcartes: number | null;
+ quorum: RegleQuorum;
+ feuilleEmargementAnnexee: boolean;
+ incidentsSeance: string;
+ };
+ reglesVote: {
+ modeScrutin: ModeScrutin;
+ baseMajorite: BaseMajorite;
+ regleMajorite: RegleMajorite;
+ };
+ rapports: {
+ moral: string;
+ exercice: { debut: string | null; fin: string | null };
+ financier: {
+ texte: string;
+ produitsCents: number | null;
+ chargesCents: number | null;
+ resultatCents: number | null;
+ tresorerieCents: number | null;
+ /**
+ * Les chiffres sont gelés au moment où ils sont repris : une écriture
+ * saisie six mois après l'assemblée ne doit pas modifier rétroactivement
+ * un procès-verbal signé.
+ */
+ sourceComptable: { extraitLe: string; brouillons: number } | null;
+ };
+ verificateur: string;
+ };
+ resolutions: PvResolution[];
+ instances: {
+ bureauEluPar: "AG" | "CA" | "non_precise";
+ compositionApres: { nom: string; fonction: string; memberId: string | null }[];
+ dureeEtEffetMandats: string;
+ siegesVacants: string;
+ };
+ vieApel: {
+ engagements: string;
+ manifestations: string;
+ representants: string;
+ };
+ age: {
+ transfertSiege: string;
+ dissolution: string;
+ devolution: string;
+ };
+ formalites: {
+ pouvoirsBancaires: string;
+ mandataireFormalites: string;
+ /** Art. 5 : trois mois pour déclarer dirigeants et statuts modifiés. */
+ echeanceDeclaration: string | null;
+ transmissionFederation: string;
+ };
+ cloture: {
+ questionsDiverses: string;
+ dateRedaction: string | null;
+ signataires: PvPersonne[];
+ mentionCertifieConforme: boolean;
+ annexes: string[];
+ prochaineAG: string | null;
+ };
+ diffusion: {
+ controleDonneesSensibles: boolean;
+ perimetre: string;
+ archivage: string;
+ };
+ /** Ce qui aide à rédiger et ne s'imprime jamais. */
+ redaction: {
+ notesBrutes: string;
+ aRetrouver: string[];
+ derniereSection: string;
+ };
+}
+
+/** Les neuf sections, dans l'ordre où un secrétaire de séance les remplit. */
+export const AG_SECTIONS = [
+ { cle: "seance", titre: "L’association et la séance" },
+ { cle: "convocation", titre: "La convocation" },
+ { cle: "bureau", titre: "Qui présidait, qui était invité" },
+ { cle: "presences", titre: "Qui était là, et combien de voix" },
+ { cle: "rapports", titre: "Les rapports et les comptes" },
+ { cle: "resolutions", titre: "Les résolutions et les votes" },
+ { cle: "instances", titre: "Les élections et les instances" },
+ { cle: "cloture", titre: "La clôture" },
+ { cle: "suites", titre: "Suites, diffusion et archivage" },
+] as const;
+
+export type AgSectionCle = (typeof AG_SECTIONS)[number]["cle"];
+
+export function personneVide(): PvPersonne {
+ return { memberId: null, nom: "", qualite: "" };
+}
+
+export function voteVide(): PvVote {
+ return {
+ modeScrutin: "non_precise",
+ baseMajorite: "non_precise",
+ regleMajorite: "non_precise",
+ votants: null,
+ pour: null,
+ contre: null,
+ abstentions: null,
+ blancsNuls: null,
+ nePrennentPasPart: null,
+ nonSoumiseAuVote: false,
+ };
+}
+
+/** Brouillon de départ : tout est vide, rien n'est affirmé. */
+export function payloadVide(
+ options: {
+ mode?: AgMinutesPayload["mode"];
+ nature?: AgMinutesPayload["natureAssemblee"];
+ date?: string | null;
+ } = {},
+): AgMinutesPayload {
+ return {
+ schema: 1,
+ mode: options.mode ?? "transcription",
+ natureAssemblee: options.nature ?? "AGO",
+ entete: { statutsVersionDate: null, affiliationApel: "" },
+ seance: {
+ date: options.date ?? null,
+ heureOuverture: "",
+ heureCloture: "",
+ lieu: "",
+ distanciel: { actif: false, outil: "", modalites: "" },
+ secondeConvocation: { actif: false, premiereSeanceDate: null },
+ },
+ convocation: {
+ auteur: "",
+ dateEnvoi: null,
+ mode: "",
+ nombreDestinataires: null,
+ incidents: "",
+ documentsJoints: [],
+ },
+ ordreDuJour: [],
+ bureauSeance: {
+ president: personneVide(),
+ secretaire: personneVide(),
+ scrutateurs: [],
+ invites: [],
+ },
+ presences: {
+ regleVoix: "non_precise",
+ dateReference: null,
+ effectifVotants: null,
+ presents: null,
+ representes: null,
+ pouvoirsEcartes: null,
+ quorum: { type: "inconnu", valeur: null, texte: "" },
+ feuilleEmargementAnnexee: false,
+ incidentsSeance: "",
+ },
+ reglesVote: {
+ modeScrutin: "main_levee",
+ baseMajorite: "non_precise",
+ regleMajorite: "non_precise",
+ },
+ rapports: {
+ moral: "",
+ exercice: { debut: null, fin: null },
+ financier: {
+ texte: "",
+ produitsCents: null,
+ chargesCents: null,
+ resultatCents: null,
+ tresorerieCents: null,
+ sourceComptable: null,
+ },
+ verificateur: "",
+ },
+ resolutions: [],
+ instances: {
+ bureauEluPar: "non_precise",
+ compositionApres: [],
+ dureeEtEffetMandats: "",
+ siegesVacants: "",
+ },
+ vieApel: { engagements: "", manifestations: "", representants: "" },
+ age: { transfertSiege: "", dissolution: "", devolution: "" },
+ formalites: {
+ pouvoirsBancaires: "",
+ mandataireFormalites: "",
+ echeanceDeclaration: null,
+ transmissionFederation: "",
+ },
+ cloture: {
+ questionsDiverses: "",
+ dateRedaction: null,
+ signataires: [],
+ mentionCertifieConforme: false,
+ annexes: [],
+ prochaineAG: null,
+ },
+ diffusion: {
+ controleDonneesSensibles: false,
+ perimetre: "",
+ archivage: "",
+ },
+ redaction: { notesBrutes: "", aRetrouver: [], derniereSection: "seance" },
+ };
+}
diff --git a/src/lib/documents/ag-validation.ts b/src/lib/documents/ag-validation.ts
new file mode 100644
index 0000000..ccc3c66
--- /dev/null
+++ b/src/lib/documents/ag-validation.ts
@@ -0,0 +1,287 @@
+import { z } from "zod";
+
+/**
+ * Validation du procès-verbal d'assemblée générale.
+ *
+ * Tout est facultatif et porte une valeur par défaut. C'est délibéré : le
+ * brouillon s'enregistre à chaque frappe, souvent à moitié rempli, parfois
+ * depuis un téléphone dans une salle de classe. Un schéma exigeant rejetterait
+ * la sauvegarde précisément quand elle est la plus utile.
+ *
+ * Les contrôles qui comptent ne sont pas ici mais dans `ag-controles.ts` : ils
+ * s'expriment en français, s'affichent à côté du champ concerné, et n'empêchent
+ * la finalisation que sur une contradiction interne.
+ */
+
+const texte = (max = 20_000) => z.string().max(max).default("");
+const texteCourt = (max = 300) => z.string().max(max).default("");
+const dateOuNull = z.string().max(40).nullable().default(null);
+const nombreOuNull = z.coerce.number().int().nullable().default(null);
+
+const personneSchema = z.object({
+ memberId: z.string().uuid().nullable().default(null),
+ nom: texteCourt(),
+ qualite: texteCourt(),
+});
+
+const quorumSchema = z.object({
+ type: z.enum(["fraction", "nombre", "aucun", "inconnu"]).default("inconnu"),
+ valeur: nombreOuNull,
+ texte: texteCourt(500),
+});
+
+const majoriteSchema = z
+ .enum(["simple", "absolue", "deux_tiers", "unanimite", "non_precise"])
+ .default("non_precise");
+
+const baseMajoriteSchema = z
+ .enum(["suffrages_exprimes", "presents_representes", "non_precise"])
+ .default("non_precise");
+
+const modeScrutinSchema = z
+ .enum(["main_levee", "bulletin_secret", "electronique", "non_precise"])
+ .default("non_precise");
+
+const voteSchema = z.object({
+ modeScrutin: modeScrutinSchema,
+ baseMajorite: baseMajoriteSchema,
+ regleMajorite: majoriteSchema,
+ votants: nombreOuNull,
+ pour: nombreOuNull,
+ contre: nombreOuNull,
+ abstentions: nombreOuNull,
+ blancsNuls: nombreOuNull,
+ nePrennentPasPart: nombreOuNull,
+ nonSoumiseAuVote: z.boolean().default(false),
+});
+
+const resolutionSchema = z.object({
+ id: z.string().min(1).max(64),
+ nature: z
+ .enum([
+ "approbation_pv_precedent",
+ "rapport_moral",
+ "approbation_comptes",
+ "affectation_resultat",
+ "quitus",
+ "budget",
+ "cotisation",
+ "election",
+ "modification_statuts",
+ "transfert_siege",
+ "dissolution",
+ "devolution",
+ "pouvoirs_bancaires",
+ "libre",
+ ])
+ .default("libre"),
+ intitule: texteCourt(),
+ texte: texte(10_000),
+ vote: voteSchema,
+ mentionsNominatives: z.array(texteCourt(500)).max(50).default([]),
+ conflitsInterets: z.array(texteCourt(200)).max(50).default([]),
+ candidats: z
+ .array(
+ z.object({
+ id: z.string().min(1).max(64),
+ memberId: z.string().uuid().nullable().default(null),
+ nom: texteCourt(),
+ fonction: texteCourt(),
+ voix: nombreOuNull,
+ accepte: z.boolean().default(false),
+ }),
+ )
+ .max(100)
+ .default([]),
+ textesStatuts: z
+ .array(
+ z.object({
+ article: texteCourt(100),
+ ancienne: texte(5000),
+ nouvelle: texte(5000),
+ }),
+ )
+ .max(30)
+ .default([]),
+});
+
+export const agMinutesPayloadSchema = z.object({
+ schema: z.literal(1).default(1),
+ mode: z.enum(["preparation", "transcription"]).default("transcription"),
+ natureAssemblee: z.enum(["AGO", "AGE", "mixte", "constitutive"]).default("AGO"),
+ entete: z
+ .object({
+ statutsVersionDate: dateOuNull,
+ affiliationApel: texteCourt(),
+ })
+ .default({}),
+ seance: z
+ .object({
+ date: dateOuNull,
+ heureOuverture: texteCourt(20),
+ heureCloture: texteCourt(20),
+ lieu: texteCourt(),
+ distanciel: z
+ .object({
+ actif: z.boolean().default(false),
+ outil: texteCourt(200),
+ modalites: texte(2000),
+ })
+ .default({}),
+ secondeConvocation: z
+ .object({
+ actif: z.boolean().default(false),
+ premiereSeanceDate: dateOuNull,
+ })
+ .default({}),
+ })
+ .default({}),
+ convocation: z
+ .object({
+ auteur: texteCourt(),
+ dateEnvoi: dateOuNull,
+ mode: texteCourt(),
+ nombreDestinataires: nombreOuNull,
+ incidents: texte(3000),
+ documentsJoints: z.array(texteCourt()).max(30).default([]),
+ })
+ .default({}),
+ ordreDuJour: z
+ .array(z.object({ id: z.string().min(1).max(64), intitule: texteCourt(500) }))
+ .max(60)
+ .default([]),
+ bureauSeance: z
+ .object({
+ president: personneSchema.default({}),
+ secretaire: personneSchema.default({}),
+ scrutateurs: z.array(personneSchema).max(20).default([]),
+ invites: z.array(personneSchema).max(40).default([]),
+ })
+ .default({}),
+ presences: z
+ .object({
+ regleVoix: z.enum(["famille", "personne", "non_precise"]).default("non_precise"),
+ dateReference: dateOuNull,
+ effectifVotants: nombreOuNull,
+ presents: nombreOuNull,
+ representes: nombreOuNull,
+ pouvoirsEcartes: nombreOuNull,
+ quorum: quorumSchema.default({}),
+ feuilleEmargementAnnexee: z.boolean().default(false),
+ incidentsSeance: texte(3000),
+ })
+ .default({}),
+ reglesVote: z
+ .object({
+ modeScrutin: modeScrutinSchema.default("main_levee"),
+ baseMajorite: baseMajoriteSchema,
+ regleMajorite: majoriteSchema,
+ })
+ .default({}),
+ rapports: z
+ .object({
+ moral: texte(30_000),
+ exercice: z.object({ debut: dateOuNull, fin: dateOuNull }).default({}),
+ financier: z
+ .object({
+ texte: texte(30_000),
+ produitsCents: nombreOuNull,
+ chargesCents: nombreOuNull,
+ resultatCents: nombreOuNull,
+ tresorerieCents: nombreOuNull,
+ sourceComptable: z
+ .object({
+ extraitLe: texteCourt(40),
+ brouillons: z.coerce.number().int().default(0),
+ })
+ .nullable()
+ .default(null),
+ })
+ .default({}),
+ verificateur: texte(3000),
+ })
+ .default({}),
+ resolutions: z.array(resolutionSchema).max(100).default([]),
+ instances: z
+ .object({
+ bureauEluPar: z.enum(["AG", "CA", "non_precise"]).default("non_precise"),
+ compositionApres: z
+ .array(
+ z.object({
+ nom: texteCourt(),
+ fonction: texteCourt(),
+ memberId: z.string().uuid().nullable().default(null),
+ }),
+ )
+ .max(60)
+ .default([]),
+ dureeEtEffetMandats: texte(3000),
+ siegesVacants: texte(2000),
+ })
+ .default({}),
+ vieApel: z
+ .object({
+ engagements: texte(10_000),
+ manifestations: texte(10_000),
+ representants: texte(3000),
+ })
+ .default({}),
+ age: z
+ .object({
+ transfertSiege: texte(5000),
+ dissolution: texte(5000),
+ devolution: texte(5000),
+ })
+ .default({}),
+ formalites: z
+ .object({
+ pouvoirsBancaires: texte(5000),
+ mandataireFormalites: texte(2000),
+ echeanceDeclaration: dateOuNull,
+ transmissionFederation: texte(2000),
+ })
+ .default({}),
+ cloture: z
+ .object({
+ questionsDiverses: texte(20_000),
+ dateRedaction: dateOuNull,
+ signataires: z.array(personneSchema).max(20).default([]),
+ mentionCertifieConforme: z.boolean().default(false),
+ annexes: z.array(texteCourt()).max(40).default([]),
+ prochaineAG: dateOuNull,
+ })
+ .default({}),
+ diffusion: z
+ .object({
+ controleDonneesSensibles: z.boolean().default(false),
+ perimetre: texteCourt(500),
+ archivage: texteCourt(500),
+ })
+ .default({}),
+ redaction: z
+ .object({
+ notesBrutes: texte(60_000),
+ aRetrouver: z.array(texteCourt(120)).max(80).default([]),
+ derniereSection: texteCourt(40),
+ })
+ .default({}),
+});
+
+/** Ce que prévoient les statuts. Chaque champ peut rester vide. */
+export const reglesStatutairesSchema = z.object({
+ articleAG: z.string().trim().max(120).optional(),
+ delaiConvocationJours: z.coerce.number().int().min(0).max(365).optional(),
+ auteurConvocation: z.string().trim().max(200).optional(),
+ quorumAGO: quorumSchema.optional(),
+ quorumAGE: quorumSchema.optional(),
+ majoriteAGO: majoriteSchema.optional(),
+ majoriteAGE: majoriteSchema.optional(),
+ baseMajorite: baseMajoriteSchema.optional(),
+ representationAutorisee: z.boolean().optional(),
+ plafondPouvoirs: z.coerce.number().int().min(0).max(50).optional(),
+ regleVoix: z.enum(["famille", "personne"]).optional(),
+ dureeMandatAnnees: z.coerce.number().int().min(1).max(20).optional(),
+ clotureExercice: z.string().trim().max(60).optional(),
+});
+
+export type AgMinutesPayloadInput = z.infer;
diff --git a/src/lib/services/association-settings.ts b/src/lib/services/association-settings.ts
index a75abca..ddeb82b 100644
--- a/src/lib/services/association-settings.ts
+++ b/src/lib/services/association-settings.ts
@@ -73,6 +73,7 @@ export async function getAssociationSettings() {
contactEmail: settings.contactEmail,
rna: settings.rna,
headquarters: settings.headquarters,
+ statutoryRules: settings.statutoryRules ?? {},
logoUrl: settings.logoUrl,
taskReminderWindowDays: settings.taskReminderWindowDays,
volunteerReminderWindowDays: settings.volunteerReminderWindowDays,
@@ -111,6 +112,7 @@ export async function getAssociationSettings() {
// installation d'avant les réglages en base, il reste vide et les
// documents le signalent.
headquarters: "",
+ statutoryRules: {},
logoUrl: null,
taskReminderWindowDays: legacyReminderWindow("REMINDER_WINDOW_DAYS", 3),
volunteerReminderWindowDays: 2,
diff --git a/src/lib/services/documents.ts b/src/lib/services/documents.ts
index 7e53858..47f50de 100644
--- a/src/lib/services/documents.ts
+++ b/src/lib/services/documents.ts
@@ -2,17 +2,24 @@ import { and, desc, eq, sql } from "drizzle-orm";
import { HttpError } from "@/lib/auth/guards";
import { formatLongDate } from "@/lib/dates";
+import { pvEnHtml, pvEnTexte } from "@/lib/documents/ag-rendu";
+import type { AgMinutesPayload } from "@/lib/documents/ag-types";
import { db } from "@/lib/db";
import {
ASSOCIATION_DOCUMENT_TYPE_LABELS,
type AssociationDocumentType,
} from "@/lib/labels";
-import { associationDocuments, associationMembers } from "@/lib/db/schema";
+import {
+ associationDocuments,
+ associationMembers,
+ associationSettings,
+} from "@/lib/db/schema";
import {
removeUpload,
storedUploadIdFromUrl,
} from "@/lib/uploads";
import { emptyToNull } from "@/lib/utils";
+import { reglesStatutairesSchema } from "@/lib/documents/ag-validation";
import {
associationDocumentSchema,
associationDocumentUpdateSchema,
@@ -34,6 +41,9 @@ export async function listAssociationDocuments(limit = 200) {
memberFirstName: associationMembers.firstName,
memberLastName: associationMembers.lastName,
fileUrl: associationDocuments.fileUrl,
+ payload: associationDocuments.payload,
+ contentSource: associationDocuments.contentSource,
+ signedAt: associationDocuments.signedAt,
version: associationDocuments.version,
createdAt: associationDocuments.createdAt,
updatedAt: associationDocuments.updatedAt,
@@ -60,6 +70,9 @@ export async function getAssociationDocument(id: string) {
memberFirstName: associationMembers.firstName,
memberLastName: associationMembers.lastName,
fileUrl: associationDocuments.fileUrl,
+ payload: associationDocuments.payload,
+ contentSource: associationDocuments.contentSource,
+ signedAt: associationDocuments.signedAt,
version: associationDocuments.version,
createdAt: associationDocuments.createdAt,
updatedAt: associationDocuments.updatedAt,
@@ -74,6 +87,23 @@ export async function getAssociationDocument(id: string) {
return document ?? null;
}
+/**
+ * Compose le texte du procès-verbal à partir de sa saisie structurée.
+ *
+ * Le texte reste la vérité stockée dans `content` : c'est lui que cherche la
+ * recherche, que lisent les outils MCP et qu'affiche la carte. Le recomposer à
+ * chaque écriture évite d'avoir deux versions du même document qui divergent.
+ */
+async function composerDepuisPayload(payload: AgMinutesPayload): Promise {
+ const association = await getAssociationSettings();
+ return pvEnTexte(payload, {
+ associationName: association.associationName,
+ schoolName: association.schoolName,
+ rna: association.rna,
+ headquarters: association.headquarters,
+ });
+}
+
export async function createAssociationDocument(
input: unknown,
actor: AuditActor,
@@ -85,6 +115,9 @@ export async function createAssociationDocument(
"Un document doit être créé en brouillon ou finalisé, puis archivé.",
);
}
+ const compose = data.payload
+ ? await composerDepuisPayload(data.payload as AgMinutesPayload)
+ : null;
const [document] = await db
.insert(associationDocuments)
.values({
@@ -92,7 +125,9 @@ export async function createAssociationDocument(
status: data.status,
title: data.title,
documentDate: data.documentDate,
- content: data.content,
+ content: compose ?? data.content,
+ payload: (data.payload as AgMinutesPayload | null | undefined) ?? null,
+ contentSource: compose === null ? "manual" : "payload",
memberId: data.memberId ?? null,
fileUrl: emptyToNull(data.fileUrl),
createdBy: actor.userId,
@@ -119,6 +154,20 @@ export async function updateAssociationDocument(
"Un document archivé ne peut plus être modifié.",
);
}
+ // Un document finalisé est celui qu'on a signé et diffusé : le laisser
+ // modifiable en silence retirait toute valeur au statut, et à la jauge du
+ // classeur qui s'y fie. Le rouvrir reste possible, mais c'est alors un geste
+ // délibéré, seul accepté ici, et il laisse une trace au journal d'audit.
+ if (current.status === "final") {
+ const rouvre =
+ data.status === "draft" && Object.keys(data).every((k) => k === "status" || k === "version");
+ if (!rouvre) {
+ throw new HttpError(
+ 409,
+ "Ce document est finalisé. Rouvrez-le en brouillon avant de le modifier.",
+ );
+ }
+ }
if (data.status === "archived") {
throw new HttpError(
409,
@@ -134,7 +183,23 @@ export async function updateAssociationDocument(
if (data.title !== undefined) updates.title = data.title;
if (data.documentDate !== undefined)
updates.documentDate = data.documentDate;
- if (data.content !== undefined) updates.content = data.content;
+ if (data.content !== undefined) {
+ updates.content = data.content;
+ // Quelqu'un a écrit le texte directement — le serveur MCP, le plus souvent.
+ // Le noter évite que la sauvegarde suivante de l'éditeur ne l'efface sans
+ // que personne ne comprenne où le texte est passé.
+ updates.contentSource = "manual";
+ }
+ if (data.payload !== undefined) {
+ updates.payload = (data.payload as AgMinutesPayload | null) ?? null;
+ if (data.payload) {
+ updates.content = await composerDepuisPayload(data.payload as AgMinutesPayload);
+ updates.contentSource = "payload";
+ } else {
+ updates.contentSource = "manual";
+ }
+ }
+ if (data.status === "final") updates.signedAt = updates.signedAt ?? null;
if (data.memberId !== undefined) updates.memberId = data.memberId ?? null;
if (data.fileUrl !== undefined)
updates.fileUrl = emptyToNull(data.fileUrl);
@@ -259,6 +324,33 @@ export async function deleteArchivedAgMinutes(
return document;
}
+/**
+ * Ce que prévoient les statuts de l'association.
+ *
+ * Séparé des autres réglages à dessein : l'écran de configuration générale
+ * n'envoie pas ces champs, et le schéma des réglages remplace par leur valeur
+ * par défaut tous ceux qu'il ne reçoit pas. Passer par ce point d'entrée
+ * dédié met la fiche à l'abri de cet effacement.
+ */
+export async function saveStatutoryRules(input: unknown, actor: AuditActor) {
+ const regles = reglesStatutairesSchema.parse(input);
+ await db
+ .insert(associationSettings)
+ .values({ id: "default", statutoryRules: regles })
+ .onConflictDoUpdate({
+ target: associationSettings.id,
+ set: { statutoryRules: regles, updatedAt: new Date() },
+ });
+ await recordAudit(
+ actor,
+ "association.statutory_rules_update",
+ "association_settings",
+ "default",
+ { champsRenseignes: Object.keys(regles).length },
+ );
+ return regles;
+}
+
function escapeHtml(value: string) {
return value
.replace(/&/g, "&")
@@ -268,14 +360,19 @@ function escapeHtml(value: string) {
.replace(/'/g, "'");
}
-export async function renderPrintableDocument(document: {
- title: string;
- type: AssociationDocumentType;
- documentDate: Date;
- content: string;
- memberFirstName: string | null;
- memberLastName: string | null;
-}) {
+export async function renderPrintableDocument(
+ document: {
+ title: string;
+ type: AssociationDocumentType;
+ documentDate: Date;
+ content: string;
+ memberFirstName: string | null;
+ memberLastName: string | null;
+ payload?: AgMinutesPayload | null;
+ status?: "draft" | "final" | "archived";
+ },
+ options: { auto?: boolean } = {},
+) {
const association = await getAssociationSettings();
const typeLabel =
document.type === "ag_minutes"
@@ -297,6 +394,22 @@ export async function renderPrintableDocument(document: {
const siege = association.headquarters.trim()
? `Siège social : ${escapeHtml(association.headquarters.trim())}
`
: "";
+ // Un procès-verbal rédigé dans l'éditeur guidé s'imprime section par
+ // section ; tous les autres documents, et les PV saisis en texte libre,
+ // gardent le rendu d'origine.
+ const corps = document.payload
+ ? pvEnHtml(
+ document.payload,
+ {
+ associationName: association.associationName,
+ schoolName: association.schoolName,
+ rna: association.rna,
+ headquarters: association.headquarters,
+ },
+ { projet: document.status !== "final" },
+ )
+ : `${escapeHtml(document.content)}
`;
+
return `
@@ -325,10 +438,10 @@ export async function renderPrintableDocument(document: {
${typeLabel} · ${formatLongDate(document.documentDate)}
${escapeHtml(document.title)}
${beneficiary}
- ${escapeHtml(document.content)}
+ ${corps}
${escapeHtml(association.associationName)}${rnaSuffix}
-
+ ${options.auto === false ? "" : ''}
`;
}
diff --git a/src/lib/validation.ts b/src/lib/validation.ts
index ba4e525..18ac45d 100644
--- a/src/lib/validation.ts
+++ b/src/lib/validation.ts
@@ -1,6 +1,7 @@
import { z } from "zod";
import { parseLocalDateTime } from "@/lib/dates";
+import { agMinutesPayloadSchema } from "@/lib/documents/ag-validation";
import { ASSOCIATION_DOCUMENT_TYPES } from "@/lib/labels";
import { checkTicketingUrl, TICKETING_URL_MAX } from "@/lib/ticketing";
import { checkWhatsappUrl, WHATSAPP_URL_MAX } from "@/lib/whatsapp";
@@ -429,6 +430,12 @@ export const associationDocumentSchema = z.object({
content: z.string().max(200_000).default(""),
memberId: nullableUuid,
fileUrl: optionalUrl("document"),
+ /**
+ * Procès-verbal d'assemblée rédigé section par section. Absent pour tous les
+ * autres documents. `null` détache explicitement un PV de l'éditeur guidé et
+ * rend la main au texte libre.
+ */
+ payload: agMinutesPayloadSchema.nullable().optional(),
version: optimisticVersion,
});