init import projet
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
import { IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class AssignProjectParticipantDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
userId!: string;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { IsBoolean, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
export class CloneProjectDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(3)
|
||||
@MaxLength(120)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(400)
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
includeParticipants?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { ProjectStatus } from '@prisma/client';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayUnique,
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { MAX_PROJECT_BABIES } from '../project-scope.constants';
|
||||
|
||||
export class CreateProjectDto {
|
||||
@IsString()
|
||||
@MinLength(3)
|
||||
@MaxLength(120)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(400)
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
babyCount?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(ProjectStatus)
|
||||
status?: ProjectStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayUnique()
|
||||
@ArrayMaxSize(MAX_PROJECT_BABIES)
|
||||
@IsString({ each: true })
|
||||
babyLabels?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayUnique()
|
||||
@IsString({ each: true })
|
||||
enabledLegacyCardCodes?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayUnique()
|
||||
@IsString({ each: true })
|
||||
participantUserIds?: string[];
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { ProjectStatus } from '@prisma/client';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayUnique,
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { MAX_PROJECT_BABIES } from '../project-scope.constants';
|
||||
|
||||
export class UpdateProjectDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(3)
|
||||
@MaxLength(120)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(400)
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
babyCount?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(ProjectStatus)
|
||||
status?: ProjectStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayUnique()
|
||||
@ArrayMaxSize(MAX_PROJECT_BABIES)
|
||||
@IsString({ each: true })
|
||||
babyLabels?: string[];
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export const DEFAULT_WORKSPACE_ID = 'default_workspace';
|
||||
export const DEFAULT_WORKSPACE_SLUG = 'default-workspace';
|
||||
export const DEFAULT_WORKSPACE_NAME = 'Espace principal';
|
||||
|
||||
export const DEFAULT_PROJECT_ID = 'default_project';
|
||||
export const DEFAULT_PROJECT_NAME = 'Concours principal';
|
||||
|
||||
export const MAX_WORKSPACE_ADMINS = 3;
|
||||
export const MAX_PROJECT_BABIES = 3;
|
||||
@@ -0,0 +1,205 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
MaxFileSizeValidator,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
ParseFilePipe,
|
||||
Req,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { UserRole } from '@prisma/client';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { diskStorage } from 'multer';
|
||||
import { extname, join } from 'path';
|
||||
import { existsSync, mkdirSync, readdirSync } from 'fs';
|
||||
import type { AuthenticatedRequest } from '../auth/jwt-auth.guard';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { Roles } from '../auth/roles.decorator';
|
||||
import { RolesGuard } from '../auth/roles.guard';
|
||||
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 { ProjectsService } from './projects.service';
|
||||
|
||||
const uploadsDir = join(process.cwd(), 'uploads');
|
||||
|
||||
const storage = diskStorage({
|
||||
destination: (_req, _file, callback) => {
|
||||
if (!existsSync(uploadsDir)) {
|
||||
mkdirSync(uploadsDir, { recursive: true });
|
||||
}
|
||||
callback(null, uploadsDir);
|
||||
},
|
||||
filename: (_req, file, callback) => {
|
||||
const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
|
||||
callback(null, `project-${uniqueSuffix}${extname(file.originalname)}`);
|
||||
},
|
||||
});
|
||||
|
||||
@Controller('projects')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ProjectsController {
|
||||
private readonly defaultProjectAvatars: string[];
|
||||
|
||||
constructor(private readonly projectsService: ProjectsService) {
|
||||
const avatarsDir = join(process.cwd(), 'public', 'default-project-avatars');
|
||||
const imageExts = ['.png', '.jpg', '.jpeg', '.webp'];
|
||||
try {
|
||||
this.defaultProjectAvatars = readdirSync(avatarsDir)
|
||||
.filter((f) => imageExts.includes(extname(f).toLowerCase()))
|
||||
.sort()
|
||||
.map((f) => `/default-project-avatars/${f}`);
|
||||
} catch {
|
||||
this.defaultProjectAvatars = [];
|
||||
}
|
||||
}
|
||||
|
||||
@Get()
|
||||
listForUser(@Req() request: AuthenticatedRequest) {
|
||||
return this.projectsService.listForUser(request.user!.sub);
|
||||
}
|
||||
|
||||
@Get(':projectId')
|
||||
getById(@Req() request: AuthenticatedRequest, @Param('projectId') projectId: string) {
|
||||
return this.projectsService.getByIdForUser(request.user!.sub, projectId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
create(@Req() request: AuthenticatedRequest, @Body() dto: CreateProjectDto) {
|
||||
return this.projectsService.create(request.user!.sub, dto);
|
||||
}
|
||||
|
||||
@Patch(':projectId')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
update(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Param('projectId') projectId: string,
|
||||
@Body() dto: UpdateProjectDto,
|
||||
) {
|
||||
return this.projectsService.update(request.user!.sub, projectId, dto);
|
||||
}
|
||||
|
||||
@Post(':projectId/clone')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
clone(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Param('projectId') projectId: string,
|
||||
@Body() dto: CloneProjectDto,
|
||||
) {
|
||||
return this.projectsService.clone(request.user!.sub, projectId, dto);
|
||||
}
|
||||
|
||||
@Get(':projectId/participants')
|
||||
listParticipants(@Req() request: AuthenticatedRequest, @Param('projectId') projectId: string) {
|
||||
return this.projectsService.listParticipants(request.user!.sub, projectId);
|
||||
}
|
||||
|
||||
@Post(':projectId/participants')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
addParticipant(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Param('projectId') projectId: string,
|
||||
@Body() dto: AssignProjectParticipantDto,
|
||||
) {
|
||||
return this.projectsService.addParticipant(request.user!.sub, projectId, dto);
|
||||
}
|
||||
|
||||
@Delete(':projectId/participants/:participantUserId')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
removeParticipant(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Param('projectId') projectId: string,
|
||||
@Param('participantUserId') participantUserId: string,
|
||||
) {
|
||||
return this.projectsService.removeParticipant(
|
||||
request.user!.sub,
|
||||
projectId,
|
||||
participantUserId,
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':projectId/default-project-avatars')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
listDefaultProjectAvatars() {
|
||||
return this.defaultProjectAvatars;
|
||||
}
|
||||
|
||||
@Post(':projectId/photo')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
@UseInterceptors(FileInterceptor('file', { storage }))
|
||||
uploadProjectPhoto(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Param('projectId') projectId: string,
|
||||
@Body() body: { bgColor?: string },
|
||||
@UploadedFile(
|
||||
new ParseFilePipe({
|
||||
validators: [new MaxFileSizeValidator({ maxSize: 5 * 1024 * 1024 })],
|
||||
fileIsRequired: false,
|
||||
}),
|
||||
)
|
||||
file?: Express.Multer.File,
|
||||
) {
|
||||
if (file) {
|
||||
const allowedMimeTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/webp'];
|
||||
if (!allowedMimeTypes.includes(file.mimetype)) {
|
||||
throw new BadRequestException('Format non supporté. Utilisez PNG, JPG ou WEBP.');
|
||||
}
|
||||
}
|
||||
|
||||
if (!file && !body.bgColor) {
|
||||
throw new BadRequestException('Envoyez une image ou une couleur de fond.');
|
||||
}
|
||||
|
||||
return this.projectsService.updateProjectImage(
|
||||
request.user!.sub,
|
||||
projectId,
|
||||
file ? `/uploads/${file.filename}` : undefined,
|
||||
body.bgColor ?? undefined,
|
||||
);
|
||||
}
|
||||
|
||||
@Delete(':projectId/photo')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
deleteProjectPhoto(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Param('projectId') projectId: string,
|
||||
) {
|
||||
return this.projectsService.updateProjectImage(request.user!.sub, projectId, null, null);
|
||||
}
|
||||
|
||||
@Patch(':projectId/avatar')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
setProjectDefaultAvatar(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Param('projectId') projectId: string,
|
||||
@Body() body: { avatarUrl: string; bgColor?: string },
|
||||
) {
|
||||
if (!body.avatarUrl || !this.defaultProjectAvatars.includes(body.avatarUrl)) {
|
||||
throw new BadRequestException('Avatar par defaut invalide.');
|
||||
}
|
||||
return this.projectsService.updateProjectImage(
|
||||
request.user!.sub,
|
||||
projectId,
|
||||
body.avatarUrl,
|
||||
body.bgColor,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { ProjectsController } from './projects.controller';
|
||||
import { ProjectsService } from './projects.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [ProjectsController],
|
||||
providers: [ProjectsService],
|
||||
exports: [ProjectsService],
|
||||
})
|
||||
export class ProjectsModule {}
|
||||
@@ -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