Merge main into feat/owner-carbet-crud (integrate SYS-2 schema + SYS-3 auth)

main now contains the Prisma schema (SYS-2) and NextAuth (SYS-3) that the
owner carbet CRUD depends on. Integrating them so the branch compiles and
the PR is cleanly mergeable.

- package.json: union of S3 SDK (@aws-sdk/client-s3) + auth deps.
- No source conflicts; espace-hote "Gérer mes carbets" link already in main.
- Verified: tsc --noEmit OK, next build OK (all carbet + auth routes compile).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Karbé Architect 2026-05-29 21:31:56 +00:00
commit d5d2ad2228
15 changed files with 1338 additions and 28 deletions

167
README.md
View file

@ -1,20 +1,85 @@
# Karbé
Karbé — marketplace de location de carbets fluviaux de Guyane.
> Marketplace de location de **carbets fluviaux** de Guyane.
Connecter voyageurs et hôtes pour des séjours authentiques le long des fleuves
de Guyane, au cœur de la forêt amazonienne.
Karbé connecte les voyageurs et les hôtes pour des séjours authentiques le long
des fleuves de Guyane, au cœur de la forêt amazonienne. La plateforme permet aux
propriétaires de publier leurs carbets, et aux voyageurs de rechercher, réserver
et payer leur séjour en ligne.
## Stack
## Sommaire
- [Next.js 16](https://nextjs.org/) (App Router, TypeScript)
- [Tailwind CSS v4](https://tailwindcss.com/)
- [Prisma](https://www.prisma.io/) (datasource PostgreSQL)
- [Présentation](#présentation)
- [Fonctionnalités](#fonctionnalités)
- [Stack technique](#stack-technique)
- [Prérequis](#prérequis)
- [Installation](#installation)
- [Variables d'environnement](#variables-denvironnement)
- [Développement](#développement)
- [Base de données (Prisma)](#base-de-données-prisma)
- [Scripts npm](#scripts-npm)
- [Structure du projet](#structure-du-projet)
- [Conventions Git & contribution](#conventions-git--contribution)
- [Documentation complémentaire](#documentation-complémentaire)
- [Licence](#licence)
## Présentation
Le **carbet** est l'habitat traditionnel amazonien : un abri ouvert, souvent en
bois, installé au bord des fleuves. En Guyane, les carbets fluviaux sont un mode
d'hébergement prisé pour découvrir la forêt, naviguer sur les fleuves (Maroni,
Oyapock, Approuague…) et vivre une expérience proche de la nature.
Karbé est une **marketplace à deux faces** :
- **Voyageurs** — recherchent un carbet, consultent les disponibilités, réservent
et paient en ligne, échangent avec l'hôte et laissent un avis après le séjour.
- **Hôtes (loueurs)** — créent leur profil, publient et gèrent leurs carbets
(photos, tarifs, calendrier de disponibilité) et suivent leurs réservations.
La plateforme prévoit également une dimension **B2B** (créneaux réservés aux
comités d'entreprise) et un modèle d'**abonnement loueur**. Voir la
[roadmap produit](./ROADMAP.md) pour le détail des phases.
## Fonctionnalités
| Domaine | Description |
| --- | --- |
| Comptes multi-rôles | Voyageur, hôte et administrateur (authentification NextAuth). |
| Fiches carbet | Titre, description, fleuve, localité, géolocalisation, capacité, équipements, photos. |
| Recherche publique | Listing et fiche carbet rendus côté serveur (SSR) pour le SEO. |
| Disponibilités & tarifs | Calendrier par date, prix personnalisés, nuits minimum. |
| Réservation | Sélection de dates, calcul du prix (nuitée, ménage, frais de service). |
| Paiement | Encaissement via Stripe (réservation + abonnement loueur). |
| Messagerie | Conversation par réservation entre voyageur et hôte. |
| Avis & notes | Évaluation du séjour après le check-out. |
| Conformité | CGV, RGPD, mentions légales. |
> Toutes ces fonctionnalités ne sont pas encore livrées : voir la
> [roadmap](./ROADMAP.md) pour l'état d'avancement par phase.
## Stack technique
- **[Next.js 16](https://nextjs.org/)** — App Router, TypeScript, rendu serveur.
- **[React 19](https://react.dev/)**.
- **[Tailwind CSS v4](https://tailwindcss.com/)** — styles utilitaires.
- **[Prisma 7](https://www.prisma.io/)** — ORM, datasource **PostgreSQL**. Le
client est généré dans `src/generated/prisma`.
- **[NextAuth](https://authjs.dev/)** — authentification multi-rôles.
- **[Stripe](https://stripe.com/)** — paiements (prévu).
Pour les choix d'architecture et les flux détaillés, voir
[`docs/ARCHITECTURE.md`](./docs/ARCHITECTURE.md).
> **Note :** ce dépôt utilise une version de Next.js qui peut différer de la
> documentation publique. En cas de doute sur une API, consultez la
> documentation embarquée dans `node_modules/next/dist/docs/`.
## Prérequis
- Node.js >= 20
- Une base de données PostgreSQL
- **Node.js >= 20**
- **npm** (fourni avec Node.js)
- Une base de données **PostgreSQL** accessible (locale ou distante)
## Installation
@ -33,8 +98,27 @@ de Guyane, au cœur de la forêt amazonienne.
cp .env.example .env
```
Puis renseignez `DATABASE_URL` (connexion PostgreSQL) et `NEXTAUTH_SECRET`
dans le fichier `.env`.
Renseignez ensuite les valeurs dans `.env` (voir ci-dessous).
3. Préparer la base de données :
```bash
npx prisma migrate dev
```
## Variables d'environnement
Le fichier [`.env.example`](./.env.example) liste les variables attendues.
Copiez-le en `.env` et renseignez vos valeurs.
| Variable | Rôle |
| --- | --- |
| `DATABASE_URL` | Chaîne de connexion PostgreSQL utilisée par Prisma. |
| `AUTH_SECRET` / `NEXTAUTH_SECRET` | Secret de signature des sessions NextAuth. Générer avec `openssl rand -base64 32`. |
> Le fichier `.env` ne doit **jamais** être commité (il est ignoré par Git).
> Au fur et à mesure de l'ajout des intégrations (ex. Stripe), de nouvelles
> variables seront ajoutées à `.env.example`.
## Développement
@ -46,22 +130,61 @@ L'application est disponible sur [http://localhost:3000](http://localhost:3000).
## Base de données (Prisma)
Le schéma vit dans `prisma/schema.prisma` (volontairement minimal pour le
moment, les modèles seront ajoutés au fur et à mesure).
Le schéma vit dans [`prisma/schema.prisma`](./prisma/schema.prisma). Il décrit
les entités de Karbé (utilisateurs, carbets, réservations, paiements, avis,
messagerie…). Le modèle de données est détaillé dans
[`docs/ARCHITECTURE.md`](./docs/ARCHITECTURE.md#modèle-de-données).
```bash
# Régénérer le client après une modification du schéma
# Régénérer le client Prisma après une modification du schéma
npx prisma generate
# Créer / appliquer une migration en développement
npx prisma migrate dev
npx prisma migrate dev --name <nom_de_la_migration>
# Inspecter la base via une interface web
npx prisma studio
```
## Scripts
## Scripts npm
| Script | Description |
| --------------- | ------------------------------------ |
| `npm run dev` | Démarre le serveur de développement |
| `npm run build` | Build de production |
| `npm run start` | Démarre le serveur de production |
| `npm run lint` | Lance ESLint |
| Script | Description |
| --- | --- |
| `npm run dev` | Démarre le serveur de développement. |
| `npm run build` | Build de production. |
| `npm run start` | Démarre le serveur de production. |
| `npm run lint` | Lance ESLint. |
## Structure du projet
```text
karbe/
├── prisma/
│ ├── schema.prisma # Modèle de données (entités Karbé)
│ └── migrations/ # Migrations SQL générées par Prisma
├── public/ # Fichiers statiques
├── src/
│ ├── app/ # Routes App Router (pages, layouts, route handlers)
│ └── generated/prisma/ # Client Prisma généré (ne pas éditer à la main)
├── docs/
│ └── ARCHITECTURE.md # Documentation d'architecture
├── ROADMAP.md # Roadmap produit (MVP → V2 → V3)
└── README.md
```
## Conventions Git & contribution
- La branche **`main` est protégée** : aucun commit direct.
- Travaillez sur une branche dédiée nommée **`feat/<sujet>`** (ou `fix/<sujet>`).
- Ouvrez une **Pull Request vers `main`** pour toute modification.
- Avant d'ouvrir la PR, vérifiez que `npm run lint` et `npm run build` passent.
## Documentation complémentaire
- [Roadmap produit](./ROADMAP.md) — phases MVP → V2 → V3.
- [Documentation d'architecture](./docs/ARCHITECTURE.md) — stack, modèle de
données et flux principaux.
## Licence
Voir le fichier [`LICENSE`](./LICENSE).

133
ROADMAP.md Normal file
View file

@ -0,0 +1,133 @@
# Roadmap produit — Karbé
Cette roadmap décrit la trajectoire produit de **Karbé**, la marketplace de
location de carbets fluviaux de Guyane. Elle est organisée en trois phases :
**MVP** (premier produit utilisable de bout en bout), **V2** (montée en gamme et
expérience), **V3** (passage à l'échelle et nouveaux marchés).
> Les phases sont indicatives et sont amenées à évoluer en fonction des retours
> utilisateurs et des priorités business. Les statuts ✅ / 🚧 / ⬜ reflètent
> l'avancement au moment de la rédaction.
## Vision
Devenir **la** plateforme de référence pour la découverte et la réservation
d'hébergements fluviaux et nature en Guyane, en valorisant l'offre des hôtes
locaux et en proposant aux voyageurs (particuliers comme comités d'entreprise)
une expérience de réservation simple, fiable et sécurisée.
## Objectifs par phase
| Phase | Objectif | Public visé |
| --- | --- | --- |
| **MVP** | Réserver et payer un carbet de bout en bout. | Voyageurs particuliers + hôtes |
| **V2** | Fidéliser, fluidifier et industrialiser l'usage. | Voyageurs récurrents, hôtes pros, comités d'entreprise |
| **V3** | Passer à l'échelle et ouvrir de nouveaux usages/marchés. | Partenaires, B2B, international |
---
## Phase 1 — MVP
**But :** un voyageur peut trouver un carbet, le réserver, le payer ; un hôte
peut publier et gérer son offre. La boucle économique de base fonctionne.
### Périmètre fonctionnel
| # | Fonctionnalité | Description | Statut |
| --- | --- | --- | --- |
| 1 | Socle technique | Scaffold Next.js + TypeScript + Prisma + Tailwind. | ✅ |
| 2 | Modèle de données | Schéma Prisma complet + migrations (entités Karbé). | ✅ |
| 3 | Authentification multi-rôles | Inscription / connexion Voyageur, Hôte, Admin (NextAuth). | 🚧 |
| 4 | Interface propriétaire | CRUD des carbets + upload des photos. | 🚧 |
| 5 | Recherche & fiche publique | Listing et fiche carbet en SSR (SEO). | ⬜ |
| 6 | Réservation & disponibilités | Calendrier de dispo, sélection de dates, créneaux comités d'entreprise. | ⬜ |
| 7 | Paiement | Encaissement Stripe (réservation + abonnement loueur). | ⬜ |
| 8 | Avis & notes | Évaluation du séjour après le check-out. | ⬜ |
| 9 | Conformité | CGV, RGPD, mentions légales. | 🚧 |
| 10 | Documentation | README, roadmap, doc d'architecture. | 🚧 |
### Critères de sortie du MVP
- Un voyageur peut s'inscrire, rechercher un carbet, réserver des dates
disponibles et payer.
- Un hôte peut publier un carbet avec photos, tarifs et calendrier.
- Les pages publiques sont indexables (SSR/SEO).
- Les obligations légales (CGV, RGPD, mentions légales) sont en place.
---
## Phase 2 — V2
**But :** améliorer l'expérience, fidéliser les utilisateurs et outiller les
hôtes professionnels et le canal B2B (comités d'entreprise).
### Pistes fonctionnelles
- **Recherche avancée** — carte interactive (fleuves/localités), filtres
(équipements, capacité, prix, note), tri pertinent.
- **Favoris & wishlists** — exploitation du modèle de favoris pour sauvegarder
et comparer des carbets.
- **Messagerie enrichie** — notifications (e-mail / in-app), messages
pré-séjour et post-séjour, modèles de réponse pour les hôtes.
- **Tableau de bord hôte** — statistiques (taux d'occupation, revenus),
gestion des paiements/payouts, politiques d'annulation paramétrables.
- **Gestion des annulations & remboursements** — règles claires côté voyageur
et hôte, remboursements automatisés via Stripe.
- **Portail comités d'entreprise (CE)** — espace dédié, créneaux et tarifs
négociés, facturation centralisée.
- **Modération des avis** — signalement, réponses des hôtes, contrôle qualité.
- **Synchronisation de calendrier** — import/export iCal pour éviter les
doubles réservations.
- **Internationalisation (i18n)** — préparation multilingue (FR puis EN/PT,
pertinent pour la zone transfrontalière Brésil/Suriname).
- **Optimisation mobile** — parcours responsive soigné, performance sur réseaux
contraints.
### Critères de sortie de la V2
- Les hôtes pilotent leur activité depuis un tableau de bord.
- Le canal comités d'entreprise est opérationnel.
- Le taux de conversion et la rétention sont mesurés et suivis.
---
## Phase 3 — V3
**But :** passer à l'échelle, ouvrir de nouveaux usages et marchés, et
renforcer la confiance.
### Pistes fonctionnelles
- **Applications mobiles** (iOS / Android) ou PWA installable.
- **Tarification dynamique** — recommandations de prix selon la saison et la
demande.
- **Recommandations personnalisées** — moteur de suggestions de carbets.
- **Vérification d'identité (KYC)** et label « hôte vérifié » renforcé.
- **Assurance séjour / annulation** via un partenaire.
- **Intégrations partenaires** — tour-opérateurs, transport fluvial,
activités (pêche, randonnée, excursions).
- **Programme de fidélité** — points, parrainage, offres récurrentes.
- **API publique** — ouverture aux partenaires et revendeurs.
- **Mode hors-ligne / faible connectivité** — consultation et préparation de
réservation adaptées aux zones isolées de Guyane.
- **Expansion géographique** — autres territoires (Antilles, Amazonie
transfrontalière), multi-devises.
### Critères de sortie de la V3
- Présence mobile native ou PWA.
- Écosystème de partenaires intégré via API.
- Modèle économique diversifié (commissions, abonnements, partenariats).
---
## Synthèse
```text
MVP ──▶ Réserver & payer un carbet de bout en bout
V2 ──▶ Expérience, fidélisation, hôtes pros & comités d'entreprise
V3 ──▶ Mobile, partenariats, API, passage à l'échelle
```
Pour le détail technique des fonctionnalités, voir
[`docs/ARCHITECTURE.md`](./docs/ARCHITECTURE.md).

282
docs/ARCHITECTURE.md Normal file
View file

@ -0,0 +1,282 @@
# Architecture — Karbé
Ce document décrit l'architecture technique de **Karbé**, la marketplace de
location de carbets fluviaux de Guyane : la **stack**, le **modèle de données**
et les **flux principaux**.
- Pour démarrer le projet, voir le [README](../README.md).
- Pour la trajectoire produit, voir la [roadmap](../ROADMAP.md).
## Sommaire
- [Vue d'ensemble](#vue-densemble)
- [Stack technique](#stack-technique)
- [Organisation du code](#organisation-du-code)
- [Modèle de données](#modèle-de-données)
- [Flux principaux](#flux-principaux)
- [Sécurité & conformité](#sécurité--conformité)
## Vue d'ensemble
Karbé est une application **Next.js (App Router)** full-stack : le même projet
sert le rendu des pages (React Server Components + Client Components) et expose
la logique serveur (route handlers, server actions). La persistance est assurée
par **PostgreSQL** via **Prisma**. L'authentification repose sur **NextAuth**,
et les paiements sur **Stripe**.
```text
┌─────────────────────────────────────────────┐
Navigateur │ Next.js (App Router) │
(voyageur / │ │
hôte / admin)│ React Server / Client Components │
│ │ Route handlers / Server actions │
│ HTTPS │ │ │ │
└───────▶│ NextAuth Prisma Client │
│ (sessions, (requêtes) │
│ rôles) │ │
└─────────────────────────────────┼─────────────┘
┌────────────────────────┼───────────────┐
▼ ▼ ▼
PostgreSQL Stripe Stockage
(données) (paiements) médias (photos)
```
> **Note de version :** ce dépôt utilise une version récente de Next.js dont
> certaines API peuvent différer de la documentation publique. La référence
> embarquée se trouve dans `node_modules/next/dist/docs/`.
## Stack technique
| Couche | Technologie | Rôle |
| --- | --- | --- |
| Framework | **Next.js 16** (App Router) | Rendu SSR/RSC, routing, logique serveur. |
| UI | **React 19** + **Tailwind CSS v4** | Composants et styles. |
| Langage | **TypeScript** | Typage statique de bout en bout. |
| ORM | **Prisma 7** | Accès aux données, migrations, typage. Client généré dans `src/generated/prisma`. |
| Base de données | **PostgreSQL** | Persistance relationnelle. |
| Authentification | **NextAuth** | Sessions et contrôle d'accès par rôle. |
| Paiement | **Stripe** | Encaissement réservations + abonnement loueur. |
| Lint | **ESLint** (`eslint-config-next`) | Qualité de code. |
## Organisation du code
```text
src/
├── app/ # App Router : pages, layouts, route handlers
│ ├── layout.tsx # Layout racine
│ ├── page.tsx # Page d'accueil
│ └── globals.css # Styles globaux (Tailwind)
└── generated/prisma/ # Client Prisma généré (NE PAS éditer à la main)
prisma/
├── schema.prisma # Source de vérité du modèle de données
└── migrations/ # Migrations SQL
```
Principes :
- **App Router** — chaque dossier de `src/app` est un segment de route. Les
pages sont des **Server Components** par défaut ; on bascule en Client
Component (`"use client"`) seulement pour l'interactivité.
- **Accès données côté serveur** — les requêtes Prisma s'exécutent côté serveur
(Server Components, route handlers, server actions), jamais dans le
navigateur.
- **Client Prisma généré** — importé depuis `src/generated/prisma` ; il est
régénéré via `npx prisma generate` (script `postinstall`).
## Modèle de données
Le modèle est défini dans [`prisma/schema.prisma`](../prisma/schema.prisma) et
porté par PostgreSQL. Il couvre cinq grands domaines : **comptes**, **offre
(carbets)**, **réservation**, **paiement** et **relation (messagerie & avis)**.
### Énumérations
| Enum | Valeurs |
| --- | --- |
| `UserRole` | `TRAVELER`, `HOST`, `ADMIN` |
| `CarbetStatus` | `DRAFT`, `PUBLISHED`, `ARCHIVED` |
| `BookingStatus` | `PENDING`, `CONFIRMED`, `CANCELLED`, `COMPLETED` |
| `PaymentStatus` | `PENDING`, `AUTHORIZED`, `SUCCEEDED`, `FAILED`, `REFUNDED` |
| `MessageSenderType` | `TRAVELER`, `HOST`, `SYSTEM` |
### Schéma relationnel
```mermaid
erDiagram
User ||--o| HostProfile : "a (si hôte)"
User ||--o{ Booking : "réserve"
User ||--o{ Review : "rédige"
User ||--o{ FavoriteCarbet : "favoris"
HostProfile ||--o{ Carbet : "publie"
Carbet ||--o{ CarbetPhoto : "photos"
Carbet ||--o{ CarbetAvailability : "disponibilités"
Carbet ||--o{ Booking : "réservations"
Carbet ||--o{ Review : "avis"
Carbet ||--o{ FavoriteCarbet : "favoris"
Carbet }o--o{ Amenity : "équipements (CarbetAmenity)"
Booking ||--o{ Payment : "paiements"
Booking ||--o| Conversation : "fil de discussion"
Booking ||--o| Review : "avis"
Conversation ||--o{ Message : "messages"
```
### Entités principales
#### Comptes
- **`User`** — compte unique pour tous les rôles (`role` : `TRAVELER`, `HOST`,
`ADMIN`). Champs clés : `email` (unique), `passwordHash`, `firstName`,
`lastName`, `phone?`, `avatarUrl?`, `isActive`. Relations : un éventuel
`HostProfile`, ses `bookings`, ses `reviews`, ses `favoriteCarbets`, et ses
conversations (en tant que voyageur ou hôte).
- **`HostProfile`** — profil hôte en **1:1** avec `User` (relation optionnelle :
seuls les hôtes en ont un). Contient `bio?`, `verificationAt?` (date de
vérification), `payoutInfo?` (infos de versement) et la liste des `carbets`.
#### Offre (carbets)
- **`Carbet`** — l'annonce. Champs : `title`, `slug` (unique, pour le SEO),
`description`, localisation (`river`, `locality`, `latitude?`, `longitude?`),
capacité (`maxGuests`, `bedrooms`, `beds`, `bathrooms`), tarification
(`basePricePerNight`, `cleaningFee`, `serviceFee`), `status`
(`DRAFT`/`PUBLISHED`/`ARCHIVED`) et `publishedAt?`. Indexé par hôte, statut,
(fleuve, localité) et prix pour la recherche.
- **`Amenity`** / **`CarbetAmenity`** — catalogue d'équipements et table de
liaison **N:N** entre carbets et équipements (clé composite
`[carbetId, amenityId]`).
- **`CarbetPhoto`** — photos d'un carbet (`url`, `alt?`, `sortOrder`).
- **`CarbetAvailability`** — calendrier : une ligne par `date` et par carbet
(`@@unique([carbetId, date])`), avec `isAvailable`, `customPrice?` (tarif
spécifique) et `minNights` (séjour minimum).
#### Réservation
- **`Booking`** — réservation reliant un `Carbet` et un `User` (voyageur).
Champs : `checkIn`, `checkOut`, `guests`, `status`, instantané de
tarification (`nightlyRate`, `cleaningFee`, `serviceFee`, `totalAmount`,
`currency` = `EUR`), `notes?`, et champs d'annulation (`canceledAt?`,
`cancellationReason?`). Les suppressions de carbet/voyageur sont en
`Restrict` pour préserver l'historique des réservations.
#### Paiement
- **`Payment`** — un ou plusieurs paiements rattachés à une `Booking`. Champs :
`provider`, `providerPaymentId?` (unique, ex. id Stripe), `amount`,
`currency`, `status` (`PENDING``AUTHORIZED``SUCCEEDED`/`FAILED`,
`REFUNDED`), `paidAt?`, `refundedAt?` et détails d'échec
(`failureCode?`, `failureMessage?`).
#### Relation : messagerie & avis
- **`Conversation`** — un fil **1:1 avec une `Booking`**, reliant le voyageur
(`travelerId`) et l'hôte (`hostId`).
- **`Message`** — message d'une conversation (`senderType` : `TRAVELER`,
`HOST` ou `SYSTEM`, `senderUserId?`, `content`, `sentAt`, `readAt?`).
- **`Review`** — avis **1:1 avec une `Booking`** (un avis par séjour), portant
une `rating`, un `title?` et un `comment?`, rattaché au carbet et au voyageur.
- **`FavoriteCarbet`** — table de liaison **N:N** (wishlist) entre `User` et
`Carbet` (clé composite `[userId, carbetId]`).
### Conventions
- **Identifiants** : `cuid()` (chaînes) en clé primaire.
- **Horodatage** : `createdAt` / `updatedAt` sur la plupart des entités.
- **Montants** : `Decimal(10,2)` pour les prix, `Decimal(9,6)` pour les
coordonnées GPS.
- **Suppressions** : `Cascade` pour les données dépendantes (photos, messages,
disponibilités) ; `Restrict` pour préserver l'historique financier
(réservations, paiements, avis).
- **Index** : posés sur les colonnes de recherche/jointure fréquentes (statut,
fleuve+localité, prix, dates, clés étrangères).
## Flux principaux
### 1. Authentification & rôles
```mermaid
sequenceDiagram
actor U as Utilisateur
participant App as Next.js
participant Auth as NextAuth
participant DB as PostgreSQL
U->>App: Inscription / Connexion (email + mot de passe)
App->>Auth: Vérifie les identifiants
Auth->>DB: Lit User (passwordHash, role, isActive)
Auth-->>App: Session (id + role)
App-->>U: Accès adapté au rôle (TRAVELER / HOST / ADMIN)
```
Le `role` porté par la session conditionne l'accès : espace voyageur, interface
hôte (gestion des carbets), ou back-office admin.
### 2. Publication d'un carbet (hôte)
1. Un `User` de rôle `HOST` (avec `HostProfile`) crée un `Carbet` en `DRAFT`.
2. Il ajoute des `CarbetPhoto`, sélectionne des `Amenity` (via `CarbetAmenity`)
et renseigne le calendrier `CarbetAvailability` (dates, prix, nuits min).
3. Il publie : `status` passe à `PUBLISHED` et `publishedAt` est renseigné. Le
carbet devient visible dans la recherche publique.
### 3. Recherche & consultation (public, SSR/SEO)
1. La page de listing interroge les `Carbet` en `PUBLISHED` (filtres : fleuve,
localité, capacité, prix…), rendue **côté serveur** pour l'indexation.
2. La fiche carbet est servie via son `slug` unique (URL stable, SEO-friendly)
et affiche photos, équipements, disponibilités et avis.
### 4. Réservation & paiement
```mermaid
sequenceDiagram
actor V as Voyageur
participant App as Next.js
participant DB as PostgreSQL
participant Stripe as Stripe
V->>App: Choisit des dates + nombre de voyageurs
App->>DB: Vérifie CarbetAvailability (dispo + minNights)
App->>App: Calcule le total (nuitées, ménage, frais de service)
App->>DB: Crée Booking (status = PENDING)
V->>App: Procède au paiement
App->>Stripe: Crée l'intention de paiement
Stripe-->>App: Webhook / callback (succès ou échec)
alt Paiement réussi
App->>DB: Payment = SUCCEEDED, Booking = CONFIRMED
App-->>V: Confirmation de réservation
else Paiement échoué
App->>DB: Payment = FAILED, Booking reste PENDING
App-->>V: Échec, nouvelle tentative possible
end
```
À l'issue du séjour, la `Booking` passe en `COMPLETED`. Une annulation renseigne
`canceledAt`/`cancellationReason` et peut déclencher un `REFUNDED` côté
`Payment`.
### 5. Messagerie
À la création d'une réservation, une `Conversation` (1:1 avec la `Booking`) est
ouverte entre le voyageur et l'hôte. Les `Message` portent un `senderType`
(`TRAVELER`, `HOST`, `SYSTEM` pour les notifications automatiques) et un statut
de lecture (`readAt`).
### 6. Avis
Après un séjour `COMPLETED`, le voyageur peut déposer un `Review` (un seul par
réservation, contrainte d'unicité sur `bookingId`). La note alimente la
réputation du carbet affichée sur sa fiche.
## Sécurité & conformité
- **Mots de passe** stockés hachés (`passwordHash`), jamais en clair.
- **Secrets** (`AUTH_SECRET`/`NEXTAUTH_SECRET`, clés Stripe, `DATABASE_URL`) en
variables d'environnement, hors du dépôt (voir `.env.example`).
- **Contrôle d'accès** par rôle (`UserRole`) appliqué côté serveur.
- **Intégrité financière** : suppressions en `Restrict` sur les réservations,
paiements et avis pour conserver l'historique.
- **RGPD & mentions légales** : pages dédiées (CGV, politique de
confidentialité, mentions légales) — voir la [roadmap](../ROADMAP.md).
```

View file

@ -0,0 +1,292 @@
-- CreateSchema
CREATE SCHEMA IF NOT EXISTS "public";
-- CreateEnum
CREATE TYPE "UserRole" AS ENUM ('OWNER', 'CE_MANAGER', 'CE_MEMBER', 'TOURIST', 'ADMIN');
-- CreateEnum
CREATE TYPE "CarbetStatus" AS ENUM ('DRAFT', 'PUBLISHED', 'ARCHIVED');
-- CreateEnum
CREATE TYPE "MediaType" AS ENUM ('PHOTO', 'VIDEO');
-- CreateEnum
CREATE TYPE "AvailabilityScope" AS ENUM ('PUBLIC', 'CE_ONLY');
-- CreateEnum
CREATE TYPE "AvailabilityBlockReason" AS ENUM ('NONE', 'CE_BLOCKED', 'WEEKEND_BLOCKED');
-- CreateEnum
CREATE TYPE "BookingStatus" AS ENUM ('PENDING', 'CONFIRMED', 'CANCELLED', 'COMPLETED');
-- CreateEnum
CREATE TYPE "PaymentStatus" AS ENUM ('PENDING', 'AUTHORIZED', 'SUCCEEDED', 'FAILED', 'REFUNDED');
-- CreateEnum
CREATE TYPE "SubscriptionStatus" AS ENUM ('TRIAL', 'ACTIVE', 'PAST_DUE', 'CANCELED');
-- CreateTable
CREATE TABLE "Organization" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Organization_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"firstName" TEXT NOT NULL,
"lastName" TEXT NOT NULL,
"phone" TEXT,
"role" "UserRole" NOT NULL,
"organizationId" TEXT,
"avatarUrl" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Carbet" (
"id" TEXT NOT NULL,
"ownerId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" TEXT NOT NULL,
"river" TEXT NOT NULL,
"latitude" DECIMAL(9,6) NOT NULL,
"longitude" DECIMAL(9,6) NOT NULL,
"embarkPoint" TEXT NOT NULL,
"pirogueDurationMin" INTEGER NOT NULL,
"capacity" INTEGER NOT NULL,
"status" "CarbetStatus" NOT NULL DEFAULT 'DRAFT',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Carbet_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Amenity" (
"id" TEXT NOT NULL,
"key" TEXT NOT NULL,
"label" TEXT NOT NULL,
"description" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Amenity_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CarbetAmenity" (
"carbetId" TEXT NOT NULL,
"amenityId" TEXT NOT NULL,
CONSTRAINT "CarbetAmenity_pkey" PRIMARY KEY ("carbetId","amenityId")
);
-- CreateTable
CREATE TABLE "Media" (
"id" TEXT NOT NULL,
"carbetId" TEXT NOT NULL,
"type" "MediaType" NOT NULL,
"s3Key" TEXT NOT NULL,
"s3Url" TEXT NOT NULL,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Media_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Availability" (
"id" TEXT NOT NULL,
"carbetId" TEXT NOT NULL,
"startDate" TIMESTAMP(3) NOT NULL,
"endDate" TIMESTAMP(3) NOT NULL,
"scope" "AvailabilityScope" NOT NULL DEFAULT 'PUBLIC',
"blockReason" "AvailabilityBlockReason" NOT NULL DEFAULT 'NONE',
"isAvailable" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Availability_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Booking" (
"id" TEXT NOT NULL,
"carbetId" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"startDate" TIMESTAMP(3) NOT NULL,
"endDate" TIMESTAMP(3) NOT NULL,
"guestCount" INTEGER NOT NULL,
"status" "BookingStatus" NOT NULL DEFAULT 'PENDING',
"amount" DECIMAL(10,2) NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'EUR',
"paymentStatus" "PaymentStatus" NOT NULL DEFAULT 'PENDING',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Booking_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Subscription" (
"id" TEXT NOT NULL,
"ownerId" TEXT NOT NULL,
"carbetId" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"providerSubId" TEXT,
"status" "SubscriptionStatus" NOT NULL DEFAULT 'TRIAL',
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"renewedAt" TIMESTAMP(3),
"canceledAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Subscription_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Review" (
"id" TEXT NOT NULL,
"bookingId" TEXT NOT NULL,
"carbetId" TEXT NOT NULL,
"authorId" TEXT NOT NULL,
"rating" INTEGER NOT NULL,
"comment" TEXT,
"hostResponse" TEXT,
"hostRespondedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Review_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Organization_slug_key" ON "Organization"("slug");
-- CreateIndex
CREATE INDEX "Organization_name_idx" ON "Organization"("name");
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE INDEX "User_organizationId_idx" ON "User"("organizationId");
-- CreateIndex
CREATE INDEX "User_role_idx" ON "User"("role");
-- CreateIndex
CREATE UNIQUE INDEX "Carbet_slug_key" ON "Carbet"("slug");
-- CreateIndex
CREATE INDEX "Carbet_ownerId_idx" ON "Carbet"("ownerId");
-- CreateIndex
CREATE INDEX "Carbet_status_idx" ON "Carbet"("status");
-- CreateIndex
CREATE INDEX "Carbet_river_idx" ON "Carbet"("river");
-- CreateIndex
CREATE UNIQUE INDEX "Amenity_key_key" ON "Amenity"("key");
-- CreateIndex
CREATE INDEX "CarbetAmenity_amenityId_idx" ON "CarbetAmenity"("amenityId");
-- CreateIndex
CREATE INDEX "Media_carbetId_sortOrder_idx" ON "Media"("carbetId", "sortOrder");
-- CreateIndex
CREATE INDEX "Availability_carbetId_idx" ON "Availability"("carbetId");
-- CreateIndex
CREATE INDEX "Availability_scope_blockReason_idx" ON "Availability"("scope", "blockReason");
-- CreateIndex
CREATE INDEX "Availability_startDate_endDate_idx" ON "Availability"("startDate", "endDate");
-- CreateIndex
CREATE INDEX "Booking_carbetId_idx" ON "Booking"("carbetId");
-- CreateIndex
CREATE INDEX "Booking_tenantId_idx" ON "Booking"("tenantId");
-- CreateIndex
CREATE INDEX "Booking_status_paymentStatus_idx" ON "Booking"("status", "paymentStatus");
-- CreateIndex
CREATE INDEX "Booking_startDate_endDate_idx" ON "Booking"("startDate", "endDate");
-- CreateIndex
CREATE UNIQUE INDEX "Subscription_providerSubId_key" ON "Subscription"("providerSubId");
-- CreateIndex
CREATE INDEX "Subscription_ownerId_idx" ON "Subscription"("ownerId");
-- CreateIndex
CREATE INDEX "Subscription_carbetId_idx" ON "Subscription"("carbetId");
-- CreateIndex
CREATE INDEX "Subscription_status_idx" ON "Subscription"("status");
-- CreateIndex
CREATE UNIQUE INDEX "Review_bookingId_key" ON "Review"("bookingId");
-- CreateIndex
CREATE INDEX "Review_carbetId_idx" ON "Review"("carbetId");
-- CreateIndex
CREATE INDEX "Review_authorId_idx" ON "Review"("authorId");
-- AddForeignKey
ALTER TABLE "User" ADD CONSTRAINT "User_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Carbet" ADD CONSTRAINT "Carbet_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CarbetAmenity" ADD CONSTRAINT "CarbetAmenity_carbetId_fkey" FOREIGN KEY ("carbetId") REFERENCES "Carbet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CarbetAmenity" ADD CONSTRAINT "CarbetAmenity_amenityId_fkey" FOREIGN KEY ("amenityId") REFERENCES "Amenity"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Media" ADD CONSTRAINT "Media_carbetId_fkey" FOREIGN KEY ("carbetId") REFERENCES "Carbet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Availability" ADD CONSTRAINT "Availability_carbetId_fkey" FOREIGN KEY ("carbetId") REFERENCES "Carbet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_carbetId_fkey" FOREIGN KEY ("carbetId") REFERENCES "Carbet"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Subscription" ADD CONSTRAINT "Subscription_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Subscription" ADD CONSTRAINT "Subscription_carbetId_fkey" FOREIGN KEY ("carbetId") REFERENCES "Carbet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Review" ADD CONSTRAINT "Review_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Review" ADD CONSTRAINT "Review_carbetId_fkey" FOREIGN KEY ("carbetId") REFERENCES "Carbet"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Review" ADD CONSTRAINT "Review_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View file

@ -0,0 +1 @@
provider = "postgresql"

View file

@ -1,8 +1,3 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
// Get a free hosted Postgres database in seconds: `npx create-db`
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
@ -12,4 +7,239 @@ datasource db {
provider = "postgresql"
}
// Les modèles Karbé (carbets, réservations, utilisateurs…) seront ajoutés ici.
enum UserRole {
OWNER
CE_MANAGER
CE_MEMBER
TOURIST
ADMIN
}
enum CarbetStatus {
DRAFT
PUBLISHED
ARCHIVED
}
enum MediaType {
PHOTO
VIDEO
}
enum AvailabilityScope {
PUBLIC
CE_ONLY
}
enum AvailabilityBlockReason {
NONE
CE_BLOCKED
WEEKEND_BLOCKED
}
enum BookingStatus {
PENDING
CONFIRMED
CANCELLED
COMPLETED
}
enum PaymentStatus {
PENDING
AUTHORIZED
SUCCEEDED
FAILED
REFUNDED
}
enum SubscriptionStatus {
TRIAL
ACTIVE
PAST_DUE
CANCELED
}
model Organization {
id String @id @default(cuid())
name String
slug String @unique
description String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
members User[]
@@index([name])
}
model User {
id String @id @default(cuid())
email String @unique
passwordHash String
firstName String
lastName String
phone String?
role UserRole
organizationId String?
avatarUrl String?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organization Organization? @relation(fields: [organizationId], references: [id], onDelete: SetNull)
carbets Carbet[] @relation("CarbetOwner")
bookings Booking[] @relation("BookingTenant")
reviews Review[] @relation("ReviewAuthor")
subscriptions Subscription[]
@@index([organizationId])
@@index([role])
}
model Carbet {
id String @id @default(cuid())
ownerId String
title String
slug String @unique
description String
river String
latitude Decimal @db.Decimal(9, 6)
longitude Decimal @db.Decimal(9, 6)
embarkPoint String
pirogueDurationMin Int
capacity Int
status CarbetStatus @default(DRAFT)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
owner User @relation("CarbetOwner", fields: [ownerId], references: [id], onDelete: Restrict)
amenities CarbetAmenity[]
media Media[]
availabilities Availability[]
bookings Booking[]
reviews Review[]
subscriptions Subscription[]
@@index([ownerId])
@@index([status])
@@index([river])
}
model Amenity {
id String @id @default(cuid())
key String @unique
label String
description String?
createdAt DateTime @default(now())
carbets CarbetAmenity[]
}
model CarbetAmenity {
carbetId String
amenityId String
carbet Carbet @relation(fields: [carbetId], references: [id], onDelete: Cascade)
amenity Amenity @relation(fields: [amenityId], references: [id], onDelete: Cascade)
@@id([carbetId, amenityId])
@@index([amenityId])
}
model Media {
id String @id @default(cuid())
carbetId String
type MediaType
s3Key String
s3Url String
sortOrder Int @default(0)
createdAt DateTime @default(now())
carbet Carbet @relation(fields: [carbetId], references: [id], onDelete: Cascade)
@@index([carbetId, sortOrder])
}
model Availability {
id String @id @default(cuid())
carbetId String
startDate DateTime
endDate DateTime
scope AvailabilityScope @default(PUBLIC)
blockReason AvailabilityBlockReason @default(NONE)
isAvailable Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
carbet Carbet @relation(fields: [carbetId], references: [id], onDelete: Cascade)
@@index([carbetId])
@@index([scope, blockReason])
@@index([startDate, endDate])
}
model Booking {
id String @id @default(cuid())
carbetId String
tenantId String
startDate DateTime
endDate DateTime
guestCount Int
status BookingStatus @default(PENDING)
amount Decimal @db.Decimal(10, 2)
currency String @default("EUR")
paymentStatus PaymentStatus @default(PENDING)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
carbet Carbet @relation(fields: [carbetId], references: [id], onDelete: Restrict)
tenant User @relation("BookingTenant", fields: [tenantId], references: [id], onDelete: Restrict)
review Review?
@@index([carbetId])
@@index([tenantId])
@@index([status, paymentStatus])
@@index([startDate, endDate])
}
model Subscription {
id String @id @default(cuid())
ownerId String
carbetId String
provider String
providerSubId String? @unique
status SubscriptionStatus @default(TRIAL)
startedAt DateTime @default(now())
renewedAt DateTime?
canceledAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
owner User @relation(fields: [ownerId], references: [id], onDelete: Restrict)
carbet Carbet @relation(fields: [carbetId], references: [id], onDelete: Cascade)
@@index([ownerId])
@@index([carbetId])
@@index([status])
}
model Review {
id String @id @default(cuid())
bookingId String @unique
carbetId String
authorId String
rating Int
comment String?
hostResponse String?
hostRespondedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
booking Booking @relation(fields: [bookingId], references: [id], onDelete: Cascade)
carbet Carbet @relation(fields: [carbetId], references: [id], onDelete: Restrict)
author User @relation("ReviewAuthor", fields: [authorId], references: [id], onDelete: Restrict)
@@index([carbetId])
@@index([authorId])
}

14
src/app/admin/page.tsx Normal file
View file

@ -0,0 +1,14 @@
import { requireRole } from "@/lib/authorization";
export default async function AdminPage() {
const session = await requireRole(["ADMIN"]);
return (
<main className="mx-auto max-w-4xl px-6 py-12">
<h1 className="text-3xl font-semibold">Espace administrateur</h1>
<p className="mt-4 text-zinc-700">
Accès autorisé pour {session.user.email} ({session.user.role}).
</p>
</main>
);
}

View file

@ -0,0 +1,3 @@
import { handlers } from "@/auth";
export const { GET, POST } = handlers;

View file

@ -0,0 +1,54 @@
import { redirect } from "next/navigation";
import { auth, signIn } from "@/auth";
export default async function SignInPage() {
const session = await auth();
if (session?.user?.id) {
redirect("/");
}
return (
<main className="mx-auto flex min-h-[70vh] max-w-md items-center px-6 py-12">
<form
action={async (formData) => {
"use server";
await signIn("credentials", formData);
}}
className="w-full space-y-4 rounded-xl border border-zinc-200 bg-white p-6"
>
<h1 className="text-2xl font-semibold">Connexion</h1>
<div className="space-y-1">
<label htmlFor="email" className="text-sm text-zinc-700">
Email
</label>
<input
id="email"
name="email"
type="email"
required
className="w-full rounded-md border border-zinc-300 px-3 py-2"
/>
</div>
<div className="space-y-1">
<label htmlFor="password" className="text-sm text-zinc-700">
Mot de passe
</label>
<input
id="password"
name="password"
type="password"
required
className="w-full rounded-md border border-zinc-300 px-3 py-2"
/>
</div>
<button
type="submit"
className="w-full rounded-md bg-black px-3 py-2 text-white"
>
Se connecter
</button>
</form>
</main>
);
}

View file

@ -0,0 +1,25 @@
import Link from "next/link";
import { requireRole } from "@/lib/authorization";
export default async function HostPage() {
const session = await requireRole(["OWNER", "ADMIN"]);
return (
<main className="mx-auto max-w-4xl px-6 py-12">
<h1 className="text-3xl font-semibold">Espace hôte</h1>
<p className="mt-4 text-zinc-700">
Accès autorisé pour {session.user.email} ({session.user.role}).
</p>
<div className="mt-8">
<Link
href="/espace-hote/carbets"
className="inline-block rounded-md bg-emerald-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-emerald-700"
>
Gérer mes carbets
</Link>
</div>
</main>
);
}

75
src/auth.ts Normal file
View file

@ -0,0 +1,75 @@
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { prisma } from "@/lib/prisma";
import { verifyPassword } from "@/lib/password";
export const { handlers, auth, signIn, signOut } = NextAuth({
session: {
strategy: "jwt",
},
providers: [
Credentials({
name: "Email et mot de passe",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Mot de passe", type: "password" },
},
async authorize(credentials) {
const email = credentials?.email?.toString().trim().toLowerCase();
const password = credentials?.password?.toString() ?? "";
if (!email || !password) {
return null;
}
const user = await prisma.user.findUnique({
where: { email },
select: {
id: true,
email: true,
firstName: true,
lastName: true,
role: true,
isActive: true,
passwordHash: true,
},
});
if (!user || !user.isActive) {
return null;
}
const isValid = await verifyPassword(password, user.passwordHash);
if (!isValid) {
return null;
}
return {
id: user.id,
email: user.email,
name: `${user.firstName} ${user.lastName}`.trim(),
role: user.role,
};
},
}),
],
callbacks: {
async jwt({ token, user }) {
if (user?.role) {
token.role = user.role;
}
return token;
},
async session({ session, token }) {
if (session.user) {
session.user.id = token.sub ?? "";
session.user.role = token.role;
}
return session;
},
},
pages: {
signIn: "/connexion",
},
});

23
src/lib/authorization.ts Normal file
View file

@ -0,0 +1,23 @@
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import type { UserRole } from "@/generated/prisma/enums";
export async function requireAuth() {
const session = await auth();
if (!session?.user?.id) {
redirect("/connexion");
}
return session;
}
export async function requireRole(allowedRoles: UserRole[]) {
const session = await requireAuth();
if (!session.user.role || !allowedRoles.includes(session.user.role)) {
redirect("/");
}
return session;
}

12
src/lib/password.ts Normal file
View file

@ -0,0 +1,12 @@
import bcrypt from "bcryptjs";
export async function verifyPassword(
plainTextPassword: string,
hashedPassword: string,
): Promise<boolean> {
return bcrypt.compare(plainTextPassword, hashedPassword);
}
export async function hashPassword(plainTextPassword: string): Promise<string> {
return bcrypt.hash(plainTextPassword, 12);
}

20
src/lib/prisma.ts Normal file
View file

@ -0,0 +1,20 @@
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "@/generated/prisma/client";
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
throw new Error("DATABASE_URL is required");
}
const globalForPrisma = globalThis as unknown as {
prisma?: PrismaClient;
};
const adapter = new PrismaPg({ connectionString });
export const prisma = globalForPrisma.prisma ?? new PrismaClient({ adapter });
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}

23
src/types/next-auth.d.ts vendored Normal file
View file

@ -0,0 +1,23 @@
import type { DefaultSession } from "next-auth";
import type { JWT as DefaultJWT } from "next-auth/jwt";
import type { UserRole } from "@/generated/prisma/enums";
declare module "next-auth" {
interface Session {
user: {
id: string;
role?: UserRole;
} & DefaultSession["user"];
}
interface User {
role?: UserRole;
}
}
declare module "next-auth/jwt" {
interface JWT extends DefaultJWT {
role?: UserRole;
}
}