Files
PleinR/src/lib/social.ts
T
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

262 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Publication des promotions sur les pages Facebook / LinkedIn de l'association.
*
* Les identifiants et jetons viennent de `social-accounts.ts` : base de données
* en premier (posés depuis Backend › Réseaux sociaux), variables
* d'environnement en repli.
*
* Si un réseau n'est pas configuré, le backoffice masque simplement sa case :
* le reste du site fonctionne normalement.
*/
import {
refreshLinkedInIfNeeded,
resolveCredentials,
publicBaseUrl,
type SocialNetwork,
} from "./social-accounts";
import { formatValidity } from "./promo-validity";
import { memberPath } from "./seo";
export {
configuredNetworks,
isNetworkConfigured,
SOCIAL_LABELS,
SOCIAL_NETWORKS,
type SocialNetwork,
} from "./social-accounts";
const FACEBOOK_GRAPH_VERSION = process.env.FACEBOOK_GRAPH_VERSION?.trim() || "v21.0";
const LINKEDIN_VERSION = process.env.LINKEDIN_API_VERSION?.trim() || "202506";
// ---- Contenu du post ----
export type PromoForSharing = {
title: string;
text: string | null;
badge: string | null;
validUntil: string | null;
startsOn?: string | null;
endsOn?: string | null;
imageUrl: string | null;
memberId: number | null;
memberName: string | null;
memberCity?: string | null;
};
export async function promoLink(promo: PromoForSharing): Promise<string | null> {
const base = await publicBaseUrl();
if (!base) return null;
return promo.memberId
? `${base}${memberPath({ id: promo.memberId, name: promo.memberName ?? "", city: promo.memberCity })}`
: `${base}/#promotions`;
}
/** Texte du post, commun aux deux réseaux. */
export async function buildPromoMessage(promo: PromoForSharing): Promise<string> {
const lines: string[] = [];
lines.push(promo.badge ? `${promo.badge} — ${promo.title}` : promo.title);
if (promo.memberName) lines.push(`Chez ${promo.memberName}`);
if (promo.text?.trim()) lines.push("", promo.text.trim());
// « 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");
return lines.join("\n");
}
// ---- Image ----
type LoadedImage = { bytes: Uint8Array; contentType: string; fileName: string };
/**
* Les images de promo sont stockées en data-URI (upload direct depuis l'espace
* adhérent) ; on accepte aussi une URL absolue au cas où.
*/
export async function loadPromoImage(imageUrl: string | null): Promise<LoadedImage | null> {
if (!imageUrl) return null;
const dataMatch = /^data:([^;,]+);base64,(.+)$/s.exec(imageUrl.trim());
if (dataMatch) {
const contentType = dataMatch[1];
const bytes = Uint8Array.from(Buffer.from(dataMatch[2], "base64"));
return { bytes, contentType, fileName: `promo.${extensionFor(contentType)}` };
}
// Volontairement : aucune récupération par URL.
//
// `imageUrl` provient d'un champ de formulaire alimenté par l'adhérent. Aller
// chercher l'adresse qu'il indique donnerait au serveur l'ordre d'appeler une
// cible arbitraire (SSRF) — services internes, métadonnées d'hébergeur — dont
// le contenu partirait ensuite sur Facebook ou LinkedIn. Toutes les images de
// l'application sont des data-URI produites par l'upload, ce chemin est donc
// le seul légitime.
return null;
}
function extensionFor(contentType: string): string {
if (contentType.includes("png")) return "png";
if (contentType.includes("webp")) return "webp";
if (contentType.includes("gif")) return "gif";
return "jpg";
}
function toBlob(image: LoadedImage): Blob {
// `Uint8Array` → `ArrayBuffer` explicite : évite le SharedArrayBuffer côté types.
return new Blob([image.bytes.slice().buffer as ArrayBuffer], { type: image.contentType });
}
// ---- Résultat ----
export type PublishResult = { externalId: string | null; url: string | null };
export class SocialPublishError extends Error {}
async function readError(res: Response): Promise<string> {
const body = await res.text().catch(() => "");
return `HTTP ${res.status} ${res.statusText}${body ? ` — ${body.slice(0, 500)}` : ""}`;
}
// ---- Facebook (Graph API, page de l'association) ----
async function publishToFacebook(promo: PromoForSharing, message: string): Promise<PublishResult> {
const credentials = await resolveCredentials("facebook");
if (!credentials) throw new SocialPublishError("Facebook n'est pas connecté.");
const { targetId: pageId, accessToken: token } = credentials;
const image = await loadPromoImage(promo.imageUrl);
const base = `https://graph.facebook.com/${FACEBOOK_GRAPH_VERSION}/${pageId}`;
const body = new FormData();
body.set("access_token", token);
let endpoint: string;
if (image) {
// Photo : on téléverse le binaire directement (les images sont en data-URI,
// Facebook ne peut donc pas aller les chercher par URL).
endpoint = `${base}/photos`;
body.set("caption", message);
body.set("source", toBlob(image), image.fileName);
} else {
endpoint = `${base}/feed`;
body.set("message", message);
const link = await promoLink(promo);
if (link) body.set("link", link);
}
const res = await fetch(endpoint, { method: "POST", body });
if (!res.ok) throw new SocialPublishError(`Facebook: ${await readError(res)}`);
const json = (await res.json()) as { id?: string; post_id?: string };
const externalId = json.post_id ?? json.id ?? null;
return {
externalId,
url: externalId ? `https://www.facebook.com/${externalId}` : null,
};
}
// ---- LinkedIn (Posts API, page organisation) ----
/**
* Le champ `commentary` utilise le « Little Text Format » : ces caractères
* doivent être échappés sous peine de rejet de la requête.
*/
function escapeLinkedInText(text: string): string {
return text.replace(/[|{}@[\]()<>#*_~\\]/g, (c) => `\\${c}`);
}
function linkedinHeaders(token: string): Record<string, string> {
return {
Authorization: `Bearer ${token}`,
"LinkedIn-Version": LINKEDIN_VERSION,
"X-Restli-Protocol-Version": "2.0.0",
};
}
async function uploadLinkedInImage(
token: string,
owner: string,
image: LoadedImage
): Promise<string> {
const initRes = await fetch("https://api.linkedin.com/rest/images?action=initializeUpload", {
method: "POST",
headers: { ...linkedinHeaders(token), "Content-Type": "application/json" },
body: JSON.stringify({ initializeUploadRequest: { owner } }),
});
if (!initRes.ok) throw new SocialPublishError(`LinkedIn (init image): ${await readError(initRes)}`);
const init = (await initRes.json()) as { value?: { uploadUrl?: string; image?: string } };
const uploadUrl = init.value?.uploadUrl;
const imageUrn = init.value?.image;
if (!uploadUrl || !imageUrn) {
throw new SocialPublishError("LinkedIn: réponse d'initialisation d'image inattendue.");
}
const uploadRes = await fetch(uploadUrl, {
method: "PUT",
headers: { Authorization: `Bearer ${token}`, "Content-Type": image.contentType },
body: toBlob(image),
});
if (!uploadRes.ok) {
throw new SocialPublishError(`LinkedIn (upload image): ${await readError(uploadRes)}`);
}
return imageUrn;
}
async function publishToLinkedIn(promo: PromoForSharing, message: string): Promise<PublishResult> {
// Rattrape un jeton proche de l'expiration quand LinkedIn a accordé les
// jetons de rafraîchissement programmatiques à l'application.
await refreshLinkedInIfNeeded();
const credentials = await resolveCredentials("linkedin");
if (!credentials) throw new SocialPublishError("LinkedIn n'est pas connecté.");
const { accessToken: token, targetId: owner } = credentials;
const image = await loadPromoImage(promo.imageUrl);
const imageUrn = image ? await uploadLinkedInImage(token, owner, image) : null;
const payload: Record<string, unknown> = {
author: owner,
commentary: escapeLinkedInText(message),
visibility: "PUBLIC",
distribution: {
feedDistribution: "MAIN_FEED",
targetEntities: [],
thirdPartyDistributionChannels: [],
},
lifecycleState: "PUBLISHED",
isReshareDisabledByAuthor: false,
};
if (imageUrn) {
payload.content = { media: { id: imageUrn, altText: promo.title.slice(0, 200) } };
}
const res = await fetch("https://api.linkedin.com/rest/posts", {
method: "POST",
headers: { ...linkedinHeaders(token), "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) throw new SocialPublishError(`LinkedIn: ${await readError(res)}`);
const externalId = res.headers.get("x-restli-id");
return {
externalId,
url: externalId ? `https://www.linkedin.com/feed/update/${externalId}/` : null,
};
}
// ---- Point d'entrée ----
export async function publishPromoToNetwork(
network: SocialNetwork,
promo: PromoForSharing
): Promise<PublishResult> {
const message = await buildPromoMessage(promo);
return network === "facebook"
? publishToFacebook(promo, message)
: publishToLinkedIn(promo, message);
}