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