mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-24 07:38:04 +02:00
Tags adhérents automatiques : vocabulaire par métier, suggestions et repli
- src/lib/tags.ts (pur) : mots-clés par métier pour les 104 catégories, vocabulaire transversal détecté dans la description (fait maison, bio, livraison, sur rendez-vous, devis gratuit…), suggestTags() qui combine métier, commune et description, autoTags() qui n'intervient qu'à vide. - Enregistrement : addMember, updateMember et updateOwnProfile passent par resolveMemberTags() ; un champ laissé vide reçoit les suggestions, une saisie n'est jamais remplacée. - Formulaires : composant TagsField (champ non contrôlé + pastilles de suggestions recalculées à chaque frappe depuis catégorie, commune et description ; « Tout ajouter »), dans l'espace adhérent et la fiche staff. - Fiche publique : les fiches jamais ré-enregistrées affichent les tags suggérés et les transmettent dans LocalBusiness.keywords. - tests/tags.test.ts : couverture de chaque métier, nettoyage, détection, ordre des suggestions, non-écrasement. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDbKbJrPWweXXYSxA8MXW7
This commit is contained in:
10 files changed
+521
-22
No files matched your search
@@ -11,6 +11,7 @@ import { getSession } from "@/lib/session";
|
||||
import { db } from "@/db";
|
||||
import { CATEGORY_PALETTE } from "@/db/categories";
|
||||
import { slugify } from "@/lib/slug";
|
||||
import { autoTags } from "@/lib/tags";
|
||||
import {
|
||||
activityLog,
|
||||
categories,
|
||||
@@ -401,6 +402,28 @@ export type IssuedCredentials = { email: string; tempPassword: string };
|
||||
|
||||
export type CreatedMemberAccount = IssuedCredentials & { memberId: number };
|
||||
|
||||
/**
|
||||
* Tags à enregistrer : la saisie si elle existe, sinon les suggestions déduites
|
||||
* du métier, de la commune et de la description (`autoTags`).
|
||||
*/
|
||||
async function resolveMemberTags(
|
||||
formTags: FormDataEntryValue | null,
|
||||
input: { categoryId: number | null; city: string | null; description: string | null }
|
||||
): Promise<string | null> {
|
||||
const [category] = input.categoryId
|
||||
? await db
|
||||
.select({ slug: categories.slug, label: categories.label })
|
||||
.from(categories)
|
||||
.where(eq(categories.id, input.categoryId))
|
||||
: [];
|
||||
return autoTags(String(formTags ?? ""), {
|
||||
categorySlug: category?.slug,
|
||||
categoryLabel: category?.label,
|
||||
city: input.city,
|
||||
description: input.description,
|
||||
});
|
||||
}
|
||||
|
||||
export async function addMember(formData: FormData): Promise<CreatedMemberAccount | undefined> {
|
||||
const { role } = await requireRole();
|
||||
if (!can(role, "manageMembers")) throw new Error("Accès refusé");
|
||||
@@ -419,9 +442,10 @@ export async function addMember(formData: FormData): Promise<CreatedMemberAccoun
|
||||
throw new Error("Un compte existe déjà avec cet e-mail.");
|
||||
}
|
||||
|
||||
const tags = await resolveMemberTags(null, { categoryId, city, description: null });
|
||||
const [newMember] = await db
|
||||
.insert(members)
|
||||
.values({ name, email, categoryId, city, status })
|
||||
.values({ name, email, categoryId, city, status, tags })
|
||||
.returning({ id: members.id });
|
||||
|
||||
// Compte de connexion adhérent avec mot de passe temporaire à changer.
|
||||
@@ -521,22 +545,27 @@ export async function updateMember(formData: FormData) {
|
||||
const id = Number(formData.get("id"));
|
||||
if (!id) return;
|
||||
|
||||
const categoryId = formData.get("categoryId") ? Number(formData.get("categoryId")) : null;
|
||||
const city = String(formData.get("city") ?? "").trim() || null;
|
||||
const description = String(formData.get("description") ?? "").trim() || null;
|
||||
const tags = await resolveMemberTags(formData.get("tags"), { categoryId, city, description });
|
||||
|
||||
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,
|
||||
categoryId,
|
||||
city,
|
||||
address: String(formData.get("address") ?? "").trim() || null,
|
||||
description: String(formData.get("description") ?? "").trim() || null,
|
||||
description,
|
||||
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,
|
||||
tags,
|
||||
hours: String(formData.get("hours") ?? "").trim() || null,
|
||||
status: String(formData.get("status") ?? "pending") as "active" | "pending",
|
||||
})
|
||||
@@ -1076,18 +1105,29 @@ export async function updateOwnProfile(formData: FormData) {
|
||||
const name = String(formData.get("name") ?? "").trim();
|
||||
if (!name) return;
|
||||
|
||||
// La catégorie n'est pas modifiable par l'adhérent : on la relit en base pour
|
||||
// en déduire les tags si le champ est laissé vide.
|
||||
const [current] = await db.select({ categoryId: members.categoryId }).from(members).where(eq(members.id, memberId));
|
||||
const city = String(formData.get("city") ?? "").trim() || null;
|
||||
const description = String(formData.get("description") ?? "").trim() || null;
|
||||
const tags = await resolveMemberTags(formData.get("tags"), {
|
||||
categoryId: current?.categoryId ?? null,
|
||||
city,
|
||||
description,
|
||||
});
|
||||
|
||||
await db
|
||||
.update(members)
|
||||
.set({
|
||||
name,
|
||||
description: String(formData.get("description") ?? "").trim() || null,
|
||||
description,
|
||||
address: String(formData.get("address") ?? "").trim() || null,
|
||||
postalCode: String(formData.get("postalCode") ?? "").trim() || null,
|
||||
city: String(formData.get("city") ?? "").trim() || null,
|
||||
city,
|
||||
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,
|
||||
tags,
|
||||
// Images restreintes aux data-URI : un adhérent ne peut pas faire pointer
|
||||
// sa fiche publique vers une ressource externe qu'il contrôle.
|
||||
coverUrl: asImageDataUri(formData, "coverUrl"),
|
||||
|
||||
Reference in new issue
Block a user