From e980ab6e04b9419c4035318122bb00d4a488a11d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 11:17:49 +0000 Subject: [PATCH] =?UTF-8?q?Promotions=20:=20publication=20programm=C3=A9e?= =?UTF-8?q?=20et=20affichage=20limit=C3=A9=20=C3=A0=20la=20p=C3=A9riode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb --- CLAUDE.md | 49 +- drizzle/0016_tiresome_korg.sql | 2 + drizzle/meta/0016_snapshot.json | 1414 ++++++++++++++++++++ drizzle/meta/_journal.json | 7 + next.config.mjs | 14 + src/app/backend/actions.ts | 139 +- src/app/backend/espace/MemberSpaceForm.tsx | 32 +- src/app/backend/espace/promotions/page.tsx | 33 +- src/app/backend/page.tsx | 5 +- src/app/backend/promotions/page.tsx | 57 +- src/db/schema.ts | 6 + src/instrumentation-node.ts | 24 + src/instrumentation.ts | 21 + src/lib/activity-log.ts | 13 + src/lib/promo-publish.ts | 175 +++ src/lib/promo-schedule.ts | 117 ++ src/lib/promo-validity.ts | 27 + src/lib/queries.ts | 29 +- tests/promo-schedule.test.ts | 67 + tests/promo-validity.test.ts | 24 + 20 files changed, 2134 insertions(+), 121 deletions(-) create mode 100644 drizzle/0016_tiresome_korg.sql create mode 100644 drizzle/meta/0016_snapshot.json create mode 100644 src/instrumentation-node.ts create mode 100644 src/instrumentation.ts create mode 100644 src/lib/activity-log.ts create mode 100644 src/lib/promo-publish.ts create mode 100644 src/lib/promo-schedule.ts create mode 100644 tests/promo-schedule.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 4ce1dde..9e95189 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,11 +50,12 @@ 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** @@ -67,6 +68,43 @@ 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. +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 - `src/lib/social.ts` publie une promo sur la page Facebook (Graph API) ou @@ -93,11 +131,12 @@ le backoffice et les réseaux. - 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. diff --git a/drizzle/0016_tiresome_korg.sql b/drizzle/0016_tiresome_korg.sql new file mode 100644 index 0000000..6d85ec7 --- /dev/null +++ b/drizzle/0016_tiresome_korg.sql @@ -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; \ No newline at end of file diff --git a/drizzle/meta/0016_snapshot.json b/drizzle/meta/0016_snapshot.json new file mode 100644 index 0000000..c23aefb --- /dev/null +++ b/drizzle/meta/0016_snapshot.json @@ -0,0 +1,1414 @@ +{ + "id": "17b58756-caa0-4c04-bd73-c6b210ce87ef", + "prevId": "921c53a4-388b-4136-bd5d-06efe6ef1bd8", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.activity_log": { + "name": "activity_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "dot": { + "name": "dot", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'#2C6FB3'" + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.categories": { + "name": "categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "accent": { + "name": "accent", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'#E0A63C'" + }, + "tint": { + "name": "tint", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'#f6efdc'" + }, + "sort": { + "name": "sort", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "categories_slug_unique": { + "name": "categories_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contact_messages": { + "name": "contact_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "contact_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'new'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.image_consents": { + "name": "image_consents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "decision": { + "name": "decision", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "signatory_name": { + "name": "signatory_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "signature_png": { + "name": "signature_png", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "consent_version": { + "name": "consent_version", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "ip": { + "name": "ip", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "image_consents_member_id_members_id_fk": { + "name": "image_consents_member_id_members_id_fk", + "tableFrom": "image_consents", + "tableTo": "members", + "columnsFrom": [ + "member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.meeting_registrations": { + "name": "meeting_registrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "meeting_id": { + "name": "meeting_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "attendee_name": { + "name": "attendee_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "attendee_company": { + "name": "attendee_company", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "attendee_email": { + "name": "attendee_email", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "attendee_phone": { + "name": "attendee_phone", + "type": "varchar(40)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "image_consent": { + "name": "image_consent", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "meeting_registrations_meeting_idx": { + "name": "meeting_registrations_meeting_idx", + "columns": [ + { + "expression": "meeting_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "meeting_registrations_member_meeting_idx": { + "name": "meeting_registrations_member_meeting_idx", + "columns": [ + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "meeting_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "meeting_registrations_meeting_id_meetings_id_fk": { + "name": "meeting_registrations_meeting_id_meetings_id_fk", + "tableFrom": "meeting_registrations", + "tableTo": "meetings", + "columnsFrom": [ + "meeting_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "meeting_registrations_member_id_members_id_fk": { + "name": "meeting_registrations_member_id_members_id_fk", + "tableFrom": "meeting_registrations", + "tableTo": "members", + "columnsFrom": [ + "member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.meetings": { + "name": "meetings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "starts_at": { + "name": "starts_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "location": { + "name": "location", + "type": "varchar(240)", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capacity": { + "name": "capacity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 30 + }, + "participants_per_account": { + "name": "participants_per_account", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.members": { + "name": "members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "contact_email": { + "name": "contact_email", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "city": { + "name": "city", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "varchar(240)", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "highlighted": { + "name": "highlighted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_url": { + "name": "cover_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "varchar(40)", + "primaryKey": false, + "notNull": false + }, + "website": { + "name": "website", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "postal_code": { + "name": "postal_code", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "member_since": { + "name": "member_since", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "hours": { + "name": "hours", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "members_category_id_categories_id_fk": { + "name": "members_category_id_categories_id_fk", + "tableFrom": "members", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.membership_requests": { + "name": "membership_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "request_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'new'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.past_meeting_photos": { + "name": "past_meeting_photos", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "past_meeting_id": { + "name": "past_meeting_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "caption": { + "name": "caption", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "past_meeting_photos_past_meeting_id_past_meetings_id_fk": { + "name": "past_meeting_photos_past_meeting_id_past_meetings_id_fk", + "tableFrom": "past_meeting_photos", + "tableTo": "past_meetings", + "columnsFrom": [ + "past_meeting_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.past_meetings": { + "name": "past_meetings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "event_date": { + "name": "event_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "location": { + "name": "location", + "type": "varchar(240)", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "participants": { + "name": "participants", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "meeting_id": { + "name": "meeting_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "past_meetings_meeting_id_meetings_id_fk": { + "name": "past_meetings_meeting_id_meetings_id_fk", + "tableFrom": "past_meetings", + "tableTo": "meetings", + "columnsFrom": [ + "meeting_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.promotions": { + "name": "promotions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "badge": { + "name": "badge", + "type": "varchar(40)", + "primaryKey": false, + "notNull": false + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "member_id": { + "name": "member_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "promo_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "starts_on": { + "name": "starts_on", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "ends_on": { + "name": "ends_on", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "valid_until": { + "name": "valid_until", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "share_facebook": { + "name": "share_facebook", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "share_linkedin": { + "name": "share_linkedin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "publish_at": { + "name": "publish_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "suspended_by": { + "name": "suspended_by", + "type": "promo_suspended_by", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "suspended_by_id": { + "name": "suspended_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "promotions_member_id_members_id_fk": { + "name": "promotions_member_id_members_id_fk", + "tableFrom": "promotions", + "tableTo": "members", + "columnsFrom": [ + "member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "promotions_suspended_by_id_users_id_fk": { + "name": "promotions_suspended_by_id_users_id_fk", + "tableFrom": "promotions", + "tableTo": "users", + "columnsFrom": [ + "suspended_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.site_settings": { + "name": "site_settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "varchar(120)", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.social_accounts": { + "name": "social_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "network": { + "name": "network", + "type": "social_network", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "app_id": { + "name": "app_id", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "app_secret": { + "name": "app_secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "target_name": { + "name": "target_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "connected_by_id": { + "name": "connected_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_check_at": { + "name": "last_check_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_check_ok": { + "name": "last_check_ok", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "last_check_error": { + "name": "last_check_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "social_accounts_connected_by_id_users_id_fk": { + "name": "social_accounts_connected_by_id_users_id_fk", + "tableFrom": "social_accounts", + "tableTo": "users", + "columnsFrom": [ + "connected_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "social_accounts_network_unique": { + "name": "social_accounts_network_unique", + "nullsNotDistinct": false, + "columns": [ + "network" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.social_posts": { + "name": "social_posts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "promotion_id": { + "name": "promotion_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "network": { + "name": "network", + "type": "social_network", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "social_post_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "posted_by_id": { + "name": "posted_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "social_posts_promotion_idx": { + "name": "social_posts_promotion_idx", + "columns": [ + { + "expression": "promotion_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "social_posts_promotion_id_promotions_id_fk": { + "name": "social_posts_promotion_id_promotions_id_fk", + "tableFrom": "social_posts", + "tableTo": "promotions", + "columnsFrom": [ + "promotion_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "social_posts_posted_by_id_users_id_fk": { + "name": "social_posts_posted_by_id_users_id_fk", + "tableFrom": "social_posts", + "tableTo": "users", + "columnsFrom": [ + "posted_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "member_id": { + "name": "member_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "must_change_password": { + "name": "must_change_password", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "session_version": { + "name": "session_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_idx": { + "name": "users_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "users_member_id_members_id_fk": { + "name": "users_member_id_members_id_fk", + "tableFrom": "users", + "tableTo": "members", + "columnsFrom": [ + "member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.contact_status": { + "name": "contact_status", + "schema": "public", + "values": [ + "new", + "read", + "archived" + ] + }, + "public.member_status": { + "name": "member_status", + "schema": "public", + "values": [ + "active", + "pending" + ] + }, + "public.promo_status": { + "name": "promo_status", + "schema": "public", + "values": [ + "pending", + "live", + "expired", + "rejected", + "suspended", + "scheduled" + ] + }, + "public.promo_suspended_by": { + "name": "promo_suspended_by", + "schema": "public", + "values": [ + "member", + "staff" + ] + }, + "public.request_status": { + "name": "request_status", + "schema": "public", + "values": [ + "new", + "approved", + "rejected" + ] + }, + "public.role": { + "name": "role", + "schema": "public", + "values": [ + "admin", + "moderator", + "editor", + "member" + ] + }, + "public.social_network": { + "name": "social_network", + "schema": "public", + "values": [ + "facebook", + "linkedin" + ] + }, + "public.social_post_status": { + "name": "social_post_status", + "schema": "public", + "values": [ + "posted", + "failed" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 0981159..61e49ba 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -113,6 +113,13 @@ "when": 1788691741621, "tag": "0015_curious_winter_soldier", "breakpoints": true + }, + { + "idx": 16, + "version": "7", + "when": 1788692576315, + "tag": "0016_tiresome_korg", + "breakpoints": true } ] } \ No newline at end of file diff --git a/next.config.mjs b/next.config.mjs index eba100c..0f9da3d 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -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 [ { diff --git a/src/app/backend/actions.ts b/src/app/backend/actions.ts index d25e5a2..3685f4f 100644 --- a/src/app/backend/actions.ts +++ b/src/app/backend/actions.ts @@ -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, @@ -44,16 +39,14 @@ import { } 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 . - 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. @@ -133,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", @@ -147,86 +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, - 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, - }); - } - } -} - // ---- Promotion moderation ---- export async function moderatePromo(formData: FormData) { const { role, userId, name } = await requireRole(); @@ -243,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. @@ -268,6 +190,15 @@ export async function moderatePromo(formData: FormData) { `Promotion « ${promo.title} » suspendue par ${name}`, "#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) @@ -388,6 +319,9 @@ export async function publishPromo(formData: FormData) { 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({ @@ -400,6 +334,7 @@ export async function publishPromo(formData: FormData) { status: "pending", startsOn, endsOn, + publishAt, ...readShareTargets(formData), }); diff --git a/src/app/backend/espace/MemberSpaceForm.tsx b/src/app/backend/espace/MemberSpaceForm.tsx index c9df2f5..efa81bf 100644 --- a/src/app/backend/espace/MemberSpaceForm.tsx +++ b/src/app/backend/espace/MemberSpaceForm.tsx @@ -8,6 +8,7 @@ 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)"; @@ -39,6 +40,7 @@ export function MemberSpaceForm({ const [badge, setBadge] = useState(""); const [startsOn, setStartsOn] = useState(""); const [endsOn, setEndsOn] = useState(""); + const [publishAt, setPublishAt] = useState(""); const [imgData, setImgData] = useState(""); const [shares, setShares] = useState([]); const [submitted, setSubmitted] = useState(false); @@ -67,6 +69,7 @@ export function MemberSpaceForm({ setBadge(""); setStartsOn(""); setEndsOn(""); + setPublishAt(""); setImgData(""); setShares([]); setSubmitted(false); @@ -83,6 +86,7 @@ export function MemberSpaceForm({ 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"); @@ -99,6 +103,14 @@ export function MemberSpaceForm({ 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 (
@@ -208,8 +220,24 @@ export function MemberSpaceForm({ {badRange ? "La date de fin est antérieure à la date de début." : validity - ? `Affiché sur le site et dans la publication : « ${validity} »` - : "Laissez vide si votre offre n'a pas de date limite : rien ne sera affiché."} + ? `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."} +
+ + +
+ + { setPublishAt(e.target.value); setSubmitted(false); }} + className="field" + aria-label="Date et heure de publication souhaitées" + /> +
+ {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."}
diff --git a/src/app/backend/espace/promotions/page.tsx b/src/app/backend/espace/promotions/page.tsx index 57bf8a7..88c03b7 100644 --- a/src/app/backend/espace/promotions/page.tsx +++ b/src/app/backend/espace/promotions/page.tsx @@ -7,7 +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 } from "@/lib/promo-validity"; +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"; @@ -20,6 +21,7 @@ const PROMO_STATUS: Record 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 = { @@ -29,6 +31,7 @@ type MyPromo = { category: string | null; badge: string | null; validUntil: string | null; + publishAt: Date | null; startsOn: string | null; endsOn: string | null; createdAt: Date; @@ -82,6 +85,7 @@ 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, @@ -116,7 +120,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 (
@@ -156,6 +165,16 @@ export default async function EspacePromotionsPage() { networks={networks} /> + {scheduled.length > 0 && ( + + )} + {pending.length > 0 && ( )}
+ {mp.status === "live" && visibilityNote(mp) && ( +
+ {visibilityNote(mp)} +
+ )} + {mp.status === "scheduled" && ( +
+ Validée par l'association. {scheduleLabel(mp.publishAt)} — site et réseaux en même temps. +
+ )} {suspendedByStaff && (
Cette promotion a été suspendue par l'association. Contactez-la pour la remettre en ligne. diff --git a/src/app/backend/page.tsx b/src/app/backend/page.tsx index 98755b5..a895849 100644 --- a/src/app/backend/page.tsx +++ b/src/app/backend/page.tsx @@ -6,6 +6,7 @@ import { db } from "@/db"; import { activityLog, contactMessages, members, membershipRequests, promotions, socialPosts } from "@/db/schema"; import { activityNodes } from "@/lib/activity"; import { can, isStaff } from "@/lib/rbac"; +import { countVisiblePromotions } from "@/lib/queries"; import { expiryStatus, getSocialAccounts, @@ -48,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")), @@ -142,7 +143,7 @@ export default async function DashboardPage() {
- + 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) { @@ -42,6 +44,7 @@ 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, @@ -54,14 +57,16 @@ export default async function PromotionsPage() { }) .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 @@ -94,6 +99,9 @@ export default async function PromotionsPage() { En attente · {pending.length} + + Programmées · {scheduled.length} + En ligne · {live.length} @@ -119,6 +127,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 @@ -188,6 +197,18 @@ export default async function PromotionsPage() {
)} + {p.status === "live" && visibilityNote(p) && ( +
+ {visibilityNote(p)} +
+ )} + + {isScheduled && ( +
+ {scheduleLabel(p.publishAt)}. Rien n'est encore visible ni diffusé. +
+ )} + {isSuspended && (
Suspendue par {p.suspendedBy === "member" ? "l'adhérent" : "l'association"} @@ -226,6 +247,24 @@ export default async function PromotionsPage() {
)} + {/* Date demandée par l'adhérent, modifiable ici. Vidée, + la promotion part immédiatement. */} +
+ + +
+ {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."} +
+
@@ -242,6 +281,16 @@ export default async function PromotionsPage() { )} + {isScheduled && ( +
+ + + +
+ )} + {!isPending && (
diff --git a/src/db/schema.ts b/src/db/schema.ts index c6b358d..4dd506a 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -22,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. @@ -118,6 +122,8 @@ export const promotions = pgTable("promotions", { // 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 }), diff --git a/src/instrumentation-node.ts b/src/instrumentation-node.ts new file mode 100644 index 0000000..3f41306 --- /dev/null +++ b/src/instrumentation-node.ts @@ -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(); diff --git a/src/instrumentation.ts b/src/instrumentation.ts new file mode 100644 index 0000000..d07a3b3 --- /dev/null +++ b/src/instrumentation.ts @@ -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"); +} diff --git a/src/lib/activity-log.ts b/src/lib/activity-log.ts new file mode 100644 index 0000000..669ba77 --- /dev/null +++ b/src/lib/activity-log.ts @@ -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 ``. + */ +export async function logActivity(message: string, dot = "#2C6FB3") { + await db.insert(activityLog).values({ message: sanitizeActivityMessage(message), dot }); +} diff --git a/src/lib/promo-publish.ts b/src/lib/promo-publish.ts new file mode 100644 index 0000000..d224394 --- /dev/null +++ b/src/lib/promo-publish.ts @@ -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 { + 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 { + const [row] = await db + .select({ n: sql`count(*)::int` }) + .from(promotions) + .where(eq(promotions.status, "scheduled")); + return row?.n ?? 0; +} diff --git a/src/lib/promo-schedule.ts b/src/lib/promo-schedule.ts new file mode 100644 index 0000000..2423251 --- /dev/null +++ b/src/lib/promo-schedule.ts @@ -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 `` (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 ``. */ +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; +} diff --git a/src/lib/promo-validity.ts b/src/lib/promo-validity.ts index b38145f..57dbf3e 100644 --- a/src/lib/promo-validity.ts +++ b/src/lib/promo-validity.ts @@ -88,3 +88,30 @@ export function isRangeInvalid(promo: PromoValidity): boolean { 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; +} diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 2c02060..a2f4101 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -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) { @@ -75,7 +90,7 @@ export async function getLivePromotions(limit = 6) { }) .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; @@ -125,7 +140,7 @@ export async function getMemberLivePromotions(memberId: number) { 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)); } @@ -222,3 +237,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 { + const [row] = await db.select({ n: count() }).from(promotions).where(VISIBLE_PROMO); + return row?.n ?? 0; +} diff --git a/tests/promo-schedule.test.ts b/tests/promo-schedule.test.ts new file mode 100644 index 0000000..6ee44c2 --- /dev/null +++ b/tests/promo-schedule.test.ts @@ -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); +}); diff --git a/tests/promo-validity.test.ts b/tests/promo-validity.test.ts index b7cbad4..0d03230 100644 --- a/tests/promo-validity.test.ts +++ b/tests/promo-validity.test.ts @@ -5,7 +5,9 @@ import { formatValidity, formatValidityShort, isExpired, + isNotStarted, isRangeInvalid, + visibilityNote, } from "../src/lib/promo-validity"; describe("Période de validité d'une promotion", () => { @@ -74,3 +76,25 @@ describe("Période de validité d'une promotion", () => { 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); + }); +});