mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-14 10:49:35 +02:00
Affiche les coordonnées du référent aux visiteurs connectés
L'annuaire, les pages métier et la fiche adhérent montrent désormais la personne à joindre chez l'adhérent — nom, prénom, ligne directe — mais uniquement à un visiteur connecté. Trois colonnes sur `members` (`contact_first_name`, `contact_last_name`, `contact_phone`), saisies dans les deux formulaires de fiche via le composant partagé `MemberContactFields`. Le filtrage ne repose pas sur du CSS ni sur un rendu conditionnel tardif : aucune requête publique ne lit ces colonnes. Seules `getMemberContacts()` et `getMemberContact()` les rapatrient, et les pages ne les appellent qu'après un `getSession()` positif. Un visiteur anonyme ne reçoit donc rien — ni dans le HTML, ni dans le JSON-LD, ni dans les props du composant client de l'annuaire. `tests/security.test.ts` verrouille cette séparation. `src/lib/member-contact.ts` reste pur (composition du nom, lien `tel:`) et est couvert par `tests/member-contact.test.ts`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012TAK3c4jAUqxVMzy746wWQ
This commit is contained in:
20 files changed
+1854
-7
No files matched your search
@@ -248,6 +248,18 @@ même raison.
|
||||
(`src/lib/promo-categories.ts`, groupes pour `<optgroup>`), pas le métier de
|
||||
l'adhérent ; `defaultPromoCategory(slug)` pré-sélectionne depuis le métier.
|
||||
|
||||
- **Référent** : `members.contact_first_name` / `contact_last_name` /
|
||||
`contact_phone` portent la personne à joindre et sa ligne directe. Ils sont
|
||||
**réservés aux visiteurs connectés** : aucune requête publique ne les lit
|
||||
(`tests/security.test.ts` le verrouille), seules `getMemberContacts()` /
|
||||
`getMemberContact()` les rapatrient, et uniquement après un `getSession()`
|
||||
positif. L'annuaire, les pages métier et la fiche affichent alors un bloc
|
||||
« Contact adhérent » ; hors session la requête n'est pas faite, donc rien
|
||||
n'est masqué en CSS, rien ne part dans le HTML ni dans le JSON-LD.
|
||||
`src/lib/member-contact.ts` est **pur** (`tests/member-contact.test.ts`) :
|
||||
`memberContact()` compose « Prénom Nom » et renvoie `null` s'il n'y a rien à
|
||||
montrer, `telHref()` fabrique le lien `tel:`. Les deux formulaires de fiche
|
||||
(espace adhérent et écran staff) partagent `MemberContactFields`.
|
||||
- `members.email` est l'e-mail **administratif** (identifiant de connexion à la
|
||||
création, échanges avec l'association) ; `members.contact_email` est l'e-mail
|
||||
**public** de la fiche, saisi par l'adhérent ou le staff. La fiche et le
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE "members" ADD COLUMN "contact_first_name" varchar(120);--> statement-breakpoint
|
||||
ALTER TABLE "members" ADD COLUMN "contact_last_name" varchar(120);--> statement-breakpoint
|
||||
ALTER TABLE "members" ADD COLUMN "contact_phone" varchar(40);
|
||||
File diff suppressed because it is too large.
Load diff
@@ -120,6 +120,13 @@
|
||||
"when": 1788692576315,
|
||||
"tag": "0016_tiresome_korg",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 17,
|
||||
"version": "7",
|
||||
"when": 1789385157231,
|
||||
"tag": "0017_regular_devos",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -7,7 +7,9 @@ import { SiteFooter } from "@/components/SiteFooter";
|
||||
import { VitrineImage } from "@/components/VitrineImage";
|
||||
import { PromoImage } from "@/components/PromoImage";
|
||||
import { JsonLd } from "@/components/JsonLd";
|
||||
import { getMemberLivePromotions, getPublicMember } from "@/lib/queries";
|
||||
import { getMemberContact, getMemberLivePromotions, getPublicMember } from "@/lib/queries";
|
||||
import { getSession } from "@/lib/session";
|
||||
import { telHref } from "@/lib/member-contact";
|
||||
import {
|
||||
NOINDEX,
|
||||
breadcrumbJsonLd,
|
||||
@@ -144,7 +146,16 @@ export default async function FicheAdherentPage({
|
||||
const canonical = memberPath(member);
|
||||
if (`/adherents/${id}` !== canonical) permanentRedirect(canonical);
|
||||
|
||||
const [promos, baseUrl] = await Promise.all([getMemberLivePromotions(memberId), publicBaseUrl()]);
|
||||
const [promos, baseUrl, session] = await Promise.all([
|
||||
getMemberLivePromotions(memberId),
|
||||
publicBaseUrl(),
|
||||
getSession(),
|
||||
]);
|
||||
// Nom, prénom et ligne directe du référent : réservés aux visiteurs
|
||||
// connectés. La requête n'est pas faite pour un visiteur anonyme, et ces
|
||||
// champs restent hors du JSON-LD et des métadonnées de la fiche.
|
||||
const contact = session?.user ? await getMemberContact(memberId) : null;
|
||||
const contactTel = telHref(contact?.phone);
|
||||
const accent = member.accent ?? "#E0A63C";
|
||||
|
||||
const fullAddress = [member.address, [member.postalCode, member.city].filter(Boolean).join(" ")]
|
||||
@@ -384,6 +395,27 @@ export default async function FicheAdherentPage({
|
||||
<div className="practical-empty">Coordonnées non communiquées.</div>
|
||||
)}
|
||||
</div>
|
||||
{contact && (
|
||||
<div className="contact-referent">
|
||||
<div className="contact-referent__label">
|
||||
<span style={{ width: 7, height: 7, borderRadius: "50%", background: accent }} />
|
||||
Contact adhérent
|
||||
</div>
|
||||
{contact.name && <div className="contact-referent__name">{contact.name}</div>}
|
||||
{contact.phone &&
|
||||
(contactTel ? (
|
||||
<a className="contact-referent__phone" href={contactTel}>
|
||||
{contact.phone}
|
||||
</a>
|
||||
) : (
|
||||
<div className="contact-referent__phone">{contact.phone}</div>
|
||||
))}
|
||||
<div className="contact-referent__hint">
|
||||
Visible uniquement parce que vous êtes connecté : ces coordonnées ne sont pas
|
||||
publiées sur le site.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { MemberCard, type MemberCardData } from "@/components/MemberCard";
|
||||
import type { MemberContact } from "@/lib/member-contact";
|
||||
|
||||
export type DirectoryMember = MemberCardData & {
|
||||
hasPromo: boolean;
|
||||
promoBadge: string | null;
|
||||
/** Référent de l'adhérent, `null` tant que le visiteur n'est pas connecté. */
|
||||
contact: MemberContact | null;
|
||||
};
|
||||
|
||||
export function AnnuaireClient({
|
||||
@@ -176,7 +179,7 @@ export function AnnuaireClient({
|
||||
{list.length > 0 ? (
|
||||
<div className="grid grid-3" style={{ gap: 20 }}>
|
||||
{list.map((m, i) => (
|
||||
<MemberCard key={m.id} m={m} index={i} />
|
||||
<MemberCard key={m.id} m={m} index={i} contact={m.contact} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -13,7 +13,9 @@ import {
|
||||
getCategoriesInUse,
|
||||
getCategoryBySlug,
|
||||
getLiveBadgesByMember,
|
||||
getMemberContacts,
|
||||
} from "@/lib/queries";
|
||||
import { getSession } from "@/lib/session";
|
||||
import {
|
||||
NOINDEX,
|
||||
breadcrumbJsonLd,
|
||||
@@ -77,12 +79,16 @@ export default async function CategoriePage({ params }: { params: Params }) {
|
||||
const category = await getCategoryBySlug(categorie);
|
||||
if (!category) notFound();
|
||||
|
||||
const [rawMembers, badges, categoriesInUse, baseUrl] = await Promise.all([
|
||||
const [rawMembers, badges, categoriesInUse, baseUrl, session] = await Promise.all([
|
||||
getActiveMembersByCategory(category.id),
|
||||
getLiveBadgesByMember(),
|
||||
getCategoriesInUse(),
|
||||
publicBaseUrl(),
|
||||
getSession(),
|
||||
]);
|
||||
// Même règle que sur l'annuaire : le référent n'est lu que pour un visiteur
|
||||
// connecté, et reste hors du JSON-LD (construit sur `members`).
|
||||
const contacts = session?.user ? await getMemberContacts() : null;
|
||||
const badgeByMember = new Map<number, string | null>();
|
||||
for (const b of badges) {
|
||||
if (b.memberId != null && !badgeByMember.has(b.memberId)) badgeByMember.set(b.memberId, b.badge);
|
||||
@@ -148,7 +154,7 @@ export default async function CategoriePage({ params }: { params: Params }) {
|
||||
{members.length > 0 ? (
|
||||
<div className="grid grid-3" style={{ gap: 20 }}>
|
||||
{members.map((m, i) => (
|
||||
<MemberCard key={m.id} m={m} index={i} />
|
||||
<MemberCard key={m.id} m={m} index={i} contact={contacts?.get(m.id) ?? null} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -8,7 +8,9 @@ import {
|
||||
getAllCategories,
|
||||
getCategoriesInUse,
|
||||
getLiveBadgesByMember,
|
||||
getMemberContacts,
|
||||
} from "@/lib/queries";
|
||||
import { getSession } from "@/lib/session";
|
||||
import { CategoryLinks } from "@/components/CategoryLinks";
|
||||
import { JsonLd } from "@/components/JsonLd";
|
||||
import { breadcrumbJsonLd, memberListJsonLd, pageMetadata } from "@/lib/seo";
|
||||
@@ -32,14 +34,21 @@ export default async function AnnuairePage({
|
||||
searchParams: Promise<{ q?: string }>;
|
||||
}) {
|
||||
const { q } = await searchParams;
|
||||
const [rawMembers, categories, badges, baseUrl, categoriesInUse] = await Promise.all([
|
||||
const [rawMembers, categories, badges, baseUrl, categoriesInUse, session] = await Promise.all([
|
||||
getActiveMembersWithCategory(),
|
||||
getAllCategories(),
|
||||
getLiveBadgesByMember(),
|
||||
publicBaseUrl(),
|
||||
getCategoriesInUse(),
|
||||
getSession(),
|
||||
]);
|
||||
|
||||
// Coordonnées du référent : réservées aux visiteurs connectés. Pour un
|
||||
// visiteur anonyme la requête n'est pas faite du tout, donc rien ne part
|
||||
// dans le HTML ni vers le composant client.
|
||||
const signedIn = Boolean(session?.user);
|
||||
const contacts = signedIn ? await getMemberContacts() : null;
|
||||
|
||||
const badgeByMember = new Map<number, string | null>();
|
||||
for (const b of badges) {
|
||||
if (b.memberId != null && !badgeByMember.has(b.memberId)) {
|
||||
@@ -51,6 +60,7 @@ export default async function AnnuairePage({
|
||||
...m,
|
||||
hasPromo: badgeByMember.has(m.id),
|
||||
promoBadge: badgeByMember.get(m.id) ?? null,
|
||||
contact: contacts?.get(m.id) ?? null,
|
||||
}));
|
||||
|
||||
return (
|
||||
@@ -97,6 +107,12 @@ export default async function AnnuairePage({
|
||||
<p style={{ margin: 0, fontSize: 16.5, color: "#6c6150", maxWidth: 560 }}>
|
||||
Trouvez un professionnel du Bassin de Pompey, découvrez sa fiche et ses bons plans.
|
||||
</p>
|
||||
{signedIn && (
|
||||
<p style={{ margin: "10px 0 0", fontSize: 13.5, color: "#9a8d72", maxWidth: 560 }}>
|
||||
Vous êtes connecté : les coordonnées du référent de chaque adhérent (nom, prénom,
|
||||
téléphone) sont affichées sur les fiches ci-dessous.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<AnnuaireClient members={members} categories={categories} initialQuery={q ?? ""} />
|
||||
|
||||
@@ -525,6 +525,9 @@ export async function updateMember(formData: FormData) {
|
||||
name: String(formData.get("name") ?? "").trim(),
|
||||
email: String(formData.get("email") ?? "").trim(),
|
||||
contactEmail: String(formData.get("contactEmail") ?? "").trim().toLowerCase() || null,
|
||||
contactFirstName: String(formData.get("contactFirstName") ?? "").trim() || null,
|
||||
contactLastName: String(formData.get("contactLastName") ?? "").trim() || null,
|
||||
contactPhone: String(formData.get("contactPhone") ?? "").trim() || null,
|
||||
categoryId,
|
||||
city,
|
||||
address: String(formData.get("address") ?? "").trim() || null,
|
||||
@@ -543,6 +546,7 @@ export async function updateMember(formData: FormData) {
|
||||
|
||||
revalidatePath("/backend/adherents");
|
||||
revalidatePath("/adherents/[id]", "page");
|
||||
revalidatePath("/annuaire");
|
||||
revalidatePath("/");
|
||||
}
|
||||
|
||||
@@ -1095,6 +1099,9 @@ export async function updateOwnProfile(formData: FormData) {
|
||||
.set({
|
||||
name,
|
||||
contactEmail: String(formData.get("contactEmail") ?? "").trim().toLowerCase() || null,
|
||||
contactFirstName: String(formData.get("contactFirstName") ?? "").trim() || null,
|
||||
contactLastName: String(formData.get("contactLastName") ?? "").trim() || null,
|
||||
contactPhone: String(formData.get("contactPhone") ?? "").trim() || null,
|
||||
description,
|
||||
address: String(formData.get("address") ?? "").trim() || null,
|
||||
postalCode: String(formData.get("postalCode") ?? "").trim() || null,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { db } from "@/db";
|
||||
import { categories, members, users } from "@/db/schema";
|
||||
import { can } from "@/lib/rbac";
|
||||
import { ImageField } from "@/components/ImageField";
|
||||
import { MemberContactFields } from "@/components/MemberContactFields";
|
||||
import { HoursEditor } from "@/components/HoursEditor";
|
||||
import { TagsField } from "@/components/TagsField";
|
||||
import { communeOptions } from "@/lib/communes";
|
||||
@@ -132,6 +133,12 @@ export default async function EditMemberPage({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MemberContactFields
|
||||
firstName={member.contactFirstName}
|
||||
lastName={member.contactLastName}
|
||||
phone={member.contactPhone}
|
||||
/>
|
||||
|
||||
<div className="grid grid-2" style={{ gap: 16, marginTop: 16 }}>
|
||||
<ImageField name="coverUrl" label="Image d'entête (bannière)" defaultValue={member.coverUrl ?? ""} height={130} />
|
||||
<ImageField name="logoUrl" label="Logo" defaultValue={member.logoUrl ?? ""} height={130} fit="contain" />
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ImageField } from "@/components/ImageField";
|
||||
import { ImageConsentForm } from "@/components/ImageConsentForm";
|
||||
import { HoursEditor } from "@/components/HoursEditor";
|
||||
import { TagsField } from "@/components/TagsField";
|
||||
import { MemberContactFields } from "@/components/MemberContactFields";
|
||||
import { communeOptions } from "@/lib/communes";
|
||||
import { saveImageConsent, updateOwnProfile } from "../actions";
|
||||
import { EspaceHeader } from "./EspaceHeader";
|
||||
@@ -167,6 +168,12 @@ export default async function EspacePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MemberContactFields
|
||||
firstName={profile.contactFirstName}
|
||||
lastName={profile.contactLastName}
|
||||
phone={profile.contactPhone}
|
||||
/>
|
||||
|
||||
<div className="grid grid-2" style={{ gap: 16, marginTop: 16 }}>
|
||||
<ImageField name="coverUrl" label="Image d'entête (bannière)" defaultValue={profile.coverUrl ?? ""} height={130} />
|
||||
<ImageField name="logoUrl" label="Logo" defaultValue={profile.logoUrl ?? ""} height={130} fit="contain" />
|
||||
|
||||
@@ -1403,6 +1403,48 @@ img {
|
||||
color: var(--muted-4);
|
||||
font-size: 13px;
|
||||
}
|
||||
/* Référent de l'adhérent : rendu seulement quand le visiteur est connecté —
|
||||
le HTML public ne contient jamais ce bloc, rien n'est masqué en CSS. */
|
||||
.contact-referent {
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
.contact-referent__label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
color: var(--muted-2);
|
||||
font-size: 10.5px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.contact-referent__name {
|
||||
margin-top: 7px;
|
||||
color: var(--ink-strong);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.contact-referent__phone {
|
||||
display: inline-block;
|
||||
margin-top: 3px;
|
||||
color: var(--blue);
|
||||
font-size: 13.5px;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
.contact-referent__phone:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.contact-referent__hint {
|
||||
margin-top: 7px;
|
||||
color: var(--muted-4);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
/* ---- Éditeur hebdomadaire des horaires ---- */
|
||||
.hours-editor {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Link from "next/link";
|
||||
import { VitrineImage } from "@/components/VitrineImage";
|
||||
import { memberPath } from "@/lib/seo";
|
||||
import type { MemberContact } from "@/lib/member-contact";
|
||||
|
||||
export type MemberCardData = {
|
||||
id: number;
|
||||
@@ -44,8 +45,21 @@ const clampLines = (lines: number) =>
|
||||
/**
|
||||
* Carte d'un adhérent dans une grille (annuaire, page métier). Composant sans
|
||||
* état : il se rend aussi bien côté serveur que dans le filtre client.
|
||||
*
|
||||
* `contact` (nom du référent, ligne directe) n'est passé qu'aux visiteurs
|
||||
* connectés : la page ne le rapatrie pas du tout pour un visiteur anonyme, il
|
||||
* n'y a donc rien à masquer en CSS. Le numéro reste en texte simple — un
|
||||
* `tel:` imbriqué dans le lien de la carte serait un lien dans un lien.
|
||||
*/
|
||||
export function MemberCard({ m, index = 0 }: { m: MemberCardData; index?: number }) {
|
||||
export function MemberCard({
|
||||
m,
|
||||
index = 0,
|
||||
contact = null,
|
||||
}: {
|
||||
m: MemberCardData;
|
||||
index?: number;
|
||||
contact?: MemberContact | null;
|
||||
}) {
|
||||
const location = [m.address, m.city].filter(Boolean).join(" · ");
|
||||
return (
|
||||
<Link
|
||||
@@ -87,6 +101,21 @@ export function MemberCard({ m, index = 0 }: { m: MemberCardData; index?: number
|
||||
<span style={{ width: 7, height: 7, borderRadius: "50%", background: m.accent ?? "#E0A63C", flex: "0 0 auto" }} />
|
||||
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{location}</span>
|
||||
</div>
|
||||
{contact && (
|
||||
<div style={{ marginTop: 10, borderTop: "1px dashed #e6dcc6", paddingTop: 10 }}>
|
||||
<div style={{ fontSize: 10.5, letterSpacing: "0.1em", textTransform: "uppercase", fontWeight: 800, color: "#9a8d72", marginBottom: 4 }}>
|
||||
Contact adhérent
|
||||
</div>
|
||||
{contact.name && (
|
||||
<div style={{ fontSize: 13, fontWeight: 700, color: "#3c3322", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{contact.name}
|
||||
</div>
|
||||
)}
|
||||
{contact.phone && (
|
||||
<div style={{ fontSize: 12.5, color: "#6c6150", marginTop: 2 }}>{contact.phone}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Coordonnées du référent de l'adhérent (nom, prénom, ligne directe), dans les
|
||||
* deux formulaires de fiche : l'espace adhérent et l'écran staff.
|
||||
*
|
||||
* Ces champs ne sont jamais publiés : l'annuaire et la fiche publique ne les
|
||||
* affichent qu'à un visiteur connecté (`src/lib/member-contact.ts`).
|
||||
*/
|
||||
export function MemberContactFields({
|
||||
firstName,
|
||||
lastName,
|
||||
phone,
|
||||
}: {
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
phone: string | null;
|
||||
}) {
|
||||
return (
|
||||
<fieldset
|
||||
style={{
|
||||
marginTop: 16,
|
||||
border: "1px solid #f0e8d6",
|
||||
borderRadius: 12,
|
||||
background: "#faf7ef",
|
||||
padding: 16,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<legend className="field-label" style={{ padding: "0 6px" }}>
|
||||
Contact adhérent
|
||||
</legend>
|
||||
<p className="field-hint" style={{ margin: "0 0 12px" }}>
|
||||
La personne à joindre et sa ligne directe. Affiché sur l'annuaire et sur la fiche
|
||||
<strong> uniquement aux visiteurs connectés</strong> : rien de tout cela n'apparaît sur
|
||||
le site public ni dans les moteurs de recherche.
|
||||
</p>
|
||||
<div className="grid grid-2" style={{ gap: 16 }}>
|
||||
<div>
|
||||
<label className="field-label">Prénom</label>
|
||||
<input name="contactFirstName" defaultValue={firstName ?? ""} className="field" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Nom</label>
|
||||
<input name="contactLastName" defaultValue={lastName ?? ""} className="field" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Téléphone direct</label>
|
||||
<input name="contactPhone" defaultValue={phone ?? ""} className="field" placeholder="06 12 ..." />
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,9 @@ export function demoMembers(catId: (slug: string) => number | null) {
|
||||
address: "12 rue de la République",
|
||||
postalCode: "54390",
|
||||
phone: "03 83 49 00 00",
|
||||
contactFirstName: "Julien",
|
||||
contactLastName: "Marchal",
|
||||
contactPhone: "06 12 34 56 78",
|
||||
website: "https://www.aubonpain-frouard.fr",
|
||||
memberSince: 2021,
|
||||
tags: "Levain naturel, Produits locaux, Fait maison, Sans conservateur",
|
||||
@@ -36,6 +39,9 @@ export function demoMembers(catId: (slug: string) => number | null) {
|
||||
address: "3 place Stanislas",
|
||||
postalCode: "54340",
|
||||
phone: "03 83 24 00 00",
|
||||
contactFirstName: "Sophie",
|
||||
contactLastName: "Renard",
|
||||
contactPhone: "06 98 76 54 32",
|
||||
memberSince: 2019,
|
||||
tags: "Cuisine de saison, Brunch, Terrasse, Produits frais",
|
||||
hours: "Lundi – Vendredi|9h – 18h\nSamedi|9h – 19h\nDimanche|10h – 15h",
|
||||
@@ -49,6 +55,9 @@ export function demoMembers(catId: (slug: string) => number | null) {
|
||||
categoryId: catId("mode-beaute"),
|
||||
city: "Champigneulles",
|
||||
address: "7 av. des Tilleuls",
|
||||
contactFirstName: "Émilie",
|
||||
contactLastName: "Bertrand",
|
||||
contactPhone: "06 45 67 89 01",
|
||||
description: "Salon de coiffure & soins, sur rendez-vous du mardi au samedi.",
|
||||
status: "active" as const,
|
||||
highlighted: true,
|
||||
|
||||
@@ -62,6 +62,13 @@ export const members = pgTable("members", {
|
||||
email: varchar("email", { length: 200 }).notNull(),
|
||||
// E-mail public de la fiche ; à vide, la fiche retombe sur `email`.
|
||||
contactEmail: varchar("contact_email", { length: 200 }),
|
||||
// Référent de l'adhérent : la personne à joindre au sein du commerce, avec
|
||||
// sa ligne directe. Contrairement aux coordonnées de l'établissement, ces
|
||||
// trois champs ne sont montrés qu'aux visiteurs connectés (cf.
|
||||
// `src/lib/member-contact.ts`), jamais dans le HTML public ni le JSON-LD.
|
||||
contactFirstName: varchar("contact_first_name", { length: 120 }),
|
||||
contactLastName: varchar("contact_last_name", { length: 120 }),
|
||||
contactPhone: varchar("contact_phone", { length: 40 }),
|
||||
categoryId: integer("category_id").references(() => categories.id),
|
||||
city: varchar("city", { length: 120 }),
|
||||
address: varchar("address", { length: 240 }),
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Référent d'un adhérent : la personne à joindre, sa ligne directe.
|
||||
*
|
||||
* Ces informations sont **réservées aux visiteurs connectés** : elles ne
|
||||
* doivent jamais partir dans le HTML public, le JSON-LD ou les métadonnées.
|
||||
* Le module est pur — le filtrage par session se fait chez l'appelant, qui
|
||||
* n'appelle `memberContact()` qu'après avoir vérifié la session, et verrouillé
|
||||
* par `tests/member-contact.test.ts`.
|
||||
*/
|
||||
|
||||
export type MemberContactSource = {
|
||||
contactFirstName?: string | null;
|
||||
contactLastName?: string | null;
|
||||
contactPhone?: string | null;
|
||||
};
|
||||
|
||||
export type MemberContact = {
|
||||
/** « Prénom Nom », ou l'un des deux seulement. */
|
||||
name: string | null;
|
||||
phone: string | null;
|
||||
};
|
||||
|
||||
function clean(value: string | null | undefined) {
|
||||
const trimmed = (value ?? "").replace(/\s+/g, " ").trim();
|
||||
return trimmed || null;
|
||||
}
|
||||
|
||||
/** « Prénom Nom » à partir des deux champs, `null` si les deux sont vides. */
|
||||
export function contactName(source: MemberContactSource): string | null {
|
||||
return clean([clean(source.contactFirstName), clean(source.contactLastName)].filter(Boolean).join(" "));
|
||||
}
|
||||
|
||||
/**
|
||||
* Le référent affichable, ou `null` s'il n'y a rien à montrer : le bloc
|
||||
* « Contact adhérent » disparaît alors plutôt que d'afficher un cadre vide.
|
||||
*/
|
||||
export function memberContact(source: MemberContactSource): MemberContact | null {
|
||||
const name = contactName(source);
|
||||
const phone = clean(source.contactPhone);
|
||||
if (!name && !phone) return null;
|
||||
return { name, phone };
|
||||
}
|
||||
|
||||
/**
|
||||
* `tel:` à partir d'un numéro saisi librement (« 03 83 24 .. », « +33 3 83 … »).
|
||||
* Seuls les chiffres sont conservés, avec l'indicatif international s'il ouvre
|
||||
* le numéro ; une saisie sans aucun chiffre ne produit pas de lien.
|
||||
*/
|
||||
export function telHref(phone: string | null | undefined): string | null {
|
||||
const raw = clean(phone);
|
||||
if (!raw) return null;
|
||||
const international = raw.startsWith("+");
|
||||
const digits = raw.replace(/\D/g, "");
|
||||
if (!digits) return null;
|
||||
return `tel:${international ? "+" : ""}${digits}`;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { and, asc, count, desc, eq, sql } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import { categories, members, promotions } from "@/db/schema";
|
||||
import { memberContact, type MemberContact } from "@/lib/member-contact";
|
||||
|
||||
/**
|
||||
* Promotion réellement visible par le public : validée **et** dans sa période
|
||||
@@ -135,6 +136,46 @@ export async function getPublicMember(id: number) {
|
||||
return m ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Référents des adhérents actifs, indexés par fiche.
|
||||
*
|
||||
* Volontairement séparé des lectures publiques (`getActiveMembersWithCategory`,
|
||||
* `getPublicMember`) : nom, prénom et ligne directe ne sont réservés aux
|
||||
* visiteurs connectés que parce qu'aucune requête publique ne les rapatrie.
|
||||
* L'appelant vérifie la session **avant** d'appeler.
|
||||
*/
|
||||
export async function getMemberContacts(): Promise<Map<number, MemberContact>> {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: members.id,
|
||||
contactFirstName: members.contactFirstName,
|
||||
contactLastName: members.contactLastName,
|
||||
contactPhone: members.contactPhone,
|
||||
})
|
||||
.from(members)
|
||||
.where(eq(members.status, "active"));
|
||||
|
||||
const byMember = new Map<number, MemberContact>();
|
||||
for (const row of rows) {
|
||||
const contact = memberContact(row);
|
||||
if (contact) byMember.set(row.id, contact);
|
||||
}
|
||||
return byMember;
|
||||
}
|
||||
|
||||
/** Référent d'une fiche. Même règle que `getMemberContacts()` : session vérifiée d'abord. */
|
||||
export async function getMemberContact(id: number): Promise<MemberContact | null> {
|
||||
const [row] = await db
|
||||
.select({
|
||||
contactFirstName: members.contactFirstName,
|
||||
contactLastName: members.contactLastName,
|
||||
contactPhone: members.contactPhone,
|
||||
})
|
||||
.from(members)
|
||||
.where(eq(members.id, id));
|
||||
return row ? memberContact(row) : null;
|
||||
}
|
||||
|
||||
export async function getMemberLivePromotions(memberId: number) {
|
||||
return db
|
||||
.select({
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { contactName, memberContact, telHref } from "../src/lib/member-contact";
|
||||
|
||||
test("le référent joint prénom et nom", () => {
|
||||
assert.equal(contactName({ contactFirstName: "Marie", contactLastName: "Durand" }), "Marie Durand");
|
||||
assert.equal(contactName({ contactFirstName: " Marie ", contactLastName: " Durand " }), "Marie Durand");
|
||||
assert.equal(contactName({ contactFirstName: "Marie" }), "Marie");
|
||||
assert.equal(contactName({ contactLastName: "Durand" }), "Durand");
|
||||
assert.equal(contactName({}), null);
|
||||
assert.equal(contactName({ contactFirstName: " ", contactLastName: null }), null);
|
||||
});
|
||||
|
||||
test("un référent sans nom ni téléphone n'existe pas", () => {
|
||||
assert.equal(memberContact({}), null);
|
||||
assert.equal(memberContact({ contactFirstName: " ", contactPhone: "" }), null);
|
||||
assert.deepEqual(memberContact({ contactPhone: "03 83 24 10 10" }), {
|
||||
name: null,
|
||||
phone: "03 83 24 10 10",
|
||||
});
|
||||
assert.deepEqual(memberContact({ contactLastName: "Durand", contactPhone: null }), {
|
||||
name: "Durand",
|
||||
phone: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("le lien tel: ne garde que les chiffres et l'indicatif", () => {
|
||||
assert.equal(telHref("03 83 24 10 10"), "tel:0383241010");
|
||||
assert.equal(telHref("+33 3 83 24 10 10"), "tel:+33383241010");
|
||||
assert.equal(telHref("03.83.24.10.10"), "tel:0383241010");
|
||||
assert.equal(telHref("sur rendez-vous"), null);
|
||||
assert.equal(telHref(null), null);
|
||||
assert.equal(telHref(" "), null);
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import { activityNodes, sanitizeActivityMessage } from "../src/lib/activity";
|
||||
@@ -109,3 +110,46 @@ describe("Chiffrement des secrets réseaux", () => {
|
||||
assert.equal(tryDecryptSecret("pas-un-secret-chiffré"), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Coordonnées du référent — jamais dans les lectures publiques", () => {
|
||||
const source = readFileSync(new URL("../src/lib/queries.ts", import.meta.url), "utf8");
|
||||
|
||||
/** Corps d'une fonction exportée de `queries.ts`, jusqu'à la suivante. */
|
||||
function bodyOf(name: string): string {
|
||||
const start = source.indexOf(`export async function ${name}(`);
|
||||
assert.notEqual(start, -1, `${name} introuvable dans src/lib/queries.ts`);
|
||||
const next = source.indexOf("\nexport ", start + 1);
|
||||
return source.slice(start, next === -1 ? source.length : next);
|
||||
}
|
||||
|
||||
// Nom, prénom et ligne directe de l'adhérent ne sont montrés qu'à un visiteur
|
||||
// connecté. La garantie tient à une seule chose : aucune requête servant une
|
||||
// page publique ne les rapatrie — sinon ils partiraient dans le HTML, le
|
||||
// JSON-LD ou les props du composant client de l'annuaire.
|
||||
const publicReads = [
|
||||
"getActiveMembersWithCategory",
|
||||
"getActiveMembersByCategory",
|
||||
"getRotatingActiveMembers",
|
||||
"getPublicMember",
|
||||
"getLivePromotions",
|
||||
];
|
||||
|
||||
for (const name of publicReads) {
|
||||
it(`${name} ne lit pas le référent`, () => {
|
||||
const body = bodyOf(name);
|
||||
for (const column of ["contactFirstName", "contactLastName", "contactPhone"]) {
|
||||
assert.ok(
|
||||
!body.includes(column),
|
||||
`${name} sélectionne ${column} : ces coordonnées deviendraient publiques.`
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it("les lectures réservées existent et sont séparées", () => {
|
||||
for (const name of ["getMemberContacts", "getMemberContact"]) {
|
||||
const body = bodyOf(name);
|
||||
assert.ok(body.includes("contactPhone"), `${name} devrait lire le référent`);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user