- Accueil : le bandeau « Faites partie de l'aventure » affiche le nombre d'adhérents validés (statut active) au lieu de « plus de 120 » codé en dur. - Pied de page : les boutons Facebook / LinkedIn ont la même largeur (grille à une colonne dimensionnée sur le plus long) et une icône à largeur fixe. - Catégories : référentiel unique dans src/db/categories.ts (104 métiers, dont assurance, bazar/discount, grande distribution, librairie-presse, tabac, vétérinaire, auto-école, etc.), inséré par la migration 0012 avec ON CONFLICT (slug) : les catégories manquantes apparaissent au prochain démarrage, sans écraser un libellé renommé depuis le backend. Le seed et l'action « Ajouter une catégorie » réutilisent ce référentiel et sa palette. - tests/categories.test.ts vérifie unicité des slugs, contraintes de la table et cohérence entre le référentiel et la migration. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDbKbJrPWweXXYSxA8MXW7
8.4 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>.
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).