671 lines
18 KiB
TypeScript
671 lines
18 KiB
TypeScript
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);
|
|
}
|
|
}
|