diff --git a/package-lock.json b/package-lock.json index eb5b2bd..7d8475d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,15 +10,23 @@ "hasInstallScript": true, "dependencies": { "@aws-sdk/client-s3": "^3.1056.0", + "@aws-sdk/s3-request-presigner": "^3.1058.0", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^8.0.0", + "@dnd-kit/utilities": "^3.2.2", "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", + "@types/leaflet": "^1.9.21", "bcryptjs": "^3.0.3", + "leaflet": "^1.9.4", "next": "16.2.6", "next-auth": "^5.0.0-beta.31", "pg": "^8.21.0", "react": "19.2.4", "react-dom": "19.2.4", + "react-leaflet": "^5.0.0", "resend": "^4.8.0", + "sharp": "^0.34.5", "stripe": "^18.3.0" }, "devDependencies": { @@ -506,6 +514,23 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/s3-request-presigner": { + "version": "3.1058.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1058.0.tgz", + "integrity": "sha512-IRgNfn8U3zfsZ0JkpmwjS59R/XyHMHxpuwW6HVuJhik+FsbClhNkujEO0w1WqJvXrF4FX+7qIAwUrvlwNvaZ7Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.15", + "@aws-sdk/signature-v4-multi-region": "^3.996.30", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/signature-v4-multi-region": { "version": "3.996.30", "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.30.tgz", @@ -836,6 +861,59 @@ "node": ">=18" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-8.0.0.tgz", + "integrity": "sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.1.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@electric-sql/pglite": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.1.tgz", @@ -1569,7 +1647,6 @@ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "license": "MIT", - "optional": true, "engines": { "node": ">=18" } @@ -2757,6 +2834,17 @@ "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" } }, + "node_modules/@react-leaflet/core": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz", + "integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==", + "license": "Hippocratic-2.1", + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.60.4", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", @@ -3609,6 +3697,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -3623,6 +3717,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/leaflet": { + "version": "1.9.21", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz", + "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/node": { "version": "20.19.41", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", @@ -5295,7 +5398,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -7597,6 +7699,12 @@ "url": "https://ko-fi.com/killymxi" } }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -9054,6 +9162,20 @@ "dev": true, "license": "MIT" }, + "node_modules/react-leaflet": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz", + "integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==", + "license": "Hippocratic-2.1", + "dependencies": { + "@react-leaflet/core": "^3.0.0" + }, + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, "node_modules/react-promise-suspense": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/react-promise-suspense/-/react-promise-suspense-0.3.4.tgz", @@ -9451,7 +9573,6 @@ "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", "hasInstallScript": true, "license": "Apache-2.0", - "optional": true, "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", @@ -9495,7 +9616,6 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", "license": "ISC", - "optional": true, "bin": { "semver": "bin/semver.js" }, diff --git a/package.json b/package.json index 6a33258..e0a10f1 100644 --- a/package.json +++ b/package.json @@ -14,15 +14,23 @@ }, "dependencies": { "@aws-sdk/client-s3": "^3.1056.0", + "@aws-sdk/s3-request-presigner": "^3.1058.0", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^8.0.0", + "@dnd-kit/utilities": "^3.2.2", "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", + "@types/leaflet": "^1.9.21", "bcryptjs": "^3.0.3", + "leaflet": "^1.9.4", "next": "16.2.6", "next-auth": "^5.0.0-beta.31", "pg": "^8.21.0", "react": "19.2.4", "react-dom": "19.2.4", + "react-leaflet": "^5.0.0", "resend": "^4.8.0", + "sharp": "^0.34.5", "stripe": "^18.3.0" }, "devDependencies": { @@ -39,4 +47,4 @@ "typescript": "^5.9.3", "vitest": "^3.2.4" } -} \ No newline at end of file +} diff --git a/prisma/migrations/20260601060000_password_reset_token/migration.sql b/prisma/migrations/20260601060000_password_reset_token/migration.sql new file mode 100644 index 0000000..50033de --- /dev/null +++ b/prisma/migrations/20260601060000_password_reset_token/migration.sql @@ -0,0 +1,9 @@ +CREATE TABLE "PasswordResetToken" ( + "tokenHash" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "PasswordResetToken_pkey" PRIMARY KEY ("tokenHash") +); +CREATE INDEX "PasswordResetToken_userId_idx" ON "PasswordResetToken"("userId"); +CREATE INDEX "PasswordResetToken_expiresAt_idx" ON "PasswordResetToken"("expiresAt"); diff --git a/prisma/migrations/20260602030000_operational_criteria/migration.sql b/prisma/migrations/20260602030000_operational_criteria/migration.sql new file mode 100644 index 0000000..5bdca5f --- /dev/null +++ b/prisma/migrations/20260602030000_operational_criteria/migration.sql @@ -0,0 +1,15 @@ +CREATE TYPE "RoadAccess" AS ENUM ('NONE', 'DRY_SEASON_ONLY', 'ALL_YEAR'); +CREATE TYPE "Electricity" AS ENUM ('NONE', 'SOLAR', 'GENERATOR_READY', 'EDF'); + +ALTER TABLE "Carbet" ADD COLUMN "roadAccess" "RoadAccess"; +ALTER TABLE "Carbet" ADD COLUMN "electricity" "Electricity"; +ALTER TABLE "Carbet" ADD COLUMN "gsmAtCarbet" BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE "Carbet" ADD COLUMN "gsmExitDistanceKm" DECIMAL(4,2); + +-- Seed des 6 carbets démo avec valeurs réalistes +UPDATE "Carbet" SET "roadAccess" = 'NONE', "electricity" = 'SOLAR', "gsmAtCarbet" = false, "gsmExitDistanceKm" = 1.5 WHERE id = 'demo-carbet-awara'; +UPDATE "Carbet" SET "roadAccess" = 'ALL_YEAR', "electricity" = 'EDF', "gsmAtCarbet" = true WHERE id = 'demo-carbet-kourou'; +UPDATE "Carbet" SET "roadAccess" = 'ALL_YEAR', "electricity" = 'EDF', "gsmAtCarbet" = true WHERE id = 'demo-carbet-mahury'; +UPDATE "Carbet" SET "roadAccess" = 'NONE', "electricity" = 'GENERATOR_READY', "gsmAtCarbet" = false, "gsmExitDistanceKm" = 4.0 WHERE id = 'demo-carbet-maripa'; +UPDATE "Carbet" SET "roadAccess" = 'DRY_SEASON_ONLY', "electricity" = 'SOLAR', "gsmAtCarbet" = false, "gsmExitDistanceKm" = 0.5 WHERE id = 'demo-carbet-paripou'; +UPDATE "Carbet" SET "roadAccess" = 'ALL_YEAR', "electricity" = 'EDF', "gsmAtCarbet" = true WHERE id = 'demo-carbet-wapa'; diff --git a/prisma/migrations/20260602100000_favorite/migration.sql b/prisma/migrations/20260602100000_favorite/migration.sql new file mode 100644 index 0000000..8abf012 --- /dev/null +++ b/prisma/migrations/20260602100000_favorite/migration.sql @@ -0,0 +1,8 @@ +CREATE TABLE "Favorite" ( + "userId" TEXT NOT NULL, + "carbetId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "Favorite_pkey" PRIMARY KEY ("userId", "carbetId") +); +CREATE INDEX "Favorite_userId_idx" ON "Favorite"("userId"); +CREATE INDEX "Favorite_carbetId_idx" ON "Favorite"("carbetId"); diff --git a/prisma/migrations/20260603000000_rental_marketplace/migration.sql b/prisma/migrations/20260603000000_rental_marketplace/migration.sql new file mode 100644 index 0000000..65b4eb1 --- /dev/null +++ b/prisma/migrations/20260603000000_rental_marketplace/migration.sql @@ -0,0 +1,112 @@ +-- UserRole : ajouter RENTAL_PROVIDER +ALTER TYPE "UserRole" ADD VALUE IF NOT EXISTS 'RENTAL_PROVIDER'; + +-- Enums dédiés +CREATE TYPE "RentalCategory" AS ENUM ('SLEEP', 'NAVIGATION', 'FISHING', 'COOKING', 'SAFETY'); +CREATE TYPE "RentalBookingStatus" AS ENUM ('PENDING', 'CONFIRMED', 'HANDED_OVER', 'RETURNED', 'CANCELLED'); + +-- RentalProvider +CREATE TABLE "RentalProvider" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "isSystemD" BOOLEAN NOT NULL DEFAULT false, + "managedByUserId" TEXT, + "contactEmail" TEXT, + "contactPhone" TEXT, + "rivers" TEXT[] DEFAULT ARRAY[]::TEXT[], + "description" TEXT, + "commissionPct" DECIMAL(5,2) NOT NULL DEFAULT 0, + "active" BOOLEAN NOT NULL DEFAULT true, + "approved" BOOLEAN NOT NULL DEFAULT false, + "approvedAt" TIMESTAMP(3), + "approvedBy" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "RentalProvider_pkey" PRIMARY KEY ("id"), + CONSTRAINT "RentalProvider_managedByUserId_fkey" FOREIGN KEY ("managedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE +); +CREATE INDEX "RentalProvider_active_approved_idx" ON "RentalProvider"("active", "approved"); +CREATE INDEX "RentalProvider_managedByUserId_idx" ON "RentalProvider"("managedByUserId"); + +-- RentalItem +CREATE TABLE "RentalItem" ( + "id" TEXT NOT NULL, + "providerId" TEXT NOT NULL, + "category" "RentalCategory" NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "imageUrl" TEXT, + "pricePerDay" DECIMAL(8,2) NOT NULL, + "pricePerWeek" DECIMAL(8,2), + "deposit" DECIMAL(8,2) NOT NULL DEFAULT 0, + "totalQty" INTEGER NOT NULL DEFAULT 1, + "withMotor" BOOLEAN NOT NULL DEFAULT false, + "fuelIncluded" BOOLEAN NOT NULL DEFAULT false, + "requiresLicense" BOOLEAN NOT NULL DEFAULT false, + "active" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "RentalItem_pkey" PRIMARY KEY ("id"), + CONSTRAINT "RentalItem_providerId_fkey" FOREIGN KEY ("providerId") REFERENCES "RentalProvider"("id") ON DELETE CASCADE ON UPDATE CASCADE +); +CREATE INDEX "RentalItem_providerId_idx" ON "RentalItem"("providerId"); +CREATE INDEX "RentalItem_category_active_idx" ON "RentalItem"("category", "active"); + +-- RentalItemAvailability +CREATE TABLE "RentalItemAvailability" ( + "id" TEXT NOT NULL, + "itemId" TEXT NOT NULL, + "startDate" TIMESTAMP(3) NOT NULL, + "endDate" TIMESTAMP(3) NOT NULL, + "qty" INTEGER NOT NULL, + "reason" TEXT NOT NULL, + "rentalBookingId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "RentalItemAvailability_pkey" PRIMARY KEY ("id"), + CONSTRAINT "RentalItemAvailability_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "RentalItem"("id") ON DELETE CASCADE ON UPDATE CASCADE +); +CREATE INDEX "RentalItemAvailability_itemId_startDate_endDate_idx" ON "RentalItemAvailability"("itemId", "startDate", "endDate"); +CREATE INDEX "RentalItemAvailability_rentalBookingId_idx" ON "RentalItemAvailability"("rentalBookingId"); + +-- RentalBooking +CREATE TABLE "RentalBooking" ( + "id" TEXT NOT NULL, + "bookingId" TEXT, + "tenantId" TEXT NOT NULL, + "providerId" TEXT NOT NULL, + "startDate" TIMESTAMP(3) NOT NULL, + "endDate" TIMESTAMP(3) NOT NULL, + "status" "RentalBookingStatus" NOT NULL DEFAULT 'PENDING', + "paymentStatus" "PaymentStatus" NOT NULL DEFAULT 'PENDING', + "itemsTotal" DECIMAL(10,2) NOT NULL, + "depositTotal" DECIMAL(10,2) NOT NULL, + "commissionAmount" DECIMAL(10,2) NOT NULL DEFAULT 0, + "amount" DECIMAL(10,2) NOT NULL, + "currency" TEXT NOT NULL DEFAULT 'EUR', + "stripeSessionId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "RentalBooking_pkey" PRIMARY KEY ("id"), + CONSTRAINT "RentalBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT "RentalBooking_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "RentalBooking_providerId_fkey" FOREIGN KEY ("providerId") REFERENCES "RentalProvider"("id") ON DELETE RESTRICT ON UPDATE CASCADE +); +CREATE INDEX "RentalBooking_tenantId_status_idx" ON "RentalBooking"("tenantId", "status"); +CREATE INDEX "RentalBooking_providerId_status_idx" ON "RentalBooking"("providerId", "status"); +CREATE INDEX "RentalBooking_bookingId_idx" ON "RentalBooking"("bookingId"); +CREATE INDEX "RentalBooking_startDate_endDate_idx" ON "RentalBooking"("startDate", "endDate"); + +-- RentalLine +CREATE TABLE "RentalLine" ( + "id" TEXT NOT NULL, + "rentalBookingId" TEXT NOT NULL, + "itemId" TEXT NOT NULL, + "qty" INTEGER NOT NULL, + "pricePerDay" DECIMAL(8,2) NOT NULL, + "deposit" DECIMAL(8,2) NOT NULL DEFAULT 0, + "lineTotal" DECIMAL(10,2) NOT NULL, + CONSTRAINT "RentalLine_pkey" PRIMARY KEY ("id"), + CONSTRAINT "RentalLine_rentalBookingId_fkey" FOREIGN KEY ("rentalBookingId") REFERENCES "RentalBooking"("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "RentalLine_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "RentalItem"("id") ON DELETE RESTRICT ON UPDATE CASCADE +); +CREATE INDEX "RentalLine_rentalBookingId_idx" ON "RentalLine"("rentalBookingId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e9aaf6d..7580413 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -13,6 +13,7 @@ enum UserRole { CE_MEMBER TOURIST ADMIN + RENTAL_PROVIDER } enum CarbetStatus { @@ -97,11 +98,13 @@ model User { 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[] + organization Organization? @relation(fields: [organizationId], references: [id], onDelete: SetNull) + carbets Carbet[] @relation("CarbetOwner") + bookings Booking[] @relation("BookingTenant") + reviews Review[] @relation("ReviewAuthor") + subscriptions Subscription[] + rentalProviders RentalProvider[] + rentalBookings RentalBooking[] @relation("RentalBookingTenant") @@index([organizationId]) @@index([role]) @@ -124,6 +127,11 @@ model Carbet { // Détails d'accès route pour ROAD_AND_RIVER (GPS, distance, type de piste). roadAccessNote String? capacity Int + // 4 critères opérationnels dealbreakers (dispo en filtres + badges UI) + roadAccess RoadAccess? + electricity Electricity? + gsmAtCarbet Boolean @default(false) + gsmExitDistanceKm Decimal? @db.Decimal(4, 2) // Prix par nuit pour le carbet entier (toute capacité). En euros. nightlyPrice Decimal @db.Decimal(10, 2) @default(0) // Contraintes séjour (plugin min-stay). null = pas de contrainte. @@ -244,7 +252,8 @@ model Booking { carbet Carbet @relation(fields: [carbetId], references: [id], onDelete: Restrict) tenant User @relation("BookingTenant", fields: [tenantId], references: [id], onDelete: Restrict) - review Review? + review Review? + rentalBookings RentalBooking[] @@index([carbetId]) @@index([tenantId]) @@ -361,3 +370,163 @@ model Translation { @@id([key, lang]) @@index([lang]) } + +model PasswordResetToken { + tokenHash String @id + userId String + expiresAt DateTime + createdAt DateTime @default(now()) + + @@index([userId]) + @@index([expiresAt]) +} + +model Favorite { + userId String + carbetId String + createdAt DateTime @default(now()) + + @@id([userId, carbetId]) + @@index([userId]) + @@index([carbetId]) +} + +enum RoadAccess { + NONE + DRY_SEASON_ONLY + ALL_YEAR +} + +enum Electricity { + NONE + SOLAR + GENERATOR_READY + EDF +} + +enum RentalCategory { + SLEEP + NAVIGATION + FISHING + COOKING + SAFETY +} + +enum RentalBookingStatus { + PENDING + CONFIRMED + HANDED_OVER + RETURNED + CANCELLED +} + +model RentalProvider { + id String @id @default(cuid()) + name String + isSystemD Boolean @default(false) + managedByUserId String? + contactEmail String? + contactPhone String? + rivers String[] @default([]) + description String? + commissionPct Decimal @db.Decimal(5, 2) @default(0) + active Boolean @default(true) + approved Boolean @default(false) + approvedAt DateTime? + approvedBy String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + manager User? @relation(fields: [managedByUserId], references: [id], onDelete: SetNull) + items RentalItem[] + rentalBookings RentalBooking[] + + @@index([active, approved]) + @@index([managedByUserId]) +} + +model RentalItem { + id String @id @default(cuid()) + providerId String + category RentalCategory + name String + description String? + imageUrl String? + pricePerDay Decimal @db.Decimal(8, 2) + pricePerWeek Decimal? @db.Decimal(8, 2) + deposit Decimal @db.Decimal(8, 2) @default(0) + totalQty Int @default(1) + withMotor Boolean @default(false) + fuelIncluded Boolean @default(false) + requiresLicense Boolean @default(false) + active Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + provider RentalProvider @relation(fields: [providerId], references: [id], onDelete: Cascade) + availabilities RentalItemAvailability[] + lines RentalLine[] + + @@index([providerId]) + @@index([category, active]) +} + +model RentalItemAvailability { + id String @id @default(cuid()) + itemId String + startDate DateTime + endDate DateTime + qty Int + reason String + rentalBookingId String? + createdAt DateTime @default(now()) + + item RentalItem @relation(fields: [itemId], references: [id], onDelete: Cascade) + + @@index([itemId, startDate, endDate]) + @@index([rentalBookingId]) +} + +model RentalBooking { + id String @id @default(cuid()) + bookingId String? + tenantId String + providerId String + startDate DateTime + endDate DateTime + status RentalBookingStatus @default(PENDING) + paymentStatus PaymentStatus @default(PENDING) + itemsTotal Decimal @db.Decimal(10, 2) + depositTotal Decimal @db.Decimal(10, 2) + commissionAmount Decimal @db.Decimal(10, 2) @default(0) + amount Decimal @db.Decimal(10, 2) + currency String @default("EUR") + stripeSessionId String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + booking Booking? @relation(fields: [bookingId], references: [id], onDelete: SetNull) + tenant User @relation("RentalBookingTenant", fields: [tenantId], references: [id], onDelete: Restrict) + provider RentalProvider @relation(fields: [providerId], references: [id], onDelete: Restrict) + lines RentalLine[] + + @@index([tenantId, status]) + @@index([providerId, status]) + @@index([bookingId]) + @@index([startDate, endDate]) +} + +model RentalLine { + id String @id @default(cuid()) + rentalBookingId String + itemId String + qty Int + pricePerDay Decimal @db.Decimal(8, 2) + deposit Decimal @db.Decimal(8, 2) @default(0) + lineTotal Decimal @db.Decimal(10, 2) + + rentalBooking RentalBooking @relation(fields: [rentalBookingId], references: [id], onDelete: Cascade) + item RentalItem @relation(fields: [itemId], references: [id], onDelete: Restrict) + + @@index([rentalBookingId]) +} diff --git a/public/icons/apple-touch-icon.png b/public/icons/apple-touch-icon.png new file mode 100644 index 0000000..a185b67 Binary files /dev/null and b/public/icons/apple-touch-icon.png differ diff --git a/public/icons/favicon-32.png b/public/icons/favicon-32.png new file mode 100644 index 0000000..c062acf Binary files /dev/null and b/public/icons/favicon-32.png differ diff --git a/public/icons/icon-192-maskable.png b/public/icons/icon-192-maskable.png new file mode 100644 index 0000000..e80f811 Binary files /dev/null and b/public/icons/icon-192-maskable.png differ diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png new file mode 100644 index 0000000..cb0fd13 Binary files /dev/null and b/public/icons/icon-192.png differ diff --git a/public/icons/icon-512-maskable.png b/public/icons/icon-512-maskable.png new file mode 100644 index 0000000..5041e00 Binary files /dev/null and b/public/icons/icon-512-maskable.png differ diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png new file mode 100644 index 0000000..abb04bf Binary files /dev/null and b/public/icons/icon-512.png differ diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 0000000..2f32e8d --- /dev/null +++ b/public/manifest.webmanifest @@ -0,0 +1,60 @@ +{ + "name": "Karbé — carbets fluviaux de Guyane", + "short_name": "Karbé", + "description": "Au fil de l'eau : louez des carbets le long des fleuves de Guyane.", + "start_url": "/decouvrir", + "id": "/decouvrir", + "scope": "/", + "display": "standalone", + "orientation": "portrait", + "background_color": "#000000", + "theme_color": "#059669", + "lang": "fr", + "categories": ["travel", "lifestyle"], + "icons": [ + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icons/icon-192-maskable.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "/icons/icon-512-maskable.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ], + "shortcuts": [ + { + "name": "Au fil de l'eau", + "short_name": "Découvrir", + "url": "/decouvrir", + "icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192" }] + }, + { + "name": "Mes favoris", + "short_name": "Favoris", + "url": "/mes-favoris", + "icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192" }] + }, + { + "name": "Mon compte", + "short_name": "Compte", + "url": "/mon-compte", + "icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192" }] + } + ] +} diff --git a/scripts/backup-postgres.sh b/scripts/backup-postgres.sh new file mode 100755 index 0000000..abe63d4 --- /dev/null +++ b/scripts/backup-postgres.sh @@ -0,0 +1,54 @@ +#!/bin/bash +# +# Backup nightly du PostgreSQL Karbé vers MinIO. +# Lancé par un systemd timer (karbe-backup.timer). +# +# Rétention 30 jours côté MinIO (s'appuyer sur une lifecycle policy ou un +# nettoyage côté `mc rm` planifié — TODO si on veut être propre). + +set -euo pipefail + +STAMP=$(date -u +%Y%m%d-%H%M%S) +DUMP_DIR=/tmp/karbe-backup +DUMP_FILE="$DUMP_DIR/karbe-${STAMP}.sql.gz" +BUCKET_DEST="karbe-backups/postgres/karbe-${STAMP}.sql.gz" + +mkdir -p "$DUMP_DIR" + +# Dump compressé depuis le conteneur postgres +docker compose -f /home/ubuntu/karbe/docker-compose.prod.yml \ + -f /home/ubuntu/karbe/docker-compose.override.yml \ + exec -T postgres pg_dump -U karbe -d karbe \ + | gzip > "$DUMP_FILE" + +SIZE=$(stat -c %s "$DUMP_FILE") +echo "[$(date -u +%FT%TZ)] dump created size=${SIZE}B path=${DUMP_FILE}" + +# Push vers MinIO via mc Docker +docker run --rm --network karbe-net \ + --entrypoint /bin/sh \ + -v "$DUMP_DIR:/dump" \ + -e MINIO_ROOT_USER \ + -e MINIO_ROOT_PASSWORD \ + minio/mc:latest -c " + mc alias set karbe http://minio:9000 \"\$MINIO_ROOT_USER\" \"\$MINIO_ROOT_PASSWORD\" >/dev/null 2>&1 && \ + mc mb karbe/karbe-backups --ignore-existing >/dev/null 2>&1 && \ + mc cp /dump/karbe-${STAMP}.sql.gz karbe/${BUCKET_DEST} + " + +echo "[$(date -u +%FT%TZ)] uploaded to karbe/${BUCKET_DEST}" + +# Nettoyage local +rm -f "$DUMP_FILE" + +# Rétention : supprime les backups > 30 jours dans MinIO +docker run --rm --network karbe-net \ + --entrypoint /bin/sh \ + -e MINIO_ROOT_USER \ + -e MINIO_ROOT_PASSWORD \ + minio/mc:latest -c " + mc alias set karbe http://minio:9000 \"\$MINIO_ROOT_USER\" \"\$MINIO_ROOT_PASSWORD\" >/dev/null 2>&1 && \ + mc rm --recursive --force --older-than 30d karbe/karbe-backups/ 2>/dev/null || true + " + +echo "[$(date -u +%FT%TZ)] retention sweep done (>30d removed)" diff --git a/src/app/accueil/page.tsx b/src/app/accueil/page.tsx new file mode 100644 index 0000000..513e1ac --- /dev/null +++ b/src/app/accueil/page.tsx @@ -0,0 +1,60 @@ +import Link from "next/link"; +import { IfPluginEnabled } from "@/components/IfPluginEnabled"; +import { HeroSection } from "@/components/landing/HeroSection"; +import { ExperiencesSection } from "@/components/landing/ExperiencesSection"; +import { HowItWorksSection } from "@/components/landing/HowItWorksSection"; +import { CESection } from "@/components/landing/CESection"; +import { TestimonialsSection } from "@/components/landing/TestimonialsSection"; +import { LandingFooter } from "@/components/landing/Footer"; + +export const metadata = { title: "Accueil — Karbé" }; + +/** + * Landing « marketing » historique (hero + sections + footer riche). Conservée + * à /accueil après la promotion de /decouvrir comme nouvelle page d'index. + */ +export default function LandingPage() { + return ( + <> + +
+

+ Karbé — carbets fluviaux de Guyane +

+

+ La marketplace pour louer des carbets le long des fleuves de Guyane. +

+
+ + Au fil de l'eau + + + Catalogue + +
+
+ + } + > + +
+ + + + + + + + + + ); +} diff --git a/src/app/admin/carbets/[id]/page.tsx b/src/app/admin/carbets/[id]/page.tsx index 02c0d80..bf7a972 100644 --- a/src/app/admin/carbets/[id]/page.tsx +++ b/src/app/admin/carbets/[id]/page.tsx @@ -7,7 +7,7 @@ import { } from "@/lib/admin/carbets"; import { CarbetForm } from "../_components/CarbetForm"; import { StatusBadge } from "@/components/admin/StatusBadge"; -import { MediaManager } from "./_components/MediaManager"; +import { MediaUploader } from "@/components/MediaUploader"; import { StatusActions } from "./_components/StatusActions"; import { updateCarbetAction } from "../actions"; @@ -61,16 +61,21 @@ export default async function EditCarbetPage({ params }: PageProps) { - ({ - id: m.id, - type: m.type, - s3Key: m.s3Key, - s3Url: m.s3Url, - sortOrder: m.sortOrder, - }))} - /> +
+

+ Médias +

+ ({ + id: m.id, + type: m.type, + s3Key: m.s3Key, + s3Url: m.s3Url, + sortOrder: m.sortOrder, + }))} + /> +
+ {/* Critères opérationnels */} +
+

+ Critères opérationnels +

+

+ Les 4 dealbreakers d'un séjour en carbet guyanais. Indispensable pour les filtres recherche. +

+
+ + + + + + + + + + + + + + + +
+
+ {/* Séjour & tarif */}

Séjour & tarif

diff --git a/src/app/admin/carbets/actions.ts b/src/app/admin/carbets/actions.ts index 9e2fbff..2004bd8 100644 --- a/src/app/admin/carbets/actions.ts +++ b/src/app/admin/carbets/actions.ts @@ -10,7 +10,9 @@ import { prisma } from "@/lib/prisma"; import { AccessType, CarbetStatus, + Electricity, MediaType, + RoadAccess, TransportMode, UserRole, } from "@/generated/prisma/enums"; @@ -29,6 +31,16 @@ const baseCarbetSchema = z.object({ capacity: z.coerce.number().int().min(1).max(100), nightlyPrice: z.coerce.number().min(0).max(100000), accessType: z.enum([AccessType.ROAD_AND_RIVER, AccessType.RIVER_ONLY]), + roadAccess: z + .enum([RoadAccess.NONE, RoadAccess.DRY_SEASON_ONLY, RoadAccess.ALL_YEAR]) + .optional() + .nullable(), + electricity: z + .enum([Electricity.NONE, Electricity.SOLAR, Electricity.GENERATOR_READY, Electricity.EDF]) + .optional() + .nullable(), + gsmAtCarbet: z.preprocess((v) => v === "yes" || v === true, z.boolean()), + gsmExitDistanceKm: z.coerce.number().min(0).max(50).optional().nullable(), roadAccessNote: z.string().trim().max(1000).optional().nullable(), pirogueDurationMin: z.coerce.number().int().min(0).max(1440).optional().nullable(), minStayNights: z.coerce.number().int().min(1).max(365).optional().nullable(), @@ -53,9 +65,11 @@ function parseFromFormData(fd: FormData) { if (typeof v === "string") obj[k] = v; } // Normalise les champs optionnels nullables - ["roadAccessNote", "pirogueDurationMin", "minStayNights", "maxStayNights", "minCapacity", "transportMode", "pirogueProviderId"].forEach( + ["roadAccessNote", "pirogueDurationMin", "minStayNights", "maxStayNights", "minCapacity", "transportMode", "pirogueProviderId", "roadAccess", "electricity", "gsmExitDistanceKm"].forEach( (k) => (obj[k] = normalizeNullable(obj[k] as string | null | undefined)), ); + // gsmAtCarbet : si pas posté, on garde la valeur (sera traité par preprocess Zod) + if (!("gsmAtCarbet" in obj)) obj.gsmAtCarbet = "no"; return obj; } diff --git a/src/app/admin/rental-items/[id]/_components/ItemInlineActions.tsx b/src/app/admin/rental-items/[id]/_components/ItemInlineActions.tsx new file mode 100644 index 0000000..8a6a00f --- /dev/null +++ b/src/app/admin/rental-items/[id]/_components/ItemInlineActions.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; + +type Props = { + active: boolean; + toggleActiveAction: (active: boolean) => Promise<{ ok: true } | { ok: false; error: string } | undefined>; + deleteAction: () => Promise<{ ok: true } | { ok: false; error: string } | undefined | void>; +}; + +export function ItemInlineActions({ active, toggleActiveAction, deleteAction }: Props) { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + const [confirmDelete, setConfirmDelete] = useState(false); + const [error, setError] = useState(null); + + function toggle() { + setError(null); + startTransition(async () => { + const res = await toggleActiveAction(!active); + if (res && (res as { ok?: boolean }).ok === false) setError((res as { error: string }).error); + router.refresh(); + }); + } + function del() { + setError(null); + startTransition(async () => { + const res = await deleteAction(); + if (res && (res as { ok?: boolean }).ok === false) { + setError((res as { error: string }).error); + setConfirmDelete(false); + } + }); + } + + return ( +
+
+ + {confirmDelete ? ( +
+ Supprimer ? + + +
+ ) : ( + + )} +
+ {error ?
{error}
: null} +
+ ); +} diff --git a/src/app/admin/rental-items/[id]/page.tsx b/src/app/admin/rental-items/[id]/page.tsx new file mode 100644 index 0000000..8f4dd4a --- /dev/null +++ b/src/app/admin/rental-items/[id]/page.tsx @@ -0,0 +1,83 @@ +import { notFound } from "next/navigation"; +import Link from "next/link"; + +import { StatusBadge } from "@/components/admin/StatusBadge"; +import { getRentalItemForAdmin, listProvidersForSelect, RENTAL_CATEGORY_LABEL } from "@/lib/admin/rental-items"; + +import { ItemForm } from "../_components/ItemForm"; +import { ItemInlineActions } from "./_components/ItemInlineActions"; +import { deleteRentalItemAction, toggleRentalItemActiveAction, updateRentalItemAction } from "../actions"; + +export const dynamic = "force-dynamic"; + +type PageProps = { params: Promise<{ id: string }> }; + +export default async function EditRentalItemPage({ params }: PageProps) { + const { id } = await params; + const [item, providers] = await Promise.all([getRentalItemForAdmin(id), listProvidersForSelect()]); + if (!item) notFound(); + + const updateThis = async (fd: FormData) => { + "use server"; + return await updateRentalItemAction(id, fd); + }; + const toggleActiveThis = async (active: boolean) => { + "use server"; + return await toggleRentalItemActiveAction(id, active); + }; + const deleteThis = async () => { + "use server"; + return await deleteRentalItemAction(id); + }; + + return ( +
+
+
+ + ← Tous les items + +

+ {item.name} + +

+

+ {RENTAL_CATEGORY_LABEL[item.category]} ·{" "} + + {item.provider.name} + + {item.provider.isSystemD ? " (System D)" : ""} +

+
+ +
+ +
+ +
+
+ ); +} diff --git a/src/app/admin/rental-items/_components/ItemForm.tsx b/src/app/admin/rental-items/_components/ItemForm.tsx new file mode 100644 index 0000000..27ad4b2 --- /dev/null +++ b/src/app/admin/rental-items/_components/ItemForm.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { FormField, inputCls, selectCls, textareaCls } from "@/components/admin/FormField"; +import { RENTAL_CATEGORY_LABEL, RENTAL_CATEGORIES } from "@/lib/rental-category-labels"; + +type Props = { + providers: { id: string; name: string; isSystemD: boolean }[]; + initial?: { + providerId?: string; + category?: string; + name?: string; + description?: string | null; + imageUrl?: string | null; + pricePerDay?: string | number; + pricePerWeek?: string | number | null; + deposit?: string | number; + totalQty?: number; + withMotor?: boolean; + fuelIncluded?: boolean; + requiresLicense?: boolean; + active?: boolean; + }; + action: (fd: FormData) => Promise<{ ok: false; error: string } | { ok: true } | undefined>; + submitLabel?: string; +}; + +export function ItemForm({ providers, initial = {}, action, submitLabel = "Enregistrer" }: Props) { + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + function onSubmit(fd: FormData) { + setError(null); + setSuccess(null); + startTransition(async () => { + const res = await action(fd); + if (res && res.ok === false) setError(res.error); + else if (res && res.ok === true) setSuccess("Enregistré."); + }); + } + + return ( +
+
+
+ + + + + + + + + + +