init import projet
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user