diff --git a/CLAUDE.md b/CLAUDE.md index f01a1a3..45408cd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,12 +53,20 @@ site sans traitement supplémentaire. - `src/lib/social.ts` publie une promo sur la page Facebook (Graph API) ou LinkedIn (Posts API). Jetons **uniquement** en variables d'environnement, jamais - en base ni dans le backoffice. Réseau non configuré = bouton masqué. + en base ni dans le backoffice. Réseau non configuré = case masquée. - Les images de promo sont stockées en data-URI : l'upload se fait donc en binaire (multipart pour Facebook, Images API en 3 étapes pour LinkedIn), pas par URL. -- Chaque tentative est journalisée dans `social_posts` (succès ou échec) ; un - échec n'interrompt pas l'action, il s'affiche sur la carte de la promo. +- La diffusion est déclenchée **par la validation**, pas par un bouton : + `promotions.share_facebook` / `share_linkedin` sont choisis par l'adhérent, + ajustables par le modérateur dans le formulaire « Valider », puis figés + (`status !== 'pending'`). +- `publishPromoShares()` dans `backend/actions.ts` est le seul point de + publication. Elle ne lève jamais et ignore tout réseau ayant déjà une ligne + `social_posts` en `posted` : c'est la garde anti-republication, qui couvre + aussi le cycle suspension → remise en ligne. +- `retryPromoShare` ne sert qu'au rattrapage d'un échec sur un réseau déjà + choisi ; il ne peut pas élargir la diffusion. - Les URLs publiques des pages FB/LinkedIn sont des `site_settings` (`association_facebook`, `association_linkedin`), éditables dans Paramètres. diff --git a/README.md b/README.md index d5819fb..0c93376 100644 --- a/README.md +++ b/README.md @@ -83,11 +83,24 @@ charge des données de démonstration. Ensuite : ## Publication sur Facebook / LinkedIn -Depuis **Backend › Promotions**, une promotion `live` peut être publiée sur la -page Facebook et/ou la page LinkedIn de l'association (image + texte + lien vers -la fiche adhérent). Chaque tentative est tracée dans la table `social_posts` -(succès avec le lien du post, ou échec avec le message d'erreur affiché sur la -carte). +La diffusion est **pilotée par la validation**, jamais déclenchée à la main : + +1. L'adhérent coche Facebook et/ou LinkedIn en soumettant sa promotion. Rien + n'est publié à ce stade ; il peut modifier son choix tant que la promo est + `pending`. +2. Le modérateur voit ces cases pré-cochées sur la carte en attente et peut les + ajuster — c'est le dernier moment où le choix est modifiable. +3. **Valider** met la promo en ligne *et* publie sur les réseaux retenus (image + + texte + lien vers la fiche adhérent). Après validation, le choix est figé. + +Chaque tentative est tracée dans `social_posts` : succès avec le lien du post, ou +échec avec le message d'erreur affiché sur la carte. En cas d'échec, un bouton +**Réessayer** apparaît, limité aux réseaux déjà choisis — il ne permet pas +d'élargir la diffusion. + +Une promotion n'est jamais publiée deux fois : un réseau ayant déjà une +publication réussie est systématiquement ignoré, y compris sur un cycle +suspension → remise en ligne. Les jetons d'accès sont des **secrets** : ils se configurent uniquement par variables d'environnement (`FACEBOOK_PAGE_ID`, `FACEBOOK_PAGE_ACCESS_TOKEN`, diff --git a/drizzle/0007_foamy_vindicator.sql b/drizzle/0007_foamy_vindicator.sql new file mode 100644 index 0000000..e9f8a4b --- /dev/null +++ b/drizzle/0007_foamy_vindicator.sql @@ -0,0 +1,2 @@ +ALTER TABLE "promotions" ADD COLUMN "share_facebook" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "promotions" ADD COLUMN "share_linkedin" boolean DEFAULT false NOT NULL; \ No newline at end of file diff --git a/drizzle/meta/0007_snapshot.json b/drizzle/meta/0007_snapshot.json new file mode 100644 index 0000000..c323092 --- /dev/null +++ b/drizzle/meta/0007_snapshot.json @@ -0,0 +1,1261 @@ +{ + "id": "42826c19-7d81-478b-9543-aa1873ac15f4", + "prevId": "0cd7b2e9-77c5-4a1b-9ea6-d4471e464f2d", + "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 + }, + "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'" + }, + "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 + }, + "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_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 + }, + "temp_password": { + "name": "temp_password", + "type": "varchar(60)", + "primaryKey": false, + "notNull": false + }, + "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" + ] + }, + "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 325ccd5..d848272 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1784930674056, "tag": "0006_tiresome_clea", "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1784931989372, + "tag": "0007_foamy_vindicator", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/app/backend/actions.ts b/src/app/backend/actions.ts index 9dc9961..81d7db3 100644 --- a/src/app/backend/actions.ts +++ b/src/app/backend/actions.ts @@ -111,6 +111,97 @@ function revalidatePromoPaths(memberId?: number | null) { if (memberId) revalidatePath(`/adherents/${memberId}`); } +/** Réseaux demandés pour une promo, dans l'ordre d'affichage. */ +function requestedNetworks(promo: { shareFacebook: boolean; shareLinkedin: boolean }): SocialNetwork[] { + return SOCIAL_NETWORKS.filter((n) => + n === "facebook" ? promo.shareFacebook : promo.shareLinkedin + ); +} + +function readShareTargets(formData: FormData) { + return { + shareFacebook: formData.get("shareFacebook") === "on", + shareLinkedin: formData.get("shareLinkedin") === "on", + }; +} + +/** + * Publie une promotion sur les réseaux demandés. Ne lève jamais : un réseau + * indisponible ne doit pas faire échouer la mise en ligne, l'échec est + * enregistré dans `social_posts` et rattrapable depuis le backoffice. + * + * Un réseau déjà publié avec succès est ignoré : c'est ce qui empêche toute + * republication, y compris sur un cycle suspension → remise en ligne. + */ +async function publishPromoShares( + promoId: number, + networks: SocialNetwork[], + userId: number | null +) { + if (networks.length === 0) return; + + const [promo] = await db + .select({ + title: promotions.title, + text: promotions.text, + badge: promotions.badge, + validUntil: promotions.validUntil, + imageUrl: promotions.imageUrl, + memberId: promotions.memberId, + memberName: members.name, + }) + .from(promotions) + .leftJoin(members, eq(promotions.memberId, members.id)) + .where(eq(promotions.id, promoId)); + if (!promo) return; + + const done = await db + .select({ network: socialPosts.network }) + .from(socialPosts) + .where(and(eq(socialPosts.promotionId, promoId), eq(socialPosts.status, "posted"))); + const alreadyPosted = new Set(done.map((d) => d.network)); + + for (const network of networks) { + if (alreadyPosted.has(network)) continue; + + if (!isNetworkConfigured(network)) { + await db.insert(socialPosts).values({ + promotionId: promoId, + network, + status: "failed", + error: `${SOCIAL_LABELS[network]} n'est pas configuré sur ce serveur.`, + postedById: userId, + }); + continue; + } + + try { + const result = await publishPromoToNetwork(network, promo); + await db.insert(socialPosts).values({ + promotionId: promoId, + network, + status: "posted", + externalId: result.externalId, + url: result.url, + postedById: userId, + }); + await logActivity( + `Promotion « ${promo.title} » publiée sur ${SOCIAL_LABELS[network]}`, + "#2C6FB3" + ); + } catch (error) { + const message = error instanceof Error ? error.message : "Erreur inconnue"; + await db.insert(socialPosts).values({ + promotionId: promoId, + network, + status: "failed", + error: message.slice(0, 2000), + postedById: userId, + }); + } + } +} + // ---- Promotion moderation ---- export async function moderatePromo(formData: FormData) { const { role, userId, name } = await requireRole(); @@ -124,11 +215,23 @@ export async function moderatePromo(formData: FormData) { if (!promo) return; if (action === "approve") { + // Le modérateur peut ajuster les réseaux demandés par l'adhérent, mais + // uniquement ici : après validation le choix est figé. + const targets = readShareTargets(formData); await db .update(promotions) - .set({ status: "live", suspendedBy: null, suspendedById: null, suspendedAt: null }) + .set({ + status: "live", + ...targets, + suspendedBy: null, + suspendedById: null, + suspendedAt: null, + }) .where(eq(promotions.id, id)); await logActivity(`Promotion « ${promo.title} » validée et mise en ligne`, "#1f8a5b"); + // Mise en ligne committée avant l'appel réseau : si un réseau répond mal, + // la promo est quand même publiée sur le site. + await publishPromoShares(id, requestedNetworks(targets), userId); } else if (action === "suspend") { // Suspension par l'association : la promotion sort du site public et // l'adhérent ne peut pas la remettre en ligne lui-même. @@ -193,63 +296,52 @@ export async function setOwnPromoSuspension(formData: FormData) { revalidatePromoPaths(memberId); } -// ---- Publication d'une promotion sur Facebook / LinkedIn ---- -export async function sharePromoToSocial(formData: FormData) { - const { role, userId, name } = await requireRole(); +// ---- Rattrapage d'une publication en échec ---- +// Ne permet pas d'élargir la diffusion : uniquement de retenter un réseau déjà +// choisi avant la validation, et qui n'est pas encore passé. +export async function retryPromoShare(formData: FormData) { + const { role, userId } = await requireRole(); if (!can(role, "publishSocial")) throw new Error("Accès refusé"); const id = Number(formData.get("id")); const network = String(formData.get("network")) as SocialNetwork; if (!id || !SOCIAL_NETWORKS.includes(network)) return; - if (!isNetworkConfigured(network)) { - throw new Error(`${SOCIAL_LABELS[network]} n'est pas configuré sur ce serveur.`); - } - const [promo] = await db - .select({ - id: promotions.id, - title: promotions.title, - text: promotions.text, - badge: promotions.badge, - validUntil: promotions.validUntil, - imageUrl: promotions.imageUrl, - status: promotions.status, - memberId: promotions.memberId, - memberName: members.name, - }) - .from(promotions) - .leftJoin(members, eq(promotions.memberId, members.id)) - .where(eq(promotions.id, id)); + const [promo] = await db.select().from(promotions).where(eq(promotions.id, id)); if (!promo) throw new Error("Promotion introuvable."); if (promo.status !== "live") { throw new Error("Seule une promotion en ligne peut être publiée sur les réseaux."); } - - try { - const result = await publishPromoToNetwork(network, promo); - await db.insert(socialPosts).values({ - promotionId: promo.id, - network, - status: "posted", - externalId: result.externalId, - url: result.url, - postedById: userId, - }); - await logActivity( - `Promotion « ${promo.title} » publiée sur ${SOCIAL_LABELS[network]} par ${name}`, - "#2C6FB3" + if (!requestedNetworks(promo).includes(network)) { + throw new Error( + `${SOCIAL_LABELS[network]} n'a pas été demandé pour cette promotion : le choix est figé depuis sa validation.` ); - } catch (error) { - const message = error instanceof Error ? error.message : "Erreur inconnue"; - await db.insert(socialPosts).values({ - promotionId: promo.id, - network, - status: "failed", - error: message.slice(0, 2000), - postedById: userId, - }); } + await publishPromoShares(id, [network], userId); + revalidatePath("/backend/promotions"); + revalidatePath("/backend/espace"); +} + +// ---- Member space: choisir les réseaux tant que la promo est en attente ---- +export async function setOwnPromoShareTargets(formData: FormData) { + const { memberId } = await requireRole(); + if (!memberId) throw new Error("Aucune fiche adhérent liée à votre compte."); + + const id = Number(formData.get("id")); + if (!id) return; + + const [promo] = await db + .select({ status: promotions.status }) + .from(promotions) + .where(and(eq(promotions.id, id), eq(promotions.memberId, memberId))); + if (!promo) throw new Error("Promotion introuvable."); + if (promo.status !== "pending") { + throw new Error("La diffusion n'est plus modifiable une fois la promotion validée."); + } + + await db.update(promotions).set(readShareTargets(formData)).where(eq(promotions.id, id)); + revalidatePath("/backend/espace"); revalidatePath("/backend/promotions"); } @@ -265,6 +357,7 @@ export async function publishPromo(formData: FormData) { const badge = String(formData.get("badge") ?? "").trim() || null; const imageUrl = String(formData.get("imageUrl") ?? "") || null; + // Réseaux souhaités : rien n'est publié ici, la diffusion attend la validation. await db.insert(promotions).values({ title, text, @@ -273,6 +366,7 @@ export async function publishPromo(formData: FormData) { imageUrl, memberId: memberId ?? null, status: "pending", + ...readShareTargets(formData), }); await logActivity(`${name} a soumis une promotion « ${title} »`, "#E0A63C"); diff --git a/src/app/backend/espace/MemberSpaceForm.tsx b/src/app/backend/espace/MemberSpaceForm.tsx index d26ecfc..de7d29a 100644 --- a/src/app/backend/espace/MemberSpaceForm.tsx +++ b/src/app/backend/espace/MemberSpaceForm.tsx @@ -4,16 +4,25 @@ import { useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { publishPromo } from "../actions"; import { PromoImage } from "@/components/PromoImage"; +import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons"; +import type { SocialNetwork } from "@/lib/social"; const STRIPE_WARM = "repeating-linear-gradient(45deg,#efe9da,#efe9da 12px,#e6ddc9 12px,#e6ddc9 24px)"; +const NETWORK_LABELS: Record = { + facebook: "Facebook", + linkedin: "LinkedIn", +}; + export function MemberSpaceForm({ memberName, categories, + networks = [], }: { memberName: string; categories: string[]; + networks?: SocialNetwork[]; }) { const CATEGORIES = categories.length > 0 ? categories : ["Autre"]; const router = useRouter(); @@ -22,10 +31,16 @@ export function MemberSpaceForm({ const [cat, setCat] = useState(CATEGORIES[0]); const [badge, setBadge] = useState(""); const [imgData, setImgData] = useState(""); + const [shares, setShares] = useState([]); const [submitted, setSubmitted] = useState(false); const [pending, setPending] = useState(false); const fileRef = useRef(null); + function toggleShare(network: SocialNetwork) { + setShares((s) => (s.includes(network) ? s.filter((n) => n !== network) : [...s, network])); + setSubmitted(false); + } + function onFile(e: React.ChangeEvent) { const f = e.target.files?.[0]; if (!f) return; @@ -42,6 +57,7 @@ export function MemberSpaceForm({ setText(""); setBadge(""); setImgData(""); + setShares([]); setSubmitted(false); if (fileRef.current) fileRef.current.value = ""; } @@ -55,6 +71,8 @@ export function MemberSpaceForm({ fd.set("category", cat); fd.set("badge", badge); fd.set("imageUrl", imgData); + if (shares.includes("facebook")) fd.set("shareFacebook", "on"); + if (shares.includes("linkedin")) fd.set("shareLinkedin", "on"); await publishPromo(fd); setPending(false); setSubmitted(true); @@ -153,6 +171,50 @@ export function MemberSpaceForm({ />
{text.length} / 240 caractères
+ {networks.length > 0 && ( +
+
+ Diffusion sur les réseaux Plein R +
+
+ Choisissez où l'association relaiera votre offre. La publication n'a lieu + qu'une fois la promotion validée — et le choix n'est plus modifiable ensuite. +
+
+ {networks.map((network) => { + const active = shares.includes(network); + return ( + + ); + })} +
+
+ )} +
); } + +/** + * État de diffusion d'une promo côté adhérent : modifiable tant qu'elle est en + * attente, purement informatif dès qu'elle est validée. + */ +function PromoShareState({ + promo, + networks, + lastShare, + editable, +}: { + promo: { id: number; shareFacebook: boolean; shareLinkedin: boolean }; + networks: SocialNetwork[]; + lastShare: Map; + editable: boolean; +}) { + const wanted = (n: SocialNetwork) => (n === "facebook" ? promo.shareFacebook : promo.shareLinkedin); + const chosen = networks.filter(wanted); + + if (editable) { + return ( +
+ +
+ Diffusion prévue après validation — modifiable tant que la promotion est en attente. +
+
+ {networks.map((network) => ( + + ))} + +
+
+ ); + } + + if (chosen.length === 0) return null; + + return ( +
+ {chosen.map((network) => { + const last = lastShare.get(`${promo.id}:${network}`); + const posted = last?.status === "posted"; + const failed = last?.status === "failed"; + const detail = posted ? "publié" : failed ? "échec de publication" : "en attente"; + const color = posted ? "#1f8a5b" : failed ? "#d8472b" : "#9a6638"; + const chip = ( + + + {SOCIAL_LABELS[network]} · {detail} + + ); + return posted && last?.url ? ( + + {chip} + + ) : ( + {chip} + ); + })} +
+ ); +} diff --git a/src/app/backend/promotions/page.tsx b/src/app/backend/promotions/page.tsx index 7ff6d07..59a6871 100644 --- a/src/app/backend/promotions/page.tsx +++ b/src/app/backend/promotions/page.tsx @@ -4,9 +4,10 @@ import { auth } from "@/auth"; import { db } from "@/db"; import { members, promotions, socialPosts, users } from "@/db/schema"; import { can } from "@/lib/rbac"; -import { configuredNetworks, SOCIAL_LABELS, type SocialNetwork } from "@/lib/social"; -import { moderatePromo, sharePromoToSocial } from "../actions"; +import { configuredNetworks, SOCIAL_LABELS } from "@/lib/social"; +import { moderatePromo, retryPromoShare } from "../actions"; import { PromoImage } from "@/components/PromoImage"; +import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons"; export const dynamic = "force-dynamic"; @@ -43,6 +44,8 @@ export default async function PromotionsPage() { imageUrl: promotions.imageUrl, suspendedBy: promotions.suspendedBy, suspendedAt: promotions.suspendedAt, + shareFacebook: promotions.shareFacebook, + shareLinkedin: promotions.shareLinkedin, memberName: members.name, }) .from(promotions) @@ -113,6 +116,9 @@ export default async function PromotionsPage() { const isPending = p.status === "pending"; const isSuspended = p.status === "suspended"; const chip = STATUS_CHIP[p.status] ?? STATUS_CHIP.pending; + const chosenNetworks = networks.filter((n) => + n === "facebook" ? p.shareFacebook : p.shareLinkedin + ); return (
+
+ {/* Les cases vivent dans le formulaire « Valider » : c'est le + seul et dernier moment où la diffusion est ajustable. */}
+ {networks.length > 0 && ( +
+
+ Diffusion demandée +
+
+ {networks.map((network) => ( + + ))} +
+
+ Publié dès la validation. Ce choix n'est plus modifiable ensuite. +
+
+ )}
-
+ {/* Aligné en bas pour rester au niveau du bouton Valider, + que le bloc diffusion rend plus haut. */} +
)} - {networks.length > 0 && ( + {!isPending && chosenNetworks.length > 0 && (
- Partager sur les réseaux + Diffusion réseaux
-
- {networks.map((network) => ( - - ))} -
- {networks.map((network) => { + {chosenNetworks.map((network) => { const last = lastShare.get(`${p.id}:${network}`); - if (!last) return null; + const posted = last?.status === "posted"; return ( -
- {last.status === "posted" ? ( - <> - ✓ {SOCIAL_LABELS[network]} · {fmtDate(last.createdAt)} - {last.authorName ? ` · ${last.authorName}` : ""} - {last.url && ( - <> - {" "} - - voir - - - )} - - ) : ( - <>✕ {SOCIAL_LABELS[network]} — échec : {last.error?.slice(0, 160)} +
+
+ + + + {SOCIAL_LABELS[network]} ·{" "} + {posted ? `publié le ${fmtDate(last!.createdAt)}` : "non publié"} + {posted && last?.url && ( + + voir + + )} +
+ {!posted && last?.error && ( +
+ {last.error.slice(0, 200)} +
+ )} + {!posted && p.status === "live" && ( + + + + + )}
); @@ -279,42 +324,3 @@ export default async function PromotionsPage() { ); } -function ShareButton({ - network, - promoId, - disabled, - last, -}: { - network: SocialNetwork; - promoId: number; - disabled: boolean; - last?: { status: string }; -}) { - const alreadyPosted = last?.status === "posted"; - const brand = network === "facebook" ? "#1877F2" : "#0A66C2"; - return ( -
- - - -
- ); -} diff --git a/src/db/schema.ts b/src/db/schema.ts index 9bd4ee3..a28370a 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -100,6 +100,10 @@ export const promotions = pgTable("promotions", { memberId: integer("member_id").references(() => members.id), status: promoStatusEnum("status").notNull().default("pending"), validUntil: varchar("valid_until", { length: 120 }), + // Réseaux demandés à la soumission. Le choix reste modifiable tant que la + // promo est « pending » ; la validation le fige ET déclenche la publication. + shareFacebook: boolean("share_facebook").notNull().default(false), + shareLinkedin: boolean("share_linkedin").notNull().default(false), suspendedBy: promoSuspendedByEnum("suspended_by"), suspendedById: integer("suspended_by_id").references(() => users.id, { onDelete: "set null" }), suspendedAt: timestamp("suspended_at", { withTimezone: true }),