feat(ce): Sprint P — seed démo CE + tests invites
All checks were successful
CI / test (push) Successful in 2m28s

This commit is contained in:
tarzzan 2026-06-03 03:13:44 +00:00
commit 9bdb3666a0
5 changed files with 325 additions and 3 deletions

View file

@ -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;
}

View file

@ -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<string, PluginHookSet | undefined> = {
);
},
},
"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)`,
);
},
},
};

View file

@ -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);

View file

@ -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 };
}

View file

@ -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);
});
});