diff --git a/src/lib/ce-invites.ts b/src/lib/ce-invites.ts index d288971..ca8fb26 100644 --- a/src/lib/ce-invites.ts +++ b/src/lib/ce-invites.ts @@ -6,10 +6,24 @@ import { prisma } from "@/lib/prisma"; const INVITE_TTL_MS = 14 * 24 * 60 * 60 * 1000; // 14 jours -function hashToken(token: string): string { +/** Hash sha256 d'un token plain → utilisé comme PK pour ne jamais persister le plain. */ +export function hashToken(token: string): string { return crypto.createHash("sha256").update(token).digest("hex"); } +/** + * Vrai si une invitation est encore consommable : pas marquée `usedAt` + * et pas encore expirée. Helper extrait pour testabilité. + */ +export function isInviteValid( + row: { expiresAt: Date; usedAt: Date | null }, + now: Date = new Date(), +): boolean { + if (row.usedAt) return false; + if (row.expiresAt < now) return false; + return true; +} + export async function createOrgInviteToken(opts: { organizationId: string; createdByUserId: string; @@ -49,8 +63,7 @@ export async function getOrgInviteByToken(plainToken: string) { }, }); if (!row) return null; - if (row.usedAt) return null; - if (row.expiresAt < new Date()) return null; + if (!isInviteValid(row)) return null; return row; } diff --git a/src/lib/plugins/hooks.ts b/src/lib/plugins/hooks.ts index d3ff76e..f984522 100644 --- a/src/lib/plugins/hooks.ts +++ b/src/lib/plugins/hooks.ts @@ -15,6 +15,7 @@ export interface PluginHookSet { } import { archiveDemoCarbets, seedDemoCarbets } from "./seeds/demo-carbets"; +import { archiveDemoCe, seedDemoCe } from "./seeds/demo-ce"; import { republishContentPages, seedContentPages, @@ -134,4 +135,18 @@ export const pluginHooks: Record = { ); }, }, + "demo-ce-seed": { + onEnable: async () => { + const { created, orgId } = await seedDemoCe(); + console.log( + `[plugin demo-ce-seed] ${created ? "créé" : "déjà présent"}: orgId=${orgId}`, + ); + }, + onDisable: async () => { + const { deletedUsers, deletedOrg } = await archiveDemoCe(); + console.log( + `[plugin demo-ce-seed] disable: ${deletedUsers} users supprimés, ${deletedOrg} org supprimée(s)`, + ); + }, + }, }; diff --git a/src/lib/plugins/registry.ts b/src/lib/plugins/registry.ts index 854b48c..ee5fe3b 100644 --- a/src/lib/plugins/registry.ts +++ b/src/lib/plugins/registry.ts @@ -149,6 +149,14 @@ export const PLUGINS: PluginDescriptor[] = [ category: "content", version: "0.1.0", }, + { + key: "demo-ce-seed", + name: "Démo Comité d'Entreprise", + description: + "Seed une organisation CE démo (Comité ESA Kourou) avec 2 managers, 3 membres, 2 carbets co-gérés et 1 provider rental org-scoped + 4 items. Utile pour visualiser le module CE sans signup manuel. Disable : carbets archivés, users + org supprimés. Dépend de `ce-management`.", + category: "visual", + version: "0.1.0", + }, ]; export const PLUGIN_KEYS = PLUGINS.map((p) => p.key); diff --git a/src/lib/plugins/seeds/demo-ce.ts b/src/lib/plugins/seeds/demo-ce.ts new file mode 100644 index 0000000..3f0ec74 --- /dev/null +++ b/src/lib/plugins/seeds/demo-ce.ts @@ -0,0 +1,234 @@ +/** + * Seed du plugin `demo-ce-seed`. + * + * Crée une organisation démo « Comité ESA Kourou » (approved=true) avec : + * - 2 CE_MANAGERs et 3 CE_MEMBERs (password "demo") + * - 2 carbets co-gérés (ownerId = manager#1) avec OrganizationCarbetMembership + * - 1 RentalProvider org-scoped avec 4 items + * + * Idempotent : check d'existence par email/slug stables avant chaque création. + * Disable : soft cleanup en cascade (les emails @karbe.demo sont supprimés, + * la cascade Prisma se charge des memberships, items, etc.). + */ + +import { + CarbetStatus, + RentalCategory, + UserRole, +} from "@/generated/prisma/enums"; +import { hashPassword } from "@/lib/password"; +import { prisma } from "@/lib/prisma"; + +const DEMO_ORG_SLUG = "demo-comite-esa-kourou"; +const DEMO_PROVIDER_NAME = "Matériel — Comité ESA Kourou (démo)"; + +const DEMO_USERS = [ + { email: "demo-ce-mgr1@karbe.demo", firstName: "Aline", lastName: "Spaceport", role: UserRole.CE_MANAGER }, + { email: "demo-ce-mgr2@karbe.demo", firstName: "Bruno", lastName: "Soyouz", role: UserRole.CE_MANAGER }, + { email: "demo-ce-mbr1@karbe.demo", firstName: "Clara", lastName: "Ariane", role: UserRole.CE_MEMBER }, + { email: "demo-ce-mbr2@karbe.demo", firstName: "David", lastName: "Vega", role: UserRole.CE_MEMBER }, + { email: "demo-ce-mbr3@karbe.demo", firstName: "Élodie", lastName: "Falcon", role: UserRole.CE_MEMBER }, +] as const; + +const DEMO_CARBETS = [ + { + slug: "demo-ce-karbe-sinnamary", + title: "Karbé CE Sinnamary", + river: "Sinnamary", + embarkPoint: "Dégrad Pointe Combi", + latitude: 5.39, + longitude: -53.0, + capacity: 6, + nightlyPrice: 95, + description: + "Carbet du Comité ESA Kourou sur la Sinnamary, accessible par 1h de pirogue depuis le dégrad. Réservé en priorité aux membres du CE le week-end, ouvert au public en semaine.", + }, + { + slug: "demo-ce-karbe-kourou", + title: "Karbé CE Tonate", + river: "Kourou", + embarkPoint: "Embarcadère Tonate", + latitude: 5.25, + longitude: -52.65, + capacity: 8, + nightlyPrice: 110, + description: + "Carbet d'entreprise sur le fleuve Kourou, accessible en voiture jusqu'au dégrad. Équipé pour 8 voyageurs.", + }, +] as const; + +const DEMO_RENTAL_ITEMS = [ + { name: "Hamac coton 2 places (démo CE)", category: RentalCategory.SLEEP, pricePerDay: 5, deposit: 15, totalQty: 12 }, + { name: "Moustiquaire fleuve (démo CE)", category: RentalCategory.SLEEP, pricePerDay: 3, deposit: 10, totalQty: 12 }, + { name: "Kayak monoplace (démo CE)", category: RentalCategory.NAVIGATION, pricePerDay: 35, deposit: 200, totalQty: 4 }, + { name: "Réchaud gaz (démo CE)", category: RentalCategory.COOKING, pricePerDay: 6, deposit: 30, totalQty: 5 }, +] as const; + +export async function seedDemoCe(): Promise<{ created: boolean; orgId: string }> { + // 1. Organisation (idempotent par slug) + const existing = await prisma.organization.findUnique({ + where: { slug: DEMO_ORG_SLUG }, + select: { id: true }, + }); + if (existing) { + return { created: false, orgId: existing.id }; + } + + const passwordHash = await hashPassword("demo"); + + const org = await prisma.organization.create({ + data: { + name: "Comité ESA Kourou (démo)", + slug: DEMO_ORG_SLUG, + description: + "Comité d'entreprise démo (fictif). Démontre la co-gestion de carbets et la location matériel par un CE sur Karbé.", + contactEmail: "demo-ce-mgr1@karbe.demo", + approved: true, + approvedAt: new Date(), + approvedBy: "demo-seed", + }, + select: { id: true }, + }); + + // 2. Membres + const users: { id: string; role: UserRole }[] = []; + for (const u of DEMO_USERS) { + const created = await prisma.user.upsert({ + where: { email: u.email }, + update: { organizationId: org.id, role: u.role }, + create: { + email: u.email, + passwordHash, + firstName: u.firstName, + lastName: u.lastName, + role: u.role, + organizationId: org.id, + isActive: true, + }, + select: { id: true, role: true }, + }); + users.push(created); + } + const mgr1 = users[0]!; + + // 3. Carbets + memberships + for (const c of DEMO_CARBETS) { + const existingCarbet = await prisma.carbet.findUnique({ + where: { slug: c.slug }, + select: { id: true }, + }); + if (existingCarbet) { + // S'assure que la membership existe + await prisma.organizationCarbetMembership + .upsert({ + where: { organizationId_carbetId: { organizationId: org.id, carbetId: existingCarbet.id } }, + update: {}, + create: { organizationId: org.id, carbetId: existingCarbet.id, addedByUserId: mgr1.id }, + }); + continue; + } + const carbet = await prisma.carbet.create({ + data: { + ownerId: mgr1.id, + title: c.title, + slug: c.slug, + description: c.description, + river: c.river, + latitude: c.latitude, + longitude: c.longitude, + embarkPoint: c.embarkPoint, + pirogueDurationMin: 60, + capacity: c.capacity, + nightlyPrice: c.nightlyPrice, + status: CarbetStatus.PUBLISHED, + }, + select: { id: true }, + }); + await prisma.organizationCarbetMembership.create({ + data: { organizationId: org.id, carbetId: carbet.id, addedByUserId: mgr1.id }, + }); + } + + // 4. RentalProvider org-scoped + items + let provider = await prisma.rentalProvider.findFirst({ + where: { organizationId: org.id }, + select: { id: true }, + }); + if (!provider) { + provider = await prisma.rentalProvider.create({ + data: { + name: DEMO_PROVIDER_NAME, + isSystemD: false, + managedByUserId: mgr1.id, + organizationId: org.id, + contactEmail: "demo-ce-mgr1@karbe.demo", + rivers: ["Sinnamary", "Kourou"], + commissionPct: 10, + active: true, + approved: true, + approvedAt: new Date(), + approvedBy: "demo-seed", + }, + select: { id: true }, + }); + } + + for (const item of DEMO_RENTAL_ITEMS) { + const existingItem = await prisma.rentalItem.findFirst({ + where: { providerId: provider.id, name: item.name }, + select: { id: true }, + }); + if (existingItem) continue; + await prisma.rentalItem.create({ + data: { + providerId: provider.id, + category: item.category, + name: item.name, + pricePerDay: item.pricePerDay, + deposit: item.deposit, + totalQty: item.totalQty, + active: true, + }, + }); + } + + return { created: true, orgId: org.id }; +} + +export async function archiveDemoCe(): Promise<{ deletedUsers: number; deletedOrg: number }> { + // Cascade Prisma : Org delete → memberships + invites cascade ; + // RentalProvider.organizationId → SetNull (l'orga disparaît, le provider + // reste rattaché au manager nominal, on le supprime explicitement). + // Users → on supprime les emails @karbe.demo (cascade RentalProvider via + // managedByUserId=SetNull, mais on garde les carbets ; archive les carbets + // démo via status=ARCHIVED pour pas casser les bookings historiques). + const org = await prisma.organization.findUnique({ + where: { slug: DEMO_ORG_SLUG }, + select: { id: true }, + }); + if (!org) return { deletedUsers: 0, deletedOrg: 0 }; + + // Soft-archive les carbets démo + await prisma.carbet.updateMany({ + where: { slug: { in: DEMO_CARBETS.map((c) => c.slug) } }, + data: { status: CarbetStatus.ARCHIVED }, + }); + + // Supprime le RentalProvider démo (cascade items + bookings → onDelete:Restrict + // sur bookings, donc skip si des bookings existent) + await prisma.rentalProvider + .deleteMany({ where: { organizationId: org.id, name: DEMO_PROVIDER_NAME } }) + .catch(() => {}); + + // Supprime les users démo (cascade memberships) + const { count: deletedUsers } = await prisma.user.deleteMany({ + where: { email: { endsWith: "@karbe.demo", in: DEMO_USERS.map((u) => u.email) } }, + }); + + // Supprime l'org (cascade memberships restantes) + const { count: deletedOrg } = await prisma.organization.deleteMany({ + where: { slug: DEMO_ORG_SLUG }, + }); + + return { deletedUsers, deletedOrg }; +} diff --git a/tests/lib/ce-invites.test.ts b/tests/lib/ce-invites.test.ts new file mode 100644 index 0000000..1d22d79 --- /dev/null +++ b/tests/lib/ce-invites.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, vi } from "vitest"; + +vi.mock("server-only", () => ({})); +vi.mock("@/lib/prisma", () => ({ prisma: {} })); + +const { hashToken, isInviteValid } = await import("@/lib/ce-invites"); + +describe("hashToken", () => { + it("est déterministe — même input → même hash", () => { + expect(hashToken("abc")).toBe(hashToken("abc")); + }); + + it("hash sha256 (64 hex chars)", () => { + expect(hashToken("token")).toMatch(/^[0-9a-f]{64}$/); + }); + + it("inputs différents → hashes différents", () => { + expect(hashToken("abc")).not.toBe(hashToken("abd")); + }); + + it("ne retourne pas le plain (jamais persisté)", () => { + expect(hashToken("secret-plain-text")).not.toContain("secret"); + }); +}); + +describe("isInviteValid", () => { + const future = new Date(Date.now() + 24 * 3600 * 1000); + const past = new Date(Date.now() - 24 * 3600 * 1000); + + it("vrai si non consommé et non expiré", () => { + expect(isInviteValid({ expiresAt: future, usedAt: null })).toBe(true); + }); + + it("faux si déjà consommé", () => { + expect(isInviteValid({ expiresAt: future, usedAt: new Date() })).toBe(false); + }); + + it("faux si expiré", () => { + expect(isInviteValid({ expiresAt: past, usedAt: null })).toBe(false); + }); + + it("faux si consommé ET expiré (les 2 raisons)", () => { + expect(isInviteValid({ expiresAt: past, usedAt: new Date() })).toBe(false); + }); + + it("accepte un `now` injecté pour tests temporels", () => { + const ref = new Date("2026-06-01"); + const justAfter = new Date("2026-06-02"); + expect(isInviteValid({ expiresAt: justAfter, usedAt: null }, ref)).toBe(true); + expect(isInviteValid({ expiresAt: ref, usedAt: null }, justAfter)).toBe(false); + }); +});