Author SHA1 Message Date
Claude dd16e05b47 Demandes & messages : boutons d'action selon l'état de la ligne
Une demande approuvée gardait ses boutons « Approuver & créer » et
« Rejeter », un message lu son « Marquer lu », un message archivé son
« Archiver » : rien n'indiquait que l'action avait déjà été faite.

Chaque ligne ne propose plus que les transitions qui ont du sens :
- demande nouvelle → Approuver & créer / Rejeter
- demande rejetée  → Rouvrir
- demande approuvée → aucun bouton (le compte existe)
- message nouveau  → Marquer lu / Archiver
- message lu       → Archiver
- message archivé  → Désarchiver

Le masquage du bouton d'approbation vit dans `ApproveRequestForm` et non
dans la page : après l'approbation, `router.refresh()` repasse la demande
en « approuvée », et un test côté serveur démonterait le bloc
d'identifiants à usage unique avant qu'il ait été lu.

Deux gardes côté serveur couvrent l'onglet resté ouvert sur l'ancien
état : `approveMembershipRequest` refuse une demande déjà traitée, et
`setRequestStatus` ne touche plus une demande approuvée.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MrfrukBXnHtMwq5iT7DwAM
2026-09-13 07:48:59 +00:00
Claude e9b6fdfd91 Annuaire : cartes adhérents de hauteur uniforme
Une description longue étirait sa carte, donc toute la rangée de la
grille, et laissait les voisines avec un grand vide sous le texte.

Le titre (2 lignes) et la description (4 lignes) ont désormais une
hauteur figée et sont tronqués en fin de ligne ; un espaceur pousse
la ligne d'adresse en bas de carte, et cette adresse tient sur une
seule ligne avec ellipsis. Toutes les cartes font la même hauteur,
quel que soit le texte saisi par l'adhérent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MrfrukBXnHtMwq5iT7DwAM
2026-09-13 07:25:22 +00:00
Claude 503adcb20e Corrige la page blanche à la création d'un adhérent en doublon
« Application error: a server-side exception has occurred », digest 2117999835,
sur /backend/adherents : l'action levait `Error("Un compte existe déjà avec cet
e-mail.")`. Next masque le message d'une exception de server action en
production et remplace la page entière par son écran d'erreur — un e-mail déjà
pris, cas de saisie parfaitement ordinaire, mettait donc la page par terre.

Ces échecs attendus sont désormais **renvoyés** (`ActionError`) au lieu d'être
jetés, dans `addMember`, `inviteAdmin` et `approveMembershipRequest` :

- Le formulaire affiche la vraie raison, l'e-mail en cause inclus.
- `AddMemberPanel` réinjecte la saisie : React vide un formulaire non contrôlé
  après chaque action, il fallait sinon tout retaper.
- `InviteAdminForm` avait un rattrapage, mais devait deviner le motif faute de
  message ; il affiche maintenant celui de l'action. Son `catch` ne couvre plus
  que l'imprévu.
- `ApproveRequestForm` n'en avait aucun et tombait de la même façon.

Le `throw` reste réservé aux violations d'accès.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
2026-09-07 07:59:25 +00:00
Claude 50b8732080 Réseaux : bouton de renouvellement du jeton, avec ses deux liens de contrôle
Renouveler un jeton demandait de deviner que « Reconnecter » servait à ça, et
les outils Meta / LinkedIn étaient enfouis sous « Comment obtenir ces
identifiants ? ». Chaque réseau connecté porte maintenant un bloc dédié :

- Un bouton « Renouveler le jeton <réseau> », qui relance l'échange OAuth
  complet (jeton court → longue durée → jeton de page).
- Les deux liens utiles à ce moment-là, chacun avec ce qu'il sert à faire :
  débogueur de jeton et outil de jetons d'accès pour Facebook, jetons de
  l'application et documentation de durée de vie pour LinkedIn.
- Le bloc passe en ambre quand le jeton est mort, expiré ou proche de l'être.

« Reconnecter » disparaît : il faisait exactement la même chose que le nouveau
bouton, à dix pixels de distance. Une fois connecté, il ne reste que
« Déconnecter » ; « Connecter » n'apparaît plus que pour la première liaison.
Les bannières d'alerte nomment désormais le bouton qui existe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
2026-09-06 15:33:44 +00:00
Claude 4b12dc5164 Espace adhérent : récapitulatif des dates, aperçu ramené à sa taille réelle
La colonne de droite portait un aperçu étiré sur toute sa largeur : le visuel
carré rendait la carte démesurée, très loin de ce que verra le visiteur.

- L'aperçu est borné à 320 px, la largeur d'une carte dans la grille du site.
- Au-dessus, un récapitulatif « Dates de votre offre » : durée de validité et
  moment de publication, les deux informations que la carte ne montre pas.
  Sans saisie il affiche les valeurs par défaut en gris (« Sans date limite »,
  « Dès la validation par l'association ») ; des bornes incohérentes s'y
  signalent en rouge, comme sous le champ.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
2026-09-06 15:10:41 +00:00
Claude 353d38bebf Promotions : la page publique suit le gabarit des pages internes
La première version reprenait le style d'une *section* de l'accueil — un grand
cartouche blanc arrondi — qui détonnait à côté de l'annuaire et de
« L'association ». Elle suit maintenant le même gabarit que l'annuaire :

- `SiteHeader … logo` : toutes les pages internes affichent le logo, seule
  l'accueil s'en passe.
- Bloc de titre sur le fond beige (pastille « N offres en cours », `<h1>` au
  même `clamp`, chapeau, étincelles) au lieu du cartouche.
- Grille de cartes à même la page, `gap` aligné sur les autres listes.
- Bandeau d'adhésion bleu en pied de page, identique à celui de l'annuaire.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
2026-09-06 14:50:41 +00:00
Claude a52e13e623 Promotions : page publique dédiée, l'accueil garde les six dernières
`/promotions` liste toutes les offres en cours ; l'accueil en montre six et
renvoie vers elle. Les deux listes partagent `PromoCard`, extrait de la page
d'accueil : un seul rendu de carte à maintenir.

- `getLivePromotions(limit)` accepte `null` pour « toutes les offres », la
  limite restant appliquée en SQL et non après coup.
- Page complète : `<h1>` unique, `<main>`, métadonnées et canonique via
  `pageMetadata`, JSON-LD `BreadcrumbList` + `ItemList`, entrée au sitemap,
  état vide qui renvoie vers l'annuaire.
- Les liens « Promotions » de l'en-tête, du pied de page et de l'espace
  adhérent visent désormais cette page. Idem pour le lien d'une publication
  réseau sans adhérent rattaché, qui pointait sur l'ancre `/#promotions`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
2026-09-06 14:34:27 +00:00
Claude cf8cf5c8e2 Promotions : le logo revient à gauche du nom de l'adhérent
L'angle de la carte est rendu à la catégorie ; la pastille du commerce se
place devant son nom, en pied de carte, là où on lit déjà qui propose l'offre.
La période de validité passe sous le nom, dans le même bloc.

Appliqué à la carte d'accueil, à l'aperçu en direct de l'espace adhérent et à
la carte de modération. Retiré de la carte de la fiche adhérent : elle n'y
affiche pas de nom de commerce, et le logo figure déjà en tête de page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
2026-09-06 14:22:03 +00:00
Claude d99ee7ec07 Promotions : logo de l'adhérent dans l'angle des cartes
Le logo du commerce identifie l'offre avant même qu'on lise le nom. Il est
posé en pastille ronde dans l'angle haut-gauche de la carte, la catégorie se
décalant juste à côté sur la même ligne.

- `src/components/MemberAvatar.tsx` : logo en `contain` sur fond blanc (les
  logos d'adhérents sont des marques sur fond clair, un `cover` les rognerait),
  repli sur les initiales colorées quand la fiche n'a pas de logo.
- Appliqué à la carte d'accueil, à la carte de la fiche adhérent, à l'aperçu
  en direct de l'espace adhérent et à la carte de modération : même composition
  partout, l'aperçu montre donc bien ce que verra le visiteur.
- `getLivePromotions` remonte `members.logo_url` ; la page espace le passe au
  formulaire.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
2026-09-06 14:02:53 +00:00
Claude e980ab6e04 Promotions : publication programmée et affichage limité à la période
Deux comportements demandés :

1. Une promotion n'apparaît sur le site que pendant sa période de validité.
   Les lectures publiques passent par `VISIBLE_PROMO` (`src/lib/queries.ts`) :
   statut `live` **et** fenêtre `starts_on` / `ends_on`, journée calculée en
   Europe/Paris pour qu'un conteneur en UTC ne retire pas une offre deux
   heures trop tôt. Le statut n'est pas touché : hors période, le backoffice
   explique pourquoi l'offre n'est pas visible (`visibilityNote`) et elle
   revient d'elle-même si les dates changent.

2. La publication peut être programmée. `promotions.publish_at` : l'adhérent
   propose une date, le modérateur la garde, la déplace ou la vide dans le
   formulaire « Valider ». Avec une échéance future la promotion passe au
   statut `scheduled` — rien n'est visible, rien n'est diffusé — et
   `releaseDuePromotions()` fait la bascule à l'heure dite, site et réseaux
   ensemble. Un bouton « Publier maintenant » court-circuite l'attente.

Mécanique :
- `src/lib/promo-schedule.ts` (pur, testé) : conversion `datetime-local` ⇄
  heure de l'association, changements d'heure compris, et refus d'une saisie
  illisible plutôt qu'une publication immédiate involontaire.
- `src/lib/promo-publish.ts` reprend `publishPromoShares()` : deux appelants
  désormais, une action serveur et la boucle de libération, qui n'a pas de
  requête et ne peut donc pas appeler `revalidatePath()`. Idem pour
  `logActivity()` dans `src/lib/activity-log.ts`.
- La bascule `scheduled → live` est un `UPDATE … RETURNING` filtré sur le
  statut : atomique, donc pas de double publication même à plusieurs
  instances, en plus de la garde existante sur `social_posts`.
- Déclencheur : `src/instrumentation.ts`, boucle d'une minute avec rattrapage
  au démarrage (`PROMO_SCHEDULER=off` la désactive). Le travail vit dans
  `instrumentation-node.ts`, écarté du bundle edge par `next.config.mjs` :
  Next compile aussi l'instrumentation pour le middleware, où `pg` ne peut
  pas être embarqué.

Migration 0016 : `promo_status += 'scheduled'`, `promotions.publish_at`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
2026-09-06 11:17:49 +00:00
Claude 02c5378a47 Promotions : période de validité (date de début / date de fin)
Une promotion peut désormais porter une date de début et une date de fin,
toutes deux facultatives et indépendantes. Sans date, rien n'est affiché.

- Schéma : `promotions.starts_on` / `ends_on` (`date`), migration 0015.
- `src/lib/promo-validity.ts` (pur, testé) : `formatValidity()` produit
  « Valable du 1er au 15 mars 2027 », « Valable jusqu'au … », « Valable à
  partir du … » en repliant mois et année quand ils se répètent ;
  `formatValidityShort()` pour les lignes de méta ; `isRangeInvalid()` pour
  la validation.
- Espace adhérent : deux champs date, aperçu en direct de la phrase et
  blocage de l'envoi si la fin précède le début.
- `publishPromo` relit et revalide les bornes côté serveur.
- Affichage : carte d'accueil, fiche adhérent, liste de l'adhérent, carte de
  modération — et le message Facebook / LinkedIn, via le même formateur.
- L'ancien texte libre `valid_until` reste comme repli d'affichage pour les
  promotions de démonstration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
2026-09-06 11:00:44 +00:00
Claude 81b99ed5fb Réseaux : contrôle réel de validité des jetons et outils d'inspection
Un jeton de page Facebook n'a pas de date d'expiration, donc `expiryStatus`
renvoyait « never » et aucune alerte ne pouvait se déclencher. Mais il meurt
quand même : mot de passe du compte changé, rôle d'administrateur perdu sur la
page, autorisation retirée, révocation par Meta. On ne l'apprenait qu'au moment
où une publication échouait.

- `checkTokenHealth()` interroge la plateforme (`/me` côté Graph, les ACL
  d'organisation côté LinkedIn) et rend un verdict motivé.
- L'écran Réseaux sociaux contrôle à chaque affichage : pastille « Jeton
  expiré », bandeau rouge et réponse brute de la plateforme quand il est mort,
  ligne verte de confirmation sinon.
- Le verdict est persisté (`social_accounts.last_check_*`) : le tableau de bord
  alerte via `tokenHealthCached()` (fenêtre de 6 h) sans appeler la plateforme à
  chaque affichage, et croise ce signal avec la dernière tentative de
  publication refusée pour motif d'autorisation.
- Bloc « Outils pour inspecter un jeton » dans l'aide de chaque réseau :
  débogueur de jeton, outil de jetons d'accès, explorateur d'API Graph,
  documentation des jetons de longue durée — en précisant que le bouton
  Connecter fait déjà l'échange court → longue durée → jeton de page, et qu'il
  n'y a aucun jeton à copier à la main.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
2026-09-06 10:43:11 +00:00
Claude 17511a73c3 Connexion réseaux : adresse de retour déduite de la requête, CSP sans upgrade en HTTP
Cliquer sur « Connecter » sans avoir enregistré l'URL publique du site
renvoyait une erreur, et la redirection retombait sur https://0.0.0.0 :
la directive upgrade-insecure-requests était posée même sur une page
servie en HTTP, le navigateur basculait donc la navigation vers un
https:// inexistant.

- `publicBaseUrl()` : réglage `site_public_url`, variables d'environnement,
  puis origine de la requête (X-Forwarded-Host / X-Forwarded-Proto). L'adresse
  de retour OAuth et le lien des publications l'utilisent ; `seo-server`
  s'appuie dessus au lieu de dupliquer la déduction.
- `src/lib/site-url.ts` : déduction pure et testée de l'origine, HTTP par
  défaut pour localhost et les IP nues, HTTPS pour un nom de domaine.
- Middleware : `upgrade-insecure-requests` uniquement quand la page est servie
  en HTTPS.
- Page Réseaux : l'adresse détectée est annoncée comme valeur par défaut, avec
  le rappel que Facebook et LinkedIn exigent du HTTPS sur un domaine.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
2026-09-06 10:11:06 +00:00
46 changed files with 6356 additions and 388 deletions

No files matched your search

+83 -5
View File
@@ -50,11 +50,68 @@ docker compose up --build # full stack
## Promotions
Statuts : `pending` → `live` → `suspended` ⇄ `live` (+ `rejected` / `expired`).
Statuts : `pending` → (`scheduled` →) `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.
publiques filtrent sur le statut `live`, donc une promo suspendue — ou encore
programmée — disparaît du site sans traitement supplémentaire.
**Période de validité** : `promotions.starts_on` / `ends_on` (colonnes `date`,
les deux facultatives et indépendantes). `src/lib/promo-validity.ts` est **pur**
et verrouillé par `tests/promo-validity.test.ts` : `formatValidity()` rend
« Valable du 1er au 15 mars 2027 » / « Valable jusqu'au … » / « Valable à partir
du … », `formatValidityShort()` la variante sans « Valable » pour les lignes de
méta, `isRangeInvalid()` garde le formulaire et l'action. Sans date, rien n'est
affiché ; l'ancien texte libre `valid_until` (jamais écrit par l'application)
ne sert plus que de repli d'affichage. La même phrase part dans le message
Facebook / LinkedIn (`buildPromoMessage`) : un seul formateur pour le site,
le backoffice et les réseaux.
La liste publique vit sur **`/promotions`** (toutes les offres en cours) ;
l'accueil n'en montre que les six dernières et renvoie vers elle. Les deux
listes partagent `src/components/PromoCard.tsx` : un seul rendu, pas deux
cartes à maintenir. `getLivePromotions(limit)` accepte `null` pour « tout »,
la limite restant appliquée en SQL. Les liens « Promotions » de l'en-tête, du
pied de page et des publications réseaux pointent sur cette page, plus sur
l'ancre `/#promotions`.
Une promotion n'est **affichée que pendant sa période** : les lectures de
`src/lib/queries.ts` passent par `VISIBLE_PROMO` (statut `live` **et** fenêtre
de dates, journée calculée en `Europe/Paris` — un conteneur en UTC retirerait
sinon une offre deux heures trop tôt). Le statut n'est pas modifié : hors
période, le backoffice affiche `visibilityNote()` (« pas encore affichée » /
« période terminée »), et l'offre revient d'elle-même si les dates changent.
**Publication programmée** : `promotions.publish_at` (`timestamptz`, vide =
mise en ligne immédiate). L'adhérent propose une date dans son formulaire, le
modérateur la garde, la déplace ou la vide dans « Valider » — comme les cases
réseaux, c'est le dernier moment où elle est ajustable. Une promotion validée
avec une échéance future passe au statut `scheduled` : invisible du site et
**rien n'est diffusé**. `releaseDuePromotions()` (`src/lib/promo-publish.ts`)
fait la bascule `scheduled → live` par un `UPDATE … RETURNING` filtré sur le
statut — atomique, donc sans double publication même à plusieurs instances —
puis appelle `publishPromoShares()`. Le bouton « Publier maintenant » de la
modération court-circuite l'attente.
`src/lib/promo-schedule.ts` est **pur** (`tests/promo-schedule.test.ts`) :
il convertit la saisie `datetime-local` depuis/vers l'heure de l'association
(`Europe/Paris`, changements d'heure compris) et refuse une saisie illisible
plutôt que de la transformer en publication immédiate.
Le déclencheur est `src/instrumentation.ts` : une boucle d'une minute démarrée
avec le serveur, avec un premier passage au démarrage pour rattraper les
échéances tombées pendant un redéploiement. `PROMO_SCHEDULER=off` la désactive.
Le travail vit dans `src/instrumentation-node.ts` parce que Next compile aussi
`instrumentation.ts` pour le runtime edge du middleware : `next.config.mjs`
l'écarte de ce bundle (`IgnorePlugin`), sans quoi webpack tente d'y embarquer
`pg` et ses dépendances Node.
`src/lib/promo-publish.ts` porte `publishPromoShares()` — et non plus
`backend/actions.ts` — parce qu'il a deux appelants : une action serveur et la
boucle de libération, qui n'a pas de requête et ne peut donc pas appeler
`revalidatePath()`. `src/lib/activity-log.ts` porte `logActivity()` pour la
même raison.
## Réseaux sociaux
@@ -74,24 +131,45 @@ site sans traitement supplémentaire.
anti-CSRF passe par un cookie httpOnly ; aucun jeton ne transite par une URL.
- Facebook : le jeton de page n'expire pas. LinkedIn : 60 jours, rafraîchissement
programmatique réservé à certains partenaires, d'où le bandeau de reconnexion.
- « N'expire pas » ≠ « ne meurt jamais » : `checkTokenHealth()` interroge la
plateforme et persiste le verdict dans `social_accounts.last_check_*`.
L'écran Réseaux contrôle à chaque affichage ; le tableau de bord passe par
`tokenHealthCached()` (6 h) et croise le résultat avec la dernière tentative
de publication en échec. Une date d'expiration seule ne suffit pas à alerter.
- 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.
- La diffusion est déclenchée **par la validation**, pas par un bouton :
- La diffusion est déclenchée **par la validation** (ou par l'échéance d'une
publication programmée), 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
- `publishPromoShares()` dans `src/lib/promo-publish.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.
- L'URL publique du site : `siteUrl()` lit le réglage `site_public_url` puis
`NEXT_PUBLIC_SITE_URL` / `AUTH_URL` ; `publicBaseUrl()` y ajoute un repli sur
l'origine de la requête (`src/lib/site-url.ts`, en-têtes `X-Forwarded-*`).
L'adresse de retour OAuth et les liens des publications passent par
`publicBaseUrl()` : la connexion marche sans réglage tant que l'admin passe
par l'adresse publique.
- La CSP (`src/middleware.ts`) ne pose `upgrade-insecure-requests` qu'en HTTPS,
sinon un test en HTTP par IP voit toutes ses navigations basculer vers un
`https://` inexistant.
- Les URLs publiques des pages FB/LinkedIn sont des `site_settings`
(`association_facebook`, `association_linkedin`), éditables dans Paramètres.
## Sécurité
- **Erreurs des server actions** : un `throw` dans une action est masqué par
Next en production (message remplacé par un digest) et fait tomber la page
sur « Application error ». Les échecs **attendus** — e-mail déjà pris, champ
manquant — sont donc **renvoyés** (`ActionError = { error: string }`) et
affichés par le formulaire, qui conserve la saisie. Le `throw` reste réservé
aux violations d'accès, qui n'ont pas à s'expliquer à l'utilisateur.
- Le journal d'activité agrège des saisies de tiers, dont le formulaire de
contact **public** : il est filtré à l'écriture (`sanitizeActivityMessage`) et
rendu en éléments React (`activityNodes`), jamais en HTML brut.
+6 -6
View File
@@ -87,12 +87,12 @@ vivent dans le volume `caddy_data` et se renouvellent seuls. Une fois le DNS
posé, vérifier la délivrance avec
`docker logs pleinr-caddy 2>&1 | grep -i "certificate obtained"`.
> **Vérification par l'IP** : en HTTP sur une adresse IP, la directive
> `upgrade-insecure-requests` de la CSP (`src/middleware.ts`) peut empêcher le
> navigateur de charger styles et scripts. Pour un test fidèle avant le DNS,
> passer par un tunnel SSH — `ssh -L 8080:localhost:80 utilisateur@IP` — puis
> ouvrir `http://localhost:8080` : `localhost` est une origine de confiance, la
> directive ne s'y applique pas.
> **Vérification par l'IP** : la directive `upgrade-insecure-requests` de la
> CSP (`src/middleware.ts`) n'est posée que sur les pages servies en HTTPS. Un
> test en HTTP par l'IP ou par le port direct fonctionne donc sans tunnel. En
> revanche, Facebook et LinkedIn n'acceptent qu'une adresse de retour OAuth en
> HTTPS sur un nom de domaine : la connexion des réseaux sociaux ne peut être
> validée qu'une fois le DNS posé.
Mise à jour : `git pull && docker compose up -d --build`.
+3
View File
@@ -0,0 +1,3 @@
ALTER TABLE "social_accounts" ADD COLUMN "last_check_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "social_accounts" ADD COLUMN "last_check_ok" boolean;--> statement-breakpoint
ALTER TABLE "social_accounts" ADD COLUMN "last_check_error" text;
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE "promotions" ADD COLUMN "starts_on" date;--> statement-breakpoint
ALTER TABLE "promotions" ADD COLUMN "ends_on" date;
+2
View File
@@ -0,0 +1,2 @@
ALTER TYPE "public"."promo_status" ADD VALUE 'scheduled';--> statement-breakpoint
ALTER TABLE "promotions" ADD COLUMN "publish_at" timestamp with time zone;
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+21
View File
@@ -99,6 +99,27 @@
"when": 1788680191881,
"tag": "0013_contact_email",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1788691242685,
"tag": "0014_lean_marvel_zombies",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1788691741621,
"tag": "0015_curious_winter_soldier",
"breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1788692576315,
"tag": "0016_tiresome_korg",
"breakpoints": true
}
]
}
+14
View File
@@ -8,6 +8,20 @@ const nextConfig = {
// Les images d'entête/logo sont envoyées en data-URL via server action.
serverActions: { bodySizeLimit: "4mb" },
},
// `instrumentation.ts` est compilé pour les deux runtimes dès qu'un
// middleware existe. Son chargement du libérateur de publications est déjà
// conditionné à `NEXT_RUNTIME === "nodejs"`, mais webpack suit quand même
// l'import et tente d'embarquer `pg` (donc `fs`, `path`, `stream`) dans le
// bundle edge, où ces modules n'existent pas. On coupe la branche à la
// source : côté edge, le module Node n'est tout simplement pas résolu.
webpack: (config, { nextRuntime, webpack }) => {
if (nextRuntime === "edge") {
config.plugins.push(
new webpack.IgnorePlugin({ resourceRegExp: /^\.\/instrumentation-node$/ })
);
}
return config;
},
async headers() {
return [
{
+3 -2
View File
@@ -26,6 +26,7 @@ import {
parseMemberHours,
websiteLabel,
} from "@/lib/member-profile";
import { formatValidity } from "@/lib/promo-validity";
export const dynamic = "force-dynamic";
@@ -437,8 +438,8 @@ export default async function FicheAdherentPage({
<div style={{ padding: "14px 15px 15px" }}>
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 16, margin: "0 0 5px", color: "#26201a" }}>{p.title}</h3>
<p style={{ margin: "0 0 11px", fontSize: 13, color: "#8c8068", lineHeight: 1.5 }}>{p.text}</p>
{p.validUntil && (
<div style={{ borderTop: "1px solid #f0e8d6", paddingTop: 10, fontSize: 12, color: "#a99c82" }}>{p.validUntil}</div>
{formatValidity(p) && (
<div style={{ borderTop: "1px solid #f0e8d6", paddingTop: 10, fontSize: 12, color: "#a99c82" }}>{formatValidity(p)}</div>
)}
</div>
</article>
@@ -6,7 +6,7 @@ import {
authorizeUrl,
getDecryptedAppSecret,
getSocialAccount,
siteUrl,
publicBaseUrl,
SOCIAL_NETWORKS,
type SocialNetwork,
} from "@/lib/social-accounts";
@@ -34,11 +34,13 @@ export async function GET(
return back(request, "Réseau inconnu.");
}
const base = await siteUrl();
// Réglage enregistré, sinon l'adresse par laquelle l'administrateur est
// arrivé : c'est celle où le réseau nous renverra.
const base = await publicBaseUrl();
if (!base) {
return back(
request,
"Renseignez d'abord l'URL publique du site : elle sert à construire l'adresse de retour."
"Impossible de déterminer l'adresse publique du site : renseignez-la dans la section « URL publique du site »."
);
}
+95 -116
View File
@@ -4,7 +4,7 @@ import { randomInt } from "node:crypto";
import { revalidatePath } from "next/cache";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { and, eq, sql } from "drizzle-orm";
import { and, eq, ne, sql } from "drizzle-orm";
import bcrypt from "bcryptjs";
import { signOut } from "@/auth";
import { getSession } from "@/lib/session";
@@ -13,7 +13,6 @@ import { CATEGORY_PALETTE } from "@/db/categories";
import { slugify } from "@/lib/slug";
import { autoTags } from "@/lib/tags";
import {
activityLog,
categories,
contactMessages,
imageConsents,
@@ -25,14 +24,10 @@ import {
pastMeetings,
promotions,
siteSettings,
socialPosts,
users,
} from "@/db/schema";
import { sanitizeActivityMessage } from "@/lib/activity";
import { can, LABEL_TO_ROLE } from "@/lib/rbac";
import {
isNetworkConfigured,
publishPromoToNetwork,
SOCIAL_LABELS,
SOCIAL_NETWORKS,
type SocialNetwork,
@@ -43,16 +38,15 @@ import {
selectTarget,
} from "@/lib/social-accounts";
import { normalizeWebsite } from "@/lib/member-profile";
import { isRangeInvalid } from "@/lib/promo-validity";
import { formatSchedule, isDue, parseScheduleInput } from "@/lib/promo-schedule";
import { publishPromoShares, requestedNetworks } from "@/lib/promo-publish";
import { logActivity } from "@/lib/activity-log";
import { SITE_SETTING_DEFAULTS } from "@/lib/site-settings";
import type { AppRole } from "@/types/next-auth";
async function logActivity(message: string, dot = "#2C6FB3") {
// Le message agrège des saisies de tiers : on ne laisse passer que <strong>.
await db.insert(activityLog).values({ message: sanitizeActivityMessage(message), dot });
}
// Mot de passe temporaire lisible (sans caractères ambigus).
// `randomInt` et non `Math.random()` : ce dernier n'est pas cryptographique et
// permet de prédire les mots de passe suivants à partir de quelques tirages.
@@ -114,6 +108,14 @@ function asImageDataUri(formData: FormData, key: string): string | null {
return value;
}
/** Date `YYYY-MM-DD` facultative issue d'un `<input type="date">`. */
function asOptionalDate(formData: FormData, key: string): string | null {
const value = asString(formData, key);
if (!value) return null;
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) throw new Error("Date invalide.");
return value;
}
function revalidatePromoPaths(memberId?: number | null) {
revalidatePath("/backend/promotions");
revalidatePath("/backend/espace");
@@ -124,13 +126,6 @@ function revalidatePromoPaths(memberId?: number | null) {
if (memberId) revalidatePath("/adherents/[id]", "page");
}
/** 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",
@@ -138,84 +133,6 @@ function readShareTargets(formData: FormData) {
};
}
/**
* 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,
memberCity: members.city,
})
.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 (!(await 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();
@@ -232,20 +149,36 @@ export async function moderatePromo(formData: FormData) {
// 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);
// Le champ est pré-rempli avec la date demandée par l'adhérent : vidé, la
// promotion part tout de suite ; une échéance déjà passée aussi.
const publishAt = parseScheduleInput(String(formData.get("publishAt") ?? ""));
const immediate = isDue(publishAt);
await db
.update(promotions)
.set({
status: "live",
status: immediate ? "live" : "scheduled",
publishAt: immediate ? null : publishAt,
...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);
if (immediate) {
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 {
// Rien n'est diffusé maintenant : `releaseDuePromotions()` s'en chargera
// à l'échéance, site et réseaux d'un seul coup.
await logActivity(
`Promotion « ${promo.title} » validée, publication programmée ${formatSchedule(publishAt)}`,
"#9a6638"
);
}
} 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.
@@ -257,6 +190,15 @@ export async function moderatePromo(formData: FormData) {
`Promotion « ${promo.title} » suspendue par <strong>${name}</strong>`,
"#d8472b"
);
} else if (action === "publish-now") {
// Raccourci sur une promo programmée : on n'attend pas l'échéance.
if (promo.status !== "scheduled") return;
await db
.update(promotions)
.set({ status: "live", publishAt: null })
.where(eq(promotions.id, id));
await logActivity(`Promotion « ${promo.title} » publiée avant l'heure programmée`, "#1f8a5b");
await publishPromoShares(id, requestedNetworks(promo), userId);
} else if (action === "restore") {
await db
.update(promotions)
@@ -371,7 +313,15 @@ export async function publishPromo(formData: FormData) {
const text = String(formData.get("text") ?? "").slice(0, 240);
const category = String(formData.get("category") ?? "");
const badge = String(formData.get("badge") ?? "").trim() || null;
const startsOn = asOptionalDate(formData, "startsOn");
const endsOn = asOptionalDate(formData, "endsOn");
if (isRangeInvalid({ startsOn, endsOn })) {
throw new Error("La date de fin doit être postérieure à la date de début.");
}
const imageUrl = asImageDataUri(formData, "imageUrl");
// Date souhaitée de mise en ligne. Elle n'engage rien : le modérateur la voit
// et peut la garder, la déplacer ou la vider dans le formulaire « Valider ».
const publishAt = parseScheduleInput(String(formData.get("publishAt") ?? ""));
// Réseaux souhaités : rien n'est publié ici, la diffusion attend la validation.
await db.insert(promotions).values({
@@ -382,6 +332,9 @@ export async function publishPromo(formData: FormData) {
imageUrl,
memberId: memberId ?? null,
status: "pending",
startsOn,
endsOn,
publishAt,
...readShareTargets(formData),
});
@@ -406,6 +359,16 @@ export type IssuedCredentials = { email: string; tempPassword: string };
export type CreatedMemberAccount = IssuedCredentials & { memberId: number };
/**
* Échec **attendu** d'une action : e-mail déjà pris, champ manquant…
*
* Il est renvoyé et non `throw` : une exception levée dans une server action
* est masquée par Next en production (message remplacé par un digest) et fait
* tomber la page entière sur « Application error ». Le `throw` reste réservé
* aux violations d'accès, qui ne doivent pas s'expliquer à l'utilisateur.
*/
export type ActionError = { error: string };
/**
* Tags à enregistrer : la saisie si elle existe, sinon les suggestions déduites
* du métier, de la commune et de la description (`autoTags`).
@@ -428,14 +391,16 @@ async function resolveMemberTags(
});
}
export async function addMember(formData: FormData): Promise<CreatedMemberAccount | undefined> {
export async function addMember(
formData: FormData
): Promise<CreatedMemberAccount | ActionError | undefined> {
const { role } = await requireRole();
if (!can(role, "manageMembers")) throw new Error("Accès refusé");
const name = String(formData.get("name") ?? "").trim();
if (!name) return undefined;
const email = String(formData.get("email") ?? "").trim().toLowerCase();
if (!email) throw new Error("L'e-mail est requis pour créer le compte de l'adhérent.");
if (!email) return { error: "L'e-mail est requis pour créer le compte de l'adhérent." };
const categoryId = formData.get("categoryId") ? Number(formData.get("categoryId")) : null;
const city = String(formData.get("city") ?? "").trim() || null;
const status = (String(formData.get("status") ?? "pending") as "active" | "pending");
@@ -443,7 +408,7 @@ export async function addMember(formData: FormData): Promise<CreatedMemberAccoun
// Un seul compte par e-mail.
const existing = await db.select({ id: users.id }).from(users).where(eq(users.email, email));
if (existing.length > 0) {
throw new Error("Un compte existe déjà avec cet e-mail.");
return { error: `Un compte existe déjà avec l'e-mail ${email}.` };
}
const tags = await resolveMemberTags(null, { categoryId, city, description: null });
@@ -598,7 +563,9 @@ export async function deleteMember(formData: FormData) {
}
// ---- Admins ----
export async function inviteAdmin(formData: FormData): Promise<IssuedCredentials | undefined> {
export async function inviteAdmin(
formData: FormData
): Promise<IssuedCredentials | ActionError | undefined> {
const { role } = await requireRole();
if (!can(role, "manageAdmins")) throw new Error("Accès refusé");
@@ -610,7 +577,7 @@ export async function inviteAdmin(formData: FormData): Promise<IssuedCredentials
const newRole: AppRole = LABEL_TO_ROLE[roleLabel] ?? "editor";
const existing = await db.select().from(users).where(eq(users.email, email));
if (existing.length > 0) throw new Error("Un compte existe déjà avec cet e-mail.");
if (existing.length > 0) return { error: `Un compte existe déjà avec l'e-mail ${email}.` };
// Mot de passe temporaire montré une seule fois à l'inviteur, à changer à
// la première connexion. Auparavant il n'était ni conservé ni affiché :
@@ -1154,7 +1121,9 @@ export async function updateOwnProfile(formData: FormData) {
// Approuve une demande d'adhésion ET crée directement l'adhérent + son compte
// de connexion. Renvoie l'id du nouvel adhérent et ses identifiants, à
// afficher une seule fois.
export async function approveMembershipRequest(formData: FormData): Promise<CreatedMemberAccount | undefined> {
export async function approveMembershipRequest(
formData: FormData
): Promise<CreatedMemberAccount | ActionError | undefined> {
const { role } = await requireRole();
if (!can(role, "manageMembers")) throw new Error("Accès refusé");
@@ -1163,18 +1132,29 @@ export async function approveMembershipRequest(formData: FormData): Promise<Crea
const [req] = await db.select().from(membershipRequests).where(eq(membershipRequests.id, id));
if (!req) throw new Error("Demande introuvable.");
// Garde serveur : un double clic ou un onglet resté ouvert ne doit jamais
// créer un second compte pour la même demande.
if (req.status === "approved") throw new Error("Cette demande a déjà été approuvée : l'adhérent existe.");
// Le bouton disparaît dès qu'une demande est traitée, mais un onglet resté
// ouvert peut encore poster : on refuse ici plutôt que de créer un doublon.
if (req.status !== "new") {
return {
error:
req.status === "approved"
? "Cette demande a déjà été approuvée."
: "Cette demande a été rejetée : rouvrez-la avant de l'approuver.",
};
}
const email = (req.email ?? "").trim().toLowerCase();
if (!email) {
throw new Error("Cette demande n'a pas d'e-mail : impossible de créer le compte. Créez l'adhérent manuellement.");
return {
error:
"Cette demande n'a pas d'e-mail : impossible de créer le compte. Créez l'adhérent manuellement.",
};
}
const existing = await db.select({ id: users.id }).from(users).where(eq(users.email, email));
if (existing.length > 0) {
throw new Error("Un compte existe déjà avec cet e-mail.");
return { error: `Un compte existe déjà avec l'e-mail ${email}.` };
}
const [newMember] = await db
@@ -1209,13 +1189,12 @@ export async function setRequestStatus(formData: FormData) {
const id = Number(formData.get("id"));
const status = String(formData.get("status") ?? "");
if (!id || !["new", "approved", "rejected"].includes(status)) return;
// Une demande approuvée a produit un adhérent : son statut ne se réécrit plus.
const [current] = await db.select({ status: membershipRequests.status }).from(membershipRequests).where(eq(membershipRequests.id, id));
if (!current || current.status === "approved") return;
// Une demande approuvée a créé un compte : elle ne se rejoue pas depuis un
// onglet resté ouvert sur l'ancien état.
await db
.update(membershipRequests)
.set({ status: status as "new" | "approved" | "rejected" })
.where(eq(membershipRequests.id, id));
.where(and(eq(membershipRequests.id, id), ne(membershipRequests.status, "approved")));
revalidatePath("/backend/demandes");
revalidatePath("/backend");
}
+25 -5
View File
@@ -14,6 +14,11 @@ export function AddMemberPanel({
}) {
const [open, setOpen] = useState(false);
const [created, setCreated] = useState<CreatedMemberAccount | null>(null);
const [error, setError] = useState<string | null>(null);
// React vide un formulaire non contrôlé après chaque action : sur un refus,
// on réinjecte la saisie plutôt que de la faire retaper.
const [draft, setDraft] = useState<Record<string, string>>({});
const [attempt, setAttempt] = useState(0);
const router = useRouter();
return (
@@ -38,8 +43,18 @@ export function AddMemberPanel({
{open && (
<form
key={attempt}
action={async (fd) => {
setError(null);
const result = await addMember(fd);
// Échec de saisie : le formulaire reste ouvert avec la raison.
if (result && "error" in result) {
setDraft(Object.fromEntries([...fd.entries()].map(([k, v]) => [k, String(v)])));
setAttempt((n) => n + 1);
setError(result.error);
return;
}
setDraft({});
setOpen(false);
// On reste sur la page : le mot de passe temporaire n'est affiché
// qu'ici, une seule fois, et n'est conservé nulle part.
@@ -48,18 +63,23 @@ export function AddMemberPanel({
}}
style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 16, padding: 22, marginTop: 14 }}
>
{error && (
<div role="alert" style={{ background: "#fdecea", border: "1px solid #f1c4bd", borderRadius: 10, padding: "10px 14px", fontSize: 13, color: "#a3372e", marginBottom: 16 }}>
{error}
</div>
)}
<div className="grid grid-2" style={{ gap: 16 }}>
<div>
<label className="field-label">Nom de l&apos;adhérent</label>
<input name="name" required placeholder="ex : Au Bon Pain" className="field" />
<input name="name" required placeholder="ex : Au Bon Pain" className="field" defaultValue={draft.name ?? ""} />
</div>
<div>
<label className="field-label">E-mail (identifiant de connexion)</label>
<input name="email" type="email" required placeholder="contact@exemple.fr" className="field" />
<input name="email" type="email" required placeholder="contact@exemple.fr" className="field" defaultValue={draft.email ?? ""} />
</div>
<div>
<label className="field-label">Catégorie</label>
<select name="categoryId" className="field" defaultValue="">
<select name="categoryId" className="field" defaultValue={draft.categoryId ?? ""}>
<option value="">—</option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
@@ -70,7 +90,7 @@ export function AddMemberPanel({
</div>
<div>
<label className="field-label">Commune</label>
<select name="city" className="field" defaultValue="">
<select name="city" className="field" defaultValue={draft.city ?? ""}>
<option value="">—</option>
{BASSIN_POMPEY_COMMUNES.map((c) => (
<option key={c} value={c}>
@@ -81,7 +101,7 @@ export function AddMemberPanel({
</div>
<div>
<label className="field-label">Statut</label>
<select name="status" className="field" defaultValue="pending">
<select name="status" className="field" defaultValue={draft.status ?? "pending"}>
<option value="pending">En attente</option>
<option value="active">Actif</option>
</select>
@@ -25,11 +25,16 @@ export function InviteAdminForm() {
setError(null);
try {
const result = await inviteAdmin(fd);
if (result && "error" in result) {
setError(result.error);
return;
}
setIssued(result ?? null);
router.refresh();
} catch {
// Next masque le détail des erreurs serveur en production.
setError("Impossible de créer ce compte : vérifiez que l'e-mail n'est pas déjà utilisé.");
// Filet pour l'imprévu : les échecs de saisie, eux, sont renvoyés
// par l'action et affichés tels quels ci-dessus.
setError("Impossible de créer ce compte. Réessayez dans un instant.");
} finally {
setPending(false);
}
@@ -10,10 +10,22 @@ import { approveMembershipRequest, type CreatedMemberAccount } from "../actions"
* Approuve une demande et affiche une seule fois les identifiants du compte
* créé. On reste sur la page : une redirection ferait perdre le mot de passe,
* qui n'est conservé nulle part.
*
* Le bouton ne s'affiche que pour une demande encore à traiter, mais c'est ce
* composant — et non la page — qui en décide : après l'approbation,
* `router.refresh()` renvoie la demande en « approuvée », et un masquage côté
* serveur démonterait le bloc d'identifiants avant qu'il ait été lu.
*/
export function ApproveRequestForm({ requestId }: { requestId: number }) {
export function ApproveRequestForm({
requestId,
status,
}: {
requestId: number;
status: string;
}) {
const [created, setCreated] = useState<CreatedMemberAccount | null>(null);
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const router = useRouter();
if (created) {
@@ -27,13 +39,20 @@ export function ApproveRequestForm({ requestId }: { requestId: number }) {
);
}
if (status !== "new") return null;
return (
<form
action={async (fd) => {
setPending(true);
try {
const result = await approveMembershipRequest(fd);
if (result) {
// Échec de saisie (e-mail déjà pris, demande sans e-mail) : la
// raison s'affiche à côté du bouton, la page reste en place.
if (result && "error" in result) {
setError(result.error);
} else if (result) {
setError(null);
setCreated(result);
router.refresh();
}
@@ -43,6 +62,11 @@ export function ApproveRequestForm({ requestId }: { requestId: number }) {
}}
>
<input type="hidden" name="id" value={requestId} />
{error && (
<div role="alert" style={{ background: "#fdecea", border: "1px solid #f1c4bd", borderRadius: 9, padding: "8px 11px", fontSize: 12.5, color: "#a3372e", marginBottom: 8, maxWidth: 280, lineHeight: 1.5 }}>
{error}
</div>
)}
<button
type="submit"
disabled={pending}
+43 -53
View File
@@ -1,9 +1,8 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { desc, inArray } from "drizzle-orm";
import { desc } from "drizzle-orm";
import { getSession } from "@/lib/session";
import { db } from "@/db";
import { contactMessages, members, membershipRequests } from "@/db/schema";
import { contactMessages, membershipRequests } from "@/db/schema";
import { can } from "@/lib/rbac";
import { setContactStatus, setRequestStatus } from "../actions";
import { ApproveRequestForm } from "./ApproveRequestForm";
@@ -53,21 +52,6 @@ export default async function DemandesPage() {
db.select().from(contactMessages).orderBy(desc(contactMessages.createdAt)),
]);
// Demandes approuvées : on retrouve la fiche créée par son e-mail, pour
// proposer un lien vers l'adhérent plutôt que des boutons déjà consommés.
const approvedEmails = requests
.filter((r) => r.status === "approved" && r.email)
.map((r) => (r.email ?? "").trim().toLowerCase())
.filter(Boolean);
const memberByEmail = new Map<string, number>();
if (approvedEmails.length > 0) {
const rows = await db
.select({ id: members.id, email: members.email })
.from(members)
.where(inArray(members.email, approvedEmails));
for (const row of rows) memberByEmail.set(row.email.trim().toLowerCase(), row.id);
}
return (
<div style={{ display: "flex", flexDirection: "column", gap: 26 }}>
{/* ---- Demandes d'adhésion ---- */}
@@ -88,31 +72,24 @@ export default async function DemandesPage() {
</div>
</div>
<StatusPill map={REQ_STATUS} status={r.status} />
{/* Les actions dépendent du statut : une demande approuvée a déjà
produit son adhérent, on ne propose plus que d'ouvrir sa fiche. */}
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
{r.status === "approved" ? (
(() => {
const memberId = memberByEmail.get((r.email ?? "").trim().toLowerCase());
return memberId ? (
<Link href={`/backend/adherents/${memberId}`} style={{ color: "#2C6FB3", fontWeight: 700, fontSize: 12.5, textDecoration: "none", whiteSpace: "nowrap" }}>
Ouvrir la fiche adhérent →
</Link>
) : (
<span style={{ fontSize: 12.5, color: "#a99c82" }}>Adhérent créé</span>
);
})()
) : (
<>
<ApproveRequestForm requestId={r.id} />
{r.status === "new" && (
<form action={setRequestStatus}>
<input type="hidden" name="id" value={r.id} />
<input type="hidden" name="status" value="rejected" />
<ActionBtn color="#d8472b">Rejeter</ActionBtn>
</form>
)}
</>
{/* Une demande traitée ne garde que la sortie qui a du sens :
« Rejeter » disparaît une fois l'adhérent créé, et un rejet
reste réversible tant qu'aucun compte n'existe. */}
<div style={{ display: "flex", gap: 8 }}>
<ApproveRequestForm requestId={r.id} status={r.status} />
{r.status === "new" && (
<form action={setRequestStatus}>
<input type="hidden" name="id" value={r.id} />
<input type="hidden" name="status" value="rejected" />
<ActionBtn color="#d8472b">Rejeter</ActionBtn>
</form>
)}
{r.status === "rejected" && (
<form action={setRequestStatus}>
<input type="hidden" name="id" value={r.id} />
<input type="hidden" name="status" value="new" />
<ActionBtn color="#6c6150">Rouvrir</ActionBtn>
</form>
)}
</div>
</div>
@@ -144,17 +121,30 @@ export default async function DemandesPage() {
</div>
</div>
<StatusPill map={MSG_STATUS} status={m.status} />
{/* Chaque bouton fait avancer le message d'un cran : on ne
propose plus « Marquer lu » sur un message déjà lu ni
« Archiver » sur un message archivé. */}
<div style={{ display: "flex", gap: 8 }}>
<form action={setContactStatus}>
<input type="hidden" name="id" value={m.id} />
<input type="hidden" name="status" value="read" />
<ActionBtn color="#2C6FB3">Marquer lu</ActionBtn>
</form>
<form action={setContactStatus}>
<input type="hidden" name="id" value={m.id} />
<input type="hidden" name="status" value="archived" />
<ActionBtn color="#a99c82">Archiver</ActionBtn>
</form>
{m.status === "new" && (
<form action={setContactStatus}>
<input type="hidden" name="id" value={m.id} />
<input type="hidden" name="status" value="read" />
<ActionBtn color="#2C6FB3">Marquer lu</ActionBtn>
</form>
)}
{m.status !== "archived" ? (
<form action={setContactStatus}>
<input type="hidden" name="id" value={m.id} />
<input type="hidden" name="status" value="archived" />
<ActionBtn color="#a99c82">Archiver</ActionBtn>
</form>
) : (
<form action={setContactStatus}>
<input type="hidden" name="id" value={m.id} />
<input type="hidden" name="status" value="read" />
<ActionBtn color="#6c6150">Désarchiver</ActionBtn>
</form>
)}
</div>
</div>
<p style={{ margin: "10px 0 0", fontSize: 13.5, color: "#5a5040", whiteSpace: "pre-wrap" }}>{m.message}</p>
+138 -3
View File
@@ -4,9 +4,12 @@ import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { publishPromo } from "../actions";
import { PromoImage } from "@/components/PromoImage";
import { MemberAvatar } from "@/components/MemberAvatar";
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
import type { SocialNetwork } from "@/lib/social";
import type { PromoCategoryGroup } from "@/lib/promo-categories";
import { formatValidity, isRangeInvalid } from "@/lib/promo-validity";
import { formatSchedule, parseScheduleInput } from "@/lib/promo-schedule";
const STRIPE_WARM =
"repeating-linear-gradient(45deg,#efe9da,#efe9da 12px,#e6ddc9 12px,#e6ddc9 24px)";
@@ -16,13 +19,48 @@ const NETWORK_LABELS: Record<SocialNetwork, string> = {
linkedin: "LinkedIn",
};
/** Une ligne du récapitulatif de dates, à droite du formulaire. */
function SummaryRow({
label,
value,
tone,
last = false,
}: {
label: string;
value: string;
tone: "set" | "muted" | "error";
last?: boolean;
}) {
const color = tone === "error" ? "#d8472b" : tone === "set" ? "#3c3322" : "#a99c82";
return (
<div
style={{
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
gap: 14,
padding: "12px 0",
borderBottom: last ? "none" : "1px solid #f0e8d6",
}}
>
<span style={{ fontSize: 12.5, color: "#9a8d72", fontWeight: 700, whiteSpace: "nowrap" }}>{label}</span>
<span style={{ fontSize: 13, color, fontWeight: tone === "muted" ? 500 : 700, textAlign: "right" }}>
{value}
</span>
</div>
);
}
export function MemberSpaceForm({
memberName,
memberLogoUrl,
categoryGroups,
defaultCategory,
networks = [],
}: {
memberName: string;
/** Logo de l'adhérent, affiché dans l'angle de l'aperçu comme sur le site. */
memberLogoUrl?: string | null;
/** Types de produits / services, groupés pour le `<select>`. */
categoryGroups: PromoCategoryGroup[];
/** Pré-sélection déduite du métier de l'adhérent. */
@@ -36,6 +74,9 @@ export function MemberSpaceForm({
const [text, setText] = useState("");
const [cat, setCat] = useState(initialCategory);
const [badge, setBadge] = useState("");
const [startsOn, setStartsOn] = useState("");
const [endsOn, setEndsOn] = useState("");
const [publishAt, setPublishAt] = useState("");
const [imgData, setImgData] = useState("");
const [shares, setShares] = useState<SocialNetwork[]>([]);
const [submitted, setSubmitted] = useState(false);
@@ -62,6 +103,9 @@ export function MemberSpaceForm({
setTitle("");
setText("");
setBadge("");
setStartsOn("");
setEndsOn("");
setPublishAt("");
setImgData("");
setShares([]);
setSubmitted(false);
@@ -76,6 +120,9 @@ export function MemberSpaceForm({
fd.set("text", text);
fd.set("category", cat);
fd.set("badge", badge);
fd.set("startsOn", startsOn);
fd.set("endsOn", endsOn);
fd.set("publishAt", publishAt);
fd.set("imageUrl", imgData);
if (shares.includes("facebook")) fd.set("shareFacebook", "on");
if (shares.includes("linkedin")) fd.set("shareLinkedin", "on");
@@ -89,6 +136,17 @@ export function MemberSpaceForm({
const hasImg = !!imgData;
const hasBadge = !!badge.trim();
const validity = formatValidity({ startsOn, endsOn });
// Bornes incohérentes : on le dit avant l'envoi plutôt qu'après un refus.
const badRange = isRangeInvalid({ startsOn, endsOn });
// Aperçu de la programmation. Le champ donne une heure locale : on la relit
// avec le même formateur que le backoffice pour éviter toute surprise.
let scheduleHint: string | null = null;
try {
scheduleHint = formatSchedule(parseScheduleInput(publishAt));
} catch {
scheduleHint = null;
}
return (
<div className="grid grid-2" style={{ alignItems: "start" }}>
@@ -169,6 +227,56 @@ export function MemberSpaceForm({
</div>
</div>
<div style={{ marginBottom: 16 }}>
<label className="field-label">Durée de validité · facultatif</label>
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
<div style={{ flex: "1 1 150px" }}>
<input
type="date"
value={startsOn}
onChange={(e) => { setStartsOn(e.target.value); setSubmitted(false); }}
className="field"
aria-label="Début de la promotion"
/>
<div style={{ fontSize: 11.5, color: "#a99c82", marginTop: 4 }}>Début</div>
</div>
<div style={{ flex: "1 1 150px" }}>
<input
type="date"
value={endsOn}
min={startsOn || undefined}
onChange={(e) => { setEndsOn(e.target.value); setSubmitted(false); }}
className="field"
aria-label="Fin de la promotion"
/>
<div style={{ fontSize: 11.5, color: "#a99c82", marginTop: 4 }}>Fin</div>
</div>
</div>
<div style={{ fontSize: 12, color: badRange ? "#d8472b" : "#8c8068", marginTop: 8, lineHeight: 1.5 }}>
{badRange
? "La date de fin est antérieure à la date de début."
: validity
? `Affiché sur le site et dans la publication : « ${validity} ». L'offre n'apparaît que pendant cette période.`
: "Laissez vide si votre offre n'a pas de date limite : elle restera affichée sans échéance."}
</div>
</div>
<div style={{ marginBottom: 16 }}>
<label className="field-label">Programmer la publication · facultatif</label>
<input
type="datetime-local"
value={publishAt}
onChange={(e) => { setPublishAt(e.target.value); setSubmitted(false); }}
className="field"
aria-label="Date et heure de publication souhaitées"
/>
<div style={{ fontSize: 12, color: "#8c8068", marginTop: 8, lineHeight: 1.5 }}>
{scheduleHint
? `Mise en ligne demandée ${scheduleHint} — site et réseaux en même temps. L'association reste libre de la déplacer.`
: "Laissez vide pour une mise en ligne dès la validation par l'association."}
</div>
</div>
<label className="field-label">Texte de la promotion</label>
<textarea
value={text}
@@ -230,7 +338,7 @@ export function MemberSpaceForm({
<div style={{ display: "flex", gap: 10 }}>
<button
onClick={submit}
disabled={pending}
disabled={pending || badRange}
className="font-display"
style={{ flex: 1, border: "none", background: "#9a6638", color: "#fff", fontWeight: 700, fontSize: 15, padding: 14, borderRadius: 11, cursor: pending ? "default" : "pointer", opacity: pending ? 0.7 : 1 }}
>
@@ -255,10 +363,31 @@ export function MemberSpaceForm({
{/* LIVE PREVIEW */}
<div>
{/* Récapitulatif des dates : quand l'offre paraît, combien de temps
elle reste. Deux informations que la carte ne montre pas. */}
<div style={{ fontSize: 12.5, letterSpacing: "0.08em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 700, marginBottom: 12 }}>
Dates de votre offre
</div>
<div style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 14, padding: "4px 16px", marginBottom: 26 }}>
<SummaryRow
label="Durée de validité"
value={badRange ? "Dates incohérentes" : validity ?? "Sans date limite"}
tone={badRange ? "error" : validity ? "set" : "muted"}
/>
<SummaryRow
label="Publication"
value={scheduleHint ? `Programmée ${scheduleHint}` : "Dès la validation par l'association"}
tone={scheduleHint ? "set" : "muted"}
last
/>
</div>
<div style={{ fontSize: 12.5, letterSpacing: "0.08em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 700, marginBottom: 12 }}>
Aperçu en direct
</div>
<article style={{ background: "#fff", border: "1px solid #ece3d0", borderRadius: 18, overflow: "hidden", boxShadow: "0 18px 40px -26px rgba(40,30,15,0.5)" }}>
{/* Largeur bornée : la carte doit ressembler à celle du site, pas
occuper toute la colonne (le visuel carré la rendait démesurée). */}
<article style={{ maxWidth: 320, background: "#fff", border: "1px solid #ece3d0", borderRadius: 18, overflow: "hidden", boxShadow: "0 18px 40px -26px rgba(40,30,15,0.5)" }}>
<div
style={{
position: "relative",
@@ -293,8 +422,14 @@ export function MemberSpaceForm({
{text.trim() ||
"Le texte de votre offre apparaîtra ici au fur et à mesure que vous le rédigez."}
</p>
{validity && (
<div style={{ fontSize: 12, color: "#a99c82", marginBottom: 12 }}>{validity}</div>
)}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", borderTop: "1px solid #f0e8d6", paddingTop: 12 }}>
<div style={{ fontSize: 13, fontWeight: 700, color: "#3c3322" }}>{memberName}</div>
<div style={{ display: "flex", alignItems: "center", gap: 9, minWidth: 0 }}>
<MemberAvatar name={memberName} logoUrl={memberLogoUrl} size={28} />
<span style={{ fontSize: 13, fontWeight: 700, color: "#3c3322" }}>{memberName}</span>
</div>
<span style={{ color: "#2C6FB3", fontWeight: 700, fontSize: 13 }}>Voir l&apos;offre →</span>
</div>
</div>
+41 -3
View File
@@ -7,6 +7,8 @@ import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
import { configuredNetworks, SOCIAL_LABELS, type SocialNetwork } from "@/lib/social";
import { PROMO_CATEGORY_GROUPS, defaultPromoCategory } from "@/lib/promo-categories";
import { memberPath } from "@/lib/seo";
import { formatValidityShort, visibilityNote } from "@/lib/promo-validity";
import { scheduleLabel } from "@/lib/promo-schedule";
import { setOwnPromoShareTargets, setOwnPromoSuspension } from "../../actions";
import { EspaceHeader } from "../EspaceHeader";
import { MemberSpaceForm } from "../MemberSpaceForm";
@@ -19,6 +21,7 @@ const PROMO_STATUS: Record<string, { label: string; bg: string; color: string }>
rejected: { label: "Refusée", bg: "#fbe9e6", color: "#d8472b" },
suspended: { label: "Suspendue", bg: "#fbe9e6", color: "#d8472b" },
expired: { label: "Expirée", bg: "#f1efe7", color: "#a99c82" },
scheduled: { label: "Programmée", bg: "#eaf0f6", color: "#2C6FB3" },
};
type MyPromo = {
@@ -28,6 +31,9 @@ type MyPromo = {
category: string | null;
badge: string | null;
validUntil: string | null;
publishAt: Date | null;
startsOn: string | null;
endsOn: string | null;
createdAt: Date;
imageUrl: string | null;
suspendedBy: "member" | "staff" | null;
@@ -46,6 +52,7 @@ export default async function EspacePromotionsPage() {
const session = await getSession();
const memberId = session?.user.memberId ?? null;
let memberName = session?.user.name ?? "Adhérent";
let memberLogoUrl: string | null = null;
let subtitle = "Espace adhérent · Promotions";
let publicPath: string | null = null;
let categorySlug: string | null = null;
@@ -59,6 +66,7 @@ export default async function EspacePromotionsPage() {
name: members.name,
city: members.city,
status: members.status,
logoUrl: members.logoUrl,
categorySlug: categories.slug,
categoryLabel: categories.label,
})
@@ -67,6 +75,7 @@ export default async function EspacePromotionsPage() {
.where(eq(members.id, memberId));
if (m) {
memberName = m.name;
memberLogoUrl = m.logoUrl;
categorySlug = m.categorySlug;
subtitle = ["Espace adhérent", m.categoryLabel, m.city].filter(Boolean).join(" · ");
publicPath = m.status === "active" ? memberPath(m) : null;
@@ -79,6 +88,9 @@ export default async function EspacePromotionsPage() {
category: promotions.category,
badge: promotions.badge,
validUntil: promotions.validUntil,
publishAt: promotions.publishAt,
startsOn: promotions.startsOn,
endsOn: promotions.endsOn,
createdAt: promotions.createdAt,
imageUrl: promotions.imageUrl,
suspendedBy: promotions.suspendedBy,
@@ -111,7 +123,12 @@ export default async function EspacePromotionsPage() {
const networks = await configuredNetworks();
const live = myPromos.filter((p) => p.status === "live");
const pending = myPromos.filter((p) => p.status === "pending");
const others = myPromos.filter((p) => p.status !== "live" && p.status !== "pending");
// Validées mais pas encore à leur date : elles n'ont rien à faire dans
// l'historique des offres terminées.
const scheduled = myPromos.filter((p) => p.status === "scheduled");
const others = myPromos.filter(
(p) => p.status !== "live" && p.status !== "pending" && p.status !== "scheduled"
);
return (
<div>
@@ -146,11 +163,22 @@ export default async function EspacePromotionsPage() {
<MemberSpaceForm
memberName={memberName}
memberLogoUrl={memberLogoUrl}
categoryGroups={PROMO_CATEGORY_GROUPS}
defaultCategory={defaultPromoCategory(categorySlug)}
networks={networks}
/>
{scheduled.length > 0 && (
<PromoSection
title="Publications programmées"
intro="Validées par l'association : elles apparaîtront sur le site et sur les réseaux à la date prévue."
promos={scheduled}
networks={networks}
lastShare={lastShare}
/>
)}
{pending.length > 0 && (
<PromoSection
title="En attente de validation"
@@ -173,7 +201,7 @@ export default async function EspacePromotionsPage() {
{myPromos.length > 0 && (
<p style={{ fontSize: 13, color: "#8c8068", marginTop: 18 }}>
Les offres en ligne apparaissent sur <Link href="/#promotions" style={{ color: "#9a6638", fontWeight: 700 }}>la page d&apos;accueil</Link>
Les offres en ligne apparaissent sur <Link href="/promotions" style={{ color: "#9a6638", fontWeight: 700 }}>la page des promotions</Link>
{publicPath ? (
<>
{" "}et sur <Link href={publicPath} style={{ color: "#9a6638", fontWeight: 700 }}>votre fiche</Link>
@@ -237,7 +265,7 @@ function PromoSection({
function PromoRow({ promo: mp, networks, lastShare }: { promo: MyPromo; networks: SocialNetwork[]; lastShare: ShareState }) {
const st = PROMO_STATUS[mp.status] ?? PROMO_STATUS.expired;
const suspendedByStaff = mp.status === "suspended" && mp.suspendedBy === "staff";
const meta = [mp.category, mp.badge, mp.validUntil].filter(Boolean).join(" · ");
const meta = [mp.category, mp.badge, formatValidityShort(mp)].filter(Boolean).join(" · ");
return (
<div style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 12, padding: "12px 14px", marginBottom: 10 }}>
<div style={{ display: "flex", alignItems: "center", gap: 13, flexWrap: "wrap" }}>
@@ -281,6 +309,16 @@ function PromoRow({ promo: mp, networks, lastShare }: { promo: MyPromo; networks
</form>
)}
</div>
{mp.status === "live" && visibilityNote(mp) && (
<div style={{ marginTop: 10, background: "#faf7ef", border: "1px solid #f0e8d6", color: "#9a8d72", borderRadius: 9, padding: "9px 11px", fontSize: 12.5, lineHeight: 1.5 }}>
{visibilityNote(mp)}
</div>
)}
{mp.status === "scheduled" && (
<div style={{ marginTop: 10, background: "#eaf0f6", border: "1px solid #d3e0ee", color: "#2C6FB3", borderRadius: 9, padding: "9px 11px", fontSize: 12.5, lineHeight: 1.5, fontWeight: 600 }}>
Validée par l&apos;association. {scheduleLabel(mp.publishAt)} — site et réseaux en même temps.
</div>
)}
{suspendedByStaff && (
<div style={{ marginTop: 10, background: "#fbe9e6", border: "1px solid #f2d5cf", color: "#a8503c", borderRadius: 9, padding: "9px 11px", fontSize: 12.5, lineHeight: 1.5 }}>
Cette promotion a été suspendue par l&apos;association. Contactez-la pour la remettre en ligne.
+57 -15
View File
@@ -3,10 +3,17 @@ import { redirect } from "next/navigation";
import { desc, eq } from "drizzle-orm";
import { getSession } from "@/lib/session";
import { db } from "@/db";
import { activityLog, contactMessages, members, membershipRequests, promotions } from "@/db/schema";
import { activityLog, contactMessages, members, membershipRequests, promotions, socialPosts } from "@/db/schema";
import { activityNodes } from "@/lib/activity";
import { can, isStaff } from "@/lib/rbac";
import { expiryStatus, getSocialAccounts, SOCIAL_LABELS } from "@/lib/social-accounts";
import { countVisiblePromotions } from "@/lib/queries";
import {
expiryStatus,
getSocialAccounts,
SOCIAL_LABELS,
tokenHealthCached,
type SocialNetwork,
} from "@/lib/social-accounts";
export const dynamic = "force-dynamic";
@@ -42,7 +49,7 @@ export default async function DashboardPage() {
const [activeMembers, livePromos, pendingPromos, requests, newMessages, activity] = await Promise.all([
db.select({ id: members.id }).from(members).where(eq(members.status, "active")),
db.select({ id: promotions.id }).from(promotions).where(eq(promotions.status, "live")),
countVisiblePromotions(),
db.select({ id: promotions.id }).from(promotions).where(eq(promotions.status, "pending")),
db.select({ id: membershipRequests.id }).from(membershipRequests).where(eq(membershipRequests.status, "new")),
db.select({ id: contactMessages.id }).from(contactMessages).where(eq(contactMessages.status, "new")),
@@ -51,14 +58,47 @@ export default async function DashboardPage() {
const pendingCount = pendingPromos.length;
// Jetons réseaux à renouveler : sans alerte, on ne découvre l'expiration
// qu'au moment où une publication échoue.
const expiringNetworks = can(session?.user.role, "manageSettings")
? (await getSocialAccounts())
.filter((a) => a.accessToken && a.targetId)
.map((a) => ({ network: a.network, status: expiryStatus(a.expiresAt) }))
.filter((a) => a.status === "soon" || a.status === "expired")
: [];
// Jetons réseaux à renouveler. Deux signaux, car une date d'expiration ne
// suffit pas : un jeton de page Facebook n'expire jamais mais peut être
// révoqué, et cela ne se voit qu'à la première publication en échec.
const expiringNetworks: { network: SocialNetwork; status: string }[] = [];
if (can(session?.user.role, "manageSettings")) {
const connected = (await getSocialAccounts()).filter((a) => a.accessToken && a.targetId);
// Dernière tentative de publication par réseau : une erreur d'autorisation
// signale un jeton mort, sans avoir à interroger la plateforme ici.
const attempts = connected.length
? await db
.select({
network: socialPosts.network,
status: socialPosts.status,
error: socialPosts.error,
})
.from(socialPosts)
.orderBy(desc(socialPosts.createdAt))
.limit(40)
: [];
const lastAttempt = new Map<string, (typeof attempts)[number]>();
for (const a of attempts) if (!lastAttempt.has(a.network)) lastAttempt.set(a.network, a);
for (const account of connected) {
const last = lastAttempt.get(account.network);
const authFailure =
last?.status === "failed" &&
/oauth|token|401|expired|invalid|permission/i.test(last.error ?? "");
// Contrôle direct, mis en cache 6 h : c'est ce qui permet de prévenir
// avant la première publication en échec, sans appeler la plateforme à
// chaque affichage du tableau de bord.
const probe = authFailure ? null : await tokenHealthCached(account.network);
const expiry = expiryStatus(account.expiresAt);
if (authFailure || probe?.ok === false) {
expiringNetworks.push({ network: account.network, status: "broken" });
} else if (expiry === "soon" || expiry === "expired") {
expiringNetworks.push({ network: account.network, status: expiry });
}
}
}
function timeAgo(date: Date) {
const diff = Date.now() - new Date(date).getTime();
@@ -90,9 +130,11 @@ export default async function DashboardPage() {
{expiringNetworks.map((n) => (
<div key={n.network}>
<strong>{SOCIAL_LABELS[n.network]}</strong>{" "}
{n.status === "expired"
? "— le jeton a expiré, les publications échoueront."
: "— le jeton expire dans moins de 7 jours."}{" "}
{n.status === "broken"
? "— la dernière publication a été refusée : le jeton ne fonctionne plus."
: n.status === "expired"
? "— le jeton a expiré, les publications échoueront."
: "— le jeton expire dans moins de 7 jours."}{" "}
Reconnecter le compte →
</div>
))}
@@ -101,7 +143,7 @@ export default async function DashboardPage() {
<div className="grid grid-4" style={{ marginBottom: 26 }}>
<StatCard label="Adhérents actifs" value={activeMembers.length} hint="sur le réseau" valueColor="#13324F" hintColor="#1f8a5b" />
<StatCard label="Promotions en ligne" value={livePromos.length} hint="visibles sur le site" />
<StatCard label="Promotions en ligne" value={livePromos} hint="visibles sur le site" />
<StatCard label="À modérer" value={pendingCount} hint="en attente de validation" valueColor="#9a6638" hintColor="#9a6638" />
<Link href="/backend/demandes" style={{ textDecoration: "none" }}>
<StatCard
+69 -5
View File
@@ -7,7 +7,10 @@ import { can } from "@/lib/rbac";
import { configuredNetworks, SOCIAL_LABELS } from "@/lib/social";
import { moderatePromo, retryPromoShare } from "../actions";
import { PromoImage } from "@/components/PromoImage";
import { MemberAvatar } from "@/components/MemberAvatar";
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
import { formatValidity, visibilityNote } from "@/lib/promo-validity";
import { formatSchedule, scheduleLabel, toScheduleInput } from "@/lib/promo-schedule";
export const dynamic = "force-dynamic";
@@ -20,6 +23,7 @@ const STATUS_CHIP: Record<string, { label: string; bg: string; color: string }>
pending: { label: "En attente", bg: "#fbeede", color: "#9a6638" },
live: { label: "En ligne", bg: "#e6f4ec", color: "#1f8a5b" },
suspended: { label: "Suspendue", bg: "#fbe9e6", color: "#d8472b" },
scheduled: { label: "Programmée", bg: "#eaf0f6", color: "#2C6FB3" },
};
function fmtDate(d: Date) {
@@ -41,23 +45,30 @@ export default async function PromotionsPage() {
text: promotions.text,
category: promotions.category,
status: promotions.status,
publishAt: promotions.publishAt,
validUntil: promotions.validUntil,
startsOn: promotions.startsOn,
endsOn: promotions.endsOn,
imageUrl: promotions.imageUrl,
suspendedBy: promotions.suspendedBy,
suspendedAt: promotions.suspendedAt,
shareFacebook: promotions.shareFacebook,
shareLinkedin: promotions.shareLinkedin,
memberName: members.name,
memberLogoUrl: members.logoUrl,
})
.from(promotions)
.leftJoin(members, eq(promotions.memberId, members.id))
.where(inArray(promotions.status, ["pending", "live", "suspended"]))
.where(inArray(promotions.status, ["pending", "live", "suspended", "scheduled"]))
.orderBy(desc(promotions.createdAt));
const pending = rows.filter((r) => r.status === "pending");
const live = rows.filter((r) => r.status === "live");
const suspended = rows.filter((r) => r.status === "suspended");
// En attente d'abord, puis en ligne, puis suspendues.
const ordered = [...pending, ...live, ...suspended];
const scheduled = rows.filter((r) => r.status === "scheduled");
// En attente d'abord, puis les programmées (elles attendent une décision de
// calendrier), puis les offres en ligne et enfin les suspendues.
const ordered = [...pending, ...scheduled, ...live, ...suspended];
const shares = ordered.length
? await db
@@ -90,6 +101,9 @@ export default async function PromotionsPage() {
<span style={{ background: "#13324F", color: "#fff", fontSize: 13, fontWeight: 600, padding: "8px 16px", borderRadius: 999 }}>
En attente · {pending.length}
</span>
<span style={{ background: "#fff", border: "1px solid #e6dcc6", color: "#6c6150", fontSize: 13, fontWeight: 600, padding: "8px 16px", borderRadius: 999 }}>
Programmées · {scheduled.length}
</span>
<span style={{ background: "#fff", border: "1px solid #e6dcc6", color: "#6c6150", fontSize: 13, fontWeight: 600, padding: "8px 16px", borderRadius: 999 }}>
En ligne · {live.length}
</span>
@@ -115,6 +129,7 @@ export default async function PromotionsPage() {
{ordered.map((p) => {
const isPending = p.status === "pending";
const isSuspended = p.status === "suspended";
const isScheduled = p.status === "scheduled";
const chip = STATUS_CHIP[p.status] ?? STATUS_CHIP.pending;
const chosenNetworks = networks.filter((n) =>
n === "facebook" ? p.shareFacebook : p.shareLinkedin
@@ -169,13 +184,34 @@ export default async function PromotionsPage() {
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 15.5, margin: "0 0 4px", color: "#26201a" }}>
{p.title}
</h3>
<div style={{ fontSize: 12.5, color: "#9a6638", fontWeight: 600, marginBottom: 8 }}>
{p.memberName}
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 8 }}>
<MemberAvatar name={p.memberName ?? ""} logoUrl={p.memberLogoUrl} size={24} />
<span style={{ fontSize: 12.5, color: "#9a6638", fontWeight: 600 }}>{p.memberName}</span>
</div>
<p style={{ margin: "0 0 12px", fontSize: 12.5, color: "#8c8068", lineHeight: 1.45, flex: 1 }}>
{p.text}
</p>
{/* Le modérateur voit la période de validité avant de valider :
c'est elle qui partira dans le message réseaux sociaux. */}
{formatValidity(p) && (
<div style={{ fontSize: 12, color: "#9a8d72", fontWeight: 600, margin: "0 0 12px" }}>
{formatValidity(p)}
</div>
)}
{p.status === "live" && visibilityNote(p) && (
<div style={{ background: "#faf7ef", border: "1px solid #f0e8d6", color: "#9a8d72", borderRadius: 10, padding: "9px 11px", fontSize: 12, lineHeight: 1.45, marginBottom: 11 }}>
{visibilityNote(p)}
</div>
)}
{isScheduled && (
<div style={{ background: "#eaf0f6", border: "1px solid #d3e0ee", color: "#2C6FB3", borderRadius: 10, padding: "9px 11px", fontSize: 12, lineHeight: 1.45, marginBottom: 11, fontWeight: 600 }}>
{scheduleLabel(p.publishAt)}. Rien n&apos;est encore visible ni diffusé.
</div>
)}
{isSuspended && (
<div style={{ background: "#fbe9e6", border: "1px solid #f2d5cf", color: "#a8503c", borderRadius: 10, padding: "9px 11px", fontSize: 12, lineHeight: 1.45, marginBottom: 11 }}>
Suspendue par {p.suspendedBy === "member" ? "l'adhérent" : "l'association"}
@@ -214,6 +250,24 @@ export default async function PromotionsPage() {
</div>
</div>
)}
{/* Date demandée par l'adhérent, modifiable ici. Vidée,
la promotion part immédiatement. */}
<div style={{ background: "#faf7ef", border: "1px solid #f0e8d6", borderRadius: 10, padding: "10px 11px", marginBottom: 8 }}>
<label style={{ display: "block", fontSize: 10.5, letterSpacing: "0.08em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 800, marginBottom: 7 }}>
Publication
</label>
<input
type="datetime-local"
name="publishAt"
defaultValue={toScheduleInput(p.publishAt)}
style={{ width: "100%", boxSizing: "border-box", border: "1px solid #e6dcc6", borderRadius: 8, padding: "7px 9px", fontSize: 12.5, fontFamily: "inherit", color: "#3c3322", background: "#fff" }}
/>
<div style={{ fontSize: 11, color: "#a99c82", marginTop: 7, lineHeight: 1.45 }}>
{p.publishAt
? `Demandé par l'adhérent ${formatSchedule(p.publishAt)}. Videz le champ pour publier tout de suite.`
: "Vide : mise en ligne immédiate. Renseignez une date pour programmer."}
</div>
</div>
<button type="submit" style={{ width: "100%", border: "none", background: "#1f8a5b", color: "#fff", fontWeight: 700, fontSize: 13, padding: 10, borderRadius: 9, cursor: "pointer" }}>
Valider
</button>
@@ -230,6 +284,16 @@ export default async function PromotionsPage() {
</div>
)}
{isScheduled && (
<form action={moderatePromo} style={{ marginBottom: 8 }}>
<input type="hidden" name="id" value={p.id} />
<input type="hidden" name="action" value="publish-now" />
<button type="submit" style={{ width: "100%", border: "none", background: "#2C6FB3", color: "#fff", fontWeight: 700, fontSize: 13, padding: 10, borderRadius: 9, cursor: "pointer" }}>
Publier maintenant
</button>
</form>
)}
{!isPending && (
<div style={{ display: "flex", gap: 8 }}>
<form action={moderatePromo} style={{ flex: 1 }}>
+198 -28
View File
@@ -1,10 +1,12 @@
import { headers } from "next/headers";
import { originFromHeaders } from "@/lib/site-url";
import { redirect } from "next/navigation";
import type { CSSProperties } from "react";
import { getSession } from "@/lib/session";
import { can } from "@/lib/rbac";
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
import {
checkTokenHealth,
expiryStatus,
getSocialAccounts,
listStoredTargets,
@@ -13,6 +15,7 @@ import {
SOCIAL_LABELS,
SOCIAL_NETWORKS,
type SocialTarget,
type TokenHealth,
} from "@/lib/social-accounts";
import { disconnectSocial, saveSitePublicUrl, saveSocialApp, selectSocialTarget } from "../actions";
@@ -20,7 +23,14 @@ export const dynamic = "force-dynamic";
const HELP: Record<
(typeof SOCIAL_NETWORKS)[number],
{ portal: string; steps: string[]; caution?: string }
{
portal: string;
steps: string[];
caution?: string;
tools?: { label: string; href: string }[];
/** Les deux liens utiles au moment de renouveler un jeton. */
renew: { note: string; links: { label: string; hint: string; href: string }[] };
}
> = {
facebook: {
portal: "https://developers.facebook.com/apps",
@@ -29,6 +39,29 @@ const HELP: Record<
"Ajoutez le produit « Connexion Facebook » puis collez l'URL de redirection ci-dessous dans « URI de redirection OAuth valides ».",
"Laissez l'application en mode développement et ajoutez le compte de l'association comme administrateur : publier sur votre propre page ne demande alors aucune revue Meta.",
],
renew: {
note:
"Le bouton refait tout l'échange : jeton court, puis jeton de longue durée, puis jeton de page. " +
"Vous n'avez aucun jeton à copier à la main — les deux liens ci-dessous ne servent qu'à vérifier.",
links: [
{
label: "Débogueur de jeton",
hint: "coller un jeton pour voir sa validité et sa date d'expiration",
href: "https://developers.facebook.com/tools/debug/accesstoken/",
},
{
label: "Outil de jetons d'accès",
hint: "lister les jetons de vos pages et en générer un de longue durée",
href: "https://developers.facebook.com/tools/accesstoken/",
},
],
},
tools: [
{ label: "Débogueur de jeton (vérifier un jeton et sa date d'expiration)", href: "https://developers.facebook.com/tools/debug/accesstoken/" },
{ label: "Outil de jetons d'accès (voir les jetons de vos pages)", href: "https://developers.facebook.com/tools/accesstoken/" },
{ label: "Explorateur d'API Graph (tester un appel à la main)", href: "https://developers.facebook.com/tools/explorer/" },
{ label: "Documentation : jetons de longue durée", href: "https://developers.facebook.com/docs/facebook-login/guides/access-tokens/get-long-lived" },
],
},
linkedin: {
portal: "https://www.linkedin.com/developers/apps",
@@ -38,7 +71,28 @@ const HELP: Record<
"Onglet Produits : demandez « Community Management API », indispensable pour publier au nom de la page.",
],
caution:
"LinkedIn soumet cette demande à une revue (page vérifiée, nom légal, adresse, politique de confidentialité) et ses jetons expirent au bout de 60 jours : il faudra recliquer sur Reconnecter environ tous les deux mois.",
"LinkedIn soumet cette demande à une revue (page vérifiée, nom légal, adresse, politique de confidentialité) et ses jetons expirent au bout de 60 jours : il faudra recliquer sur « Renouveler le jeton » environ tous les deux mois.",
renew: {
note:
"Les jetons LinkedIn expirent au bout de 60 jours : le bouton relance l'autorisation et " +
"repart pour deux mois. Les deux liens ci-dessous permettent de contrôler l'échéance.",
links: [
{
label: "Jetons de votre application",
hint: "voir la durée de validité restante des jetons émis",
href: "https://www.linkedin.com/developers/tools/oauth",
},
{
label: "Documentation : durée de vie des jetons",
hint: "règles de rafraîchissement et conditions d'accès",
href: "https://learn.microsoft.com/linkedin/shared/authentication/programmatic-refresh-tokens",
},
],
},
tools: [
{ label: "Jetons de votre application (durée de validité)", href: "https://www.linkedin.com/developers/tools/oauth" },
{ label: "Documentation : durée de vie et rafraîchissement des jetons", href: "https://learn.microsoft.com/linkedin/shared/authentication/programmatic-refresh-tokens" },
],
},
};
@@ -61,10 +115,7 @@ export default async function ReseauxPage({
// Adresse par laquelle l'administrateur consulte cette page : proposée par
// défaut pour lui éviter de la recopier à la main.
const requestHeaders = await headers();
const host = requestHeaders.get("x-forwarded-host") ?? requestHeaders.get("host") ?? "";
const protocol = requestHeaders.get("x-forwarded-proto") ?? (host.startsWith("localhost") ? "http" : "https");
const detected = host ? `${protocol}://${host}` : "";
const detected = originFromHeaders(await headers());
// Calculées ici : le rendu des cartes n'est pas asynchrone.
const redirectUris = new Map<string, string>();
@@ -72,6 +123,16 @@ export default async function ReseauxPage({
redirectUris.set(network, await redirectUri(network));
}
// État réel du jeton, demandé à la plateforme à chaque affichage : c'est le
// seul moyen de savoir qu'un jeton « sans expiration » a été révoqué.
const health = new Map<string, TokenHealth | null>();
for (const network of SOCIAL_NETWORKS) {
const account = byNetwork.get(network);
if (account?.accessToken && account.targetId) {
health.set(network, await checkTokenHealth(network));
}
}
// Cibles à proposer quand le compte administre plusieurs pages.
const targets = new Map<string, SocialTarget[]>();
let targetError: string | null = null;
@@ -124,8 +185,9 @@ export default async function ReseauxPage({
</form>
{!base && detected && (
<div style={{ marginTop: 10, fontSize: 12.5, color: "#9a6638" }}>
Adresse détectée depuis votre navigation : <strong>{detected}</strong>. Vérifiez-la puis
enregistrez.
Aucune adresse enregistrée : celle de votre navigation, <strong>{detected}</strong>, est
utilisée par défaut. Facebook et LinkedIn n&apos;acceptent qu&apos;une adresse de retour
en <code>https</code> sur un nom de domaine : vérifiez-la puis enregistrez.
</div>
)}
{!base && !detected && (
@@ -141,6 +203,11 @@ export default async function ReseauxPage({
const status = expiryStatus(account?.expiresAt);
const isConnected = !!account?.accessToken && !!account.targetId;
const candidates = targets.get(network) ?? [];
const tokenHealth = health.get(network) ?? null;
const broken = tokenHealth?.ok === false;
// Le bloc de renouvellement passe en ambre quand il y a urgence :
// jeton mort, expiré, ou sur le point de l'être.
const needsRenewal = broken || status === "expired" || status === "soon";
return (
<section key={network} style={panel}>
@@ -163,7 +230,7 @@ export default async function ReseauxPage({
<h2 className="font-display" style={{ ...title, margin: 0, flex: 1 }}>
{SOCIAL_LABELS[network]}
</h2>
<StatusChip connected={isConnected} status={status} />
<StatusChip connected={isConnected} status={broken ? "expired" : status} />
</div>
{isConnected && (
@@ -173,15 +240,30 @@ export default async function ReseauxPage({
{account?.expiresAt && status !== "never" && ` Jeton valable jusqu'au ${fmtDate(account.expiresAt)}.`}
{account?.connectedAt && ` Connecté le ${fmtDate(account.connectedAt)}.`}
<br />
Pour changer de page, relancez une connexion.
{tokenHealth?.ok && (
<span style={{ color: "#1f8a5b", fontWeight: 600 }}>
✓ Jeton vérifié à l&apos;instant auprès de {SOCIAL_LABELS[network]} : il fonctionne.{" "}
</span>
)}
Pour changer de page ou renouveler l&apos;accès, utilisez le bouton ci-dessous.
</div>
)}
{broken && (
<Banner tone="error">
Le jeton {SOCIAL_LABELS[network]} ne fonctionne plus : les publications échoueront
tant que vous n&apos;aurez pas cliqué sur « Renouveler le jeton ».
<div style={{ marginTop: 6, fontSize: 12, opacity: 0.85 }}>
Réponse de la plateforme : {tokenHealth.reason.slice(0, 220)}
</div>
</Banner>
)}
{(status === "soon" || status === "expired") && isConnected && (
<Banner tone={status === "expired" ? "error" : "warn"}>
{status === "expired"
? `Le jeton ${SOCIAL_LABELS[network]} a expiré : les publications échoueront tant que vous n'aurez pas reconnecté le compte.`
: `Le jeton ${SOCIAL_LABELS[network]} expire bientôt. Un clic sur Reconnecter suffit à le renouveler.`}
? `Le jeton ${SOCIAL_LABELS[network]} a expiré : les publications échoueront tant que vous ne l'aurez pas renouvelé.`
: `Le jeton ${SOCIAL_LABELS[network]} expire bientôt. Un clic sur « Renouveler le jeton » suffit.`}
</Banner>
)}
@@ -234,24 +316,91 @@ export default async function ReseauxPage({
</button>
</form>
<div style={{ display: "flex", gap: 10, flexWrap: "wrap", borderTop: "1px solid #f0e8d6", paddingTop: 14 }}>
<a
href={`/api/social/${network}/connect`}
className="font-display"
{/* Renouvellement : l'action et ses deux liens de contrôle, visibles
sans avoir à déplier l'aide de configuration. */}
{isConnected && (
<div
style={{
textDecoration: "none",
border: "none",
background: account?.appId ? SOCIAL_BRAND[network] : "#d8cdb4",
color: "#fff",
fontWeight: 700,
fontSize: 14,
padding: "12px 20px",
borderRadius: 10,
pointerEvents: account?.appId ? undefined : "none",
background: needsRenewal ? "#fbeede" : "#faf7ef",
border: `1px solid ${needsRenewal ? "#ecd8b8" : "#f0e8d6"}`,
borderRadius: 12,
padding: 15,
marginBottom: 16,
}}
>
{isConnected ? "Reconnecter" : "Connecter"}
</a>
<div className="field-label" style={{ marginBottom: 4 }}>
Renouveler le jeton
</div>
<div style={{ fontSize: 12.5, color: "#8c8068", lineHeight: 1.6, marginBottom: 12 }}>
{help.renew.note}
</div>
<a
href={`/api/social/${network}/connect`}
className="font-display"
style={{
display: "inline-block",
textDecoration: "none",
background: SOCIAL_BRAND[network],
color: "#fff",
fontWeight: 700,
fontSize: 13.5,
padding: "11px 18px",
borderRadius: 10,
marginBottom: 12,
}}
>
Renouveler le jeton {SOCIAL_LABELS[network]}
</a>
<div style={{ display: "grid", gap: 8 }}>
{help.renew.links.map((link) => (
<a
key={link.href}
href={link.href}
target="_blank"
rel="noopener noreferrer"
style={{
display: "flex",
alignItems: "baseline",
gap: 8,
textDecoration: "none",
fontSize: 13,
lineHeight: 1.5,
color: "#2C6FB3",
fontWeight: 700,
}}
>
{link.label} ↗
<span style={{ color: "#8c8068", fontWeight: 500, fontSize: 12.5 }}>
{link.hint}
</span>
</a>
))}
</div>
</div>
)}
<div style={{ display: "flex", gap: 10, flexWrap: "wrap", borderTop: "1px solid #f0e8d6", paddingTop: 14 }}>
{/* Une fois connecté, l'action vit dans le bloc de renouvellement
ci-dessus : deux boutons identiques côte à côte n'aidaient personne. */}
{!isConnected && (
<a
href={`/api/social/${network}/connect`}
className="font-display"
style={{
textDecoration: "none",
border: "none",
background: account?.appId ? SOCIAL_BRAND[network] : "#d8cdb4",
color: "#fff",
fontWeight: 700,
fontSize: 14,
padding: "12px 20px",
borderRadius: 10,
pointerEvents: account?.appId ? undefined : "none",
}}
>
Connecter
</a>
)}
{isConnected && (
<form action={disconnectSocial}>
<input type="hidden" name="network" value={network} />
@@ -277,7 +426,28 @@ export default async function ReseauxPage({
{help.portal}
</a>
</div>
<div style={{ marginTop: 10 }}>
{help.tools && (
<div style={{ marginTop: 14 }}>
<div className="field-label">Outils pour inspecter un jeton</div>
<ul style={{ margin: "6px 0 0", paddingLeft: 20, fontSize: 13, lineHeight: 1.8 }}>
{help.tools.map((tool) => (
<li key={tool.href}>
<a href={tool.href} target="_blank" rel="noopener noreferrer" style={{ color: "#2C6FB3", fontWeight: 600 }}>
{tool.label}
</a>
</li>
))}
</ul>
<div style={{ marginTop: 8, fontSize: 12.5, color: "#8c8068", lineHeight: 1.6 }}>
Ces outils servent à contrôler ou dépanner : le bouton
<strong> Connecter</strong> fait déjà l&apos;échange complet — jeton court,
puis jeton de longue durée, puis jeton de page — vous n&apos;avez aucun jeton à
copier à la main.
</div>
</div>
)}
<div style={{ marginTop: 14 }}>
<div className="field-label">URL de redirection à déclarer</div>
<code style={{ display: "block", background: "#faf7ef", border: "1px solid #f0e8d6", borderRadius: 9, padding: "10px 12px", fontSize: 12.5, color: "#3c3322", overflowWrap: "anywhere" }}>
{base ? redirectUris.get(network) : "— renseignez d'abord l'URL publique du site ci-dessus —"}
+5 -98
View File
@@ -7,11 +7,11 @@ import { SiteFooter } from "@/components/SiteFooter";
import { MembershipModalButton } from "@/components/MembershipModalButton";
import { MembersCarousel } from "@/components/MembersCarousel";
import { StarField } from "@/components/StarField";
import { PromoImage } from "@/components/PromoImage";
import { PromoCard } from "@/components/PromoCard";
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
import { getActiveMemberCount, getLivePromotions, getRotatingActiveMembers } from "@/lib/queries";
import { getSiteSettings, socialLinks } from "@/lib/site-settings";
import { SITE_DESCRIPTION, SITE_TITLE, memberPath, pageMetadata } from "@/lib/seo";
import { SITE_DESCRIPTION, SITE_TITLE, pageMetadata } from "@/lib/seo";
export const dynamic = "force-dynamic";
@@ -26,14 +26,6 @@ export const metadata: Metadata = {
title: { absolute: SITE_TITLE },
};
const STRIPE_WARM =
"repeating-linear-gradient(45deg,#efe9da,#efe9da 12px,#e6ddc9 12px,#e6ddc9 24px)";
function badgeColor(badge: string | null) {
if (!badge) return "#1f8a5b";
return badge.includes("%") ? "#d8472b" : "#1f8a5b";
}
export default async function AccueilPage() {
const [promos, rotatingMembers, settings, memberCount] = await Promise.all([
getLivePromotions(6),
@@ -226,99 +218,14 @@ export default async function AccueilPage() {
Offres, réductions et nouveautés publiées par les commerçants du réseau.
</p>
</div>
<a href="#promotions" className="link-arrow" style={{ textDecoration: "none", color: "#9a6638", fontWeight: 700, fontSize: 14.5, whiteSpace: "nowrap" }}>
<Link href="/promotions" className="link-arrow" style={{ textDecoration: "none", color: "#9a6638", fontWeight: 700, fontSize: 14.5, whiteSpace: "nowrap" }}>
Toutes les promos →
</a>
</Link>
</div>
<Reveal className="grid grid-3 reveal-stagger" style={{ marginTop: 26 }}>
{promos.map((p) => (
<Link
key={p.id}
href={p.memberId ? memberPath({ id: p.memberId, name: p.memberName ?? "", city: p.memberCity }) : "#"}
className="lift-card"
style={{
textDecoration: "none",
color: "inherit",
background: "#fff",
border: "1px solid #ece3d0",
borderRadius: 18,
overflow: "hidden",
display: "flex",
flexDirection: "column",
}}
>
<div
style={{
position: "relative",
aspectRatio: "1 / 1",
background: STRIPE_WARM,
overflow: "hidden",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{p.imageUrl && <PromoImage src={p.imageUrl} alt={p.title ?? ""} />}
{!p.imageUrl && (
<span style={{ fontSize: 10.5, letterSpacing: "0.12em", color: "#a99c82", textTransform: "uppercase" }}>
photo de l&apos;offre
</span>
)}
{p.badge && (
<span
className="font-display"
style={{
position: "absolute",
top: 0,
right: 0,
background: badgeColor(p.badge),
color: "#fff",
fontWeight: 800,
fontSize: 18,
padding: "8px 16px",
borderRadius: "0 0 0 16px",
}}
>
{p.badge}
</span>
)}
{p.category && (
<span
style={{
position: "absolute",
top: 12,
left: 12,
background: "#9a6638",
color: "#fff",
borderRadius: 999,
padding: "5px 12px",
fontSize: 11,
fontWeight: 700,
}}
>
{p.category}
</span>
)}
</div>
<div style={{ padding: "17px 18px 18px", display: "flex", flexDirection: "column", flex: 1 }}>
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 18, margin: "0 0 5px", color: "#26201a" }}>
{p.title}
</h3>
<p style={{ margin: "0 0 14px", fontSize: 13.5, color: "#8c8068", lineHeight: 1.5, flex: 1 }}>
{p.text}
</p>
<div
style={{
borderTop: "1px solid #f0e8d6",
paddingTop: 12,
}}
>
<div style={{ fontSize: 13, fontWeight: 700, color: "#3c3322" }}>{p.memberName}</div>
<div style={{ fontSize: 11.5, color: "#a99c82" }}>{p.validUntil}</div>
</div>
</div>
</Link>
<PromoCard key={p.id} promo={p} />
))}
</Reveal>
</section>
+143
View File
@@ -0,0 +1,143 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Sparkle } from "@/components/Sparkle";
import { SiteHeader } from "@/components/SiteHeader";
import { SiteFooter } from "@/components/SiteFooter";
import { MembershipModalButton } from "@/components/MembershipModalButton";
import { PromoCard } from "@/components/PromoCard";
import { JsonLd } from "@/components/JsonLd";
import { getLivePromotions } from "@/lib/queries";
import { breadcrumbJsonLd, memberListJsonLd, pageMetadata } from "@/lib/seo";
import { publicBaseUrl } from "@/lib/seo-server";
export const dynamic = "force-dynamic";
export const metadata: Metadata = pageMetadata({
title: "Bons plans et promotions du Bassin de Pompey",
description:
"Toutes les offres en cours chez les commerçants, artisans et entreprises adhérents Plein R : " +
"réductions, nouveautés et bons plans à Pompey, Frouard, Liverdun, Champigneulles…",
path: "/promotions",
});
export default async function PromotionsPage() {
// `null` : toutes les offres en cours, là où l'accueil n'en montre que six.
const [promos, baseUrl] = await Promise.all([getLivePromotions(null), publicBaseUrl()]);
// Une offre pointe vers la fiche de son commerçant : la liste structurée
// renvoie donc vers les adhérents, pas vers des URLs de promotion.
const listed = promos
.filter((p) => p.memberId != null)
.map((p) => ({ id: p.memberId as number, name: p.memberName ?? "", city: p.memberCity }));
return (
<div style={{ background: "#F6F2E8", minHeight: "100vh", fontFamily: "'Public Sans',sans-serif", color: "#33291D" }}>
<SiteHeader active="promotions" logo />
<JsonLd
data={[
breadcrumbJsonLd(baseUrl, [
{ name: "Accueil", path: "/" },
{ name: "Promotions", path: "/promotions" },
]),
memberListJsonLd(baseUrl, "Promotions en cours chez les adhérents Plein R", listed),
]}
/>
<main className="container" style={{ paddingBottom: 56 }}>
{/* title block */}
<section style={{ position: "relative", padding: "14px 0 22px", overflow: "hidden" }}>
<Sparkle color="#E0A63C" size={18} style={{ top: 22, right: 30 }} duration={3.2} />
<Sparkle color="#6FB0C6" size={12} style={{ top: 60, right: 90 }} duration={2.7} delay={0.5} />
<div
style={{
display: "inline-flex",
alignItems: "center",
gap: 9,
background: "#fff",
border: "1px solid #e6dcc6",
color: "#9a6638",
fontSize: 12,
letterSpacing: "0.12em",
textTransform: "uppercase",
fontWeight: 800,
padding: "6px 13px",
borderRadius: 999,
marginBottom: 14,
}}
>
<span style={{ width: 7, height: 7, borderRadius: "50%", background: "#E0A63C" }} />
{promos.length} offre{promos.length > 1 ? "s" : ""} en cours
</div>
<h1 className="font-display" style={{ fontWeight: 800, fontSize: "clamp(26px, 6vw, 40px)", letterSpacing: "-0.02em", margin: "0 0 8px", color: "#26201a" }}>
Les bons plans de nos adhérents
</h1>
<p style={{ margin: 0, fontSize: 16.5, color: "#6c6150", maxWidth: 560 }}>
Toutes les offres en cours chez les commerçants, artisans et entreprises du réseau.
Chaque promotion renvoie vers la fiche de son commerce.
</p>
</section>
{promos.length === 0 ? (
<section
style={{
background: "#fff",
border: "1px solid #e6dcc6",
borderRadius: 18,
padding: "38px 24px",
textAlign: "center",
color: "#8c8068",
fontSize: 15,
lineHeight: 1.7,
}}
>
Aucune promotion en cours pour le moment.
<br />
<Link href="/annuaire" style={{ color: "#9a6638", fontWeight: 700 }}>
Parcourir l&apos;annuaire des adhérents →
</Link>
</section>
) : (
<div className="grid grid-3" style={{ gap: 20 }}>
{promos.map((p) => (
<PromoCard key={p.id} promo={p} />
))}
</div>
)}
{/* join CTA */}
<section
style={{
marginTop: 34,
position: "relative",
background: "linear-gradient(120deg,#13324F,#1d4a72)",
borderRadius: 20,
padding: "34px 40px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 28,
overflow: "hidden",
flexWrap: "wrap",
}}
>
<Sparkle color="#E0A63C" size={18} style={{ top: 20, right: 40 }} duration={3.2} />
<div>
<div style={{ fontSize: 12.5, letterSpacing: "0.12em", textTransform: "uppercase", color: "#9fc6e6", fontWeight: 700, marginBottom: 8 }}>
Vous êtes commerçant du Bassin de Pompey ?
</div>
<h2 className="font-display" style={{ fontWeight: 800, fontSize: 26, margin: 0, color: "#fff" }}>
Publiez vos offres sur Plein R
</h2>
</div>
<MembershipModalButton
label="Adhérer à l'association"
className="font-display"
style={{ border: "none", background: "#E0A63C", color: "#33291D", fontWeight: 700, fontSize: 15.5, padding: "14px 26px", borderRadius: 12, whiteSpace: "nowrap" }}
/>
</section>
</main>
<SiteFooter />
</div>
);
}
+73
View File
@@ -0,0 +1,73 @@
/**
* Pastille ronde d'un adhérent : son logo, sinon ses initiales.
*
* Sert à identifier le commerce d'un coup d'œil devant son nom, notamment sur
* les cartes de promotion. Le logo est rendu en `contain` sur fond blanc : la
* plupart des logos d'adhérents sont des marques sur fond clair, un `cover` les
* rognerait.
*/
export function memberInitials(name: string): string {
return name
.split(/\s+/)
.filter(Boolean)
.map((w) => w[0])
.join("")
.slice(0, 2)
.toUpperCase();
}
export function MemberAvatar({
name,
logoUrl,
size = 26,
accent = "#E0A63C",
}: {
name: string;
logoUrl?: string | null;
size?: number;
accent?: string;
}) {
const common: React.CSSProperties = {
width: size,
height: size,
borderRadius: "50%",
flexShrink: 0,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
overflow: "hidden",
};
if (logoUrl) {
return (
<span
aria-hidden="true"
style={{ ...common, background: "#fff", border: "1px solid #eee3cf" }}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={logoUrl}
alt=""
style={{ width: "100%", height: "100%", objectFit: "contain", padding: 2, boxSizing: "border-box" }}
/>
</span>
);
}
return (
<span
aria-hidden="true"
style={{
...common,
background: accent,
color: "#fff",
fontWeight: 800,
fontSize: Math.round(size * 0.4),
letterSpacing: "0.02em",
}}
>
{memberInitials(name)}
</span>
);
}
+31 -4
View File
@@ -21,11 +21,32 @@ const STRIPE_WARM =
const STRIPE_COOL =
"repeating-linear-gradient(45deg,#eef0ec,#eef0ec 12px,#e2e8e6 12px,#e2e8e6 24px)";
/**
* Hauteurs figées des deux blocs de texte : une description longue ne doit pas
* étirer la carte (donc toute la rangée de la grille). Titre et description
* sont tronqués en fin de ligne et occupent toujours la même place, ce qui
* aligne les cartes entre elles quel que soit le texte saisi par l'adhérent.
*/
const TITLE_LINE = 1.25;
const TITLE_LINES = 2;
const DESC_FONT = 13;
const DESC_LINE = 1.5;
const DESC_LINES = 4;
const clampLines = (lines: number) =>
({
display: "-webkit-box",
WebkitLineClamp: lines,
WebkitBoxOrient: "vertical" as const,
overflow: "hidden",
}) as const;
/**
* Carte d'un adhérent dans une grille (annuaire, page métier). Composant sans
* état : il se rend aussi bien côté serveur que dans le filtre client.
*/
export function MemberCard({ m, index = 0 }: { m: MemberCardData; index?: number }) {
const location = [m.address, m.city].filter(Boolean).join(" · ");
return (
<Link
href={memberPath(m)}
@@ -50,15 +71,21 @@ export function MemberCard({ m, index = 0 }: { m: MemberCardData; index?: number
)}
</VitrineImage>
<div style={{ padding: "16px 18px 18px", display: "flex", flexDirection: "column", flex: 1 }}>
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 18, margin: 0, color: "#26201a" }}>
<h3
className="font-display"
style={{ fontWeight: 700, fontSize: 18, lineHeight: TITLE_LINE, margin: 0, color: "#26201a", minHeight: 18 * TITLE_LINE * TITLE_LINES, ...clampLines(TITLE_LINES) }}
>
{m.name}
</h3>
<p style={{ margin: "6px 0 12px", fontSize: 13, color: "#8c8068", lineHeight: 1.5, flex: 1 }}>
<p
style={{ margin: "6px 0 12px", fontSize: DESC_FONT, color: "#8c8068", lineHeight: DESC_LINE, height: DESC_FONT * DESC_LINE * DESC_LINES, ...clampLines(DESC_LINES) }}
>
{m.description}
</p>
<div style={{ flex: 1 }} />
<div style={{ display: "flex", alignItems: "center", gap: 7, fontSize: 12.5, color: "#6c6150", borderTop: "1px solid #f0e8d6", paddingTop: 11 }}>
<span style={{ width: 7, height: 7, borderRadius: "50%", background: m.accent ?? "#E0A63C" }} />
{[m.address, m.city].filter(Boolean).join(" · ")}
<span style={{ width: 7, height: 7, borderRadius: "50%", background: m.accent ?? "#E0A63C", flex: "0 0 auto" }} />
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{location}</span>
</div>
</div>
</Link>
+129
View File
@@ -0,0 +1,129 @@
/**
* Carte d'une promotion sur le site public.
*
* Un seul rendu pour l'accueil (les 6 dernières) et la page `/promotions`
* (toutes les offres en cours) : les deux listes doivent rester identiques.
*/
import Link from "next/link";
import { PromoImage } from "@/components/PromoImage";
import { MemberAvatar } from "@/components/MemberAvatar";
import { formatValidity } from "@/lib/promo-validity";
import { memberPath } from "@/lib/seo";
const STRIPE_WARM =
"repeating-linear-gradient(45deg,#efe9da,#efe9da 12px,#e6ddc9 12px,#e6ddc9 24px)";
/** Rouge pour une remise chiffrée, vert pour une offre en nature. */
function badgeColor(badge: string | null) {
if (!badge) return "#1f8a5b";
return badge.includes("%") ? "#d8472b" : "#1f8a5b";
}
export type PublicPromo = {
id: number;
title: string;
text: string | null;
category: string | null;
badge: string | null;
imageUrl: string | null;
validUntil: string | null;
startsOn: string | null;
endsOn: string | null;
memberId: number | null;
memberName: string | null;
memberCity: string | null;
memberLogoUrl: string | null;
};
export function PromoCard({ promo: p }: { promo: PublicPromo }) {
const validity = formatValidity(p);
return (
<Link
href={p.memberId ? memberPath({ id: p.memberId, name: p.memberName ?? "", city: p.memberCity }) : "#"}
className="lift-card"
style={{
textDecoration: "none",
color: "inherit",
background: "#fff",
border: "1px solid #ece3d0",
borderRadius: 18,
overflow: "hidden",
display: "flex",
flexDirection: "column",
}}
>
<div
style={{
position: "relative",
aspectRatio: "1 / 1",
background: STRIPE_WARM,
overflow: "hidden",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{p.imageUrl && <PromoImage src={p.imageUrl} alt={p.title ?? ""} />}
{!p.imageUrl && (
<span style={{ fontSize: 10.5, letterSpacing: "0.12em", color: "#a99c82", textTransform: "uppercase" }}>
photo de l&apos;offre
</span>
)}
{p.badge && (
<span
className="font-display"
style={{
position: "absolute",
top: 0,
right: 0,
background: badgeColor(p.badge),
color: "#fff",
fontWeight: 800,
fontSize: 18,
padding: "8px 16px",
borderRadius: "0 0 0 16px",
}}
>
{p.badge}
</span>
)}
{p.category && (
<span
style={{
position: "absolute",
top: 12,
left: 12,
background: "#9a6638",
color: "#fff",
borderRadius: 999,
padding: "5px 12px",
fontSize: 11,
fontWeight: 700,
}}
>
{p.category}
</span>
)}
</div>
<div style={{ padding: "17px 18px 18px", display: "flex", flexDirection: "column", flex: 1 }}>
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 18, margin: "0 0 5px", color: "#26201a" }}>
{p.title}
</h3>
<p style={{ margin: "0 0 14px", fontSize: 13.5, color: "#8c8068", lineHeight: 1.5, flex: 1 }}>
{p.text}
</p>
<div style={{ borderTop: "1px solid #f0e8d6", paddingTop: 12 }}>
{/* Le logo du commerce juste à gauche de son nom. */}
<div style={{ display: "flex", alignItems: "center", gap: 9 }}>
<MemberAvatar name={p.memberName ?? ""} logoUrl={p.memberLogoUrl} size={28} />
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 700, color: "#3c3322" }}>{p.memberName}</div>
{validity && <div style={{ fontSize: 11.5, color: "#a99c82" }}>{validity}</div>}
</div>
</div>
</div>
</div>
</Link>
);
}
+1 -1
View File
@@ -32,7 +32,7 @@ export async function SiteFooter() {
<div className="footer-title">Accès rapide</div>
<div className="footer-links">
<Link href="/annuaire">Annuaire</Link>
<Link href="/#promotions">Promotions</Link>
<Link href="/promotions">Promotions</Link>
<Link href="/association">L&apos;association</Link>
<Link href="/rencontres-passees">Rencontres passées</Link>
<Link href="/login">Espace adhérent</Link>
+1 -1
View File
@@ -3,7 +3,7 @@ import Link from "next/link";
const NAV = [
{ key: "accueil", label: "Accueil", href: "/" },
{ key: "annuaire", label: "Annuaire", href: "/annuaire" },
{ key: "promotions", label: "Promotions", href: "/#promotions" },
{ key: "promotions", label: "Promotions", href: "/promotions" },
{ key: "association", label: "L'association", href: "/association" },
];
+17
View File
@@ -6,6 +6,7 @@ import {
text,
varchar,
boolean,
date,
timestamp,
index,
uniqueIndex,
@@ -21,6 +22,10 @@ export const promoStatusEnum = pgEnum("promo_status", [
"expired",
"rejected",
"suspended",
// Validée par l'association mais en attente de sa date de publication
// (`publish_at`). Invisible du site public tant que l'échéance n'est pas
// atteinte, et non encore diffusée sur les réseaux.
"scheduled",
]);
// 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.
@@ -107,11 +112,18 @@ export const promotions = pgTable("promotions", {
imageUrl: text("image_url"),
memberId: integer("member_id").references(() => members.id),
status: promoStatusEnum("status").notNull().default("pending"),
// Période de validité. Les deux bornes sont facultatives et indépendantes :
// une offre peut n'avoir qu'une date de fin, qu'une date de début, ou aucune.
startsOn: date("starts_on"),
endsOn: date("ends_on"),
/** @deprecated Texte libre des anciennes promos ; sert de repli à l'affichage. */
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),
// Programmation de la publication. Vide = mise en ligne dès la validation.
publishAt: timestamp("publish_at", { withTimezone: true }),
suspendedBy: promoSuspendedByEnum("suspended_by"),
suspendedById: integer("suspended_by_id").references(() => users.id, { onDelete: "set null" }),
suspendedAt: timestamp("suspended_at", { withTimezone: true }),
@@ -155,6 +167,11 @@ export const socialAccounts = pgTable("social_accounts", {
targetName: varchar("target_name", { length: 200 }),
connectedById: integer("connected_by_id").references(() => users.id, { onDelete: "set null" }),
connectedAt: timestamp("connected_at", { withTimezone: true }),
// Dernier contrôle de validité auprès de la plateforme : permet au tableau de
// bord d'alerter sans refaire un appel réseau à chaque affichage.
lastCheckAt: timestamp("last_check_at", { withTimezone: true }),
lastCheckOk: boolean("last_check_ok"),
lastCheckError: text("last_check_error"),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
});
+24
View File
@@ -0,0 +1,24 @@
/**
* Boucle de libération des publications programmées. Fichier séparé de
* `instrumentation.ts` : il touche la base et ne doit donc jamais entrer dans
* le bundle edge du middleware.
*/
import { releaseDuePromotions } from "@/lib/promo-publish";
const INTERVAL_MS = 60_000;
async function tick() {
try {
const n = await releaseDuePromotions();
if (n > 0) console.log(`[promotions] ${n} publication(s) programmée(s) mise(s) en ligne`);
} catch (error) {
// `releaseDuePromotions` avale déjà ses erreurs ; ce filet couvre le cas
// d'une base injoignable au démarrage, qui ne doit pas tuer le serveur.
console.error("[promotions] libérateur :", error);
}
}
// Un premier passage au démarrage rattrape les échéances tombées pendant un
// redéploiement ou une coupure.
void tick();
setInterval(tick, INTERVAL_MS).unref();
+21
View File
@@ -0,0 +1,21 @@
/**
* Démarrage du serveur : libérateur des publications programmées.
*
* Le déploiement est un conteneur unique et permanent (`docker-compose`), il
* n'y a donc ni cron ni file d'attente. Une boucle minute suffit : elle relit
* les promotions `scheduled` dont l'échéance est passée, les met en ligne et
* lance la diffusion réseaux. La minute est aussi la précision annoncée dans
* le formulaire.
*
* Sûr à plusieurs instances : la bascule passe par un `UPDATE … RETURNING`
* filtré sur le statut (voir `releaseDuePromotions`), une seule instance
* récupère chaque promotion.
*/
export async function register() {
// `register()` est aussi appelé pour le runtime edge, où il n'y a ni base
// ni `setInterval` durable : le module Node est chargé à la demande pour ne
// pas entrer dans ce bundle.
if (process.env.NEXT_RUNTIME !== "nodejs") return;
if (process.env.PROMO_SCHEDULER === "off") return;
await import("./instrumentation-node");
}
+13
View File
@@ -0,0 +1,13 @@
import { db } from "@/db";
import { activityLog } from "@/db/schema";
import { sanitizeActivityMessage } from "@/lib/activity";
/**
* Écrit une ligne du journal d'activité.
*
* Le message agrège des saisies de tiers (jusqu'au formulaire de contact
* public) : il ne laisse passer que `<strong>`.
*/
export async function logActivity(message: string, dot = "#2C6FB3") {
await db.insert(activityLog).values({ message: sanitizeActivityMessage(message), dot });
}
+175
View File
@@ -0,0 +1,175 @@
/**
* Mise en ligne d'une promotion : diffusion sur les réseaux et libération des
* publications programmées.
*
* Ce module vit hors de `backend/actions.ts` parce qu'il a deux appelants : la
* validation par un modérateur (une requête HTTP) et le libérateur périodique
* démarré par `instrumentation.ts` (aucune requête). Il ne fait donc jamais
* appel à `revalidatePath()`, qui n'a pas de sens hors requête ; les pages
* publiques sont en `force-dynamic` et relisent la base à chaque affichage.
*/
import { and, eq, isNotNull, lte, sql } from "drizzle-orm";
import { db } from "@/db";
import { members, promotions, socialPosts } from "@/db/schema";
import { logActivity } from "@/lib/activity-log";
import {
isNetworkConfigured,
publishPromoToNetwork,
SOCIAL_LABELS,
SOCIAL_NETWORKS,
type SocialNetwork,
} from "@/lib/social";
/** Réseaux demandés pour une promo, dans l'ordre d'affichage. */
export function requestedNetworks(promo: {
shareFacebook: boolean;
shareLinkedin: boolean;
}): SocialNetwork[] {
return SOCIAL_NETWORKS.filter((n) =>
n === "facebook" ? promo.shareFacebook : promo.shareLinkedin
);
}
/**
* 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, et ce
* qui rend le libérateur périodique sans danger s'il repasse sur une promo.
*/
export 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,
startsOn: promotions.startsOn,
endsOn: promotions.endsOn,
imageUrl: promotions.imageUrl,
memberId: promotions.memberId,
memberName: members.name,
memberCity: members.city,
})
.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 (!(await 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,
});
}
}
}
/**
* Met en ligne les promotions programmées dont l'échéance est passée.
*
* La bascule `scheduled → live` est faite par un `UPDATE … RETURNING` filtré
* sur le statut : deux instances de l'application qui tourneraient en même
* temps ne peuvent pas récupérer la même ligne, chacune n'obtient que celles
* qu'elle a effectivement changées. La diffusion réseaux suit, protégée en
* plus par la garde anti-republication de `publishPromoShares`.
*
* Ne lève jamais : le libérateur tourne en tâche de fond, une erreur ne doit
* pas arrêter la boucle.
*/
export async function releaseDuePromotions(now = new Date()): Promise<number> {
let released: { id: number; title: string; memberId: number | null; shareFacebook: boolean; shareLinkedin: boolean }[];
try {
released = await db
.update(promotions)
.set({ status: "live", publishAt: null })
.where(
and(
eq(promotions.status, "scheduled"),
isNotNull(promotions.publishAt),
lte(promotions.publishAt, now)
)
)
.returning({
id: promotions.id,
title: promotions.title,
memberId: promotions.memberId,
shareFacebook: promotions.shareFacebook,
shareLinkedin: promotions.shareLinkedin,
});
} catch (error) {
console.error("[promotions] libération programmée impossible :", error);
return 0;
}
for (const promo of released) {
try {
await logActivity(
`Promotion « ${promo.title} » publiée à l'heure programmée`,
"#1f8a5b"
);
// `null` : la publication n'est déclenchée par personne, c'est l'échéance.
await publishPromoShares(promo.id, requestedNetworks(promo), null);
} catch (error) {
console.error(`[promotions] publication programmée #${promo.id} :`, error);
}
}
return released.length;
}
/** Nombre de promotions encore en attente de leur date de publication. */
export async function countScheduledPromotions(): Promise<number> {
const [row] = await db
.select({ n: sql<number>`count(*)::int` })
.from(promotions)
.where(eq(promotions.status, "scheduled"));
return row?.n ?? 0;
}
+117
View File
@@ -0,0 +1,117 @@
/**
* Programmation de la publication d'une promotion.
*
* Une promotion validée peut soit partir tout de suite, soit attendre une
* date choisie : c'est `promotions.publish_at`. Tant que cette date n'est pas
* atteinte la promotion reste au statut `scheduled` — invisible sur le site
* public (les lectures filtrent `status = 'live'`) et non diffusée sur les
* réseaux. Le libérateur (`releaseDuePromotions`) fait la bascule.
*
* Ce module est **pur** : aucune base, aucun réseau. Il ne s'occupe que de la
* conversion et du rendu des dates, et il est verrouillé par
* `tests/promo-schedule.test.ts`.
*
* Le fuseau est celui de l'association, pas celui du serveur : un conteneur
* qui tourne en UTC ne doit pas décaler d'une heure la publication demandée
* par un adhérent lorrain.
*/
export const ASSOCIATION_TIME_ZONE = "Europe/Paris";
const LOCAL_DATETIME = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::\d{2})?$/;
/**
* Décalage du fuseau de l'association à un instant donné, en millisecondes.
* Passe par `Intl` plutôt que par une constante : l'heure d'été change deux
* fois par an et une valeur figée publierait à la mauvaise heure la moitié de
* l'année.
*/
function zoneOffsetMs(instant: Date): number {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: ASSOCIATION_TIME_ZONE,
hour12: false,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}).formatToParts(instant);
const get = (type: string) => Number(parts.find((p) => p.type === type)?.value ?? "0");
// `hour` vaut 24 à minuit avec hour12:false sur certaines plateformes.
const hour = get("hour") % 24;
const asIfUtc = Date.UTC(get("year"), get("month") - 1, get("day"), hour, get("minute"), get("second"));
return asIfUtc - instant.getTime();
}
/**
* Convertit la saisie d'un `<input type="datetime-local">` (heure locale de
* l'association) en instant absolu.
*
* Renvoie `null` si le champ est vide, et lève si la saisie n'a pas la forme
* attendue : une date illisible ne doit pas se transformer silencieusement en
* publication immédiate.
*/
export function parseScheduleInput(value: string | null | undefined): Date | null {
const raw = (value ?? "").trim();
if (!raw) return null;
const m = LOCAL_DATETIME.exec(raw);
if (!m) throw new Error("Date de programmation invalide.");
const [, y, mo, d, h, mi] = m.map(Number) as unknown as number[];
// `Date.UTC` accepte 13 comme mois ou 99 comme heure en débordant sur la
// date suivante : on refuse plutôt que de publier à un moment inattendu.
if (mo < 1 || mo > 12 || d < 1 || d > 31 || h > 23 || mi > 59) {
throw new Error("Date de programmation invalide.");
}
const naive = Date.UTC(y, mo - 1, d, h, mi);
if (new Date(naive).getUTCDate() !== d) throw new Error("Date de programmation invalide.");
// Deux passes : la première donne un décalage approché, la seconde le corrige
// quand la date tombe juste après un changement d'heure.
const first = new Date(naive - zoneOffsetMs(new Date(naive)));
const exact = new Date(naive - zoneOffsetMs(first));
if (Number.isNaN(exact.getTime())) throw new Error("Date de programmation invalide.");
return exact;
}
/** Valeur à remettre dans un `<input type="datetime-local">`. */
export function toScheduleInput(date: Date | null | undefined): string {
if (!date || Number.isNaN(date.getTime())) return "";
const local = new Date(date.getTime() + zoneOffsetMs(date));
return local.toISOString().slice(0, 16);
}
/** Une programmation passée n'a plus lieu d'être : on publie tout de suite. */
export function isDue(publishAt: Date | null | undefined, now = new Date()): boolean {
return !publishAt || publishAt.getTime() <= now.getTime();
}
/** `true` si la promotion doit attendre. */
export function isPending(publishAt: Date | null | undefined, now = new Date()): boolean {
return !isDue(publishAt, now);
}
/** « le 12 mars 2027 à 09:00 » — sans majuscule, à insérer dans une phrase. */
export function formatSchedule(publishAt: Date | null | undefined): string | null {
if (!publishAt || Number.isNaN(publishAt.getTime())) return null;
const day = new Intl.DateTimeFormat("fr-FR", {
timeZone: ASSOCIATION_TIME_ZONE,
day: "numeric",
month: "long",
year: "numeric",
}).format(publishAt);
const time = new Intl.DateTimeFormat("fr-FR", {
timeZone: ASSOCIATION_TIME_ZONE,
hour: "2-digit",
minute: "2-digit",
}).format(publishAt);
return `le ${day} à ${time}`;
}
/** Phrase complète pour le backoffice. */
export function scheduleLabel(publishAt: Date | null | undefined): string | null {
const when = formatSchedule(publishAt);
return when ? `Publication programmée ${when}` : null;
}
+117
View File
@@ -0,0 +1,117 @@
/**
* Période de validité d'une promotion.
*
* Module **pur** : aucune dépendance à la base ni à React, pour être verrouillé
* par les tests. Les dates arrivent en `YYYY-MM-DD` (colonnes `date` de
* Postgres, lues telles quelles par Drizzle) et sont donc traitées comme des
* jours calendaires, sans fuseau horaire.
*/
export type PromoValidity = {
startsOn?: string | null;
endsOn?: string | null;
/** Texte libre des anciennes promotions, utilisé à défaut de dates. */
validUntil?: string | null;
};
const MONTHS = [
"janvier", "février", "mars", "avril", "mai", "juin",
"juillet", "août", "septembre", "octobre", "novembre", "décembre",
];
type Parts = { day: number; month: number; year: number };
function parse(value: string | null | undefined): Parts | null {
if (!value) return null;
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(value.trim());
if (!match) return null;
const [, year, month, day] = match;
const parts = { day: Number(day), month: Number(month) - 1, year: Number(year) };
if (parts.month < 0 || parts.month > 11 || parts.day < 1 || parts.day > 31) return null;
return parts;
}
/** « 1er » et non « 1 », comme on l'écrit en français. */
function dayLabel(day: number): string {
return day === 1 ? "1er" : String(day);
}
function full(p: Parts): string {
return `${dayLabel(p.day)} ${MONTHS[p.month]} ${p.year}`;
}
/**
* Phrase de validité, ou `null` si la promotion n'en a pas.
* L'année et le mois ne sont répétés que lorsqu'ils changent : « du 1er au
* 15 mars 2027 » plutôt que « du 1er mars 2027 au 15 mars 2027 ».
*/
export function formatValidity(promo: PromoValidity): string | null {
const start = parse(promo.startsOn);
const end = parse(promo.endsOn);
if (start && end) {
const sameYear = start.year === end.year;
const sameMonth = sameYear && start.month === end.month;
const from = sameMonth
? dayLabel(start.day)
: sameYear
? `${dayLabel(start.day)} ${MONTHS[start.month]}`
: full(start);
return `Valable du ${from} au ${full(end)}`;
}
if (end) return `Valable jusqu'au ${full(end)}`;
if (start) return `Valable à partir du ${full(start)}`;
const legacy = promo.validUntil?.trim();
return legacy || null;
}
/** Variante sans le mot « Valable », pour les libellés déjà introduits. */
export function formatValidityShort(promo: PromoValidity): string | null {
const formatted = formatValidity(promo);
if (!formatted) return null;
return formatted.replace(/^Valable /, "");
}
/** `true` si la date de fin est dépassée. Un jour de fin reste valable en entier. */
export function isExpired(promo: PromoValidity, today = new Date()): boolean {
const end = parse(promo.endsOn);
if (!end) return false;
const endOfDay = Date.UTC(end.year, end.month, end.day, 23, 59, 59);
return today.getTime() > endOfDay;
}
/** Bornes invalides : fin antérieure au début. */
export function isRangeInvalid(promo: PromoValidity): boolean {
const start = parse(promo.startsOn);
const end = parse(promo.endsOn);
if (!start || !end) return false;
return Date.UTC(end.year, end.month, end.day) < Date.UTC(start.year, start.month, start.day);
}
/** `true` si la période n'a pas encore commencé. */
export function isNotStarted(promo: PromoValidity, today = new Date()): boolean {
const start = parse(promo.startsOn);
if (!start) return false;
return today.getTime() < Date.UTC(start.year, start.month, start.day);
}
/**
* Pourquoi une promotion validée n'apparaît pas sur le site aujourd'hui.
*
* Les lectures publiques (`src/lib/queries.ts`) écartent les offres hors
* période ; ce message l'explique côté backoffice, pour qu'un adhérent ne
* cherche pas une offre « en ligne » qui n'est pas encore, ou plus, affichée.
* Renvoie `null` quand la promotion est bien visible.
*/
export function visibilityNote(promo: PromoValidity, today = new Date()): string | null {
const start = parse(promo.startsOn);
const end = parse(promo.endsOn);
if (start && isNotStarted(promo, today)) {
return `Pas encore affichée : elle apparaîtra sur le site le ${full(start)}.`;
}
if (end && isExpired(promo, today)) {
return `Période terminée le ${full(end)} : elle n'apparaît plus sur le site.`;
}
return null;
}
+42 -8
View File
@@ -1,7 +1,22 @@
import { and, asc, count, desc, eq } from "drizzle-orm";
import { and, asc, count, desc, eq, sql } from "drizzle-orm";
import { db } from "@/db";
import { categories, members, promotions } from "@/db/schema";
/**
* Promotion réellement visible par le public : validée **et** dans sa période
* de validité.
*
* Les deux bornes sont facultatives — une offre sans date reste affichée sans
* limite. La journée de référence est celle de l'association, pas celle du
* serveur : un conteneur en UTC retirerait sinon une offre « valable jusqu'au
* 30 juin » deux heures trop tôt.
*/
const VISIBLE_PROMO = and(
eq(promotions.status, "live"),
sql`(${promotions.startsOn} is null or ${promotions.startsOn} <= (current_timestamp at time zone 'Europe/Paris')::date)`,
sql`(${promotions.endsOn} is null or ${promotions.endsOn} >= (current_timestamp at time zone 'Europe/Paris')::date)`
);
export async function getActiveMembersWithCategory() {
return db
.select({
@@ -32,7 +47,7 @@ export async function getLiveBadgesByMember() {
return db
.select({ memberId: promotions.memberId, badge: promotions.badge })
.from(promotions)
.where(eq(promotions.status, "live"));
.where(VISIBLE_PROMO);
}
export async function getCategoriesWithCounts(limit = 6) {
@@ -57,8 +72,14 @@ export async function getCategoriesWithCounts(limit = 6) {
return rows;
}
export async function getLivePromotions(limit = 6) {
const rows = await db
/**
* Promotions visibles, de la plus récente à la plus ancienne.
*
* `limit` à `null` : toutes les offres en cours (page `/promotions`) ; sinon
* les N dernières (accueil).
*/
export async function getLivePromotions(limit: number | null = 6) {
const query = db
.select({
id: promotions.id,
title: promotions.title,
@@ -67,16 +88,21 @@ export async function getLivePromotions(limit = 6) {
badge: promotions.badge,
imageUrl: promotions.imageUrl,
validUntil: promotions.validUntil,
startsOn: promotions.startsOn,
endsOn: promotions.endsOn,
memberId: promotions.memberId,
memberName: members.name,
memberCity: members.city,
memberLogoUrl: members.logoUrl,
})
.from(promotions)
.leftJoin(members, eq(promotions.memberId, members.id))
.where(eq(promotions.status, "live"))
.where(VISIBLE_PROMO)
.orderBy(desc(promotions.createdAt))
.limit(limit);
return rows;
.$dynamic();
// La limite reste dans le SQL : la page « toutes les promos » est la seule à
// tout demander, l'accueil ne rapatrie que ses six cartes.
return limit === null ? query : query.limit(limit);
}
export async function getPublicMember(id: number) {
@@ -119,9 +145,11 @@ export async function getMemberLivePromotions(memberId: number) {
badge: promotions.badge,
imageUrl: promotions.imageUrl,
validUntil: promotions.validUntil,
startsOn: promotions.startsOn,
endsOn: promotions.endsOn,
})
.from(promotions)
.where(and(eq(promotions.memberId, memberId), eq(promotions.status, "live")))
.where(and(eq(promotions.memberId, memberId), VISIBLE_PROMO))
.orderBy(desc(promotions.createdAt));
}
@@ -218,3 +246,9 @@ export async function getCategoriesInUse() {
const rows = await getCategoriesWithCounts(1000);
return rows.filter((c) => Number(c.memberCount) > 0);
}
/** Promotions réellement affichées aujourd'hui — le chiffre du tableau de bord. */
export async function countVisiblePromotions(): Promise<number> {
const [row] = await db.select({ n: count() }).from(promotions).where(VISIBLE_PROMO);
return row?.n ?? 0;
}
+4 -19
View File
@@ -1,28 +1,13 @@
import { headers } from "next/headers";
import { siteUrl } from "@/lib/social-accounts";
import { publicBaseUrl } from "@/lib/social-accounts";
/**
* URL publique du site pour le référencement (canoniques, sitemap, JSON-LD).
*
* Priorité au réglage `site_public_url` (Backend › Réseaux sociaux) puis aux
* variables d'environnement, via `siteUrl()`. En dernier recours, l'URL est
* déduite des en-têtes de la requête (reverse proxy compris) : le site reste
* ainsi correctement référencé tant que le réglage n'est pas renseigné.
* variables d'environnement ; en dernier recours, l'URL est déduite des
* en-têtes de la requête (reverse proxy compris). Voir `publicBaseUrl()`.
*/
export async function publicBaseUrl(): Promise<string> {
const configured = await siteUrl().catch(() => "");
if (configured) return configured;
try {
const h = await headers();
const host = h.get("x-forwarded-host") ?? h.get("host") ?? "";
if (!host) return "";
const protocol = h.get("x-forwarded-proto") ?? (host.startsWith("localhost") ? "http" : "https");
return `${protocol}://${host}`.replace(/\/+$/, "");
} catch {
// Hors requête (génération de robots/sitemap au build par exemple).
return "";
}
}
export { publicBaseUrl };
/** `metadataBase` pour Next : indéfini si aucune URL n'est connue. */
export async function metadataBase(): Promise<URL | undefined> {
+1
View File
@@ -71,6 +71,7 @@ export const PRIVATE_PATHS = ["/backend", "/api", "/login", "/inscription"];
export const STATIC_ROUTES: { path: string; priority: number; changeFrequency: "daily" | "weekly" | "monthly" | "yearly" }[] = [
{ path: "/", priority: 1, changeFrequency: "daily" },
{ path: "/annuaire", priority: 0.9, changeFrequency: "daily" },
{ path: "/promotions", priority: 0.9, changeFrequency: "daily" },
{ path: "/association", priority: 0.8, changeFrequency: "weekly" },
{ path: "/rencontres-passees", priority: 0.6, changeFrequency: "monthly" },
{ path: "/mentions-legales", priority: 0.2, changeFrequency: "yearly" },
+50
View File
@@ -0,0 +1,50 @@
/**
* Déduction de l'origine publique du site à partir des en-têtes d'une requête.
*
* Sans dépendance à la base ni à Next : utilisable partout et testable. Sert de
* repli quand aucune URL publique n'est enregistrée dans Backend › Réseaux
* sociaux : derrière Caddy ou Nginx Proxy Manager, `X-Forwarded-Host` et
* `X-Forwarded-Proto` décrivent l'adresse vue par le visiteur.
*/
type HeaderReader = { get(name: string): string | null };
const LOCAL_HOSTS = /^(localhost|.*\.localhost|127(?:\.\d{1,3}){3}|0\.0\.0\.0|\[::1\]|\[::\])$/i;
const IPV4 = /^\d{1,3}(?:\.\d{1,3}){3}$/;
function first(value: string | null): string {
return (value ?? "").split(",")[0]?.trim() ?? "";
}
/** Nom d'hôte sans port, en minuscules. */
function hostname(host: string): string {
const match = /^(\[[^\]]*\]|[^:]+)/.exec(host);
return (match?.[1] ?? host).toLowerCase();
}
/**
* Sans `X-Forwarded-Proto`, on présume HTTPS pour un nom de domaine — c'est le
* cas de tout déploiement réel — et HTTP pour une adresse locale ou une IP nue,
* qu'aucun certificat ne couvre.
*/
export function defaultProtocol(host: string): "http" | "https" {
const name = hostname(host);
if (!name || LOCAL_HOSTS.test(name) || IPV4.test(name)) return "http";
return "https";
}
/**
* Origine (`https://pleinr.example.fr`) vue par le visiteur, ou chaîne vide si
* la requête ne porte aucun hôte.
*/
export function originFromHeaders(headers: HeaderReader): string {
const host = first(headers.get("x-forwarded-host")) || first(headers.get("host"));
if (!host || /[\s/\\]/.test(host)) return "";
const forwarded = first(headers.get("x-forwarded-proto")).toLowerCase();
const protocol = forwarded === "https" || forwarded === "http" ? forwarded : defaultProtocol(host);
try {
return new URL(`${protocol}://${host}`).origin;
} catch {
return "";
}
}
+110 -1
View File
@@ -1,7 +1,9 @@
import { eq } from "drizzle-orm";
import { headers } from "next/headers";
import { db } from "@/db";
import { siteSettings, socialAccounts, type SocialAccount, type SocialNetwork } from "@/db/schema";
import { decryptSecret, encryptSecret } from "./crypto";
import { originFromHeaders } from "./site-url";
/**
* Configuration des comptes Facebook / LinkedIn.
@@ -43,8 +45,29 @@ export async function siteUrl(): Promise<string> {
return (stored || env("NEXT_PUBLIC_SITE_URL") || env("AUTH_URL")).replace(/\/+$/, "");
}
/**
* URL publique effective : le réglage s'il existe, sinon l'adresse par laquelle
* la requête en cours est arrivée (en-têtes du reverse proxy compris). Chaîne
* vide hors requête si rien n'est configuré.
*/
export async function publicBaseUrl(): Promise<string> {
const configured = await siteUrl().catch(() => "");
if (configured) return configured;
try {
return originFromHeaders(await headers());
} catch {
// Hors requête (génération de robots/sitemap au build par exemple).
return "";
}
}
/**
* Adresse de retour OAuth. Elle doit être identique au clic sur « Connecter »
* et au retour du réseau : les deux passent par le même hôte, la déduction
* depuis la requête est donc stable.
*/
export async function redirectUri(network: SocialNetwork): Promise<string> {
return `${await siteUrl()}/api/social/${network}/callback`;
return `${await publicBaseUrl()}/api/social/${network}/callback`;
}
// ---- Lecture ----
@@ -118,6 +141,92 @@ export function expiryStatus(expiresAt: Date | null | undefined): ExpiryStatus {
return remainingMs <= EXPIRY_WARNING_DAYS * 86_400_000 ? "soon" : "ok";
}
/**
* Vérifie que le jeton fonctionne encore, en interrogeant la plateforme.
*
* « N'expire pas » ne veut pas dire « ne meurt jamais » : un jeton de page
* Facebook est invalidé si le mot de passe du compte connecté change, si ce
* compte perd son rôle d'administrateur de la page, si l'autorisation est
* retirée, ou si Meta le révoque. Sans cette vérification, on ne l'apprend
* qu'au moment où une publication échoue.
*/
export type TokenHealth =
| { ok: true; detail: string }
| { ok: false; reason: string };
export async function checkTokenHealth(network: SocialNetwork): Promise<TokenHealth | null> {
const health = await probeToken(network);
if (health) await rememberCheck(network, health);
return health;
}
/** Contrôle mis en cache : évite un appel réseau à chaque affichage. */
export async function tokenHealthCached(
network: SocialNetwork,
maxAgeMs = 6 * 3_600_000
): Promise<TokenHealth | null> {
const account = await getSocialAccount(network);
if (!account?.accessToken || !account.targetId) return null;
const fresh =
account.lastCheckAt && Date.now() - new Date(account.lastCheckAt).getTime() < maxAgeMs;
if (fresh && account.lastCheckOk !== null) {
return account.lastCheckOk
? { ok: true, detail: "Vérifié récemment" }
: { ok: false, reason: account.lastCheckError ?? "Jeton refusé" };
}
return checkTokenHealth(network);
}
async function rememberCheck(network: SocialNetwork, health: TokenHealth) {
await db
.update(socialAccounts)
.set({
lastCheckAt: new Date(),
lastCheckOk: health.ok,
lastCheckError: health.ok ? null : health.reason.slice(0, 2000),
})
.where(eq(socialAccounts.network, network));
}
async function probeToken(network: SocialNetwork): Promise<TokenHealth | null> {
const credentials = await resolveCredentials(network);
if (!credentials) return null;
try {
if (network === "facebook") {
// Un jeton de page répond `/me` avec la page elle-même.
const res = await fetch(
`https://graph.facebook.com/${FACEBOOK_GRAPH_VERSION}/me?${new URLSearchParams({
fields: "id,name",
access_token: credentials.accessToken,
})}`
);
if (!res.ok) return { ok: false, reason: await readError(res) };
const page = (await res.json()) as { id?: string; name?: string };
if (page.id && credentials.targetId && page.id !== credentials.targetId) {
return {
ok: false,
reason: `Le jeton ne pointe plus vers la bonne page (${page.name ?? page.id}).`,
};
}
return { ok: true, detail: page.name ?? "Page accessible" };
}
const res = await fetch(
"https://api.linkedin.com/rest/organizationAcls?q=roleAssignee&role=ADMINISTRATOR&state=APPROVED&count=1",
{ headers: linkedinHeaders(credentials.accessToken) }
);
if (!res.ok) return { ok: false, reason: await readError(res) };
return { ok: true, detail: "Accès à la page confirmé" };
} catch (error) {
return {
ok: false,
reason: error instanceof Error ? error.message : "Plateforme injoignable",
};
}
}
// ---- OAuth ----
export class SocialAuthError extends Error {}
+9 -4
View File
@@ -12,9 +12,10 @@
import {
refreshLinkedInIfNeeded,
resolveCredentials,
siteUrl,
publicBaseUrl,
type SocialNetwork,
} from "./social-accounts";
import { formatValidity } from "./promo-validity";
import { memberPath } from "./seo";
export {
@@ -35,6 +36,8 @@ export type PromoForSharing = {
text: string | null;
badge: string | null;
validUntil: string | null;
startsOn?: string | null;
endsOn?: string | null;
imageUrl: string | null;
memberId: number | null;
memberName: string | null;
@@ -42,11 +45,11 @@ export type PromoForSharing = {
};
export async function promoLink(promo: PromoForSharing): Promise<string | null> {
const base = await siteUrl();
const base = await publicBaseUrl();
if (!base) return null;
return promo.memberId
? `${base}${memberPath({ id: promo.memberId, name: promo.memberName ?? "", city: promo.memberCity })}`
: `${base}/#promotions`;
: `${base}/promotions`;
}
/** Texte du post, commun aux deux réseaux. */
@@ -55,7 +58,9 @@ export async function buildPromoMessage(promo: PromoForSharing): Promise<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());
// « Valable du … au … » : la même phrase que sur la carte du site.
const validity = formatValidity(promo);
if (validity) lines.push("", validity);
const link = await promoLink(promo);
if (link) lines.push("", link);
lines.push("", "#PleinR #BassinDePompey #CommerceLocal");
+11 -4
View File
@@ -18,7 +18,7 @@ const { auth } = NextAuth(authConfig);
* `style` et sur la feuille Google Fonts. C'est un compromis assumé — une
* injection de style est sans commune mesure avec une injection de script.
*/
function contentSecurityPolicy(nonce: string, isDev: boolean): string {
function contentSecurityPolicy(nonce: string, isDev: boolean, isHttps: boolean): string {
return [
"default-src 'self'",
"base-uri 'self'",
@@ -35,14 +35,21 @@ function contentSecurityPolicy(nonce: string, isDev: boolean): string {
"connect-src 'self'",
// Carte « Nous situer » de la fiche adhérent.
"frame-src https://www.google.com https://maps.google.com",
"upgrade-insecure-requests",
].join("; ");
// Uniquement quand la page est servie en HTTPS : sur une page HTTP (test
// par IP avant la pose du DNS, accès direct au port), le navigateur
// basculerait toutes les navigations vers un https:// inexistant.
isHttps ? "upgrade-insecure-requests" : "",
]
.filter(Boolean)
.join("; ");
}
export default auth((request: NextRequest) => {
// `btoa` et non `Buffer` : le middleware tourne sur le runtime Edge.
const nonce = btoa(crypto.randomUUID());
const csp = contentSecurityPolicy(nonce, process.env.NODE_ENV !== "production");
const forwardedProto = request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim();
const isHttps = forwardedProto ? forwardedProto === "https" : request.nextUrl.protocol === "https:";
const csp = contentSecurityPolicy(nonce, process.env.NODE_ENV !== "production", isHttps);
// L'en-tête est posé sur la REQUÊTE : Next y lit le nonce pour l'appliquer à
// ses balises <script>. Il est ensuite renvoyé sur la réponse au navigateur.
+67
View File
@@ -0,0 +1,67 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
formatSchedule,
isDue,
isPending,
parseScheduleInput,
scheduleLabel,
toScheduleInput,
} from "../src/lib/promo-schedule";
test("programmation — champ vide = publication immédiate", () => {
assert.equal(parseScheduleInput(""), null);
assert.equal(parseScheduleInput(null), null);
assert.equal(parseScheduleInput(" "), null);
assert.equal(isDue(null), true);
});
test("programmation — heure d'hiver interprétée en heure de Paris (UTC+1)", () => {
const d = parseScheduleInput("2027-01-15T09:00");
assert.ok(d);
assert.equal(d.toISOString(), "2027-01-15T08:00:00.000Z");
});
test("programmation — heure d'été interprétée en heure de Paris (UTC+2)", () => {
const d = parseScheduleInput("2027-07-15T09:00");
assert.ok(d);
assert.equal(d.toISOString(), "2027-07-15T07:00:00.000Z");
});
test("programmation — minuit ne bascule pas d'un jour", () => {
const d = parseScheduleInput("2027-03-10T00:00");
assert.ok(d);
assert.equal(d.toISOString(), "2027-03-09T23:00:00.000Z");
});
test("programmation — aller-retour avec le champ du formulaire", () => {
for (const value of ["2027-01-15T09:00", "2027-07-15T18:30", "2027-12-31T23:45"]) {
assert.equal(toScheduleInput(parseScheduleInput(value)), value);
}
assert.equal(toScheduleInput(null), "");
});
test("programmation — une saisie illisible est refusée, jamais ignorée", () => {
assert.throws(() => parseScheduleInput("demain matin"));
assert.throws(() => parseScheduleInput("2027-13-45T99:99"));
assert.throws(() => parseScheduleInput("2027-01-15"));
});
test("programmation — échéance atteinte", () => {
const at = new Date("2027-01-15T08:00:00.000Z");
assert.equal(isDue(at, new Date("2027-01-15T07:59:59.000Z")), false);
assert.equal(isDue(at, at), true);
assert.equal(isDue(at, new Date("2027-01-15T08:00:01.000Z")), true);
assert.equal(isPending(at, new Date("2027-01-15T07:00:00.000Z")), true);
});
test("programmation — libellé en heure de Paris", () => {
assert.equal(formatSchedule(new Date("2027-01-15T08:00:00.000Z")), "le 15 janvier 2027 à 09:00");
assert.equal(formatSchedule(new Date("2027-07-15T07:00:00.000Z")), "le 15 juillet 2027 à 09:00");
assert.equal(formatSchedule(null), null);
assert.equal(
scheduleLabel(new Date("2027-01-15T08:00:00.000Z")),
"Publication programmée le 15 janvier 2027 à 09:00"
);
assert.equal(scheduleLabel(null), null);
});
+100
View File
@@ -0,0 +1,100 @@
import { strict as assert } from "node:assert";
import { describe, it } from "node:test";
import {
formatValidity,
formatValidityShort,
isExpired,
isNotStarted,
isRangeInvalid,
visibilityNote,
} from "../src/lib/promo-validity";
describe("Période de validité d'une promotion", () => {
it("affiche les deux bornes", () => {
assert.equal(
formatValidity({ startsOn: "2027-03-01", endsOn: "2027-03-15" }),
"Valable du 1er au 15 mars 2027"
);
});
it("ne répète pas l'année quand elle est identique", () => {
assert.equal(
formatValidity({ startsOn: "2027-03-01", endsOn: "2027-04-10" }),
"Valable du 1er mars au 10 avril 2027"
);
});
it("écrit les deux années quand elles diffèrent", () => {
assert.equal(
formatValidity({ startsOn: "2027-12-20", endsOn: "2028-01-05" }),
"Valable du 20 décembre 2027 au 5 janvier 2028"
);
});
it("gère une seule borne", () => {
assert.equal(formatValidity({ endsOn: "2027-06-30" }), "Valable jusqu'au 30 juin 2027");
assert.equal(
formatValidity({ startsOn: "2027-06-01" }),
"Valable à partir du 1er juin 2027"
);
});
it("ne renvoie rien sans date ni texte : la carte n'affiche alors aucune mention", () => {
assert.equal(formatValidity({}), null);
assert.equal(formatValidity({ startsOn: null, endsOn: null, validUntil: " " }), null);
});
it("retombe sur l'ancien texte libre des promotions existantes", () => {
assert.equal(formatValidity({ validUntil: "Tout l'été" }), "Tout l'été");
// Les dates priment sur le texte libre.
assert.equal(
formatValidity({ endsOn: "2027-07-31", validUntil: "Tout l'été" }),
"Valable jusqu'au 31 juillet 2027"
);
});
it("ignore une date illisible", () => {
assert.equal(formatValidity({ endsOn: "pas-une-date" }), null);
assert.equal(formatValidity({ endsOn: "2027-13-45" }), null);
});
it("propose une variante sans le mot « Valable »", () => {
assert.equal(formatValidityShort({ endsOn: "2027-06-30" }), "jusqu'au 30 juin 2027");
});
it("considère le dernier jour comme encore valable", () => {
const end = { endsOn: "2027-06-30" };
assert.equal(isExpired(end, new Date("2027-06-30T23:00:00Z")), false);
assert.equal(isExpired(end, new Date("2027-07-01T12:00:00Z")), true);
assert.equal(isExpired({}, new Date("2099-01-01T00:00:00Z")), false);
});
it("détecte une fin antérieure au début", () => {
assert.equal(isRangeInvalid({ startsOn: "2027-06-10", endsOn: "2027-06-01" }), true);
assert.equal(isRangeInvalid({ startsOn: "2027-06-01", endsOn: "2027-06-01" }), false);
assert.equal(isRangeInvalid({ startsOn: "2027-06-01" }), false);
});
});
describe("Visibilité publique d'une promotion", () => {
it("distingue avant, pendant et après la période", () => {
const promo = { startsOn: "2027-03-01", endsOn: "2027-03-15" };
assert.equal(
visibilityNote(promo, new Date("2027-02-20T12:00:00Z")),
"Pas encore affichée : elle apparaîtra sur le site le 1er mars 2027."
);
assert.equal(visibilityNote(promo, new Date("2027-03-01T00:00:00Z")), null);
assert.equal(visibilityNote(promo, new Date("2027-03-15T23:00:00Z")), null);
assert.equal(
visibilityNote(promo, new Date("2027-03-16T08:00:00Z")),
"Période terminée le 15 mars 2027 : elle n'apparaît plus sur le site."
);
});
it("laisse visible une offre sans date", () => {
assert.equal(visibilityNote({ startsOn: null, endsOn: null }), null);
assert.equal(visibilityNote({ validUntil: "Ce week-end" }), null);
assert.equal(isNotStarted({ startsOn: null }), false);
});
});
+41
View File
@@ -0,0 +1,41 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { defaultProtocol, originFromHeaders } from "../src/lib/site-url";
function headersOf(entries: Record<string, string>) {
const map = new Map(Object.entries(entries).map(([k, v]) => [k.toLowerCase(), v]));
return { get: (name: string) => map.get(name.toLowerCase()) ?? null };
}
test("l'origine suit les en-têtes du reverse proxy", () => {
assert.equal(
originFromHeaders(headersOf({ host: "pleinr-app:3000", "x-forwarded-host": "pleinr.example.fr", "x-forwarded-proto": "https" })),
"https://pleinr.example.fr"
);
});
test("une liste d'hôtes transmis retient le premier", () => {
assert.equal(
originFromHeaders(headersOf({ "x-forwarded-host": "pleinr.example.fr, proxy.interne", "x-forwarded-proto": "https, http" })),
"https://pleinr.example.fr"
);
});
test("un accès direct en HTTP sur une IP reste en HTTP", () => {
assert.equal(originFromHeaders(headersOf({ host: "0.0.0.0:3000" })), "http://0.0.0.0:3000");
assert.equal(originFromHeaders(headersOf({ host: "192.168.1.20:8413" })), "http://192.168.1.20:8413");
assert.equal(originFromHeaders(headersOf({ host: "localhost:3000", "x-forwarded-proto": "http" })), "http://localhost:3000");
});
test("un nom de domaine sans indication de protocole est présumé en HTTPS", () => {
assert.equal(originFromHeaders(headersOf({ host: "pleinr.example.fr" })), "https://pleinr.example.fr");
assert.equal(defaultProtocol("pleinr.example.fr:8443"), "https");
assert.equal(defaultProtocol("[::1]:3000"), "http");
});
test("un en-tête absent ou malformé ne produit rien", () => {
assert.equal(originFromHeaders(headersOf({})), "");
assert.equal(originFromHeaders(headersOf({ host: "bad host/with path" })), "");
assert.equal(originFromHeaders(headersOf({ host: "pleinr.example.fr", "x-forwarded-proto": "ftp" })), "https://pleinr.example.fr");
});