diff --git a/CLAUDE.md b/CLAUDE.md index 106bd4e..638e0e8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -248,6 +248,18 @@ même raison. (`src/lib/promo-categories.ts`, groupes pour ``), pas le métier de l'adhérent ; `defaultPromoCategory(slug)` pré-sélectionne depuis le métier. +- **Référent** : `members.contact_first_name` / `contact_last_name` / + `contact_phone` portent la personne à joindre et sa ligne directe. Ils sont + **réservés aux visiteurs connectés** : aucune requête publique ne les lit + (`tests/security.test.ts` le verrouille), seules `getMemberContacts()` / + `getMemberContact()` les rapatrient, et uniquement après un `getSession()` + positif. L'annuaire, les pages métier et la fiche affichent alors un bloc + « Contact adhérent » ; hors session la requête n'est pas faite, donc rien + n'est masqué en CSS, rien ne part dans le HTML ni dans le JSON-LD. + `src/lib/member-contact.ts` est **pur** (`tests/member-contact.test.ts`) : + `memberContact()` compose « Prénom Nom » et renvoie `null` s'il n'y a rien à + montrer, `telHref()` fabrique le lien `tel:`. Les deux formulaires de fiche + (espace adhérent et écran staff) partagent `MemberContactFields`. - `members.email` est l'e-mail **administratif** (identifiant de connexion à la création, échanges avec l'association) ; `members.contact_email` est l'e-mail **public** de la fiche, saisi par l'adhérent ou le staff. La fiche et le diff --git a/drizzle/0017_regular_devos.sql b/drizzle/0017_regular_devos.sql new file mode 100644 index 0000000..33892a4 --- /dev/null +++ b/drizzle/0017_regular_devos.sql @@ -0,0 +1,3 @@ +ALTER TABLE "members" ADD COLUMN "contact_first_name" varchar(120);--> statement-breakpoint +ALTER TABLE "members" ADD COLUMN "contact_last_name" varchar(120);--> statement-breakpoint +ALTER TABLE "members" ADD COLUMN "contact_phone" varchar(40); \ No newline at end of file diff --git a/drizzle/meta/0017_snapshot.json b/drizzle/meta/0017_snapshot.json new file mode 100644 index 0000000..59bc647 --- /dev/null +++ b/drizzle/meta/0017_snapshot.json @@ -0,0 +1,1432 @@ +{ + "id": "e64a4fd6-108b-4300-889a-2a4482c0db79", + "prevId": "17b58756-caa0-4c04-bd73-c6b210ce87ef", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.activity_log": { + "name": "activity_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "dot": { + "name": "dot", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'#2C6FB3'" + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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.categories": { + "name": "categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "accent": { + "name": "accent", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'#E0A63C'" + }, + "tint": { + "name": "tint", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'#f6efdc'" + }, + "sort": { + "name": "sort", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "categories_slug_unique": { + "name": "categories_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contact_messages": { + "name": "contact_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "contact_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'new'" + }, + "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.image_consents": { + "name": "image_consents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "decision": { + "name": "decision", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "signatory_name": { + "name": "signatory_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "signature_png": { + "name": "signature_png", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "consent_version": { + "name": "consent_version", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "ip": { + "name": "ip", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "image_consents_member_id_members_id_fk": { + "name": "image_consents_member_id_members_id_fk", + "tableFrom": "image_consents", + "tableTo": "members", + "columnsFrom": [ + "member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.meeting_registrations": { + "name": "meeting_registrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "meeting_id": { + "name": "meeting_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "attendee_name": { + "name": "attendee_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "attendee_company": { + "name": "attendee_company", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "attendee_email": { + "name": "attendee_email", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "attendee_phone": { + "name": "attendee_phone", + "type": "varchar(40)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "image_consent": { + "name": "image_consent", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "meeting_registrations_meeting_idx": { + "name": "meeting_registrations_meeting_idx", + "columns": [ + { + "expression": "meeting_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "meeting_registrations_member_meeting_idx": { + "name": "meeting_registrations_member_meeting_idx", + "columns": [ + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "meeting_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "meeting_registrations_meeting_id_meetings_id_fk": { + "name": "meeting_registrations_meeting_id_meetings_id_fk", + "tableFrom": "meeting_registrations", + "tableTo": "meetings", + "columnsFrom": [ + "meeting_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "meeting_registrations_member_id_members_id_fk": { + "name": "meeting_registrations_member_id_members_id_fk", + "tableFrom": "meeting_registrations", + "tableTo": "members", + "columnsFrom": [ + "member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.meetings": { + "name": "meetings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "starts_at": { + "name": "starts_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "location": { + "name": "location", + "type": "varchar(240)", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capacity": { + "name": "capacity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 30 + }, + "participants_per_account": { + "name": "participants_per_account", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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.members": { + "name": "members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "contact_email": { + "name": "contact_email", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "contact_first_name": { + "name": "contact_first_name", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "contact_last_name": { + "name": "contact_last_name", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "contact_phone": { + "name": "contact_phone", + "type": "varchar(40)", + "primaryKey": false, + "notNull": false + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "city": { + "name": "city", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "varchar(240)", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "highlighted": { + "name": "highlighted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_url": { + "name": "cover_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "varchar(40)", + "primaryKey": false, + "notNull": false + }, + "website": { + "name": "website", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "postal_code": { + "name": "postal_code", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "member_since": { + "name": "member_since", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "hours": { + "name": "hours", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "members_category_id_categories_id_fk": { + "name": "members_category_id_categories_id_fk", + "tableFrom": "members", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.membership_requests": { + "name": "membership_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "request_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'new'" + }, + "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.past_meeting_photos": { + "name": "past_meeting_photos", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "past_meeting_id": { + "name": "past_meeting_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "caption": { + "name": "caption", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "position": { + "name": "position", + "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": { + "past_meeting_photos_past_meeting_id_past_meetings_id_fk": { + "name": "past_meeting_photos_past_meeting_id_past_meetings_id_fk", + "tableFrom": "past_meeting_photos", + "tableTo": "past_meetings", + "columnsFrom": [ + "past_meeting_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.past_meetings": { + "name": "past_meetings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "event_date": { + "name": "event_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "location": { + "name": "location", + "type": "varchar(240)", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "participants": { + "name": "participants", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "meeting_id": { + "name": "meeting_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "past_meetings_meeting_id_meetings_id_fk": { + "name": "past_meetings_meeting_id_meetings_id_fk", + "tableFrom": "past_meetings", + "tableTo": "meetings", + "columnsFrom": [ + "meeting_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.promotions": { + "name": "promotions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "badge": { + "name": "badge", + "type": "varchar(40)", + "primaryKey": false, + "notNull": false + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "member_id": { + "name": "member_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "promo_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "starts_on": { + "name": "starts_on", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "ends_on": { + "name": "ends_on", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "valid_until": { + "name": "valid_until", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "share_facebook": { + "name": "share_facebook", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "share_linkedin": { + "name": "share_linkedin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "publish_at": { + "name": "publish_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "suspended_by": { + "name": "suspended_by", + "type": "promo_suspended_by", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "suspended_by_id": { + "name": "suspended_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "suspended_at": { + "name": "suspended_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": { + "promotions_member_id_members_id_fk": { + "name": "promotions_member_id_members_id_fk", + "tableFrom": "promotions", + "tableTo": "members", + "columnsFrom": [ + "member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "promotions_suspended_by_id_users_id_fk": { + "name": "promotions_suspended_by_id_users_id_fk", + "tableFrom": "promotions", + "tableTo": "users", + "columnsFrom": [ + "suspended_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.site_settings": { + "name": "site_settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "varchar(120)", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.social_accounts": { + "name": "social_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "network": { + "name": "network", + "type": "social_network", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "app_id": { + "name": "app_id", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "app_secret": { + "name": "app_secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "target_name": { + "name": "target_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "connected_by_id": { + "name": "connected_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_check_at": { + "name": "last_check_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_check_ok": { + "name": "last_check_ok", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "last_check_error": { + "name": "last_check_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "social_accounts_connected_by_id_users_id_fk": { + "name": "social_accounts_connected_by_id_users_id_fk", + "tableFrom": "social_accounts", + "tableTo": "users", + "columnsFrom": [ + "connected_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "social_accounts_network_unique": { + "name": "social_accounts_network_unique", + "nullsNotDistinct": false, + "columns": [ + "network" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.social_posts": { + "name": "social_posts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "promotion_id": { + "name": "promotion_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "network": { + "name": "network", + "type": "social_network", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "social_post_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "posted_by_id": { + "name": "posted_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "social_posts_promotion_idx": { + "name": "social_posts_promotion_idx", + "columns": [ + { + "expression": "promotion_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "social_posts_promotion_id_promotions_id_fk": { + "name": "social_posts_promotion_id_promotions_id_fk", + "tableFrom": "social_posts", + "tableTo": "promotions", + "columnsFrom": [ + "promotion_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "social_posts_posted_by_id_users_id_fk": { + "name": "social_posts_posted_by_id_users_id_fk", + "tableFrom": "social_posts", + "tableTo": "users", + "columnsFrom": [ + "posted_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "member_id": { + "name": "member_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "must_change_password": { + "name": "must_change_password", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "session_version": { + "name": "session_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": { + "users_email_idx": { + "name": "users_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "users_member_id_members_id_fk": { + "name": "users_member_id_members_id_fk", + "tableFrom": "users", + "tableTo": "members", + "columnsFrom": [ + "member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.contact_status": { + "name": "contact_status", + "schema": "public", + "values": [ + "new", + "read", + "archived" + ] + }, + "public.member_status": { + "name": "member_status", + "schema": "public", + "values": [ + "active", + "pending" + ] + }, + "public.promo_status": { + "name": "promo_status", + "schema": "public", + "values": [ + "pending", + "live", + "expired", + "rejected", + "suspended", + "scheduled" + ] + }, + "public.promo_suspended_by": { + "name": "promo_suspended_by", + "schema": "public", + "values": [ + "member", + "staff" + ] + }, + "public.request_status": { + "name": "request_status", + "schema": "public", + "values": [ + "new", + "approved", + "rejected" + ] + }, + "public.role": { + "name": "role", + "schema": "public", + "values": [ + "admin", + "moderator", + "editor", + "member" + ] + }, + "public.social_network": { + "name": "social_network", + "schema": "public", + "values": [ + "facebook", + "linkedin" + ] + }, + "public.social_post_status": { + "name": "social_post_status", + "schema": "public", + "values": [ + "posted", + "failed" + ] + } + }, + "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 61e49ba..de31eed 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -120,6 +120,13 @@ "when": 1788692576315, "tag": "0016_tiresome_korg", "breakpoints": true + }, + { + "idx": 17, + "version": "7", + "when": 1789385157231, + "tag": "0017_regular_devos", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/app/adherents/[id]/page.tsx b/src/app/adherents/[id]/page.tsx index 8cebdc5..705f17c 100644 --- a/src/app/adherents/[id]/page.tsx +++ b/src/app/adherents/[id]/page.tsx @@ -7,7 +7,9 @@ import { SiteFooter } from "@/components/SiteFooter"; import { VitrineImage } from "@/components/VitrineImage"; import { PromoImage } from "@/components/PromoImage"; import { JsonLd } from "@/components/JsonLd"; -import { getMemberLivePromotions, getPublicMember } from "@/lib/queries"; +import { getMemberContact, getMemberLivePromotions, getPublicMember } from "@/lib/queries"; +import { getSession } from "@/lib/session"; +import { telHref } from "@/lib/member-contact"; import { NOINDEX, breadcrumbJsonLd, @@ -144,7 +146,16 @@ export default async function FicheAdherentPage({ const canonical = memberPath(member); if (`/adherents/${id}` !== canonical) permanentRedirect(canonical); - const [promos, baseUrl] = await Promise.all([getMemberLivePromotions(memberId), publicBaseUrl()]); + const [promos, baseUrl, session] = await Promise.all([ + getMemberLivePromotions(memberId), + publicBaseUrl(), + getSession(), + ]); + // Nom, prénom et ligne directe du référent : réservés aux visiteurs + // connectés. La requête n'est pas faite pour un visiteur anonyme, et ces + // champs restent hors du JSON-LD et des métadonnées de la fiche. + const contact = session?.user ? await getMemberContact(memberId) : null; + const contactTel = telHref(contact?.phone); const accent = member.accent ?? "#E0A63C"; const fullAddress = [member.address, [member.postalCode, member.city].filter(Boolean).join(" ")] @@ -384,6 +395,27 @@ export default async function FicheAdherentPage({
Coordonnées non communiquées.
)} + {contact && ( +
+
+ + Contact adhérent +
+ {contact.name &&
{contact.name}
} + {contact.phone && + (contactTel ? ( + + {contact.phone} + + ) : ( +
{contact.phone}
+ ))} +
+ Visible uniquement parce que vous êtes connecté : ces coordonnées ne sont pas + publiées sur le site. +
+
+ )} diff --git a/src/app/annuaire/AnnuaireClient.tsx b/src/app/annuaire/AnnuaireClient.tsx index 12251ed..354a266 100644 --- a/src/app/annuaire/AnnuaireClient.tsx +++ b/src/app/annuaire/AnnuaireClient.tsx @@ -2,10 +2,13 @@ import { useMemo, useState } from "react"; import { MemberCard, type MemberCardData } from "@/components/MemberCard"; +import type { MemberContact } from "@/lib/member-contact"; export type DirectoryMember = MemberCardData & { hasPromo: boolean; promoBadge: string | null; + /** Référent de l'adhérent, `null` tant que le visiteur n'est pas connecté. */ + contact: MemberContact | null; }; export function AnnuaireClient({ @@ -176,7 +179,7 @@ export function AnnuaireClient({ {list.length > 0 ? (
{list.map((m, i) => ( - + ))}
) : ( diff --git a/src/app/annuaire/[categorie]/page.tsx b/src/app/annuaire/[categorie]/page.tsx index 0391705..c979be1 100644 --- a/src/app/annuaire/[categorie]/page.tsx +++ b/src/app/annuaire/[categorie]/page.tsx @@ -13,7 +13,9 @@ import { getCategoriesInUse, getCategoryBySlug, getLiveBadgesByMember, + getMemberContacts, } from "@/lib/queries"; +import { getSession } from "@/lib/session"; import { NOINDEX, breadcrumbJsonLd, @@ -77,12 +79,16 @@ export default async function CategoriePage({ params }: { params: Params }) { const category = await getCategoryBySlug(categorie); if (!category) notFound(); - const [rawMembers, badges, categoriesInUse, baseUrl] = await Promise.all([ + const [rawMembers, badges, categoriesInUse, baseUrl, session] = await Promise.all([ getActiveMembersByCategory(category.id), getLiveBadgesByMember(), getCategoriesInUse(), publicBaseUrl(), + getSession(), ]); + // Même règle que sur l'annuaire : le référent n'est lu que pour un visiteur + // connecté, et reste hors du JSON-LD (construit sur `members`). + const contacts = session?.user ? await getMemberContacts() : null; const badgeByMember = new Map(); for (const b of badges) { if (b.memberId != null && !badgeByMember.has(b.memberId)) badgeByMember.set(b.memberId, b.badge); @@ -148,7 +154,7 @@ export default async function CategoriePage({ params }: { params: Params }) { {members.length > 0 ? (
{members.map((m, i) => ( - + ))}
) : ( diff --git a/src/app/annuaire/page.tsx b/src/app/annuaire/page.tsx index 538038f..8e6930d 100644 --- a/src/app/annuaire/page.tsx +++ b/src/app/annuaire/page.tsx @@ -8,7 +8,9 @@ import { getAllCategories, getCategoriesInUse, getLiveBadgesByMember, + getMemberContacts, } from "@/lib/queries"; +import { getSession } from "@/lib/session"; import { CategoryLinks } from "@/components/CategoryLinks"; import { JsonLd } from "@/components/JsonLd"; import { breadcrumbJsonLd, memberListJsonLd, pageMetadata } from "@/lib/seo"; @@ -32,14 +34,21 @@ export default async function AnnuairePage({ searchParams: Promise<{ q?: string }>; }) { const { q } = await searchParams; - const [rawMembers, categories, badges, baseUrl, categoriesInUse] = await Promise.all([ + const [rawMembers, categories, badges, baseUrl, categoriesInUse, session] = await Promise.all([ getActiveMembersWithCategory(), getAllCategories(), getLiveBadgesByMember(), publicBaseUrl(), getCategoriesInUse(), + getSession(), ]); + // Coordonnées du référent : réservées aux visiteurs connectés. Pour un + // visiteur anonyme la requête n'est pas faite du tout, donc rien ne part + // dans le HTML ni vers le composant client. + const signedIn = Boolean(session?.user); + const contacts = signedIn ? await getMemberContacts() : null; + const badgeByMember = new Map(); for (const b of badges) { if (b.memberId != null && !badgeByMember.has(b.memberId)) { @@ -51,6 +60,7 @@ export default async function AnnuairePage({ ...m, hasPromo: badgeByMember.has(m.id), promoBadge: badgeByMember.get(m.id) ?? null, + contact: contacts?.get(m.id) ?? null, })); return ( @@ -97,6 +107,12 @@ export default async function AnnuairePage({

Trouvez un professionnel du Bassin de Pompey, découvrez sa fiche et ses bons plans.

+ {signedIn && ( +

+ Vous êtes connecté : les coordonnées du référent de chaque adhérent (nom, prénom, + téléphone) sont affichées sur les fiches ci-dessous. +

+ )} diff --git a/src/app/backend/actions.ts b/src/app/backend/actions.ts index a6581e6..55143c9 100644 --- a/src/app/backend/actions.ts +++ b/src/app/backend/actions.ts @@ -525,6 +525,9 @@ export async function updateMember(formData: FormData) { name: String(formData.get("name") ?? "").trim(), email: String(formData.get("email") ?? "").trim(), contactEmail: String(formData.get("contactEmail") ?? "").trim().toLowerCase() || null, + contactFirstName: String(formData.get("contactFirstName") ?? "").trim() || null, + contactLastName: String(formData.get("contactLastName") ?? "").trim() || null, + contactPhone: String(formData.get("contactPhone") ?? "").trim() || null, categoryId, city, address: String(formData.get("address") ?? "").trim() || null, @@ -543,6 +546,7 @@ export async function updateMember(formData: FormData) { revalidatePath("/backend/adherents"); revalidatePath("/adherents/[id]", "page"); + revalidatePath("/annuaire"); revalidatePath("/"); } @@ -1095,6 +1099,9 @@ export async function updateOwnProfile(formData: FormData) { .set({ name, contactEmail: String(formData.get("contactEmail") ?? "").trim().toLowerCase() || null, + contactFirstName: String(formData.get("contactFirstName") ?? "").trim() || null, + contactLastName: String(formData.get("contactLastName") ?? "").trim() || null, + contactPhone: String(formData.get("contactPhone") ?? "").trim() || null, description, address: String(formData.get("address") ?? "").trim() || null, postalCode: String(formData.get("postalCode") ?? "").trim() || null, diff --git a/src/app/backend/adherents/[id]/page.tsx b/src/app/backend/adherents/[id]/page.tsx index 2941b8a..2ac95ca 100644 --- a/src/app/backend/adherents/[id]/page.tsx +++ b/src/app/backend/adherents/[id]/page.tsx @@ -6,6 +6,7 @@ import { db } from "@/db"; import { categories, members, users } from "@/db/schema"; import { can } from "@/lib/rbac"; import { ImageField } from "@/components/ImageField"; +import { MemberContactFields } from "@/components/MemberContactFields"; import { HoursEditor } from "@/components/HoursEditor"; import { TagsField } from "@/components/TagsField"; import { communeOptions } from "@/lib/communes"; @@ -132,6 +133,12 @@ export default async function EditMemberPage({ + +
diff --git a/src/app/backend/espace/page.tsx b/src/app/backend/espace/page.tsx index 4c3502e..4c2abca 100644 --- a/src/app/backend/espace/page.tsx +++ b/src/app/backend/espace/page.tsx @@ -9,6 +9,7 @@ import { ImageField } from "@/components/ImageField"; import { ImageConsentForm } from "@/components/ImageConsentForm"; import { HoursEditor } from "@/components/HoursEditor"; import { TagsField } from "@/components/TagsField"; +import { MemberContactFields } from "@/components/MemberContactFields"; import { communeOptions } from "@/lib/communes"; import { saveImageConsent, updateOwnProfile } from "../actions"; import { EspaceHeader } from "./EspaceHeader"; @@ -167,6 +168,12 @@ export default async function EspacePage() {
+ +
diff --git a/src/app/globals.css b/src/app/globals.css index 5332c88..d30aff9 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1403,6 +1403,48 @@ img { color: var(--muted-4); font-size: 13px; } +/* Référent de l'adhérent : rendu seulement quand le visiteur est connecté — + le HTML public ne contient jamais ce bloc, rien n'est masqué en CSS. */ +.contact-referent { + margin-top: 12px; + padding: 12px; + border: 1px dashed var(--border); + border-radius: 12px; + background: #fff; +} +.contact-referent__label { + display: flex; + align-items: center; + gap: 7px; + color: var(--muted-2); + font-size: 10.5px; + font-weight: 800; + letter-spacing: 0.1em; + text-transform: uppercase; +} +.contact-referent__name { + margin-top: 7px; + color: var(--ink-strong); + font-size: 14px; + font-weight: 700; +} +.contact-referent__phone { + display: inline-block; + margin-top: 3px; + color: var(--blue); + font-size: 13.5px; + font-weight: 700; + text-decoration: none; +} +.contact-referent__phone:hover { + text-decoration: underline; +} +.contact-referent__hint { + margin-top: 7px; + color: var(--muted-4); + font-size: 11.5px; + line-height: 1.45; +} /* ---- Éditeur hebdomadaire des horaires ---- */ .hours-editor { diff --git a/src/components/MemberCard.tsx b/src/components/MemberCard.tsx index cee61ec..6a37634 100644 --- a/src/components/MemberCard.tsx +++ b/src/components/MemberCard.tsx @@ -1,6 +1,7 @@ import Link from "next/link"; import { VitrineImage } from "@/components/VitrineImage"; import { memberPath } from "@/lib/seo"; +import type { MemberContact } from "@/lib/member-contact"; export type MemberCardData = { id: number; @@ -44,8 +45,21 @@ const clampLines = (lines: number) => /** * Carte d'un adhérent dans une grille (annuaire, page métier). Composant sans * état : il se rend aussi bien côté serveur que dans le filtre client. + * + * `contact` (nom du référent, ligne directe) n'est passé qu'aux visiteurs + * connectés : la page ne le rapatrie pas du tout pour un visiteur anonyme, il + * n'y a donc rien à masquer en CSS. Le numéro reste en texte simple — un + * `tel:` imbriqué dans le lien de la carte serait un lien dans un lien. */ -export function MemberCard({ m, index = 0 }: { m: MemberCardData; index?: number }) { +export function MemberCard({ + m, + index = 0, + contact = null, +}: { + m: MemberCardData; + index?: number; + contact?: MemberContact | null; +}) { const location = [m.address, m.city].filter(Boolean).join(" · "); return ( {location}
+ {contact && ( +
+
+ Contact adhérent +
+ {contact.name && ( +
+ {contact.name} +
+ )} + {contact.phone && ( +
{contact.phone}
+ )} +
+ )} ); diff --git a/src/components/MemberContactFields.tsx b/src/components/MemberContactFields.tsx new file mode 100644 index 0000000..4a77057 --- /dev/null +++ b/src/components/MemberContactFields.tsx @@ -0,0 +1,52 @@ +/** + * Coordonnées du référent de l'adhérent (nom, prénom, ligne directe), dans les + * deux formulaires de fiche : l'espace adhérent et l'écran staff. + * + * Ces champs ne sont jamais publiés : l'annuaire et la fiche publique ne les + * affichent qu'à un visiteur connecté (`src/lib/member-contact.ts`). + */ +export function MemberContactFields({ + firstName, + lastName, + phone, +}: { + firstName: string | null; + lastName: string | null; + phone: string | null; +}) { + return ( +
+ + Contact adhérent + +

+ La personne à joindre et sa ligne directe. Affiché sur l'annuaire et sur la fiche + uniquement aux visiteurs connectés : rien de tout cela n'apparaît sur + le site public ni dans les moteurs de recherche. +

+
+
+ + +
+
+ + +
+
+ + +
+
+
+ ); +} diff --git a/src/db/demo-data.ts b/src/db/demo-data.ts index 1861be8..342da3b 100644 --- a/src/db/demo-data.ts +++ b/src/db/demo-data.ts @@ -19,6 +19,9 @@ export function demoMembers(catId: (slug: string) => number | null) { address: "12 rue de la République", postalCode: "54390", phone: "03 83 49 00 00", + contactFirstName: "Julien", + contactLastName: "Marchal", + contactPhone: "06 12 34 56 78", website: "https://www.aubonpain-frouard.fr", memberSince: 2021, tags: "Levain naturel, Produits locaux, Fait maison, Sans conservateur", @@ -36,6 +39,9 @@ export function demoMembers(catId: (slug: string) => number | null) { address: "3 place Stanislas", postalCode: "54340", phone: "03 83 24 00 00", + contactFirstName: "Sophie", + contactLastName: "Renard", + contactPhone: "06 98 76 54 32", memberSince: 2019, tags: "Cuisine de saison, Brunch, Terrasse, Produits frais", hours: "Lundi – Vendredi|9h – 18h\nSamedi|9h – 19h\nDimanche|10h – 15h", @@ -49,6 +55,9 @@ export function demoMembers(catId: (slug: string) => number | null) { categoryId: catId("mode-beaute"), city: "Champigneulles", address: "7 av. des Tilleuls", + contactFirstName: "Émilie", + contactLastName: "Bertrand", + contactPhone: "06 45 67 89 01", description: "Salon de coiffure & soins, sur rendez-vous du mardi au samedi.", status: "active" as const, highlighted: true, diff --git a/src/db/schema.ts b/src/db/schema.ts index 4dd506a..6c5a598 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -62,6 +62,13 @@ export const members = pgTable("members", { email: varchar("email", { length: 200 }).notNull(), // E-mail public de la fiche ; à vide, la fiche retombe sur `email`. contactEmail: varchar("contact_email", { length: 200 }), + // Référent de l'adhérent : la personne à joindre au sein du commerce, avec + // sa ligne directe. Contrairement aux coordonnées de l'établissement, ces + // trois champs ne sont montrés qu'aux visiteurs connectés (cf. + // `src/lib/member-contact.ts`), jamais dans le HTML public ni le JSON-LD. + contactFirstName: varchar("contact_first_name", { length: 120 }), + contactLastName: varchar("contact_last_name", { length: 120 }), + contactPhone: varchar("contact_phone", { length: 40 }), categoryId: integer("category_id").references(() => categories.id), city: varchar("city", { length: 120 }), address: varchar("address", { length: 240 }), diff --git a/src/lib/member-contact.ts b/src/lib/member-contact.ts new file mode 100644 index 0000000..5e642da --- /dev/null +++ b/src/lib/member-contact.ts @@ -0,0 +1,56 @@ +/** + * Référent d'un adhérent : la personne à joindre, sa ligne directe. + * + * Ces informations sont **réservées aux visiteurs connectés** : elles ne + * doivent jamais partir dans le HTML public, le JSON-LD ou les métadonnées. + * Le module est pur — le filtrage par session se fait chez l'appelant, qui + * n'appelle `memberContact()` qu'après avoir vérifié la session, et verrouillé + * par `tests/member-contact.test.ts`. + */ + +export type MemberContactSource = { + contactFirstName?: string | null; + contactLastName?: string | null; + contactPhone?: string | null; +}; + +export type MemberContact = { + /** « Prénom Nom », ou l'un des deux seulement. */ + name: string | null; + phone: string | null; +}; + +function clean(value: string | null | undefined) { + const trimmed = (value ?? "").replace(/\s+/g, " ").trim(); + return trimmed || null; +} + +/** « Prénom Nom » à partir des deux champs, `null` si les deux sont vides. */ +export function contactName(source: MemberContactSource): string | null { + return clean([clean(source.contactFirstName), clean(source.contactLastName)].filter(Boolean).join(" ")); +} + +/** + * Le référent affichable, ou `null` s'il n'y a rien à montrer : le bloc + * « Contact adhérent » disparaît alors plutôt que d'afficher un cadre vide. + */ +export function memberContact(source: MemberContactSource): MemberContact | null { + const name = contactName(source); + const phone = clean(source.contactPhone); + if (!name && !phone) return null; + return { name, phone }; +} + +/** + * `tel:` à partir d'un numéro saisi librement (« 03 83 24 .. », « +33 3 83 … »). + * Seuls les chiffres sont conservés, avec l'indicatif international s'il ouvre + * le numéro ; une saisie sans aucun chiffre ne produit pas de lien. + */ +export function telHref(phone: string | null | undefined): string | null { + const raw = clean(phone); + if (!raw) return null; + const international = raw.startsWith("+"); + const digits = raw.replace(/\D/g, ""); + if (!digits) return null; + return `tel:${international ? "+" : ""}${digits}`; +} diff --git a/src/lib/queries.ts b/src/lib/queries.ts index a9418ae..b6f714f 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -1,6 +1,7 @@ import { and, asc, count, desc, eq, sql } from "drizzle-orm"; import { db } from "@/db"; import { categories, members, promotions } from "@/db/schema"; +import { memberContact, type MemberContact } from "@/lib/member-contact"; /** * Promotion réellement visible par le public : validée **et** dans sa période @@ -135,6 +136,46 @@ export async function getPublicMember(id: number) { return m ?? null; } +/** + * Référents des adhérents actifs, indexés par fiche. + * + * Volontairement séparé des lectures publiques (`getActiveMembersWithCategory`, + * `getPublicMember`) : nom, prénom et ligne directe ne sont réservés aux + * visiteurs connectés que parce qu'aucune requête publique ne les rapatrie. + * L'appelant vérifie la session **avant** d'appeler. + */ +export async function getMemberContacts(): Promise> { + const rows = await db + .select({ + id: members.id, + contactFirstName: members.contactFirstName, + contactLastName: members.contactLastName, + contactPhone: members.contactPhone, + }) + .from(members) + .where(eq(members.status, "active")); + + const byMember = new Map(); + for (const row of rows) { + const contact = memberContact(row); + if (contact) byMember.set(row.id, contact); + } + return byMember; +} + +/** Référent d'une fiche. Même règle que `getMemberContacts()` : session vérifiée d'abord. */ +export async function getMemberContact(id: number): Promise { + const [row] = await db + .select({ + contactFirstName: members.contactFirstName, + contactLastName: members.contactLastName, + contactPhone: members.contactPhone, + }) + .from(members) + .where(eq(members.id, id)); + return row ? memberContact(row) : null; +} + export async function getMemberLivePromotions(memberId: number) { return db .select({ diff --git a/tests/member-contact.test.ts b/tests/member-contact.test.ts new file mode 100644 index 0000000..41e8917 --- /dev/null +++ b/tests/member-contact.test.ts @@ -0,0 +1,35 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { contactName, memberContact, telHref } from "../src/lib/member-contact"; + +test("le référent joint prénom et nom", () => { + assert.equal(contactName({ contactFirstName: "Marie", contactLastName: "Durand" }), "Marie Durand"); + assert.equal(contactName({ contactFirstName: " Marie ", contactLastName: " Durand " }), "Marie Durand"); + assert.equal(contactName({ contactFirstName: "Marie" }), "Marie"); + assert.equal(contactName({ contactLastName: "Durand" }), "Durand"); + assert.equal(contactName({}), null); + assert.equal(contactName({ contactFirstName: " ", contactLastName: null }), null); +}); + +test("un référent sans nom ni téléphone n'existe pas", () => { + assert.equal(memberContact({}), null); + assert.equal(memberContact({ contactFirstName: " ", contactPhone: "" }), null); + assert.deepEqual(memberContact({ contactPhone: "03 83 24 10 10" }), { + name: null, + phone: "03 83 24 10 10", + }); + assert.deepEqual(memberContact({ contactLastName: "Durand", contactPhone: null }), { + name: "Durand", + phone: null, + }); +}); + +test("le lien tel: ne garde que les chiffres et l'indicatif", () => { + assert.equal(telHref("03 83 24 10 10"), "tel:0383241010"); + assert.equal(telHref("+33 3 83 24 10 10"), "tel:+33383241010"); + assert.equal(telHref("03.83.24.10.10"), "tel:0383241010"); + assert.equal(telHref("sur rendez-vous"), null); + assert.equal(telHref(null), null); + assert.equal(telHref(" "), null); +}); diff --git a/tests/security.test.ts b/tests/security.test.ts index da4c9c3..8844b92 100644 --- a/tests/security.test.ts +++ b/tests/security.test.ts @@ -1,4 +1,5 @@ import { strict as assert } from "node:assert"; +import { readFileSync } from "node:fs"; import { describe, it } from "node:test"; import { activityNodes, sanitizeActivityMessage } from "../src/lib/activity"; @@ -109,3 +110,46 @@ describe("Chiffrement des secrets réseaux", () => { assert.equal(tryDecryptSecret("pas-un-secret-chiffré"), null); }); }); + +describe("Coordonnées du référent — jamais dans les lectures publiques", () => { + const source = readFileSync(new URL("../src/lib/queries.ts", import.meta.url), "utf8"); + + /** Corps d'une fonction exportée de `queries.ts`, jusqu'à la suivante. */ + function bodyOf(name: string): string { + const start = source.indexOf(`export async function ${name}(`); + assert.notEqual(start, -1, `${name} introuvable dans src/lib/queries.ts`); + const next = source.indexOf("\nexport ", start + 1); + return source.slice(start, next === -1 ? source.length : next); + } + + // Nom, prénom et ligne directe de l'adhérent ne sont montrés qu'à un visiteur + // connecté. La garantie tient à une seule chose : aucune requête servant une + // page publique ne les rapatrie — sinon ils partiraient dans le HTML, le + // JSON-LD ou les props du composant client de l'annuaire. + const publicReads = [ + "getActiveMembersWithCategory", + "getActiveMembersByCategory", + "getRotatingActiveMembers", + "getPublicMember", + "getLivePromotions", + ]; + + for (const name of publicReads) { + it(`${name} ne lit pas le référent`, () => { + const body = bodyOf(name); + for (const column of ["contactFirstName", "contactLastName", "contactPhone"]) { + assert.ok( + !body.includes(column), + `${name} sélectionne ${column} : ces coordonnées deviendraient publiques.` + ); + } + }); + } + + it("les lectures réservées existent et sont séparées", () => { + for (const name of ["getMemberContacts", "getMemberContact"]) { + const body = bodyOf(name); + assert.ok(body.includes("contactPhone"), `${name} devrait lire le référent`); + } + }); +});