URL publique du site réglable depuis le backoffice

Connecter un réseau social imposait de poser NEXT_PUBLIC_SITE_URL dans
docker-compose et de redémarrer le conteneur. L'écran Réseaux sociaux porte
désormais le champ, pré-rempli avec l'adresse par laquelle l'administrateur
consulte la page.

- Nouveau réglage `site_public_url` ; `siteUrl()` lit la base puis retombe sur
  les variables d'environnement, donc les déploiements actuels ne bougent pas.
- `siteUrl()`, `redirectUri()`, `authorizeUrl()`, `promoLink()` et
  `buildPromoMessage()` deviennent asynchrones.
- Seule l'origine est conservée : le schéma est ajouté s'il manque, un chemin
  est retiré, et une adresse invalide revient sur l'écran avec un message au
  lieu d'une page d'erreur brute.
- `saveSiteSettings` saute cette clé : le formulaire Paramètres ne la contient
  pas et l'aurait écrasée à chaque enregistrement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
This commit is contained in:
Claude committed 2026-07-25 08:28:04 +00:00
1 parent db361051e6
commit e86c67c17e
9 files changed
+146 -37

No files matched your search

+3
View File
@@ -3,6 +3,9 @@ import { db } from "@/db";
import { siteSettings } from "@/db/schema";
export const SITE_SETTING_DEFAULTS = {
// URL publique de l'application : sert à l'adresse de retour OAuth des réseaux
// sociaux et au lien inséré dans les publications.
site_public_url: "",
association_name: "Plein R",
association_tagline: "Association des commerçants et entreprises du Bassin de Pompey.",
association_intro:
+24 -10
View File
@@ -1,6 +1,6 @@
import { eq } from "drizzle-orm";
import { db } from "@/db";
import { socialAccounts, type SocialAccount, type SocialNetwork } from "@/db/schema";
import { siteSettings, socialAccounts, type SocialAccount, type SocialNetwork } from "@/db/schema";
import { decryptSecret, encryptSecret } from "./crypto";
/**
@@ -30,12 +30,21 @@ function env(key: string): string {
return (process.env[key] ?? "").trim();
}
export function siteUrl(): string {
return (env("NEXT_PUBLIC_SITE_URL") || env("AUTH_URL")).replace(/\/+$/, "");
/**
* URL publique de l'application. Réglée dans Backend › Réseaux sociaux ; les
* variables d'environnement restent acceptées en repli.
*/
export async function siteUrl(): Promise<string> {
const [row] = await db
.select({ value: siteSettings.value })
.from(siteSettings)
.where(eq(siteSettings.key, "site_public_url"));
const stored = (row?.value ?? "").trim();
return (stored || env("NEXT_PUBLIC_SITE_URL") || env("AUTH_URL")).replace(/\/+$/, "");
}
export function redirectUri(network: SocialNetwork): string {
return `${siteUrl()}/api/social/${network}/callback`;
export async function redirectUri(network: SocialNetwork): Promise<string> {
return `${await siteUrl()}/api/social/${network}/callback`;
}
// ---- Lecture ----
@@ -123,11 +132,16 @@ const SCOPES: Record<SocialNetwork, string> = {
linkedin: "w_organization_social r_organization_social rw_organization_admin",
};
export function authorizeUrl(network: SocialNetwork, appId: string, state: string): string {
export async function authorizeUrl(
network: SocialNetwork,
appId: string,
state: string
): Promise<string> {
const redirect = await redirectUri(network);
if (network === "facebook") {
const params = new URLSearchParams({
client_id: appId,
redirect_uri: redirectUri("facebook"),
redirect_uri: redirect,
state,
scope: SCOPES.facebook,
response_type: "code",
@@ -137,7 +151,7 @@ export function authorizeUrl(network: SocialNetwork, appId: string, state: strin
const params = new URLSearchParams({
response_type: "code",
client_id: appId,
redirect_uri: redirectUri("linkedin"),
redirect_uri: redirect,
state,
scope: SCOPES.linkedin,
});
@@ -176,7 +190,7 @@ async function exchangeFacebook(
`${base}/oauth/access_token?${new URLSearchParams({
client_id: appId,
client_secret: appSecret,
redirect_uri: redirectUri("facebook"),
redirect_uri: await redirectUri("facebook"),
code,
})}`
);
@@ -232,7 +246,7 @@ async function exchangeLinkedIn(
code,
client_id: clientId,
client_secret: clientSecret,
redirect_uri: redirectUri("linkedin"),
redirect_uri: await redirectUri("linkedin"),
}),
});
if (!tokenRes.ok) throw new SocialAuthError(`LinkedIn (code) : ${await readError(tokenRes)}`);
+6 -6
View File
@@ -39,20 +39,20 @@ export type PromoForSharing = {
memberName: string | null;
};
export function promoLink(promo: PromoForSharing): string | null {
const base = siteUrl();
export async function promoLink(promo: PromoForSharing): Promise<string | null> {
const base = await siteUrl();
if (!base) return null;
return promo.memberId ? `${base}/adherents/${promo.memberId}` : `${base}/#promotions`;
}
/** Texte du post, commun aux deux réseaux. */
export function buildPromoMessage(promo: PromoForSharing): string {
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());
if (promo.validUntil?.trim()) lines.push("", promo.validUntil.trim());
const link = promoLink(promo);
const link = await promoLink(promo);
if (link) lines.push("", link);
lines.push("", "#PleinR #BassinDePompey #CommerceLocal");
return lines.join("\n");
@@ -133,7 +133,7 @@ async function publishToFacebook(promo: PromoForSharing, message: string): Promi
} else {
endpoint = `${base}/feed`;
body.set("message", message);
const link = promoLink(promo);
const link = await promoLink(promo);
if (link) body.set("link", link);
}
@@ -245,7 +245,7 @@ export async function publishPromoToNetwork(
network: SocialNetwork,
promo: PromoForSharing
): Promise<PublishResult> {
const message = buildPromoMessage(promo);
const message = await buildPromoMessage(promo);
return network === "facebook"
? publishToFacebook(promo, message)
: publishToLinkedIn(promo, message);