From e3a4771edfd9bf61b5ec22b7af016745691ef6bf Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 22:20:51 +0000 Subject: [PATCH] =?UTF-8?q?Refonte=20du=20bloc=20infos=20pratiques,=20susp?= =?UTF-8?q?ension=20des=20promos=20et=20publication=20r=C3=A9seaux?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bloc « Informations pratiques » (fiche adhérent) - Suppression des dégradés hors palette (radial doré de la section, dégradés crème/bleu des deux sous-cartes) au profit du vocabulaire visuel du site : carte blanche bordée, sous-cartes plates sur le fond de champ, variables CSS. - La pastille « Préparer votre visite » devient une puce arrondie identique aux badges du reste du site ; le chip d'ouverture reprend les teintes de statut. - Les glyphes texte ◷ et ↗ sont remplacés par des icônes SVG cohérentes. Suspension des promotions - Nouveau statut `suspended` + colonnes `suspended_by` / `suspended_by_id` / `suspended_at`. - L'adhérent suspend et réactive ses propres promotions depuis Mon espace ; l'association suspend et réactive n'importe laquelle depuis le backoffice. - Une suspension décidée par l'association ne peut pas être levée par l'adhérent : le motif est affiché dans son espace. - Les lectures publiques filtrant déjà sur `live`, une promo suspendue quitte immédiatement l'accueil, l'annuaire et la fiche. Publication Facebook / LinkedIn - `src/lib/social.ts` : publication d'une promo en ligne sur la page Facebook (Graph API, upload multipart car les images sont en data-URI) et sur la page LinkedIn (Posts API + Images API en trois étapes). - Jetons d'accès exclusivement en variables d'environnement ; un réseau non configuré masque son bouton. - Table `social_posts` : chaque tentative est tracée, un échec est rattrapé et affiché sur la carte de la promo sans interrompre le backoffice. - Nouvelle capacité RBAC `publishSocial` (admin + modérateur). Liens sociaux publics - Réglages `association_facebook` / `association_linkedin` éditables dans Paramètres, préremplis avec les pages de l'association. - Section « Plein R sur les réseaux » sur l'accueil et icônes dans le pied de page, masquées si le réglage est vide. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb --- .env.example | 33 + CLAUDE.md | 21 + README.md | 35 + docker-compose.yml | 10 + drizzle/0006_tiresome_clea.sql | 23 + drizzle/meta/0006_snapshot.json | 1247 +++++++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + src/app/adherents/[id]/page.tsx | 10 +- src/app/backend/actions.ts | 158 +++- src/app/backend/espace/page.tsx | 89 +- src/app/backend/parametres/page.tsx | 18 + src/app/backend/promotions/page.tsx | 195 ++++- src/app/globals.css | 123 +-- src/app/page.tsx | 102 ++- src/components/SiteFooter.tsx | 32 +- src/components/SocialIcons.tsx | 21 + src/db/schema.ts | 42 +- src/lib/rbac.ts | 10 +- src/lib/site-settings.ts | 35 + src/lib/social.ts | 268 ++++++ 20 files changed, 2370 insertions(+), 109 deletions(-) create mode 100644 drizzle/0006_tiresome_clea.sql create mode 100644 drizzle/meta/0006_snapshot.json create mode 100644 src/components/SocialIcons.tsx create mode 100644 src/lib/social.ts diff --git a/.env.example b/.env.example index 3f31391..49a9150 100644 --- a/.env.example +++ b/.env.example @@ -29,3 +29,36 @@ SEED_ADMIN_NAME=Administrateur Plein R # ---- App ---- NODE_ENV=production PORT=3000 + +# URL publique du site, utilisée dans le texte des publications réseaux sociaux +# (lien vers la fiche de l'adhérent). À défaut, AUTH_URL est utilisé. +NEXT_PUBLIC_SITE_URL= + +# ---- Publication des promotions sur les réseaux sociaux ---- +# Facultatif : si ces variables sont vides, le bouton correspondant n'apparaît +# pas dans le backoffice (le reste du site fonctionne normalement). +# Ce sont des SECRETS : ils ne doivent jamais être saisis dans le backoffice. +# +# Facebook — page de l'association +# 1. Créer une app sur developers.facebook.com (type « Business »). +# 2. Ajouter le produit « Facebook Login » et les permissions +# pages_manage_posts + pages_read_engagement. +# 3. Générer un jeton de PAGE longue durée (Graph API Explorer puis +# /oauth/access_token?grant_type=fb_exchange_token) et le coller ci-dessous. +FACEBOOK_PAGE_ID= +FACEBOOK_PAGE_ACCESS_TOKEN= +# Version de la Graph API (facultatif) +FACEBOOK_GRAPH_VERSION=v21.0 +# +# LinkedIn — page organisation +# 1. Créer une app sur linkedin.com/developers, la rattacher à la page +# « Association Plein R — Bassin de Pompey ». +# 2. Demander le produit « Community Management API ». +# 3. Générer un jeton avec les scopes w_organization_social + r_organization_social. +# L'URN se lit dans l'URL d'administration de la page (numéro d'organisation). +LINKEDIN_ORGANIZATION_URN= +# Alternative à l'URN : uniquement le numéro d'organisation +LINKEDIN_ORGANIZATION_ID= +LINKEDIN_ACCESS_TOKEN= +# Version de l'API LinkedIn (facultatif, format AAAAMM) +LINKEDIN_API_VERSION=202506 diff --git a/CLAUDE.md b/CLAUDE.md index 63f73ee..f01a1a3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,6 +41,27 @@ docker compose up --build # full stack - After changing `src/db/schema.ts`, run `npm run db:generate` and commit the new file under `drizzle/`. +## Promotions + +Statuts : `pending` → `live` → `suspended` ⇄ `live` (+ `rejected` / `expired`). +`promotions.suspended_by` retient qui a suspendu (`member` ou `staff`) : une +suspension par le staff ne peut être levée que par le staff. Les lectures +publiques filtrent sur `status = 'live'`, donc une promo suspendue disparaît du +site sans traitement supplémentaire. + +## Réseaux sociaux + +- `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é. +- 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. +- Les URLs publiques des pages FB/LinkedIn sont des `site_settings` + (`association_facebook`, `association_linkedin`), éditables dans Paramètres. + ## Roles `admin` > `moderator` > `editor` are staff; `member` is an adhérent linked to a diff --git a/README.md b/README.md index a21502b..d5819fb 100644 --- a/README.md +++ b/README.md @@ -62,11 +62,43 @@ charge des données de démonstration. Ensuite : | Tableau de bord | ✅ | ✅ | ✅ | — | | Adhérents (CRUD) | ✅ | ✅ | ✅ | — | | Modération des promotions | ✅ | ✅ | — | — | +| Publication réseaux sociaux | ✅ | ✅ | — | — | | Administrateurs | ✅ | — | — | — | | Mon espace (publier une promo) | — | — | — | ✅ | `/backend` est protégé par le middleware ; chaque vue affine l'accès selon le rôle. +## Cycle de vie d'une promotion + +`pending` → `live` → `suspended` ⇄ `live`, ou suppression. + +- L'adhérent soumet la promotion depuis **Mon espace** : elle part en `pending`. +- L'association la valide dans **Backend › Promotions** : elle passe `live` et + s'affiche sur l'accueil, l'annuaire et la fiche de l'adhérent. +- **Suspension** : l'adhérent peut suspendre *ses* promotions depuis Mon espace, + l'association peut suspendre n'importe laquelle. Une promotion suspendue sort + immédiatement du site public et peut être remise en ligne à tout moment. + Nuance importante : une suspension décidée par l'association ne peut pas être + levée par l'adhérent (`promotions.suspended_by` mémorise l'auteur). + +## 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). + +Les jetons d'accès sont des **secrets** : ils se configurent uniquement par +variables d'environnement (`FACEBOOK_PAGE_ID`, `FACEBOOK_PAGE_ACCESS_TOKEN`, +`LINKEDIN_ORGANIZATION_URN` ou `LINKEDIN_ORGANIZATION_ID`, +`LINKEDIN_ACCESS_TOKEN`), jamais depuis le backoffice. Voir +[`.env.example`](./.env.example) pour la marche à suivre côté Meta et LinkedIn. +Si un réseau n'est pas configuré, son bouton n'apparaît simplement pas. + +Les **liens publics** vers les deux pages (affichés sur l'accueil et dans le pied +de page) se règlent, eux, dans **Backend › Paramètres**. + ## Développement local (sans Docker) ```bash @@ -98,6 +130,9 @@ Voir [`.env.example`](./.env.example). Les principales : - `AUTH_URL` — URL publique de l'application - `SEED_ON_START` — `true` pour seeder au démarrage du conteneur - `SEED_ADMIN_EMAIL` / `SEED_ADMIN_PASSWORD` / `SEED_ADMIN_NAME` — premier admin +- `NEXT_PUBLIC_SITE_URL` — URL publique reprise dans les posts réseaux sociaux +- `FACEBOOK_PAGE_ID` / `FACEBOOK_PAGE_ACCESS_TOKEN` — publication Facebook (optionnel) +- `LINKEDIN_ORGANIZATION_URN` / `LINKEDIN_ACCESS_TOKEN` — publication LinkedIn (optionnel) ## Note sur le logo diff --git a/docker-compose.yml b/docker-compose.yml index 6668e82..d4f40e5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -47,6 +47,16 @@ services: AUTH_URL: ${AUTH_URL:-} # Lets Auth.js trust the X-Forwarded-* headers from the proxy. AUTH_TRUST_HOST: "true" + # URL publique, reprise dans le texte des posts Facebook / LinkedIn. + NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-} + # Publication des promotions sur les réseaux (facultatif — voir .env.example). + FACEBOOK_PAGE_ID: ${FACEBOOK_PAGE_ID:-} + FACEBOOK_PAGE_ACCESS_TOKEN: ${FACEBOOK_PAGE_ACCESS_TOKEN:-} + FACEBOOK_GRAPH_VERSION: ${FACEBOOK_GRAPH_VERSION:-} + LINKEDIN_ORGANIZATION_URN: ${LINKEDIN_ORGANIZATION_URN:-} + LINKEDIN_ORGANIZATION_ID: ${LINKEDIN_ORGANIZATION_ID:-} + LINKEDIN_ACCESS_TOKEN: ${LINKEDIN_ACCESS_TOKEN:-} + LINKEDIN_API_VERSION: ${LINKEDIN_API_VERSION:-} SEED_ON_START: ${SEED_ON_START:-true} SEED_ADMIN_EMAIL: ${SEED_ADMIN_EMAIL:-admin@plein-r.fr} SEED_ADMIN_PASSWORD: ${SEED_ADMIN_PASSWORD:-changeme123} diff --git a/drizzle/0006_tiresome_clea.sql b/drizzle/0006_tiresome_clea.sql new file mode 100644 index 0000000..4c2d301 --- /dev/null +++ b/drizzle/0006_tiresome_clea.sql @@ -0,0 +1,23 @@ +CREATE TYPE "public"."promo_suspended_by" AS ENUM('member', 'staff');--> statement-breakpoint +CREATE TYPE "public"."social_network" AS ENUM('facebook', 'linkedin');--> statement-breakpoint +CREATE TYPE "public"."social_post_status" AS ENUM('posted', 'failed');--> statement-breakpoint +ALTER TYPE "public"."promo_status" ADD VALUE 'suspended';--> statement-breakpoint +CREATE TABLE "social_posts" ( + "id" serial PRIMARY KEY NOT NULL, + "promotion_id" integer NOT NULL, + "network" "social_network" NOT NULL, + "status" "social_post_status" NOT NULL, + "external_id" varchar(200), + "url" text, + "error" text, + "posted_by_id" integer, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "promotions" ADD COLUMN "suspended_by" "promo_suspended_by";--> statement-breakpoint +ALTER TABLE "promotions" ADD COLUMN "suspended_by_id" integer;--> statement-breakpoint +ALTER TABLE "promotions" ADD COLUMN "suspended_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "social_posts" ADD CONSTRAINT "social_posts_promotion_id_promotions_id_fk" FOREIGN KEY ("promotion_id") REFERENCES "public"."promotions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "social_posts" ADD CONSTRAINT "social_posts_posted_by_id_users_id_fk" FOREIGN KEY ("posted_by_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "social_posts_promotion_idx" ON "social_posts" USING btree ("promotion_id");--> statement-breakpoint +ALTER TABLE "promotions" ADD CONSTRAINT "promotions_suspended_by_id_users_id_fk" FOREIGN KEY ("suspended_by_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action; \ No newline at end of file diff --git a/drizzle/meta/0006_snapshot.json b/drizzle/meta/0006_snapshot.json new file mode 100644 index 0000000..4f592ef --- /dev/null +++ b/drizzle/meta/0006_snapshot.json @@ -0,0 +1,1247 @@ +{ + "id": "0cd7b2e9-77c5-4a1b-9ea6-d4471e464f2d", + "prevId": "9b4bca4a-67df-47b5-83c4-59046495e751", + "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 + }, + "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 2ed7732..325ccd5 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1784339279276, "tag": "0005_round_exiles", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1784930674056, + "tag": "0006_tiresome_clea", + "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 6b6ca5f..b479a7c 100644 --- a/src/app/adherents/[id]/page.tsx +++ b/src/app/adherents/[id]/page.tsx @@ -38,7 +38,7 @@ function initialsOf(name: string) { return name.split(/\s+/).map((w) => w[0]).join("").slice(0, 2).toUpperCase(); } -type ContactIconName = "phone" | "mail" | "website" | "pin"; +type ContactIconName = "phone" | "mail" | "website" | "pin" | "clock" | "chat"; function ContactIcon({ name }: { name: ContactIconName }) { const paths: Record = { @@ -46,6 +46,8 @@ function ContactIcon({ name }: { name: ContactIconName }) { mail: <>, website: <>, pin: <>, + clock: <>, + chat: <>, }; return (
Préparer votre visite -

Informations pratiques

+

Informations pratiques

{openStatus && (
@@ -263,7 +265,7 @@ export default async function FicheAdherentPage({
- +
Cette semaine

Horaires d'ouverture

@@ -297,7 +299,7 @@ export default async function FicheAdherentPage({
- +
Contact direct

Joindre l'établissement

diff --git a/src/app/backend/actions.ts b/src/app/backend/actions.ts index 26d10b1..9dc9961 100644 --- a/src/app/backend/actions.ts +++ b/src/app/backend/actions.ts @@ -20,9 +20,17 @@ import { pastMeetings, promotions, siteSettings, + socialPosts, users, } from "@/db/schema"; import { can, LABEL_TO_ROLE } from "@/lib/rbac"; +import { + isNetworkConfigured, + publishPromoToNetwork, + SOCIAL_LABELS, + SOCIAL_NETWORKS, + type SocialNetwork, +} from "@/lib/social"; import { normalizeWebsite } from "@/lib/member-profile"; import { SITE_SETTING_DEFAULTS } from "@/lib/site-settings"; import type { AppRole } from "@/types/next-auth"; @@ -62,13 +70,20 @@ function generateTempPassword(): string { return out; } -async function requireRole(): Promise<{ role: AppRole; memberId: number | null; name: string }> { +async function requireRole(): Promise<{ + role: AppRole; + memberId: number | null; + name: string; + userId: number | null; +}> { const session = await auth(); if (!session?.user) throw new Error("Non authentifié"); + const userId = Number(session.user.id); return { role: session.user.role, memberId: session.user.memberId, name: session.user.name ?? "Adhérent", + userId: Number.isFinite(userId) ? userId : null, }; } @@ -87,26 +102,155 @@ function asNullableString(formData: FormData, key: string) { return asString(formData, key) || null; } +function revalidatePromoPaths(memberId?: number | null) { + revalidatePath("/backend/promotions"); + revalidatePath("/backend/espace"); + revalidatePath("/backend"); + revalidatePath("/"); + revalidatePath("/annuaire"); + if (memberId) revalidatePath(`/adherents/${memberId}`); +} + // ---- Promotion moderation ---- export async function moderatePromo(formData: FormData) { - const { role } = await requireRole(); + const { role, userId, name } = await requireRole(); if (!can(role, "moderatePromos")) throw new Error("Accès refusé"); const id = Number(formData.get("id")); const action = String(formData.get("action")); if (!id) return; + const [promo] = await db.select().from(promotions).where(eq(promotions.id, id)); + if (!promo) return; + if (action === "approve") { - await db.update(promotions).set({ status: "live" }).where(eq(promotions.id, id)); - const [p] = await db.select().from(promotions).where(eq(promotions.id, id)); - if (p) await logActivity(`Promotion « ${p.title} » validée et mise en ligne`, "#1f8a5b"); + await db + .update(promotions) + .set({ status: "live", suspendedBy: null, suspendedById: null, suspendedAt: null }) + .where(eq(promotions.id, id)); + await logActivity(`Promotion « ${promo.title} » validée et mise en ligne`, "#1f8a5b"); + } 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. + await db + .update(promotions) + .set({ status: "suspended", suspendedBy: "staff", suspendedById: userId, suspendedAt: new Date() }) + .where(eq(promotions.id, id)); + await logActivity( + `Promotion « ${promo.title} » suspendue par ${name}`, + "#d8472b" + ); + } else if (action === "restore") { + await db + .update(promotions) + .set({ status: "live", suspendedBy: null, suspendedById: null, suspendedAt: null }) + .where(eq(promotions.id, id)); + await logActivity(`Promotion « ${promo.title} » remise en ligne`, "#1f8a5b"); } else if (action === "reject" || action === "remove") { await db.delete(promotions).where(eq(promotions.id, id)); } + revalidatePromoPaths(promo.memberId); +} + +// ---- Member space: suspendre / réactiver sa propre promotion ---- +export async function setOwnPromoSuspension(formData: FormData) { + const { memberId, userId, name } = await requireRole(); + if (!memberId) throw new Error("Aucune fiche adhérent liée à votre compte."); + + const id = Number(formData.get("id")); + const action = String(formData.get("action")); + if (!id) return; + + const [promo] = await db + .select() + .from(promotions) + .where(and(eq(promotions.id, id), eq(promotions.memberId, memberId))); + if (!promo) throw new Error("Promotion introuvable."); + + if (action === "suspend") { + if (promo.status !== "live") return; + await db + .update(promotions) + .set({ status: "suspended", suspendedBy: "member", suspendedById: userId, suspendedAt: new Date() }) + .where(eq(promotions.id, id)); + await logActivity(`${name} a suspendu sa promotion « ${promo.title} »`, "#9a6638"); + } else if (action === "restore") { + if (promo.status !== "suspended") return; + // Une suspension décidée par l'association ne se lève que par elle. + if (promo.suspendedBy === "staff") { + throw new Error( + "Cette promotion a été suspendue par l'association : contactez-la pour la remettre en ligne." + ); + } + await db + .update(promotions) + .set({ status: "live", suspendedBy: null, suspendedById: null, suspendedAt: null }) + .where(eq(promotions.id, id)); + await logActivity(`${name} a réactivé sa promotion « ${promo.title} »`, "#1f8a5b"); + } + + revalidatePromoPaths(memberId); +} + +// ---- Publication d'une promotion sur Facebook / LinkedIn ---- +export async function sharePromoToSocial(formData: FormData) { + const { role, userId, name } = 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)); + 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" + ); + } 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, + }); + } + revalidatePath("/backend/promotions"); - revalidatePath("/backend"); - revalidatePath("/"); } // ---- Member space: publish a promotion ---- diff --git a/src/app/backend/espace/page.tsx b/src/app/backend/espace/page.tsx index b657310..6ab8174 100644 --- a/src/app/backend/espace/page.tsx +++ b/src/app/backend/espace/page.tsx @@ -7,7 +7,7 @@ import { ImageField } from "@/components/ImageField"; import { ImageConsentForm } from "@/components/ImageConsentForm"; import { HoursEditor } from "@/components/HoursEditor"; import { communeOptions } from "@/lib/communes"; -import { saveImageConsent, updateOwnProfile } from "../actions"; +import { saveImageConsent, setOwnPromoSuspension, updateOwnProfile } from "../actions"; import { MemberSpaceForm } from "./MemberSpaceForm"; export const dynamic = "force-dynamic"; @@ -16,6 +16,7 @@ const PROMO_STATUS: Record live: { label: "En ligne", bg: "#e6f4ec", color: "#1f8a5b" }, pending: { label: "En attente", bg: "#fbeede", color: "#9a6638" }, rejected: { label: "Refusée", bg: "#fbe9e6", color: "#d8472b" }, + suspended: { label: "Suspendue", bg: "#fbe9e6", color: "#d8472b" }, expired: { label: "Expirée", bg: "#f1efe7", color: "#a99c82" }, }; @@ -31,7 +32,14 @@ export default async function EspacePage() { let memberName = fallbackName; let subtitle = "Espace adhérent"; let profile: Member | null = null; - let myPromos: { id: number; title: string; status: string; createdAt: Date; imageUrl: string | null }[] = []; + let myPromos: { + id: number; + title: string; + status: string; + createdAt: Date; + imageUrl: string | null; + suspendedBy: "member" | "staff" | null; + }[] = []; let myRegistrations: { meetingId: number; title: string; startsAt: Date; location: string | null; participants: number; confirmed: boolean }[] = []; let latestConsent: { decision: string; createdAt: Date } | null = null; @@ -52,6 +60,7 @@ export default async function EspacePage() { status: promotions.status, createdAt: promotions.createdAt, imageUrl: promotions.imageUrl, + suspendedBy: promotions.suspendedBy, }) .from(promotions) .where(eq(promotions.memberId, memberId)) @@ -241,38 +250,70 @@ export default async function EspacePage() {
-
+
Mes promotions
+
+ Vous pouvez suspendre une promotion en ligne à tout moment : elle disparaît du site et + vous la remettez en ligne quand vous voulez. +
{myPromos.length === 0 && (
Vous n'avez pas encore publié de promotion.
)} {myPromos.map((mp) => { const st = PROMO_STATUS[mp.status] ?? PROMO_STATUS.expired; + const suspendedByStaff = mp.status === "suspended" && mp.suspendedBy === "staff"; return ( -
- -
-
- {mp.title} +
+
+ +
+
+ {mp.title} +
+
Publié le {fmtDate(mp.createdAt)}
-
Publié le {fmtDate(mp.createdAt)}
+ + {st.label} + + {mp.status === "live" && ( +
+ + + +
+ )} + {mp.status === "suspended" && !suspendedByStaff && ( +
+ + + +
+ )}
- - {st.label} - + {suspendedByStaff && ( +
+ Cette promotion a été suspendue par l'association. Contactez-la pour la + remettre en ligne. +
+ )}
); })} diff --git a/src/app/backend/parametres/page.tsx b/src/app/backend/parametres/page.tsx index b6f0b8a..a2b57de 100644 --- a/src/app/backend/parametres/page.tsx +++ b/src/app/backend/parametres/page.tsx @@ -25,12 +25,30 @@ export default async function ParametresPage() { + +
+
+ Les pages Facebook et LinkedIn s'affichent sur la page d'accueil et dans le pied + de page. Laissez un champ vide pour masquer le réseau correspondant. La publication + automatique des promotions, elle, se configure côté serveur (jetons d'accès dans les + variables d'environnement). +
diff --git a/src/app/backend/promotions/page.tsx b/src/app/backend/promotions/page.tsx index feaa832..7ff6d07 100644 --- a/src/app/backend/promotions/page.tsx +++ b/src/app/backend/promotions/page.tsx @@ -2,9 +2,10 @@ import { redirect } from "next/navigation"; import { desc, eq, inArray } from "drizzle-orm"; import { auth } from "@/auth"; import { db } from "@/db"; -import { members, promotions } from "@/db/schema"; +import { members, promotions, socialPosts, users } from "@/db/schema"; import { can } from "@/lib/rbac"; -import { moderatePromo } from "../actions"; +import { configuredNetworks, SOCIAL_LABELS, type SocialNetwork } from "@/lib/social"; +import { moderatePromo, sharePromoToSocial } from "../actions"; import { PromoImage } from "@/components/PromoImage"; export const dynamic = "force-dynamic"; @@ -14,11 +15,23 @@ const STRIPE_WARM = const STRIPE_COOL = "repeating-linear-gradient(45deg,#eef0ec,#eef0ec 11px,#e2e8e6 11px,#e2e8e6 22px)"; +const STATUS_CHIP: Record = { + pending: { label: "En attente", bg: "#fbeede", color: "#9a6638" }, + live: { label: "En ligne", bg: "#e6f4ec", color: "#1f8a5b" }, + suspended: { label: "Suspendue", bg: "#fbe9e6", color: "#d8472b" }, +}; + +function fmtDate(d: Date) { + return new Date(d).toLocaleDateString("fr-FR", { day: "numeric", month: "long", year: "numeric" }); +} + export default async function PromotionsPage() { const session = await auth(); if (!can(session?.user.role, "moderatePromos")) { redirect("/backend"); } + const canShare = can(session?.user.role, "publishSocial"); + const networks = canShare ? configuredNetworks() : []; const rows = await db .select({ @@ -28,17 +41,45 @@ export default async function PromotionsPage() { category: promotions.category, status: promotions.status, imageUrl: promotions.imageUrl, + suspendedBy: promotions.suspendedBy, + suspendedAt: promotions.suspendedAt, memberName: members.name, }) .from(promotions) .leftJoin(members, eq(promotions.memberId, members.id)) - .where(inArray(promotions.status, ["pending", "live"])) - .orderBy(desc(promotions.status), desc(promotions.createdAt)); + .where(inArray(promotions.status, ["pending", "live", "suspended"])) + .orderBy(desc(promotions.createdAt)); const pending = rows.filter((r) => r.status === "pending"); const live = rows.filter((r) => r.status === "live"); - // pending first, then live - const ordered = [...pending, ...live]; + const suspended = rows.filter((r) => r.status === "suspended"); + // En attente d'abord, puis en ligne, puis suspendues. + const ordered = [...pending, ...live, ...suspended]; + + const shares = ordered.length + ? await db + .select({ + promotionId: socialPosts.promotionId, + network: socialPosts.network, + status: socialPosts.status, + url: socialPosts.url, + error: socialPosts.error, + createdAt: socialPosts.createdAt, + authorName: users.name, + }) + .from(socialPosts) + .leftJoin(users, eq(socialPosts.postedById, users.id)) + .where(inArray(socialPosts.promotionId, ordered.map((r) => r.id))) + .orderBy(desc(socialPosts.createdAt)) + : []; + + // La liste est triée du plus récent au plus ancien : la première occurrence + // rencontrée pour un couple (promo, réseau) est donc la plus récente. + const lastShare = new Map(); + for (const s of shares) { + const key = `${s.promotionId}:${s.network}`; + if (!lastShare.has(key)) lastShare.set(key, s); + } return (
@@ -49,8 +90,18 @@ export default async function PromotionsPage() { En ligne · {live.length} + + Suspendues · {suspended.length} +
+ {canShare && networks.length === 0 && ( +
+ Publication sur les réseaux indisponible : aucun jeton d'accès Facebook ou LinkedIn + n'est configuré sur le serveur (voir .env.example). +
+ )} + {ordered.length === 0 && (
Aucune promotion pour le moment. @@ -60,6 +111,8 @@ export default async function PromotionsPage() {
{ordered.map((p) => { const isPending = p.status === "pending"; + const isSuspended = p.status === "suspended"; + const chip = STATUS_CHIP[p.status] ?? STATUS_CHIP.pending; return (
{p.imageUrl && } @@ -98,11 +152,11 @@ export default async function PromotionsPage() { padding: "4px 11px", fontSize: 11, fontWeight: 800, - background: isPending ? "#fbeede" : "#e6f4ec", - color: isPending ? "#9a6638" : "#1f8a5b", + background: chip.bg, + color: chip.color, }} > - {isPending ? "En attente" : "En ligne"} + {chip.label}
@@ -116,7 +170,14 @@ export default async function PromotionsPage() { {p.text}

- {isPending ? ( + {isSuspended && ( +
+ Suspendue par {p.suspendedBy === "member" ? "l'adhérent" : "l'association"} + {p.suspendedAt ? ` le ${fmtDate(p.suspendedAt)}` : ""}. +
+ )} + + {isPending && (
@@ -133,18 +194,82 @@ export default async function PromotionsPage() {
- ) : ( -
- ● En ligne + )} + + {!isPending && ( +
+
+ + + +
-
)} + + {networks.length > 0 && ( +
+
+ Partager sur les réseaux +
+
+ {networks.map((network) => ( + + ))} +
+ {networks.map((network) => { + const last = lastShare.get(`${p.id}:${network}`); + if (!last) return null; + 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)} + )} +
+ ); + })} +
+ )}
); @@ -153,3 +278,43 @@ 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/app/globals.css b/src/app/globals.css index 14805fe..22f4ef9 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -580,11 +580,9 @@ img { .practical-section { overflow: hidden; padding: 26px 28px 28px; - border: 1px solid #e6dcc6; + border: 1px solid var(--border); border-radius: 18px; - background: - radial-gradient(circle at 100% 0, rgba(224, 166, 60, 0.12), transparent 190px), - #fff; + background: #fff; } .practical-section__heading { display: flex; @@ -593,31 +591,44 @@ img { gap: 18px; margin-bottom: 20px; } +/* Pastille de section, identique aux badges « Bons plans » du reste du site. */ .eyebrow { - color: #9a6638; - font-size: 10.5px; + display: inline-flex; + align-items: center; + gap: 8px; + padding: 5px 12px; + border-radius: 999px; + background: #fdeccd; + color: var(--brown); + font-size: 11.5px; font-weight: 800; - letter-spacing: 0.14em; + letter-spacing: 0.1em; text-transform: uppercase; } +.eyebrow::before { + content: ""; + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--gold); +} .open-status { display: flex; align-items: center; gap: 9px; min-width: 154px; - padding: 8px 12px; - border: 1px solid #e7d3ce; - border-radius: 12px; - background: #fff8f6; - color: #9f3e2e; + padding: 8px 13px; + border: 1px solid #f2d5cf; + border-radius: 999px; + background: #fbe9e6; + color: var(--red); } .open-status > span { - width: 9px; - height: 9px; + width: 8px; + height: 8px; flex: 0 0 auto; border-radius: 50%; - background: #d8472b; - box-shadow: 0 0 0 4px rgba(216, 71, 43, 0.12); + background: var(--red); } .open-status div { display: flex; @@ -626,23 +637,25 @@ img { } .open-status strong { font-size: 12.5px; + font-weight: 800; line-height: 1.25; } .open-status small { margin-top: 2px; - color: #916a61; + color: #b1786c; font-size: 10.5px; } .open-status.is-open { - border-color: #cce7d7; - background: #f1faf5; - color: #176c48; + border-color: #c7e6d5; + background: #e6f4ec; + color: var(--green); } .open-status.is-open > span { - background: #1f8a5b; - box-shadow: 0 0 0 4px rgba(31, 138, 91, 0.12); + background: var(--green); +} +.open-status.is-open small { + color: #6d9d86; } -.open-status.is-open small { color: #5d7f6e; } .practical-grid { display: grid; grid-template-columns: minmax(0, 1.08fr) minmax(0, 0.92fr); @@ -651,22 +664,17 @@ img { .schedule-card, .contact-card { min-width: 0; - border: 1px solid #eadfca; - border-radius: 16px; padding: 18px; -} -.schedule-card { - background: linear-gradient(145deg, #fffdf8, #fbf5e8); -} -.contact-card { - background: linear-gradient(145deg, #fff, #f3f7fa); + border: 1px solid var(--border-2); + border-radius: 16px; + background: var(--field-bg); } .practical-card-title { display: flex; align-items: center; gap: 11px; padding-bottom: 14px; - border-bottom: 1px solid rgba(216, 205, 180, 0.68); + border-bottom: 1px solid var(--border-2); } .practical-card-title__icon { display: grid; @@ -675,19 +683,21 @@ img { flex: 0 0 auto; place-items: center; border-radius: 11px; - background: #f3dfb6; - color: #8c5f2e; - font-size: 20px; - font-weight: 800; + background: #f6efdc; + color: var(--brown); +} +.practical-card-title__icon svg { + width: 18px; + height: 18px; } .practical-card-title__icon--blue { - background: #dfeef5; - color: #2c6fb3; + background: #eaf0f6; + color: var(--blue); } .practical-card-title span:not(.practical-card-title__icon) { display: block; margin-bottom: 2px; - color: #a09279; + color: var(--muted-3); font-size: 9.5px; font-weight: 800; letter-spacing: 0.12em; @@ -695,8 +705,9 @@ img { } .practical-card-title h3 { margin: 0; - color: #26201a; + color: var(--ink-strong); font-size: 15.5px; + font-weight: 700; line-height: 1.25; } .schedule-list { @@ -710,14 +721,15 @@ img { align-items: center; gap: 12px; min-height: 38px; - padding: 7px 8px; - border-radius: 9px; - color: #655b4c; + padding: 7px 9px; + border: 1px solid transparent; + border-radius: 10px; + color: var(--muted); font-size: 12.5px; } .schedule-row.is-today { + border-color: var(--border); background: #fff; - box-shadow: 0 5px 16px -12px rgba(80, 55, 22, 0.58); } .schedule-row__day { display: flex; @@ -727,20 +739,20 @@ img { } .schedule-row__day small { margin-top: 1px; - color: #9a6638; + color: var(--brown); font-size: 9px; font-weight: 800; letter-spacing: 0.06em; text-transform: uppercase; } .schedule-row > strong { - color: #352d22; + color: var(--ink-soft); font-size: 12px; font-weight: 700; text-align: right; } .schedule-row > strong.is-closed { - color: #b16a5e; + color: var(--muted-4); font-weight: 600; } .contact-actions { @@ -758,12 +770,12 @@ img { padding: 10px; border: 1px solid transparent; border-radius: 11px; - color: #4d4437; + color: var(--ink); text-decoration: none; transition: background 160ms ease, border-color 160ms ease, transform 160ms ease; } .contact-action:hover { - border-color: #d7e4eb; + border-color: var(--border); background: #fff; transform: translateX(2px); } @@ -773,8 +785,8 @@ img { height: 34px; place-items: center; border-radius: 10px; - background: #eaf2f7; - color: #2c6fb3; + background: #eaf0f6; + color: var(--blue); } .contact-action__icon svg { width: 17px; @@ -786,24 +798,25 @@ img { flex-direction: column; } .contact-action__copy strong { - color: #33291d; + color: var(--ink-strong); font-size: 12.5px; + font-weight: 700; } .contact-action__copy > span { overflow: hidden; margin-top: 2px; - color: #7d715f; + color: var(--muted-2); font-size: 10.5px; text-overflow: ellipsis; white-space: nowrap; } .contact-action__arrow { - color: #7f9db5; + color: var(--muted-4); font-size: 14px; } .practical-empty { padding: 20px 4px 8px; - color: #9b8e78; + color: var(--muted-4); font-size: 13px; } diff --git a/src/app/page.tsx b/src/app/page.tsx index 30a706e..7539fd3 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -7,7 +7,9 @@ import { MembershipModalButton } from "@/components/MembershipModalButton"; import { MembersCarousel } from "@/components/MembersCarousel"; import { StarField } from "@/components/StarField"; import { PromoImage } from "@/components/PromoImage"; +import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons"; import { getLivePromotions, getRotatingActiveMembers } from "@/lib/queries"; +import { getSiteSettings, socialLinks } from "@/lib/site-settings"; export const dynamic = "force-dynamic"; @@ -20,10 +22,12 @@ function badgeColor(badge: string | null) { } export default async function AccueilPage() { - const [promos, rotatingMembers] = await Promise.all([ + const [promos, rotatingMembers, settings] = await Promise.all([ getLivePromotions(6), getRotatingActiveMembers(), + getSiteSettings(), ]); + const socials = socialLinks(settings); return (
+ {/* réseaux sociaux */} + {socials.length > 0 && ( +
+ + +
+ + Suivez-nous +
+

+ Plein R sur les réseaux +

+

+ Bons plans, rencontres et actualités des commerçants du Bassin de Pompey : retrouvez-nous + sur Facebook et LinkedIn. +

+ + +
+ )} + {/* warm CTA */}
@@ -17,6 +20,33 @@ export function SiteFooter() { Association des commerçants et entreprises du Bassin de Pompey. Réseau · Rencontre · Réussite.

+ {socials.length > 0 && ( +
+ {socials.map((s) => ( + + + + ))} +
+ )}
diff --git a/src/components/SocialIcons.tsx b/src/components/SocialIcons.tsx new file mode 100644 index 0000000..70e47e8 --- /dev/null +++ b/src/components/SocialIcons.tsx @@ -0,0 +1,21 @@ +import type { SocialLink } from "@/lib/site-settings"; + +const PATHS: Record = { + facebook: + "M22 12.06C22 6.5 17.52 2 12 2S2 6.5 2 12.06c0 5.02 3.66 9.18 8.44 9.94v-7.03H7.9v-2.91h2.54V9.85c0-2.52 1.5-3.91 3.77-3.91 1.09 0 2.23.2 2.23.2v2.46h-1.26c-1.24 0-1.63.78-1.63 1.57v1.89h2.78l-.44 2.91h-2.34V22c4.78-.76 8.44-4.92 8.44-9.94Z", + linkedin: + "M6.94 5.5a2.06 2.06 0 1 1-4.12 0 2.06 2.06 0 0 1 4.12 0ZM6.6 9.05H3.15V21H6.6V9.05Zm5.52 0H8.8V21h3.32v-6.27c0-3.1 4-3.35 4-.01V21h3.33v-7.42c0-5.26-5.94-5.07-7.33-2.48V9.05Z", +}; + +export const SOCIAL_BRAND: Record = { + facebook: "#1877F2", + linkedin: "#0A66C2", +}; + +export function SocialIcon({ network, size = 18 }: { network: SocialLink["network"]; size?: number }) { + return ( + + ); +} diff --git a/src/db/schema.ts b/src/db/schema.ts index e3184ae..9bd4ee3 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -20,7 +20,13 @@ export const promoStatusEnum = pgEnum("promo_status", [ "live", "expired", "rejected", + "suspended", ]); +// Qui a suspendu la promotion : son créateur (l'adhérent) ou l'association. +// Une promotion suspendue par l'association ne peut être réactivée que par elle. +export const promoSuspendedByEnum = pgEnum("promo_suspended_by", ["member", "staff"]); +export const socialNetworkEnum = pgEnum("social_network", ["facebook", "linkedin"]); +export const socialPostStatusEnum = pgEnum("social_post_status", ["posted", "failed"]); export const requestStatusEnum = pgEnum("request_status", [ "new", "approved", @@ -94,9 +100,33 @@ export const promotions = pgTable("promotions", { memberId: integer("member_id").references(() => members.id), status: promoStatusEnum("status").notNull().default("pending"), validUntil: varchar("valid_until", { length: 120 }), + suspendedBy: promoSuspendedByEnum("suspended_by"), + suspendedById: integer("suspended_by_id").references(() => users.id, { onDelete: "set null" }), + suspendedAt: timestamp("suspended_at", { withTimezone: true }), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }); +// ---- Publications réseaux sociaux (Facebook / LinkedIn) ---- +export const socialPosts = pgTable( + "social_posts", + { + id: serial("id").primaryKey(), + promotionId: integer("promotion_id") + .notNull() + .references(() => promotions.id, { onDelete: "cascade" }), + network: socialNetworkEnum("network").notNull(), + status: socialPostStatusEnum("status").notNull(), + externalId: varchar("external_id", { length: 200 }), + url: text("url"), + error: text("error"), + postedById: integer("posted_by_id").references(() => users.id, { onDelete: "set null" }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (t) => ({ + promoIdx: index("social_posts_promotion_idx").on(t.promotionId), + }) +); + // ---- Membership requests (demandes d'adhésion) ---- export const membershipRequests = pgTable("membership_requests", { id: serial("id").primaryKey(), @@ -218,11 +248,19 @@ export const membersRelations = relations(members, ({ one, many }) => ({ imageConsents: many(imageConsents), })); -export const promotionsRelations = relations(promotions, ({ one }) => ({ +export const promotionsRelations = relations(promotions, ({ one, many }) => ({ member: one(members, { fields: [promotions.memberId], references: [members.id], }), + socialPosts: many(socialPosts), +})); + +export const socialPostsRelations = relations(socialPosts, ({ one }) => ({ + promotion: one(promotions, { + fields: [socialPosts.promotionId], + references: [promotions.id], + }), })); export const usersRelations = relations(users, ({ one }) => ({ @@ -273,6 +311,8 @@ export type Category = typeof categories.$inferSelect; export type Member = typeof members.$inferSelect; export type User = typeof users.$inferSelect; export type Promotion = typeof promotions.$inferSelect; +export type SocialPost = typeof socialPosts.$inferSelect; +export type SocialNetwork = (typeof socialNetworkEnum.enumValues)[number]; export type MembershipRequest = typeof membershipRequests.$inferSelect; export type ContactMessage = typeof contactMessages.$inferSelect; export type ActivityEntry = typeof activityLog.$inferSelect; diff --git a/src/lib/rbac.ts b/src/lib/rbac.ts index 5ed8b7d..bd1de3d 100644 --- a/src/lib/rbac.ts +++ b/src/lib/rbac.ts @@ -18,6 +18,7 @@ type Capability = | "viewDashboard" | "manageMembers" | "moderatePromos" + | "publishSocial" | "manageAdmins" | "manageCategories" | "manageMeetings" @@ -30,6 +31,7 @@ const CAPABILITIES: Record = { "viewDashboard", "manageMembers", "moderatePromos", + "publishSocial", "manageAdmins", "manageCategories", "manageMeetings", @@ -37,7 +39,13 @@ const CAPABILITIES: Record = { "manageSettings", "memberSpace", ], - moderator: ["viewDashboard", "manageMembers", "moderatePromos", "manageMeetings"], + moderator: [ + "viewDashboard", + "manageMembers", + "moderatePromos", + "publishSocial", + "manageMeetings", + ], editor: ["viewDashboard", "manageMembers"], member: ["memberSpace"], }; diff --git a/src/lib/site-settings.ts b/src/lib/site-settings.ts index 244c4e5..27c75b6 100644 --- a/src/lib/site-settings.ts +++ b/src/lib/site-settings.ts @@ -13,6 +13,10 @@ export const SITE_SETTING_DEFAULTS = { association_email: "", association_phone: "", association_website: "", + association_facebook: + "https://www.facebook.com/search/top?q=pleinr%20-%20les%20bons%20plan%20du%20bassin%20de%20pompey", + association_linkedin: + "https://www.linkedin.com/company/association-plein-r-bassin-de-pompey/", association_siret: "", association_rna: "", association_contact: "", @@ -46,6 +50,37 @@ export async function getSiteSettings(): Promise { return merged; } +export type SocialLink = { + network: "facebook" | "linkedin"; + label: string; + handle: string; + href: string; +}; + +/** Réseaux sociaux publics de l'association, dans l'ordre d'affichage. */ +export function socialLinks(settings: SiteSettings): SocialLink[] { + const links: SocialLink[] = []; + const facebook = settings.association_facebook.trim(); + const linkedin = settings.association_linkedin.trim(); + if (facebook) { + links.push({ + network: "facebook", + label: "Facebook", + handle: "Plein R — Les bons plans du Bassin de Pompey", + href: facebook, + }); + } + if (linkedin) { + links.push({ + network: "linkedin", + label: "LinkedIn", + handle: "Association Plein R — Bassin de Pompey", + href: linkedin, + }); + } + return links; +} + export function splitLines(value: string) { return value .split(/\r?\n/) diff --git a/src/lib/social.ts b/src/lib/social.ts new file mode 100644 index 0000000..a3b3215 --- /dev/null +++ b/src/lib/social.ts @@ -0,0 +1,268 @@ +/** + * Publication des promotions sur les pages Facebook / LinkedIn de l'association. + * + * Les jetons d'accès sont des SECRETS : ils vivent dans les variables + * d'environnement du conteneur, jamais en base ni dans le backoffice. Voir + * `.env.example` pour la marche à suivre côté Meta / LinkedIn. + * + * Si un réseau n'est pas configuré, le backoffice masque simplement son bouton : + * le reste du site fonctionne normalement. + */ + +export type SocialNetwork = "facebook" | "linkedin"; + +export const SOCIAL_NETWORKS: SocialNetwork[] = ["facebook", "linkedin"]; + +export const SOCIAL_LABELS: Record = { + facebook: "Facebook", + linkedin: "LinkedIn", +}; + +const FACEBOOK_GRAPH_VERSION = process.env.FACEBOOK_GRAPH_VERSION ?? "v21.0"; +const LINKEDIN_VERSION = process.env.LINKEDIN_API_VERSION ?? "202506"; + +function env(key: string): string { + return (process.env[key] ?? "").trim(); +} + +export function isNetworkConfigured(network: SocialNetwork): boolean { + if (network === "facebook") { + return !!env("FACEBOOK_PAGE_ID") && !!env("FACEBOOK_PAGE_ACCESS_TOKEN"); + } + return !!linkedinOrganizationUrn() && !!env("LINKEDIN_ACCESS_TOKEN"); +} + +export function configuredNetworks(): SocialNetwork[] { + return SOCIAL_NETWORKS.filter(isNetworkConfigured); +} + +function linkedinOrganizationUrn(): string { + const urn = env("LINKEDIN_ORGANIZATION_URN"); + if (urn) return urn; + const id = env("LINKEDIN_ORGANIZATION_ID"); + return id ? `urn:li:organization:${id}` : ""; +} + +function siteUrl(): string { + return (env("NEXT_PUBLIC_SITE_URL") || env("AUTH_URL")).replace(/\/+$/, ""); +} + +// ---- Contenu du post ---- + +export type PromoForSharing = { + title: string; + text: string | null; + badge: string | null; + validUntil: string | null; + imageUrl: string | null; + memberId: number | null; + memberName: string | null; +}; + +export function promoLink(promo: PromoForSharing): string | null { + const base = siteUrl(); + if (!base) return null; + return promo.memberId ? `${base}/adherents/${promo.memberId}` : `${base}/#promotions`; +} + +/** Texte du post, commun aux deux réseaux. */ +export function buildPromoMessage(promo: PromoForSharing): string { + const lines: string[] = []; + lines.push(promo.badge ? `${promo.badge} — ${promo.title}` : promo.title); + if (promo.memberName) lines.push(`Chez ${promo.memberName}`); + if (promo.text?.trim()) lines.push("", promo.text.trim()); + if (promo.validUntil?.trim()) lines.push("", promo.validUntil.trim()); + const link = promoLink(promo); + if (link) lines.push("", link); + lines.push("", "#PleinR #BassinDePompey #CommerceLocal"); + return lines.join("\n"); +} + +// ---- Image ---- + +type LoadedImage = { bytes: Uint8Array; contentType: string; fileName: string }; + +/** + * Les images de promo sont stockées en data-URI (upload direct depuis l'espace + * adhérent) ; on accepte aussi une URL absolue au cas où. + */ +export async function loadPromoImage(imageUrl: string | null): Promise { + if (!imageUrl) return null; + + const dataMatch = /^data:([^;,]+);base64,(.+)$/s.exec(imageUrl.trim()); + if (dataMatch) { + const contentType = dataMatch[1]; + const bytes = Uint8Array.from(Buffer.from(dataMatch[2], "base64")); + return { bytes, contentType, fileName: `promo.${extensionFor(contentType)}` }; + } + + if (/^https?:\/\//i.test(imageUrl)) { + const res = await fetch(imageUrl); + if (!res.ok) return null; + const contentType = res.headers.get("content-type")?.split(";")[0] ?? "image/jpeg"; + const bytes = new Uint8Array(await res.arrayBuffer()); + return { bytes, contentType, fileName: `promo.${extensionFor(contentType)}` }; + } + + return null; +} + +function extensionFor(contentType: string): string { + if (contentType.includes("png")) return "png"; + if (contentType.includes("webp")) return "webp"; + if (contentType.includes("gif")) return "gif"; + return "jpg"; +} + +function toBlob(image: LoadedImage): Blob { + // `Uint8Array` → `ArrayBuffer` explicite : évite le SharedArrayBuffer côté types. + return new Blob([image.bytes.slice().buffer as ArrayBuffer], { type: image.contentType }); +} + +// ---- Résultat ---- + +export type PublishResult = { externalId: string | null; url: string | null }; + +export class SocialPublishError extends Error {} + +async function readError(res: Response): Promise { + const body = await res.text().catch(() => ""); + return `HTTP ${res.status} ${res.statusText}${body ? ` — ${body.slice(0, 500)}` : ""}`; +} + +// ---- Facebook (Graph API, page de l'association) ---- + +async function publishToFacebook(promo: PromoForSharing, message: string): Promise { + const pageId = env("FACEBOOK_PAGE_ID"); + const token = env("FACEBOOK_PAGE_ACCESS_TOKEN"); + if (!pageId || !token) throw new SocialPublishError("Facebook n'est pas configuré."); + + const image = await loadPromoImage(promo.imageUrl); + const base = `https://graph.facebook.com/${FACEBOOK_GRAPH_VERSION}/${pageId}`; + + const body = new FormData(); + body.set("access_token", token); + + let endpoint: string; + if (image) { + // Photo : on téléverse le binaire directement (les images sont en data-URI, + // Facebook ne peut donc pas aller les chercher par URL). + endpoint = `${base}/photos`; + body.set("caption", message); + body.set("source", toBlob(image), image.fileName); + } else { + endpoint = `${base}/feed`; + body.set("message", message); + const link = promoLink(promo); + if (link) body.set("link", link); + } + + const res = await fetch(endpoint, { method: "POST", body }); + if (!res.ok) throw new SocialPublishError(`Facebook: ${await readError(res)}`); + + const json = (await res.json()) as { id?: string; post_id?: string }; + const externalId = json.post_id ?? json.id ?? null; + return { + externalId, + url: externalId ? `https://www.facebook.com/${externalId}` : null, + }; +} + +// ---- LinkedIn (Posts API, page organisation) ---- + +/** + * Le champ `commentary` utilise le « Little Text Format » : ces caractères + * doivent être échappés sous peine de rejet de la requête. + */ +function escapeLinkedInText(text: string): string { + return text.replace(/[|{}@[\]()<>#*_~\\]/g, (c) => `\\${c}`); +} + +function linkedinHeaders(token: string): Record { + return { + Authorization: `Bearer ${token}`, + "LinkedIn-Version": LINKEDIN_VERSION, + "X-Restli-Protocol-Version": "2.0.0", + }; +} + +async function uploadLinkedInImage( + token: string, + owner: string, + image: LoadedImage +): Promise { + const initRes = await fetch("https://api.linkedin.com/rest/images?action=initializeUpload", { + method: "POST", + headers: { ...linkedinHeaders(token), "Content-Type": "application/json" }, + body: JSON.stringify({ initializeUploadRequest: { owner } }), + }); + if (!initRes.ok) throw new SocialPublishError(`LinkedIn (init image): ${await readError(initRes)}`); + + const init = (await initRes.json()) as { value?: { uploadUrl?: string; image?: string } }; + const uploadUrl = init.value?.uploadUrl; + const imageUrn = init.value?.image; + if (!uploadUrl || !imageUrn) { + throw new SocialPublishError("LinkedIn: réponse d'initialisation d'image inattendue."); + } + + const uploadRes = await fetch(uploadUrl, { + method: "PUT", + headers: { Authorization: `Bearer ${token}`, "Content-Type": image.contentType }, + body: toBlob(image), + }); + if (!uploadRes.ok) { + throw new SocialPublishError(`LinkedIn (upload image): ${await readError(uploadRes)}`); + } + + return imageUrn; +} + +async function publishToLinkedIn(promo: PromoForSharing, message: string): Promise { + const token = env("LINKEDIN_ACCESS_TOKEN"); + const owner = linkedinOrganizationUrn(); + if (!token || !owner) throw new SocialPublishError("LinkedIn n'est pas configuré."); + + const image = await loadPromoImage(promo.imageUrl); + const imageUrn = image ? await uploadLinkedInImage(token, owner, image) : null; + + const payload: Record = { + author: owner, + commentary: escapeLinkedInText(message), + visibility: "PUBLIC", + distribution: { + feedDistribution: "MAIN_FEED", + targetEntities: [], + thirdPartyDistributionChannels: [], + }, + lifecycleState: "PUBLISHED", + isReshareDisabledByAuthor: false, + }; + if (imageUrn) { + payload.content = { media: { id: imageUrn, altText: promo.title.slice(0, 200) } }; + } + + const res = await fetch("https://api.linkedin.com/rest/posts", { + method: "POST", + headers: { ...linkedinHeaders(token), "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (!res.ok) throw new SocialPublishError(`LinkedIn: ${await readError(res)}`); + + const externalId = res.headers.get("x-restli-id"); + return { + externalId, + url: externalId ? `https://www.linkedin.com/feed/update/${externalId}/` : null, + }; +} + +// ---- Point d'entrée ---- + +export async function publishPromoToNetwork( + network: SocialNetwork, + promo: PromoForSharing +): Promise { + const message = buildPromoMessage(promo); + return network === "facebook" + ? publishToFacebook(promo, message) + : publishToLinkedIn(promo, message); +}