- Espace adhérent : /backend/espace (profil, inscriptions, droit à l'image) et /backend/espace/promotions (dépôt et suivi). Bandeau et onglets communs (EspaceHeader) avec le nombre d'offres en ligne ; la page promotions ouvre sur « Mes promotions en cours », puis le formulaire, puis les offres en attente et l'historique. Deux entrées dans le menu, titres de page dédiés, revalidation des deux chemins. - Fiche adhérent : colonne members.contact_email (migration 0013), saisie dans l'espace adhérent et la fiche staff ; la fiche publique et le JSON-LD affichent contact_email, sinon l'e-mail du compte. - VitrineImage : sans photo de couverture, le logo est contenu à ~60 % d'un cadre à hauteur fixe sur un fond du logo flouté ; le conteneur sans hauteur qui laissait le logo déborder est supprimé. - Promotions : la catégorie devient un type de produit ou de service (src/lib/promo-categories.ts, 10 groupes, ~85 entrées) pré-sélectionné depuis le métier de l'adhérent, à la place de la liste des métiers. - Tests : catégories de promo (largeur, unicité, pré-sélection pour chaque métier du référentiel). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDbKbJrPWweXXYSxA8MXW7
11 KiB
CLAUDE.md
Guidance for working in this repository.
What this is
Plein R — website for an association of merchants/businesses in the Bassin de Pompey. A single Next.js application serving a public Accueil page and an authenticated, role-based Backend (admin + member space). Postgres is an external container.
Stack
- Next.js 15 (App Router, TypeScript,
output: "standalone") - PostgreSQL 16 + Drizzle ORM (
src/db/schema.ts) withdrizzle-kitmigrations - Auth.js v5 (credentials provider, JWT sessions) — RBAC in
src/lib/rbac.ts - One app container + one Postgres container (
docker-compose.yml)
Commands
npm run dev # dev server
npm run build # production build
npm run db:generate # regenerate SQL after editing src/db/schema.ts
npm run db:migrate # apply migrations
npm run db:seed # catégories + admin initial ; données de démo si SEED_DEMO=true
npm run db:purge-demo # retire les données de démo d'une base qui les a reçues
docker compose up --build # full stack
Conventions
- Styling: faithful port of the original design as inline styles + a small
design-system in
src/app/globals.css(palette as CSS vars, fonts, twinkle/float animations, hover lifts, responsive grid helpers). No Tailwind. - Mutations: server actions in
src/app/backend/actions.ts. Each action re-checks auth + capability viagetSession()andcan()before writing, thenrevalidatePath(). - Access control:
src/middleware.tsgates/backend/*; each page further guards by role (isStaff,can) and redirects. - Data reads for the public site live in
src/lib/queries.ts. - After changing
src/db/schema.ts, runnpm run db:generateand commit the new file underdrizzle/. - Catégories : le référentiel vit dans
src/db/categories.ts(slugs stables, jamais renommés). Il est inséré par la migration0012_referentiel_categories(ON CONFLICT (slug): seulsortest réaligné, un libellé renommé depuis le backend est conservé) et rejoué par le seed. Pour ajouter une catégorie, on l'ajoute au référentiel et on génère une nouvelle migration--customdu même modèle ;tests/categories.test.tsvérifie la cohérence.
Promotions
Statuts : pending → live → suspended ⇄ live (+ rejected / expired).
promotions.suspended_by retient qui a suspendu (member ou staff) : une
suspension par le staff ne peut être levée que par le staff. Les lectures
publiques filtrent sur status = 'live', donc une promo suspendue disparaît du
site sans traitement supplémentaire.
Réseaux sociaux
src/lib/social.tspublie une promo sur la page Facebook (Graph API) ou LinkedIn (Posts API).src/lib/social-accounts.tsgère la configuration : OAuth, jetons, cibles. Réseau non configuré = case masquée.- Les identifiants et jetons vivent en base (
social_accounts), chiffrés viasrc/lib/crypto.ts(AES-256-GCM, cléSOCIAL_TOKEN_KEYouAUTH_SECRET), posés depuis/backend/reseaux. Les variables d'environnement restent lues en repli. Aucun secret ne doit jamais repartir vers le navigateur. isNetworkConfigured()/configuredNetworks()/siteUrl()/redirectUri()sont asynchrones (accès base).- L'URL publique est le réglage
site_public_url, édité sur/backend/reseaux(pré-rempli depuis les en-têtes de la requête).saveSiteSettingssaute cette clé : le formulaire Paramètres ne la contient pas et l'écraserait. - Routes OAuth :
src/app/api/social/[network]/{connect,callback}. Lestateanti-CSRF passe par un cookie httpOnly ; aucun jeton ne transite par une URL. - Facebook : le jeton de page n'expire pas. LinkedIn : 60 jours, rafraîchissement programmatique réservé à certains partenaires, d'où le bandeau de reconnexion.
- Les images de promo sont stockées en data-URI : l'upload se fait donc en binaire (multipart pour Facebook, Images API en 3 étapes pour LinkedIn), pas par URL.
- La diffusion est déclenchée par la validation, pas par un bouton :
promotions.share_facebook/share_linkedinsont choisis par l'adhérent, ajustables par le modérateur dans le formulaire « Valider », puis figés (status !== 'pending'). publishPromoShares()dansbackend/actions.tsest le seul point de publication. Elle ne lève jamais et ignore tout réseau ayant déjà une lignesocial_postsenposted: c'est la garde anti-republication, qui couvre aussi le cycle suspension → remise en ligne.retryPromoSharene sert qu'au rattrapage d'un échec sur un réseau déjà choisi ; il ne peut pas élargir la diffusion.- Les URLs publiques des pages FB/LinkedIn sont des
site_settings(association_facebook,association_linkedin), éditables dans Paramètres.
Sécurité
- Le journal d'activité agrège des saisies de tiers, dont le formulaire de
contact public : il est filtré à l'écriture (
sanitizeActivityMessage) et rendu en éléments React (activityNodes), jamais en HTML brut. getSession()(src/lib/session.ts) remplaceauth()partout : le rôle et le rattachement adhérent sont relus en base à chaque requête, etusers.session_versioninvalide les jetons émis avant un changement de mot de passe. N'appelez plusauth()directement depuis une page ou une action.- Les images ne sont acceptées qu'en data-URI (
asImageDataUri) : une URL ferait appeler par le serveur une cible choisie par l'utilisateur (SSRF). - La CSP à nonce est posée par
src/middleware.ts. Elle impose un rendu dynamique :export const dynamic = "force-dynamic"est dansapp/layout.tsx, un HTML pré-généré ne pouvant pas porter de nonce. - Les mots de passe temporaires (création d'adhérent, réinitialisation,
invitation staff) ne sont jamais stockés : l'action les renvoie et le
composant
OneTimeCredentialsles affiche une seule fois, sans redirection.users.must_change_passwordseul persiste. - Le seed ne crée en production que le référentiel des catégories et
l'administrateur initial, avec un mot de passe aléatoire affiché une fois
dans les journaux (ou
SEED_ADMIN_PASSWORD) et un changement obligatoire à la première connexion. Toutes les données de démonstration (adhérents, promotions, demandes, journal, compteschangeme123) vivent danssrc/db/demo-data.ts, exigentSEED_DEMO=true, etnpm run db:purge-demoles retire d'une base existante. - Sessions JWT limitées à 7 jours (
auth.config.ts), HSTS et suppression deX-Powered-Bydansnext.config.mjs. Le port Postgres dedocker-composen'est publié que sur127.0.0.1. npm testverrouille ces protections (tests/security.test.ts).
Référencement (SEO)
src/lib/seo.tsest pur (constantes,pageMetadata(), générateurs JSON-LD,serializeJsonLd()qui échappe<>&) et verrouillé partests/seo.test.ts.src/lib/seo-server.tsfournitpublicBaseUrl(): réglagesite_public_url, puis variables d'environnement, puis en-têtes de la requête.app/layout.tsxposemetadataBase, le gabarit de titre%s · Plein R, Open Graph / Twitter,robots, le manifeste, et les JSON-LDOrganization+WebSite. Chaque page publique appellepageMetadata({ title, description, path }): lepathsert de canonique (l'annuaire ignore ainsi?q=).- Données structurées par page via
<JsonLd data={…} />:BreadcrumbListpartout,ItemListsur l'annuaire,LocalBusiness(+ horaires) sur la fiche adhérent,BusinessEventpour chaque rencontre à venir. app/robots.ts,app/sitemap.ts(pages statiques + adhérents actifs),app/manifest.tsetapp/opengraph-image.tsx(vignette 1200×630 générée)./backend,/login,/inscription/*et les fiches non actives sont enNOINDEX; les pages publiques utilisent<main>et un seul<h1>.- Tags adhérents :
src/lib/tags.ts(pur) porte un vocabulaire par métier (CATEGORY_TAGS, un par slug du référentiel, vérifié partests/tags.test.ts) et un vocabulaire transversal détecté dans la description.autoTags()ne remplit le champ qu'à vide : à l'enregistrement (resolveMemberTagsdansbackend/actions.ts) et à l'affichage de la fiche publique. Le composantTagsFieldpropose les suggestions en pastilles cliquables dans les deux formulaires. Un nouveau métier danscategories.tsexige son entrée dansCATEGORY_TAGS. - URLs de fiche :
memberPath({ id, name, city })donne/adherents/12-au-bon-pain-frouard. L'identifiant en tête suffit (parseMemberParam), toute autre écriture est redirigée en 301 vers la forme canonique par la page : ne construisez jamais/adherents/${id}à la main. - Pages métier :
/annuaire/[categorie](slug decategories) rend une page indexable par activité (titre, intro, grilleMemberCard,ItemList) ; sans adhérent elle passe enNOINDEXet sort du sitemap. L'annuaire et ces pages listent les métiers viaCategoryLinkspour le maillage interne.
Adhérents
-
L'espace adhérent est en deux pages :
/backend/espace(profil, inscriptions, droit à l'image) et/backend/espace/promotions(dépôt et suivi des promos, les offres en ligne en tête).EspaceHeaderporte le bandeau et les onglets ; toute action qui touche l'espace revalide les deux chemins. -
La catégorie d'une promotion est un type de produit ou de service (
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. -
members.emailest l'e-mail administratif (identifiant de connexion à la création, échanges avec l'association) ;members.contact_emailest l'e-mail public de la fiche, saisi par l'adhérent ou le staff. La fiche et le JSON-LD affichentcontact_email || email: ne montrez jamaisemailseul. -
VitrineImagerend la couverture encover, sinon le logo encontain(~60 % d'un cadre à hauteur fixe) sur un fond du logo flouté, sinon un placeholder rayé. Ne pas l'entourer d'un conteneur sans hauteur : lesmax-heighten % ne seraient plus résolus et le logo déborderait.
Roles
admin > moderator > editor are staff; member is an adhérent linked to a
members row via users.memberId. Capability matrix is in src/lib/rbac.ts.
Docker notes
- Migrations + optional seed run on container start via
docker-entrypoint.sh. npm run build:scriptsbundlesmigrate/seedintodist/*.cjsso the runtime image needs no dev dependencies.- The standalone server binds
HOSTNAME=0.0.0.0,PORT=3000.
Logo
public/assets/logo.svg is a brand-colour recreation; swap in the official asset
when available (referenced as /assets/logo.svg).