init import projet

This commit is contained in:
2026-05-03 21:53:59 +02:00
parent f3756fdf8d
commit f4795e538c
179 changed files with 37694 additions and 132 deletions
+670
View File
@@ -0,0 +1,670 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
OnModuleInit,
} from '@nestjs/common';
import { GameStatus, ProjectStatus, UserRole } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { AssignProjectParticipantDto } from './dto/assign-project-participant.dto';
import { CloneProjectDto } from './dto/clone-project.dto';
import { CreateProjectDto } from './dto/create-project.dto';
import { UpdateProjectDto } from './dto/update-project.dto';
import {
DEFAULT_PROJECT_ID,
DEFAULT_WORKSPACE_ID,
DEFAULT_WORKSPACE_NAME,
DEFAULT_WORKSPACE_SLUG,
MAX_PROJECT_BABIES,
} from './project-scope.constants';
@Injectable()
export class ProjectsService implements OnModuleInit {
constructor(private readonly prismaService: PrismaService) {}
async onModuleInit() {
await this.ensureDefaultWorkspace();
}
private clampBabyCount(value?: number) {
if (!value) {
return 1;
}
return Math.min(MAX_PROJECT_BABIES, Math.max(1, Math.trunc(value)));
}
private mapProjectStatusToGameStatus(status: ProjectStatus): GameStatus {
if (status === ProjectStatus.CLOSED || status === ProjectStatus.FINALIZED) {
return GameStatus.CLOSED;
}
return GameStatus.OPEN;
}
private normalizeSlug(value: string) {
const normalized = value
.trim()
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
return normalized || `workspace-${Date.now()}`;
}
private async ensureWorkspaceForAdmin(userId: string) {
const user = await this.prismaService.user.findUnique({
where: { id: userId },
select: { id: true, role: true, workspaceId: true, username: true },
});
if (!user) {
throw new NotFoundException('Utilisateur introuvable');
}
if (user.role !== UserRole.ADMIN) {
throw new ForbiddenException('Seuls les comptes admin peuvent creer des projets');
}
if (user.workspaceId) {
return user.workspaceId;
}
const baseSlug = this.normalizeSlug(user.username);
const workspace = await this.prismaService.workspace.create({
data: {
name: `Espace ${user.username}`,
slug: `${baseSlug}-${Date.now()}`,
ownerUserId: user.id,
},
});
await this.prismaService.user.update({
where: { id: user.id },
data: { workspaceId: workspace.id },
});
return workspace.id;
}
private async ensureProjectBabies(projectId: string, babyCount: number, babyLabels?: string[]) {
const safeCount = this.clampBabyCount(babyCount);
for (let index = 1; index <= safeCount; index += 1) {
await this.prismaService.projectBaby.upsert({
where: {
projectId_babyIndex: {
projectId,
babyIndex: index,
},
},
update: {
label: babyLabels?.[index - 1] ?? `Bebe ${index}`,
},
create: {
projectId,
babyIndex: index,
label: babyLabels?.[index - 1] ?? `Bebe ${index}`,
},
});
}
await this.prismaService.projectBaby.deleteMany({
where: {
projectId,
babyIndex: { gt: safeCount },
},
});
}
private async assertProjectAccess(
userId: string,
projectId: string,
options?: { adminOnly?: boolean },
) {
const [user, project, membership] = await Promise.all([
this.prismaService.user.findUnique({
where: { id: userId },
select: { id: true, role: true, workspaceId: true },
}),
this.prismaService.project.findUnique({
where: { id: projectId },
}),
this.prismaService.projectMembership.findUnique({
where: { userId },
select: { id: true, projectId: true },
}),
]);
if (!user) {
throw new NotFoundException('Utilisateur introuvable');
}
if (!project) {
throw new NotFoundException('Projet introuvable');
}
if (!user.workspaceId || user.workspaceId !== project.workspaceId) {
throw new ForbiddenException('Acces refuse a ce projet');
}
if (user.role === UserRole.ADMIN) {
return { user, project, isAdmin: true };
}
if (options?.adminOnly) {
throw new ForbiddenException('Action reservee aux admins');
}
if (!membership || membership.projectId !== project.id) {
throw new ForbiddenException('Vous n etes pas rattache a ce projet');
}
return { user, project, isAdmin: false };
}
private async copyCardsFromProject(params: {
sourceProjectId: string;
targetProjectId: string;
targetGameId: string;
actorUserId: string;
filterLegacyCodes?: string[];
}) {
const sourceProjectFilter =
params.sourceProjectId === DEFAULT_PROJECT_ID
? { OR: [{ projectId: params.sourceProjectId }, { projectId: null }] }
: { projectId: params.sourceProjectId };
const sourceCards = await this.prismaService.predictionCard.findMany({
where: {
...sourceProjectFilter,
...(params.filterLegacyCodes?.length
? {
code: {
in: params.filterLegacyCodes,
},
}
: {}),
},
include: {
fields: { orderBy: { sortOrder: 'asc' } },
options: { orderBy: { sortOrder: 'asc' } },
},
orderBy: { sortOrder: 'asc' },
});
for (const sourceCard of sourceCards) {
const newCard = await this.prismaService.predictionCard.create({
data: {
gameId: params.targetGameId,
projectId: params.targetProjectId,
code: sourceCard.code,
title: sourceCard.title,
description: sourceCard.description,
type: sourceCard.type,
valueType: sourceCard.valueType,
styleId: sourceCard.styleId,
unit: sourceCard.unit,
isActive: sourceCard.isActive,
isDeletable: sourceCard.isDeletable,
sortOrder: sourceCard.sortOrder,
basePoints: sourceCard.basePoints,
createdById: params.actorUserId,
},
});
for (const field of sourceCard.fields) {
await this.prismaService.predictionCardField.create({
data: {
cardId: newCard.id,
label: field.label,
sortOrder: field.sortOrder,
points: field.points,
isPrimary: field.isPrimary,
isRequired: field.isRequired,
minNumber: field.minNumber,
maxNumber: field.maxNumber,
stepNumber: field.stepNumber,
},
});
}
for (const option of sourceCard.options) {
await this.prismaService.predictionCardOption.create({
data: {
cardId: newCard.id,
label: option.label,
value: option.value,
sortOrder: option.sortOrder,
},
});
}
}
}
private async assignParticipantToProjectInternal(
actorUserId: string,
projectId: string,
dto: AssignProjectParticipantDto,
) {
const project = await this.prismaService.project.findUnique({
where: { id: projectId },
select: { id: true, workspaceId: true },
});
if (!project) {
throw new NotFoundException('Projet introuvable');
}
const participant = await this.prismaService.user.findUnique({
where: { id: dto.userId },
select: {
id: true,
role: true,
workspaceId: true,
username: true,
displayName: true,
},
});
if (!participant) {
throw new NotFoundException('Compte participant introuvable');
}
if (participant.role === UserRole.ADMIN) {
throw new BadRequestException('Un admin ne peut pas etre rattache comme participant');
}
if (participant.workspaceId && participant.workspaceId !== project.workspaceId) {
throw new ForbiddenException('Ce participant depend d un autre espace');
}
if (!participant.workspaceId) {
await this.prismaService.user.update({
where: { id: participant.id },
data: { workspaceId: project.workspaceId },
});
}
const existingMembership = await this.prismaService.projectMembership.findUnique({
where: { userId: participant.id },
include: {
user: {
select: {
id: true,
username: true,
displayName: true,
role: true,
},
},
},
});
if (existingMembership) {
if (existingMembership.projectId !== projectId) {
return this.prismaService.projectMembership.update({
where: { userId: participant.id },
data: {
projectId,
assignedById: actorUserId,
},
include: {
user: {
select: {
id: true,
username: true,
displayName: true,
role: true,
},
},
},
});
}
return existingMembership;
}
return this.prismaService.projectMembership.create({
data: {
projectId,
userId: participant.id,
assignedById: actorUserId,
},
include: {
user: {
select: {
id: true,
username: true,
displayName: true,
role: true,
},
},
},
});
}
async ensureDefaultWorkspace() {
const firstAdmin = await this.prismaService.user.findFirst({
where: { role: UserRole.ADMIN },
orderBy: { createdAt: 'asc' },
select: {
id: true,
workspaceId: true,
},
});
if (!firstAdmin) {
return;
}
await this.prismaService.workspace.upsert({
where: { id: DEFAULT_WORKSPACE_ID },
update: {
name: DEFAULT_WORKSPACE_NAME,
slug: DEFAULT_WORKSPACE_SLUG,
ownerUserId: firstAdmin.id,
},
create: {
id: DEFAULT_WORKSPACE_ID,
name: DEFAULT_WORKSPACE_NAME,
slug: DEFAULT_WORKSPACE_SLUG,
ownerUserId: firstAdmin.id,
},
});
if (firstAdmin.workspaceId !== DEFAULT_WORKSPACE_ID) {
await this.prismaService.user.update({
where: { id: firstAdmin.id },
data: { workspaceId: DEFAULT_WORKSPACE_ID },
});
}
}
async listForUser(userId: string) {
const user = await this.prismaService.user.findUnique({
where: { id: userId },
select: { id: true, role: true, workspaceId: true },
});
if (!user) {
throw new NotFoundException('Utilisateur introuvable');
}
if (!user.workspaceId) {
return [];
}
if (user.role === UserRole.ADMIN) {
return this.prismaService.project.findMany({
where: { workspaceId: user.workspaceId },
include: {
babies: { orderBy: { babyIndex: 'asc' } },
_count: {
select: {
memberships: true,
cards: true,
},
},
},
orderBy: { createdAt: 'desc' },
});
}
const memberships = await this.prismaService.projectMembership.findMany({
where: { userId },
include: {
project: {
include: {
babies: { orderBy: { babyIndex: 'asc' } },
_count: {
select: {
memberships: true,
cards: true,
},
},
},
},
},
orderBy: { createdAt: 'desc' },
});
return memberships.map((membership) => membership.project);
}
async getByIdForUser(userId: string, projectId: string) {
await this.assertProjectAccess(userId, projectId);
const project = await this.prismaService.project.findUnique({
where: { id: projectId },
include: {
babies: { orderBy: { babyIndex: 'asc' } },
_count: {
select: {
memberships: true,
cards: true,
},
},
},
});
if (!project) {
throw new NotFoundException('Projet introuvable');
}
return project;
}
async create(adminUserId: string, dto: CreateProjectDto) {
const workspaceId = await this.ensureWorkspaceForAdmin(adminUserId);
const babyCount = this.clampBabyCount(dto.babyCount);
const project = await this.prismaService.project.create({
data: {
workspaceId,
createdById: adminUserId,
name: dto.name.trim(),
description: dto.description?.trim() || null,
status: dto.status ?? ProjectStatus.OPEN,
babyCount,
},
});
await this.ensureProjectBabies(project.id, babyCount, dto.babyLabels);
const game = await this.prismaService.predictionGame.create({
data: {
id: project.id,
projectId: project.id,
title: `Pronostics ${project.name}`,
status: this.mapProjectStatusToGameStatus(project.status),
},
});
await this.copyCardsFromProject({
sourceProjectId: DEFAULT_PROJECT_ID,
targetProjectId: project.id,
targetGameId: game.id,
actorUserId: adminUserId,
filterLegacyCodes: dto.enabledLegacyCardCodes,
});
if (dto.participantUserIds?.length) {
for (const participantUserId of dto.participantUserIds) {
await this.assignParticipantToProjectInternal(adminUserId, project.id, {
userId: participantUserId,
});
}
}
return this.getByIdForUser(adminUserId, project.id);
}
async update(adminUserId: string, projectId: string, dto: UpdateProjectDto) {
const { project } = await this.assertProjectAccess(adminUserId, projectId, {
adminOnly: true,
});
const babyCount = dto.babyCount ? this.clampBabyCount(dto.babyCount) : project.babyCount;
const updatedProject = await this.prismaService.project.update({
where: { id: projectId },
data: {
name: dto.name?.trim(),
description: dto.description?.trim() ?? (dto.description === '' ? null : undefined),
status: dto.status,
babyCount,
},
});
if (dto.babyCount || dto.babyLabels) {
await this.ensureProjectBabies(projectId, babyCount, dto.babyLabels);
}
if (dto.status) {
await this.prismaService.predictionGame.updateMany({
where: { projectId },
data: {
status: this.mapProjectStatusToGameStatus(dto.status),
closedAt: dto.status === ProjectStatus.CLOSED ? new Date() : null,
reopenedAt:
dto.status === ProjectStatus.OPEN || dto.status === ProjectStatus.DRAFT
? new Date()
: null,
},
});
}
return this.getByIdForUser(adminUserId, updatedProject.id);
}
async clone(adminUserId: string, sourceProjectId: string, dto: CloneProjectDto) {
const { project: sourceProject } = await this.assertProjectAccess(
adminUserId,
sourceProjectId,
{ adminOnly: true },
);
const sourceBabies = await this.prismaService.projectBaby.findMany({
where: { projectId: sourceProjectId },
orderBy: { babyIndex: 'asc' },
});
const cloneName = dto.name?.trim() || `${sourceProject.name} (copie)`;
const clonedProject = await this.prismaService.project.create({
data: {
workspaceId: sourceProject.workspaceId,
createdById: adminUserId,
clonedFromProjectId: sourceProject.id,
name: cloneName,
description: dto.description?.trim() || sourceProject.description,
status: sourceProject.status,
babyCount: sourceProject.babyCount,
},
});
await this.ensureProjectBabies(
clonedProject.id,
sourceProject.babyCount,
sourceBabies.map((baby) => baby.label ?? `Bebe ${baby.babyIndex}`),
);
const clonedGame = await this.prismaService.predictionGame.create({
data: {
id: clonedProject.id,
projectId: clonedProject.id,
title: `Pronostics ${cloneName}`,
status: this.mapProjectStatusToGameStatus(clonedProject.status),
},
});
await this.copyCardsFromProject({
sourceProjectId,
targetProjectId: clonedProject.id,
targetGameId: clonedGame.id,
actorUserId: adminUserId,
});
if (dto.includeParticipants) {
const sourceMemberships = await this.prismaService.projectMembership.findMany({
where: { projectId: sourceProjectId },
select: { userId: true },
});
for (const membership of sourceMemberships) {
await this.assignParticipantToProjectInternal(adminUserId, clonedProject.id, {
userId: membership.userId,
});
}
}
return this.getByIdForUser(adminUserId, clonedProject.id);
}
async listParticipants(userId: string, projectId: string) {
await this.assertProjectAccess(userId, projectId);
return this.prismaService.projectMembership.findMany({
where: { projectId },
include: {
user: {
select: {
id: true,
username: true,
displayName: true,
profileImageUrl: true,
profileBgColor: true,
role: true,
createdAt: true,
},
},
},
orderBy: { createdAt: 'asc' },
});
}
async addParticipant(
adminUserId: string,
projectId: string,
dto: AssignProjectParticipantDto,
) {
await this.assertProjectAccess(adminUserId, projectId, { adminOnly: true });
return this.assignParticipantToProjectInternal(adminUserId, projectId, dto);
}
async removeParticipant(adminUserId: string, projectId: string, participantUserId: string) {
await this.assertProjectAccess(adminUserId, projectId, { adminOnly: true });
const result = await this.prismaService.projectMembership.deleteMany({
where: {
projectId,
userId: participantUserId,
},
});
return { removed: result.count > 0 };
}
async updateProjectImage(
adminUserId: string,
projectId: string,
projectImageUrl?: string | null,
projectBgColor?: string | null,
) {
await this.assertProjectAccess(adminUserId, projectId, { adminOnly: true });
const data: { projectImageUrl?: string | null; projectBgColor?: string | null } = {};
if (projectImageUrl !== undefined) data.projectImageUrl = projectImageUrl;
if (projectBgColor !== undefined) data.projectBgColor = projectBgColor;
await this.prismaService.project.update({
where: { id: projectId },
data,
});
return this.getByIdForUser(adminUserId, projectId);
}
}