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:
Claude committed 2026-09-14 11:31:59 +00:00
1 parent fb09bf0e3a
commit 67cd9d6684
20 files changed
+1854 -7

No files matched your search

+12
View File
@@ -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
+3
View File
@@ -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
+7
View File
@@ -120,6 +120,13 @@
"when": 1788692576315,
"tag": "0016_tiresome_korg",
"breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1789385157231,
"tag": "0017_regular_devos",
"breakpoints": true
}
]
}
+34 -2
View File
@@ -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>
+4 -1
View File
@@ -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>
) : (
+8 -2
View File
@@ -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>
) : (
+17 -1
View File
@@ -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 ?? ""} />
+7
View File
@@ -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,
+7
View File
@@ -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" />
+7
View File
@@ -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" />
+42
View File
@@ -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 {
+30 -1
View File
@@ -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>
);
+52
View File
@@ -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&apos;annuaire et sur la fiche
<strong> uniquement aux visiteurs connectés</strong> : rien de tout cela n&apos;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>
);
}
+9
View File
@@ -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,
+7
View File
@@ -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 }),
+56
View File
@@ -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}`;
}
+41
View File
@@ -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({
+35
View File
@@ -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);
});
+44
View File
@@ -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`);
}
});
});