feat(ce): Sprint P — seed démo CE + tests invites
All checks were successful
CI / test (push) Successful in 2m28s
All checks were successful
CI / test (push) Successful in 2m28s
This commit is contained in:
commit
9bdb3666a0
5 changed files with 325 additions and 3 deletions
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)`,
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
234
src/lib/plugins/seeds/demo-ce.ts
Normal file
234
src/lib/plugins/seeds/demo-ce.ts
Normal 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 };
|
||||
}
|
||||
52
tests/lib/ce-invites.test.ts
Normal file
52
tests/lib/ce-invites.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue