feat(ce): Sprint H — signup CE public + /espace-ce shell
All checks were successful
CI / test (pull_request) Successful in 2m33s
All checks were successful
CI / test (pull_request) Successful in 2m33s
src/lib/ce-access.ts (NEW) :
- requireCeManagerSession (redirect connexion ou / si rôle insuffisant)
- getCurrentCeOrganization (CE_MANAGER → son org via organizationId,
ADMIN → org ciblée par paramètre ou null)
- canManageCarbetForCe (owner direct OU membre d'une org liée)
- requireApprovedOrg (redirect /espace-ce?pending=1 si non validée)
Emails best-effort :
- sendNewCeRequest → admin (contact@karbe) avec lien filtré
/admin/organizations?status=pending
- sendCeApproved → CE_MANAGERs actifs de l'org après validation
- Branchement dans approveOrganizationAction : envoie le mail à tous
les CE_MANAGERs actifs de l'org en best-effort.
Signup CE public :
- SignupForm 4e tuile « Comité d'Entreprise » avec champ orgName.
Layout grid 4 colonnes sur lg, 2 sur sm.
- /api/signup étendu :
- zod accepte CE_MANAGER + orgName
- transaction $tx atomique : Organization (approved=false, slug
auto-unique via slugify + suffix) + User (role=CE_MANAGER,
organizationId lié)
- sendNewCeRequest best-effort
- réponse étendue avec organizationId
- Pattern slug : retry avec suffix -2, -3… jusqu'à libre
Dashboard /espace-ce :
- layout.tsx : requirePluginOr404("ce-management") +
requireCeManagerSession
- page.tsx : 4 KPIs (carbets co-gérés, items rental, bookings 30j,
revenu 30j), bannière « En attente de validation » si pending,
2 ActionCards (Mes carbets, Matériel rental) marquées « Bientôt »
jusqu'aux sprints I et J
- ce-dashboard.ts : getCeOrgKpis (agrège bookings carbets via
membership + rentalBookings via provider.organizationId) +
listCeCarbets pour Sprint I
SiteHeader : lien « Espace CE » conditionné par role + plugin
(mirror du lien Espace prestataire).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8609c3c98b
commit
63a29d9ade
9 changed files with 544 additions and 36 deletions
|
|
@ -5,8 +5,9 @@ import { UserRole } from "@/generated/prisma/enums";
|
|||
import { hashPassword } from "@/lib/password";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { recordAudit } from "@/lib/admin/audit";
|
||||
import { sendNewRentalProviderRequest, sendSignupWelcome } from "@/lib/email";
|
||||
import { sendNewCeRequest, sendNewRentalProviderRequest, sendSignupWelcome } from "@/lib/email";
|
||||
import { rateLimitRequest } from "@/lib/rate-limit";
|
||||
import { slugify } from "@/lib/slug";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
|
|
@ -17,10 +18,11 @@ const schema = z.object({
|
|||
lastName: z.string().trim().min(1).max(100),
|
||||
phone: z.string().trim().max(40).optional().nullable(),
|
||||
role: z
|
||||
.enum([UserRole.TOURIST, UserRole.OWNER, UserRole.RENTAL_PROVIDER])
|
||||
.enum([UserRole.TOURIST, UserRole.OWNER, UserRole.RENTAL_PROVIDER, UserRole.CE_MANAGER])
|
||||
.default(UserRole.TOURIST),
|
||||
providerName: z.string().trim().min(2).max(200).optional(),
|
||||
providerRivers: z.array(z.string().trim().min(1).max(80)).max(20).optional(),
|
||||
orgName: z.string().trim().min(2).max(200).optional(),
|
||||
});
|
||||
|
||||
export async function POST(req: Request) {
|
||||
|
|
@ -49,6 +51,9 @@ export async function POST(req: Request) {
|
|||
if (data.role === UserRole.RENTAL_PROVIDER && (!data.providerName || data.providerName.trim().length < 2)) {
|
||||
return NextResponse.json({ error: "Nom de votre activité requis." }, { status: 400 });
|
||||
}
|
||||
if (data.role === UserRole.CE_MANAGER && (!data.orgName || data.orgName.trim().length < 2)) {
|
||||
return NextResponse.json({ error: "Nom de votre Comité d'Entreprise requis." }, { status: 400 });
|
||||
}
|
||||
|
||||
const existing = await prisma.user.findUnique({ where: { email: data.email }, select: { id: true } });
|
||||
if (existing) {
|
||||
|
|
@ -56,38 +61,87 @@ export async function POST(req: Request) {
|
|||
}
|
||||
|
||||
const passwordHash = await hashPassword(data.password);
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: data.email,
|
||||
passwordHash,
|
||||
firstName: data.firstName,
|
||||
lastName: data.lastName,
|
||||
phone: data.phone?.trim() || null,
|
||||
role: data.role,
|
||||
isActive: true,
|
||||
},
|
||||
select: { id: true, email: true, role: true },
|
||||
});
|
||||
|
||||
// Pour un RENTAL_PROVIDER : crée le RentalProvider associé en attente d'approbation.
|
||||
// CE_MANAGER : transaction atomique User + Organization. Le slug est unique
|
||||
// sur Organization → on retente avec un suffixe en cas de collision.
|
||||
let createdProviderId: string | null = null;
|
||||
if (user.role === UserRole.RENTAL_PROVIDER && data.providerName) {
|
||||
const provider = await prisma.rentalProvider.create({
|
||||
data: {
|
||||
name: data.providerName,
|
||||
isSystemD: false,
|
||||
managedByUserId: user.id,
|
||||
contactEmail: user.email,
|
||||
contactPhone: data.phone?.trim() || null,
|
||||
rivers: data.providerRivers ?? [],
|
||||
commissionPct: 10, // valeur par défaut, ajustable par admin
|
||||
active: true,
|
||||
approved: false,
|
||||
},
|
||||
select: { id: true, name: true },
|
||||
let createdOrgId: string | null = null;
|
||||
let user: { id: string; email: string; role: UserRole };
|
||||
|
||||
if (data.role === UserRole.CE_MANAGER) {
|
||||
const orgName = data.orgName!.trim();
|
||||
const baseSlug = slugify(orgName);
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
// Trouve un slug libre
|
||||
let candidate = baseSlug || "ce";
|
||||
let suffix = 1;
|
||||
for (;;) {
|
||||
const exists = await tx.organization.findUnique({ where: { slug: candidate }, select: { id: true } });
|
||||
if (!exists) break;
|
||||
suffix += 1;
|
||||
candidate = `${baseSlug}-${suffix}`;
|
||||
}
|
||||
// candidate now holds a free slug
|
||||
const org = await tx.organization.create({
|
||||
data: {
|
||||
name: orgName,
|
||||
slug: candidate,
|
||||
contactEmail: data.email,
|
||||
approved: false,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
const u = await tx.user.create({
|
||||
data: {
|
||||
email: data.email,
|
||||
passwordHash,
|
||||
firstName: data.firstName,
|
||||
lastName: data.lastName,
|
||||
phone: data.phone?.trim() || null,
|
||||
role: UserRole.CE_MANAGER,
|
||||
organizationId: org.id,
|
||||
isActive: true,
|
||||
},
|
||||
select: { id: true, email: true, role: true },
|
||||
});
|
||||
return { user: u, orgId: org.id };
|
||||
});
|
||||
createdProviderId = provider.id;
|
||||
sendNewRentalProviderRequest(provider.name, user.email).catch(() => {});
|
||||
user = result.user;
|
||||
createdOrgId = result.orgId;
|
||||
sendNewCeRequest(orgName, user.email).catch(() => {});
|
||||
} else {
|
||||
user = await prisma.user.create({
|
||||
data: {
|
||||
email: data.email,
|
||||
passwordHash,
|
||||
firstName: data.firstName,
|
||||
lastName: data.lastName,
|
||||
phone: data.phone?.trim() || null,
|
||||
role: data.role,
|
||||
isActive: true,
|
||||
},
|
||||
select: { id: true, email: true, role: true },
|
||||
});
|
||||
|
||||
// Pour un RENTAL_PROVIDER : crée le RentalProvider associé en attente d'approbation.
|
||||
if (user.role === UserRole.RENTAL_PROVIDER && data.providerName) {
|
||||
const provider = await prisma.rentalProvider.create({
|
||||
data: {
|
||||
name: data.providerName,
|
||||
isSystemD: false,
|
||||
managedByUserId: user.id,
|
||||
contactEmail: user.email,
|
||||
contactPhone: data.phone?.trim() || null,
|
||||
rivers: data.providerRivers ?? [],
|
||||
commissionPct: 10, // valeur par défaut, ajustable par admin
|
||||
active: true,
|
||||
approved: false,
|
||||
},
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
createdProviderId = provider.id;
|
||||
sendNewRentalProviderRequest(provider.name, user.email).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
await recordAudit({
|
||||
|
|
@ -95,10 +149,15 @@ export async function POST(req: Request) {
|
|||
event: "user.create",
|
||||
target: user.id,
|
||||
actorEmail: user.email,
|
||||
details: { role: user.role, rentalProviderId: createdProviderId },
|
||||
details: { role: user.role, rentalProviderId: createdProviderId, organizationId: createdOrgId },
|
||||
});
|
||||
|
||||
sendSignupWelcome(user.email, data.firstName).catch(() => {});
|
||||
|
||||
return NextResponse.json({ ok: true, userId: user.id, providerId: createdProviderId });
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
userId: user.id,
|
||||
providerId: createdProviderId,
|
||||
organizationId: createdOrgId,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue