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
@@ -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,
);
}
}
+12
View File
@@ -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 {}
+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);
}
}