Files
PleinR/src/app/backend/actions.ts
T
Claude e86c67c17e 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
2026-07-25 08:28:04 +00:00

1230 lines
45 KiB
TypeScript

"use server";
import { revalidatePath } from "next/cache";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { and, eq, sql } from "drizzle-orm";
import bcrypt from "bcryptjs";
import { auth, signOut } from "@/auth";
import { db } from "@/db";
import {
activityLog,
categories,
contactMessages,
imageConsents,
meetingRegistrations,
meetings,
members,
membershipRequests,
pastMeetingPhotos,
pastMeetings,
promotions,
siteSettings,
socialPosts,
users,
} from "@/db/schema";
import { can, LABEL_TO_ROLE } from "@/lib/rbac";
import {
isNetworkConfigured,
publishPromoToNetwork,
SOCIAL_LABELS,
SOCIAL_NETWORKS,
type SocialNetwork,
} from "@/lib/social";
import {
disconnectAccount,
saveAppCredentials,
selectTarget,
} from "@/lib/social-accounts";
import { normalizeWebsite } from "@/lib/member-profile";
import { SITE_SETTING_DEFAULTS } from "@/lib/site-settings";
import type { AppRole } from "@/types/next-auth";
const CATEGORY_PALETTE = [
{ accent: "#E0A63C", tint: "#f6efdc" },
{ accent: "#6FB0C6", tint: "#e7f0f3" },
{ accent: "#9a6638", tint: "#f4ebda" },
{ accent: "#2C6FB3", tint: "#eaf0f6" },
{ accent: "#5a7a5a", tint: "#eef0ec" },
{ accent: "#7a6f9c", tint: "#efe9f3" },
{ accent: "#c98a2e", tint: "#f7efe0" },
{ accent: "#3f8aa3", tint: "#e6eff2" },
];
function slugify(input: string): string {
return input
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 70);
}
async function logActivity(message: string, dot = "#2C6FB3") {
await db.insert(activityLog).values({ message, dot });
}
// Mot de passe temporaire lisible (sans caractères ambigus).
function generateTempPassword(): string {
const alphabet = "ABCDEFGHJKMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789";
let out = "";
for (let i = 0; i < 10; i++) {
out += alphabet[Math.floor(Math.random() * alphabet.length)];
}
return out;
}
async function requireRole(): Promise<{
role: AppRole;
memberId: number | null;
name: string;
userId: number | null;
}> {
const session = await auth();
if (!session?.user) throw new Error("Non authentifié");
const userId = Number(session.user.id);
return {
role: session.user.role,
memberId: session.user.memberId,
name: session.user.name ?? "Adhérent",
userId: Number.isFinite(userId) ? userId : null,
};
}
function asString(formData: FormData, key: string) {
return String(formData.get(key) ?? "").trim();
}
function asDate(formData: FormData, key: string) {
const raw = asString(formData, key);
const date = raw ? new Date(raw) : null;
if (!date || Number.isNaN(date.getTime())) throw new Error("Date invalide");
return date;
}
function asNullableString(formData: FormData, key: string) {
return asString(formData, key) || null;
}
function revalidatePromoPaths(memberId?: number | null) {
revalidatePath("/backend/promotions");
revalidatePath("/backend/espace");
revalidatePath("/backend");
revalidatePath("/");
revalidatePath("/annuaire");
if (memberId) revalidatePath(`/adherents/${memberId}`);
}
/** 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",
shareLinkedin: formData.get("shareLinkedin") === "on",
};
}
/**
* Publie une promotion sur les réseaux demandés. Ne lève jamais : un réseau
* indisponible ne doit pas faire échouer la mise en ligne, l'échec est
* enregistré dans `social_posts` et rattrapable depuis le backoffice.
*
* Un réseau déjà publié avec succès est ignoré : c'est ce qui empêche toute
* republication, y compris sur un cycle suspension → remise en ligne.
*/
async function publishPromoShares(
promoId: number,
networks: SocialNetwork[],
userId: number | null
) {
if (networks.length === 0) return;
const [promo] = await db
.select({
title: promotions.title,
text: promotions.text,
badge: promotions.badge,
validUntil: promotions.validUntil,
imageUrl: promotions.imageUrl,
memberId: promotions.memberId,
memberName: members.name,
})
.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();
if (!can(role, "moderatePromos")) throw new Error("Accès refusé");
const id = Number(formData.get("id"));
const action = String(formData.get("action"));
if (!id) return;
const [promo] = await db.select().from(promotions).where(eq(promotions.id, id));
if (!promo) return;
if (action === "approve") {
// 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);
await db
.update(promotions)
.set({
status: "live",
...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);
} else if (action === "suspend") {
// Suspension par l'association : la promotion sort du site public et
// l'adhérent ne peut pas la remettre en ligne lui-même.
await db
.update(promotions)
.set({ status: "suspended", suspendedBy: "staff", suspendedById: userId, suspendedAt: new Date() })
.where(eq(promotions.id, id));
await logActivity(
`Promotion « ${promo.title} » suspendue par <strong>${name}</strong>`,
"#d8472b"
);
} else if (action === "restore") {
await db
.update(promotions)
.set({ status: "live", suspendedBy: null, suspendedById: null, suspendedAt: null })
.where(eq(promotions.id, id));
await logActivity(`Promotion « ${promo.title} » remise en ligne`, "#1f8a5b");
} else if (action === "reject" || action === "remove") {
await db.delete(promotions).where(eq(promotions.id, id));
}
revalidatePromoPaths(promo.memberId);
}
// ---- Member space: suspendre / réactiver sa propre promotion ----
export async function setOwnPromoSuspension(formData: FormData) {
const { memberId, userId, name } = await requireRole();
if (!memberId) throw new Error("Aucune fiche adhérent liée à votre compte.");
const id = Number(formData.get("id"));
const action = String(formData.get("action"));
if (!id) return;
const [promo] = await db
.select()
.from(promotions)
.where(and(eq(promotions.id, id), eq(promotions.memberId, memberId)));
if (!promo) throw new Error("Promotion introuvable.");
if (action === "suspend") {
if (promo.status !== "live") return;
await db
.update(promotions)
.set({ status: "suspended", suspendedBy: "member", suspendedById: userId, suspendedAt: new Date() })
.where(eq(promotions.id, id));
await logActivity(`<strong>${name}</strong> a suspendu sa promotion « ${promo.title} »`, "#9a6638");
} else if (action === "restore") {
if (promo.status !== "suspended") return;
// Une suspension décidée par l'association ne se lève que par elle.
if (promo.suspendedBy === "staff") {
throw new Error(
"Cette promotion a été suspendue par l'association : contactez-la pour la remettre en ligne."
);
}
await db
.update(promotions)
.set({ status: "live", suspendedBy: null, suspendedById: null, suspendedAt: null })
.where(eq(promotions.id, id));
await logActivity(`<strong>${name}</strong> a réactivé sa promotion « ${promo.title} »`, "#1f8a5b");
}
revalidatePromoPaths(memberId);
}
// ---- Rattrapage d'une publication en échec ----
// Ne permet pas d'élargir la diffusion : uniquement de retenter un réseau déjà
// choisi avant la validation, et qui n'est pas encore passé.
export async function retryPromoShare(formData: FormData) {
const { role, userId } = await requireRole();
if (!can(role, "publishSocial")) throw new Error("Accès refusé");
const id = Number(formData.get("id"));
const network = String(formData.get("network")) as SocialNetwork;
if (!id || !SOCIAL_NETWORKS.includes(network)) return;
const [promo] = await db.select().from(promotions).where(eq(promotions.id, id));
if (!promo) throw new Error("Promotion introuvable.");
if (promo.status !== "live") {
throw new Error("Seule une promotion en ligne peut être publiée sur les réseaux.");
}
if (!requestedNetworks(promo).includes(network)) {
throw new Error(
`${SOCIAL_LABELS[network]} n'a pas été demandé pour cette promotion : le choix est figé depuis sa validation.`
);
}
await publishPromoShares(id, [network], userId);
revalidatePath("/backend/promotions");
revalidatePath("/backend/espace");
}
// ---- Member space: choisir les réseaux tant que la promo est en attente ----
export async function setOwnPromoShareTargets(formData: FormData) {
const { memberId } = await requireRole();
if (!memberId) throw new Error("Aucune fiche adhérent liée à votre compte.");
const id = Number(formData.get("id"));
if (!id) return;
const [promo] = await db
.select({ status: promotions.status })
.from(promotions)
.where(and(eq(promotions.id, id), eq(promotions.memberId, memberId)));
if (!promo) throw new Error("Promotion introuvable.");
if (promo.status !== "pending") {
throw new Error("La diffusion n'est plus modifiable une fois la promotion validée.");
}
await db.update(promotions).set(readShareTargets(formData)).where(eq(promotions.id, id));
revalidatePath("/backend/espace");
revalidatePath("/backend/promotions");
}
// ---- Member space: publish a promotion ----
export async function publishPromo(formData: FormData) {
const { memberId, name } = await requireRole();
const title = String(formData.get("title") ?? "").trim();
if (!title) return;
const text = String(formData.get("text") ?? "").slice(0, 240);
const category = String(formData.get("category") ?? "");
const badge = String(formData.get("badge") ?? "").trim() || null;
const imageUrl = String(formData.get("imageUrl") ?? "") || null;
// Réseaux souhaités : rien n'est publié ici, la diffusion attend la validation.
await db.insert(promotions).values({
title,
text,
category,
badge,
imageUrl,
memberId: memberId ?? null,
status: "pending",
...readShareTargets(formData),
});
await logActivity(`<strong>${name}</strong> a soumis une promotion « ${title} »`, "#E0A63C");
revalidatePath("/backend/espace");
revalidatePath("/backend");
revalidatePath("/backend/promotions");
}
// ---- Members CRUD ----
export async function addMember(formData: FormData): Promise<number | undefined> {
const { role } = await requireRole();
if (!can(role, "manageMembers")) throw new Error("Accès refusé");
const name = String(formData.get("name") ?? "").trim();
if (!name) return undefined;
const email = String(formData.get("email") ?? "").trim().toLowerCase();
if (!email) throw new Error("L'e-mail est requis pour créer le compte de l'adhérent.");
const categoryId = formData.get("categoryId") ? Number(formData.get("categoryId")) : null;
const city = String(formData.get("city") ?? "").trim() || null;
const status = (String(formData.get("status") ?? "pending") as "active" | "pending");
// Un seul compte par e-mail.
const existing = await db.select({ id: users.id }).from(users).where(eq(users.email, email));
if (existing.length > 0) {
throw new Error("Un compte existe déjà avec cet e-mail.");
}
const [newMember] = await db
.insert(members)
.values({ name, email, categoryId, city, status })
.returning({ id: members.id });
// Compte de connexion adhérent avec mot de passe temporaire à changer.
const tempPassword = generateTempPassword();
await db.insert(users).values({
name,
email,
role: "member",
memberId: newMember.id,
passwordHash: await bcrypt.hash(tempPassword, 10),
tempPassword,
mustChangePassword: true,
});
await logActivity(`Nouvel adhérent ajouté : <strong>${name}</strong>`, "#2C6FB3");
revalidatePath("/backend/adherents");
revalidatePath("/backend");
revalidatePath("/");
return newMember.id;
}
// Crée des comptes de connexion pour les adhérents existants qui n'en ont pas
// encore (ceux ajoutés avant l'arrivée des comptes adhérent). Chaque compte
// reçoit un mot de passe temporaire à changer à la première connexion.
export async function createMissingMemberAccounts() {
const { role } = await requireRole();
if (!can(role, "manageMembers")) throw new Error("Accès refusé");
const allMembers = await db
.select({ id: members.id, name: members.name, email: members.email })
.from(members);
const allUsers = await db
.select({ email: users.email, memberId: users.memberId })
.from(users);
const takenEmails = new Set(allUsers.map((u) => u.email.toLowerCase()));
const linkedMemberIds = new Set(allUsers.map((u) => u.memberId).filter((x): x is number => x != null));
let created = 0;
for (const m of allMembers) {
if (linkedMemberIds.has(m.id)) continue;
const email = (m.email ?? "").trim().toLowerCase();
if (!email || takenEmails.has(email)) continue; // pas d'e-mail ou déjà pris : on saute
const tempPassword = generateTempPassword();
await db.insert(users).values({
name: m.name,
email,
role: "member",
memberId: m.id,
passwordHash: await bcrypt.hash(tempPassword, 10),
tempPassword,
mustChangePassword: true,
});
takenEmails.add(email);
created++;
}
if (created > 0) {
await logActivity(`${created} compte(s) adhérent créé(s) pour les fiches existantes`, "#2C6FB3");
}
revalidatePath("/backend/adherents");
}
// Réinitialise le mot de passe d'un adhérent : nouveau mot de passe temporaire
// visible par l'admin jusqu'à la prochaine connexion de l'adhérent.
export async function resetMemberPassword(formData: FormData) {
const { role } = await requireRole();
if (!can(role, "manageMembers")) throw new Error("Accès refusé");
const memberId = Number(formData.get("memberId"));
if (!memberId) return;
const [u] = await db.select().from(users).where(eq(users.memberId, memberId));
if (!u) throw new Error("Aucun compte de connexion lié à cet adhérent.");
const tempPassword = generateTempPassword();
await db
.update(users)
.set({
passwordHash: await bcrypt.hash(tempPassword, 10),
tempPassword,
mustChangePassword: true,
})
.where(eq(users.id, u.id));
revalidatePath(`/backend/adherents/${memberId}`);
}
export async function updateMember(formData: FormData) {
const { role } = await requireRole();
if (!can(role, "manageMembers")) throw new Error("Accès refusé");
const id = Number(formData.get("id"));
if (!id) return;
await db
.update(members)
.set({
name: String(formData.get("name") ?? "").trim(),
email: String(formData.get("email") ?? "").trim(),
categoryId: formData.get("categoryId") ? Number(formData.get("categoryId")) : null,
city: String(formData.get("city") ?? "").trim() || null,
address: String(formData.get("address") ?? "").trim() || null,
description: String(formData.get("description") ?? "").trim() || null,
postalCode: String(formData.get("postalCode") ?? "").trim() || null,
phone: String(formData.get("phone") ?? "").trim() || null,
website: normalizeWebsite(String(formData.get("website") ?? "")),
memberSince: formData.get("memberSince") ? Number(formData.get("memberSince")) : null,
coverUrl: String(formData.get("coverUrl") ?? "").trim() || null,
logoUrl: String(formData.get("logoUrl") ?? "").trim() || null,
tags: String(formData.get("tags") ?? "").trim() || null,
hours: String(formData.get("hours") ?? "").trim() || null,
status: String(formData.get("status") ?? "pending") as "active" | "pending",
})
.where(eq(members.id, id));
revalidatePath("/backend/adherents");
revalidatePath(`/adherents/${id}`);
revalidatePath("/");
}
export async function deleteMember(formData: FormData) {
const { role } = await requireRole();
if (!can(role, "manageMembers")) throw new Error("Accès refusé");
const id = Number(formData.get("id"));
if (!id) return;
// Remove dependent rows first to satisfy foreign-key constraints:
// delete the member's promotions and detach any linked user account.
await db.transaction(async (tx) => {
await tx.delete(promotions).where(eq(promotions.memberId, id));
await tx.update(users).set({ memberId: null }).where(eq(users.memberId, id));
await tx.delete(members).where(eq(members.id, id));
});
revalidatePath("/backend/adherents");
revalidatePath("/");
}
// ---- Admins ----
export async function inviteAdmin(formData: FormData) {
const { role } = await requireRole();
if (!can(role, "manageAdmins")) throw new Error("Accès refusé");
const name = String(formData.get("name") ?? "").trim();
if (!name) return;
const email = String(formData.get("email") ?? "").trim().toLowerCase();
if (!email) return;
const roleLabel = String(formData.get("role") ?? "Administrateur");
const newRole: AppRole = LABEL_TO_ROLE[roleLabel] ?? "editor";
const existing = await db.select().from(users).where(eq(users.email, email));
if (existing.length > 0) return;
// Temporary password — the invitee resets it on first login (out of scope here).
const tempPassword = Math.random().toString(36).slice(2, 12);
await db.insert(users).values({
name,
email,
role: newRole,
passwordHash: await bcrypt.hash(tempPassword, 10),
});
await logActivity(`<strong>${name}</strong> a été invité comme ${roleLabel}`, "#2C6FB3");
revalidatePath("/backend/administrateurs");
}
export async function removeAdmin(formData: FormData) {
const { role } = await requireRole();
if (!can(role, "manageAdmins")) throw new Error("Accès refusé");
const id = Number(formData.get("id"));
if (!id) return;
await db.delete(users).where(eq(users.id, id));
revalidatePath("/backend/administrateurs");
}
// ---- Categories ----
export async function addCategory(formData: FormData) {
const { role } = await requireRole();
if (!can(role, "manageCategories")) throw new Error("Accès refusé");
const label = String(formData.get("label") ?? "").trim();
if (!label) return;
let base = slugify(label) || "categorie";
let slug = base;
let n = 2;
while ((await db.select({ id: categories.id }).from(categories).where(eq(categories.slug, slug))).length > 0) {
slug = `${base}-${n++}`;
}
const existing = await db.select({ id: categories.id }).from(categories);
const palette = CATEGORY_PALETTE[existing.length % CATEGORY_PALETTE.length];
const [{ max } = { max: 0 }] = await db
.select({ max: sql<number>`coalesce(max(${categories.sort}), 0)` })
.from(categories);
await db.insert(categories).values({
slug,
label,
accent: palette.accent,
tint: palette.tint,
sort: Number(max) + 1,
});
revalidatePath("/backend/categories");
revalidatePath("/annuaire");
}
export async function renameCategory(formData: FormData) {
const { role } = await requireRole();
if (!can(role, "manageCategories")) throw new Error("Accès refusé");
const id = Number(formData.get("id"));
const label = String(formData.get("label") ?? "").trim();
if (!id || !label) return;
await db.update(categories).set({ label }).where(eq(categories.id, id));
revalidatePath("/backend/categories");
revalidatePath("/annuaire");
}
export async function deleteCategory(formData: FormData) {
const { role } = await requireRole();
if (!can(role, "manageCategories")) throw new Error("Accès refusé");
const id = Number(formData.get("id"));
if (!id) return;
// Detach members from this category, then delete it (avoids FK violation).
await db.transaction(async (tx) => {
await tx.update(members).set({ categoryId: null }).where(eq(members.categoryId, id));
await tx.delete(categories).where(eq(categories.id, id));
});
revalidatePath("/backend/categories");
revalidatePath("/backend/adherents");
revalidatePath("/annuaire");
}
// ---- Rencontres ----
export async function createMeeting(formData: FormData) {
const { role } = await requireRole();
if (!can(role, "manageMeetings")) throw new Error("Accès refusé");
const title = asString(formData, "title");
if (!title) return;
await db.insert(meetings).values({
title,
startsAt: asDate(formData, "startsAt"),
location: asNullableString(formData, "location"),
description: asNullableString(formData, "description"),
capacity: Math.min(100000, Math.max(1, Number(formData.get("capacity") ?? 30) || 30)),
participantsPerAccount: Math.min(100, Math.max(1, Number(formData.get("participantsPerAccount") ?? 1) || 1)),
imageUrl: asNullableString(formData, "imageUrl"),
});
await logActivity(`Rencontre ajoutée : <strong>${title}</strong>`, "#2C6FB3");
revalidatePath("/backend/rencontres");
revalidatePath("/backend/inscriptions");
revalidatePath("/association");
}
export async function updateMeeting(formData: FormData) {
const { role } = await requireRole();
if (!can(role, "manageMeetings")) throw new Error("Accès refusé");
const id = Number(formData.get("id"));
const title = asString(formData, "title");
if (!id || !title) return;
await db
.update(meetings)
.set({
title,
startsAt: asDate(formData, "startsAt"),
location: asNullableString(formData, "location"),
description: asNullableString(formData, "description"),
capacity: Math.min(100000, Math.max(1, Number(formData.get("capacity") ?? 30) || 30)),
participantsPerAccount: Math.min(100, Math.max(1, Number(formData.get("participantsPerAccount") ?? 1) || 1)),
imageUrl: asNullableString(formData, "imageUrl"),
})
.where(eq(meetings.id, id));
revalidatePath("/backend/rencontres");
revalidatePath("/backend/inscriptions");
revalidatePath("/association");
}
export async function deleteMeeting(formData: FormData) {
const { role } = await requireRole();
if (!can(role, "manageMeetings")) throw new Error("Accès refusé");
const id = Number(formData.get("id"));
if (!id) return;
await db.delete(meetings).where(eq(meetings.id, id));
revalidatePath("/backend/rencontres");
revalidatePath("/backend/inscriptions");
revalidatePath("/association");
}
export type MeetingRegistrationState = {
status: "idle" | "success" | "error";
message: string;
registrationCount?: number;
};
export async function saveMeetingRegistration(
_previousState: MeetingRegistrationState,
formData: FormData,
): Promise<MeetingRegistrationState> {
const session = await auth();
if (!session?.user?.memberId) {
return { status: "error", message: "Connectez-vous avec un compte adhérent pour vous inscrire." };
}
const memberId = session.user.memberId;
const meetingId = Number(formData.get("meetingId"));
if (!meetingId) return { status: "error", message: "Rencontre introuvable." };
try {
if (asString(formData, "intent") === "cancel") {
await db
.delete(meetingRegistrations)
.where(and(eq(meetingRegistrations.meetingId, meetingId), eq(meetingRegistrations.memberId, memberId)));
revalidateMeetingPaths(meetingId);
return { status: "success", message: "Votre inscription a été annulée.", registrationCount: 0 };
}
const participantCount = Number(formData.get("participantCount"));
if (!Number.isInteger(participantCount) || participantCount < 1 || participantCount > 100) {
return { status: "error", message: "Ajoutez au moins un participant." };
}
const participants = Array.from({ length: participantCount }, (_, index) => ({
name: asString(formData, `participantName-${index}`),
imageConsent: formData.get(`imageConsent-${index}`) === "on",
}));
if (participants.some((participant) => !participant.name || participant.name.length > 200)) {
return { status: "error", message: "Renseignez le nom et le prénom de chaque participant." };
}
const normalizedNames = participants.map((participant) => participant.name.toLocaleLowerCase("fr-FR"));
if (new Set(normalizedNames).size !== normalizedNames.length) {
return { status: "error", message: "Chaque participant doit avoir un nom différent." };
}
if (formData.get("attestation") !== "on") {
return { status: "error", message: "Vous devez confirmer avoir recueilli le choix de chaque participant." };
}
await db.transaction(async (tx) => {
await tx.execute(sql`select ${meetings.id} from ${meetings} where ${meetings.id} = ${meetingId} for update`);
const [meeting] = await tx.select().from(meetings).where(eq(meetings.id, meetingId));
if (!meeting) throw new Error("Rencontre introuvable.");
if (meeting.startsAt.getTime() < Date.now()) throw new Error("Cette rencontre est passée.");
if (participants.length > meeting.participantsPerAccount) {
throw new Error(`Cette rencontre autorise ${meeting.participantsPerAccount} participant(s) maximum par compte.`);
}
const [member] = await tx
.select({ name: members.name, email: members.email, phone: members.phone, status: members.status })
.from(members)
.where(eq(members.id, memberId));
if (!member || member.status !== "active") {
throw new Error("Votre adhésion doit être active pour vous inscrire.");
}
const [{ total } = { total: 0 }] = await tx
.select({ total: sql<number>`count(*)` })
.from(meetingRegistrations)
.where(
and(
eq(meetingRegistrations.meetingId, meetingId),
sql`${meetingRegistrations.memberId} is distinct from ${memberId}`,
),
);
if (Number(total) + participants.length > meeting.capacity) {
throw new Error("Il ne reste pas assez de places pour tous les participants.");
}
await tx
.delete(meetingRegistrations)
.where(and(eq(meetingRegistrations.meetingId, meetingId), eq(meetingRegistrations.memberId, memberId)));
await tx.insert(meetingRegistrations).values(
participants.map((participant) => ({
meetingId,
memberId,
attendeeName: participant.name,
attendeeCompany: member.name,
attendeeEmail: member.email ?? session.user.email ?? null,
attendeePhone: member.phone,
status: "pending",
imageConsent: participant.imageConsent,
})),
);
});
revalidateMeetingPaths(meetingId);
return {
status: "success",
message: `Inscription enregistrée pour ${participants.length} participant${participants.length > 1 ? "s" : ""}.`,
registrationCount: participants.length,
};
} catch (error) {
return { status: "error", message: error instanceof Error ? error.message : "Inscription impossible." };
}
}
function revalidateMeetingPaths(meetingId?: number) {
revalidatePath("/association");
revalidatePath("/backend/rencontres");
revalidatePath("/backend/inscriptions");
revalidatePath("/backend/rencontres-passees");
revalidatePath("/backend/espace");
revalidatePath("/rencontres-passees");
if (meetingId) revalidatePath(`/inscription/${meetingId}`);
}
export async function updateMeetingRegistration(formData: FormData) {
const { role } = await requireRole();
if (!can(role, "manageMeetings")) throw new Error("Accès refusé");
const id = Number(formData.get("id"));
const attendeeName = asString(formData, "attendeeName");
const status = asString(formData, "status");
if (!id || !attendeeName || !["pending", "confirmed"].includes(status)) return;
const [registration] = await db
.update(meetingRegistrations)
.set({
attendeeName,
attendeeCompany: asString(formData, "attendeeCompany"),
attendeeEmail: asNullableString(formData, "attendeeEmail"),
attendeePhone: asNullableString(formData, "attendeePhone"),
status,
imageConsent: formData.get("imageConsent") === "on",
})
.where(eq(meetingRegistrations.id, id))
.returning({ meetingId: meetingRegistrations.meetingId });
revalidateMeetingPaths(registration?.meetingId);
}
export async function confirmMeetingRegistration(formData: FormData) {
const { role } = await requireRole();
if (!can(role, "manageMeetings")) throw new Error("Accès refusé");
const id = Number(formData.get("id"));
if (!id) return;
const [registration] = await db
.update(meetingRegistrations)
.set({ status: "confirmed" })
.where(eq(meetingRegistrations.id, id))
.returning({ meetingId: meetingRegistrations.meetingId });
revalidateMeetingPaths(registration?.meetingId);
}
export async function deleteMeetingRegistration(formData: FormData) {
const { role } = await requireRole();
if (!can(role, "manageMeetings")) throw new Error("Accès refusé");
const id = Number(formData.get("id"));
if (!id) return;
const [registration] = await db
.delete(meetingRegistrations)
.where(eq(meetingRegistrations.id, id))
.returning({ meetingId: meetingRegistrations.meetingId });
revalidateMeetingPaths(registration?.meetingId);
}
export async function createPastMeeting(formData: FormData) {
const { role } = await requireRole();
if (!can(role, "manageMeetings")) throw new Error("Accès refusé");
const title = asString(formData, "title");
if (!title) return;
await db.insert(pastMeetings).values({
title,
eventDate: asDate(formData, "eventDate"),
location: asNullableString(formData, "location"),
description: asNullableString(formData, "description"),
participants: asNullableString(formData, "participants"),
meetingId: formData.get("meetingId") ? Number(formData.get("meetingId")) : null,
});
revalidatePath("/backend/rencontres");
revalidatePath("/association");
revalidatePath("/backend/rencontres-passees");
revalidatePath("/rencontres-passees");
}
export async function updatePastMeeting(formData: FormData) {
const { role } = await requireRole();
if (!can(role, "manageMeetings")) throw new Error("Accès refusé");
const id = Number(formData.get("id"));
const title = asString(formData, "title");
if (!id || !title) return;
await db
.update(pastMeetings)
.set({
title,
eventDate: asDate(formData, "eventDate"),
location: asNullableString(formData, "location"),
description: asNullableString(formData, "description"),
participants: asNullableString(formData, "participants"),
meetingId: formData.get("meetingId") ? Number(formData.get("meetingId")) : null,
})
.where(eq(pastMeetings.id, id));
revalidatePath("/backend/rencontres");
revalidatePath("/association");
revalidatePath("/backend/rencontres-passees");
revalidatePath("/rencontres-passees");
}
export async function deletePastMeeting(formData: FormData) {
const { role } = await requireRole();
if (!can(role, "manageMeetings")) throw new Error("Accès refusé");
const id = Number(formData.get("id"));
if (!id) return;
await db.delete(pastMeetings).where(eq(pastMeetings.id, id));
revalidatePath("/backend/rencontres");
revalidatePath("/association");
revalidatePath("/backend/rencontres-passees");
revalidatePath("/rencontres-passees");
}
export async function addPastMeetingPhoto(formData: FormData) {
const { role } = await requireRole();
if (!can(role, "manageMeetings")) throw new Error("Accès refusé");
const pastMeetingId = Number(formData.get("pastMeetingId"));
const imageUrl = asString(formData, "imageUrl");
if (!pastMeetingId || !imageUrl) return;
const [{ max } = { max: 0 }] = await db
.select({ max: sql<number>`coalesce(max(${pastMeetingPhotos.position}), 0)` })
.from(pastMeetingPhotos)
.where(eq(pastMeetingPhotos.pastMeetingId, pastMeetingId));
await db.insert(pastMeetingPhotos).values({
pastMeetingId,
imageUrl,
caption: asNullableString(formData, "caption"),
position: Number(max) + 1,
});
revalidatePath("/backend/rencontres");
revalidatePath("/association");
revalidatePath("/backend/rencontres-passees");
revalidatePath("/rencontres-passees");
}
export async function deletePastMeetingPhoto(formData: FormData) {
const { role } = await requireRole();
if (!can(role, "manageMeetings")) throw new Error("Accès refusé");
const id = Number(formData.get("id"));
if (!id) return;
await db.delete(pastMeetingPhotos).where(eq(pastMeetingPhotos.id, id));
revalidatePath("/backend/rencontres");
revalidatePath("/association");
revalidatePath("/backend/rencontres-passees");
revalidatePath("/rencontres-passees");
}
// ---- Paramètres du site ----
export async function saveSiteSettings(formData: FormData) {
const { role } = await requireRole();
if (!can(role, "manageSettings")) throw new Error("Accès refusé");
const updatedAt = new Date();
for (const key of Object.keys(SITE_SETTING_DEFAULTS)) {
// Réglée sur l'écran Réseaux sociaux : ce formulaire ne la contient pas et
// l'écraserait avec une chaîne vide.
if (key === "site_public_url") continue;
await db
.insert(siteSettings)
.values({ key, value: asString(formData, key), updatedAt })
.onConflictDoUpdate({
target: siteSettings.key,
set: { value: asString(formData, key), updatedAt },
});
}
revalidatePath("/backend/parametres");
revalidatePath("/association");
revalidatePath("/mentions-legales");
revalidatePath("/confidentialite");
revalidatePath("/");
}
// ---- RGPD / droit à l'image ----
export async function saveImageConsent(formData: FormData) {
const session = await auth();
if (!session?.user?.memberId) throw new Error("Compte adhérent requis");
const decision = asString(formData, "decision");
if (!["accepted", "refused"].includes(decision)) throw new Error("Décision invalide");
const signatoryName = asString(formData, "signatoryName") || session.user.name || "Adhérent";
const signaturePng = asString(formData, "signaturePng");
if (decision === "accepted" && !signaturePng) {
throw new Error("Signature requise pour autoriser le droit à l'image");
}
const h = await headers();
await db.insert(imageConsents).values({
memberId: session.user.memberId,
decision,
scopes: decision === "accepted" ? "site,social,print" : "",
signatoryName,
signaturePng: decision === "accepted" ? signaturePng : null,
consentVersion: "2026-07-v1",
ip: h.get("x-forwarded-for")?.split(",")[0]?.trim() ?? null,
userAgent: h.get("user-agent") ?? null,
});
revalidatePath("/backend");
revalidatePath("/backend/espace");
redirect("/backend/espace");
}
// ---- Self password change (forced at first login) ----
export async function changeOwnPassword(formData: FormData) {
const session = await auth();
if (!session?.user) throw new Error("Non authentifié");
const userId = Number(session.user.id);
const password = String(formData.get("password") ?? "");
const confirm = String(formData.get("confirm") ?? "");
if (password.length < 8) throw new Error("Le mot de passe doit faire au moins 8 caractères.");
if (password !== confirm) throw new Error("Les deux mots de passe ne correspondent pas.");
await db
.update(users)
.set({
passwordHash: await bcrypt.hash(password, 10),
tempPassword: null,
mustChangePassword: false,
})
.where(eq(users.id, userId));
// Force une nouvelle session (jeton sans le drapeau « mot de passe à changer »).
await signOut({ redirectTo: "/login" });
}
// ---- Member: edit own profile ----
export async function updateOwnProfile(formData: FormData) {
const session = await auth();
if (!session?.user) throw new Error("Non authentifié");
const memberId = session.user.memberId;
if (!memberId) throw new Error("Aucune fiche adhérent liée à votre compte.");
const name = String(formData.get("name") ?? "").trim();
if (!name) return;
await db
.update(members)
.set({
name,
description: String(formData.get("description") ?? "").trim() || null,
address: String(formData.get("address") ?? "").trim() || null,
postalCode: String(formData.get("postalCode") ?? "").trim() || null,
city: String(formData.get("city") ?? "").trim() || null,
phone: String(formData.get("phone") ?? "").trim() || null,
website: normalizeWebsite(String(formData.get("website") ?? "")),
hours: String(formData.get("hours") ?? "").trim() || null,
tags: String(formData.get("tags") ?? "").trim() || null,
coverUrl: String(formData.get("coverUrl") ?? "").trim() || null,
logoUrl: String(formData.get("logoUrl") ?? "").trim() || null,
})
// Sécurité : on ne touche que SA propre fiche, jamais statut/catégorie/mise à l'honneur.
.where(eq(members.id, memberId));
revalidatePath("/backend/espace");
revalidatePath(`/adherents/${memberId}`);
revalidatePath("/annuaire");
revalidatePath("/");
}
// Approuve une demande d'adhésion ET crée directement l'adhérent + son compte
// de connexion (mot de passe temporaire). Renvoie l'id du nouvel adhérent.
export async function approveMembershipRequest(formData: FormData): Promise<number | undefined> {
const { role } = await requireRole();
if (!can(role, "manageMembers")) throw new Error("Accès refusé");
const id = Number(formData.get("id"));
if (!id) return undefined;
const [req] = await db.select().from(membershipRequests).where(eq(membershipRequests.id, id));
if (!req) throw new Error("Demande introuvable.");
const email = (req.email ?? "").trim().toLowerCase();
if (!email) {
throw new Error("Cette demande n'a pas d'e-mail : impossible de créer le compte. Créez l'adhérent manuellement.");
}
const existing = await db.select({ id: users.id }).from(users).where(eq(users.email, email));
if (existing.length > 0) {
throw new Error("Un compte existe déjà avec cet e-mail.");
}
const [newMember] = await db
.insert(members)
.values({ name: req.name, email, status: "active" })
.returning({ id: members.id });
const tempPassword = generateTempPassword();
await db.insert(users).values({
name: req.name,
email,
role: "member",
memberId: newMember.id,
passwordHash: await bcrypt.hash(tempPassword, 10),
tempPassword,
mustChangePassword: true,
});
await db.update(membershipRequests).set({ status: "approved" }).where(eq(membershipRequests.id, id));
await logActivity(`Demande approuvée : adhérent <strong>${req.name}</strong> créé`, "#1f8a5b");
revalidatePath("/backend/demandes");
revalidatePath("/backend/adherents");
revalidatePath("/backend");
revalidatePath("/");
return newMember.id;
}
// ---- Inbox: membership requests + contact messages ----
export async function setRequestStatus(formData: FormData) {
const { role } = await requireRole();
if (!can(role, "manageMembers")) throw new Error("Accès refusé");
const id = Number(formData.get("id"));
const status = String(formData.get("status") ?? "");
if (!id || !["new", "approved", "rejected"].includes(status)) return;
await db
.update(membershipRequests)
.set({ status: status as "new" | "approved" | "rejected" })
.where(eq(membershipRequests.id, id));
revalidatePath("/backend/demandes");
revalidatePath("/backend");
}
export async function setContactStatus(formData: FormData) {
const { role } = await requireRole();
if (!can(role, "manageMembers")) throw new Error("Accès refusé");
const id = Number(formData.get("id"));
const status = String(formData.get("status") ?? "");
if (!id || !["new", "read", "archived"].includes(status)) return;
await db
.update(contactMessages)
.set({ status: status as "new" | "read" | "archived" })
.where(eq(contactMessages.id, id));
revalidatePath("/backend/demandes");
revalidatePath("/backend");
}
// ---- URL publique de l'application ----
// Sert à l'adresse de retour OAuth et au lien inséré dans les publications.
export async function saveSitePublicUrl(formData: FormData) {
const { role } = await requireRole();
if (!can(role, "manageSettings")) throw new Error("Accès refusé");
const raw = asString(formData, "sitePublicUrl");
let value = "";
if (raw) {
// Le schéma est facultatif à la saisie ; seul l'origine est conservée, un
// chemin ou des paramètres casseraient l'adresse de retour OAuth.
const candidate = /^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `https://${raw}`;
let parsed: URL | null = null;
try {
parsed = new URL(candidate);
} catch {
parsed = null;
}
if (!parsed || !["http:", "https:"].includes(parsed.protocol) || !parsed.hostname) {
// Message rendu par le bandeau de l'écran, plutôt qu'une page d'erreur brute.
redirect(
`/backend/reseaux?error=${encodeURIComponent(
"Adresse invalide : indiquez par exemple https://pleinr.example.fr"
)}`
);
}
value = parsed.origin;
}
const updatedAt = new Date();
await db
.insert(siteSettings)
.values({ key: "site_public_url", value, updatedAt })
.onConflictDoUpdate({ target: siteSettings.key, set: { value, updatedAt } });
revalidatePath("/backend/reseaux");
revalidatePath("/backend/parametres");
}
// ---- Réseaux sociaux : identifiants d'application et connexion ----
export async function saveSocialApp(formData: FormData) {
const { role } = await requireRole();
if (!can(role, "manageSettings")) throw new Error("Accès refusé");
const network = String(formData.get("network")) as SocialNetwork;
if (!SOCIAL_NETWORKS.includes(network)) return;
const appId = asString(formData, "appId");
if (!appId) throw new Error("L'identifiant de l'application est requis.");
// Champ secret laissé vide = on garde celui déjà enregistré.
const appSecret = asString(formData, "appSecret") || null;
await saveAppCredentials(network, appId, appSecret);
revalidatePath("/backend/reseaux");
}
export async function selectSocialTarget(formData: FormData) {
const { role } = await requireRole();
if (!can(role, "manageSettings")) throw new Error("Accès refusé");
const network = String(formData.get("network")) as SocialNetwork;
const targetId = asString(formData, "targetId");
if (!SOCIAL_NETWORKS.includes(network) || !targetId) return;
await selectTarget(network, targetId);
await logActivity(`Page ${SOCIAL_LABELS[network]} sélectionnée pour la publication`, "#2C6FB3");
revalidatePath("/backend/reseaux");
revalidatePath("/backend/promotions");
}
export async function disconnectSocial(formData: FormData) {
const { role, name } = await requireRole();
if (!can(role, "manageSettings")) throw new Error("Accès refusé");
const network = String(formData.get("network")) as SocialNetwork;
if (!SOCIAL_NETWORKS.includes(network)) return;
await disconnectAccount(network);
await logActivity(
`Compte ${SOCIAL_LABELS[network]} déconnecté par <strong>${name}</strong>`,
"#d8472b"
);
revalidatePath("/backend/reseaux");
revalidatePath("/backend/promotions");
revalidatePath("/backend/espace");
}
// ---- Sign out ----
export async function doSignOut() {
await signOut({ redirectTo: "/" });
}