From 26994a9a225ac6494ebc5a80d9375c2e6d3a4411 Mon Sep 17 00:00:00 2001 From: mpJunot Date: Wed, 11 Feb 2026 00:48:53 +0100 Subject: [PATCH 1/9] chore(backend): add BoardTemplate model and visibility migrations --- .../migration.sql | 25 ++++++++ .../migration.sql | 2 + backend/prisma/schema.prisma | 61 +++++++++++++------ 3 files changed, 71 insertions(+), 17 deletions(-) create mode 100644 backend/prisma/migrations/20260210185600_add_board_templates/migration.sql create mode 100644 backend/prisma/migrations/20260210223940_add_visibility_to_template/migration.sql diff --git a/backend/prisma/migrations/20260210185600_add_board_templates/migration.sql b/backend/prisma/migrations/20260210185600_add_board_templates/migration.sql new file mode 100644 index 0000000..28d86ff --- /dev/null +++ b/backend/prisma/migrations/20260210185600_add_board_templates/migration.sql @@ -0,0 +1,25 @@ +-- CreateTable +CREATE TABLE "board_templates" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT NOT NULL, + "lists" JSONB NOT NULL, + "workspaceId" TEXT, + "creatorId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "board_templates_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "board_templates_workspaceId_idx" ON "board_templates"("workspaceId"); + +-- CreateIndex +CREATE INDEX "board_templates_creatorId_idx" ON "board_templates"("creatorId"); + +-- AddForeignKey +ALTER TABLE "board_templates" ADD CONSTRAINT "board_templates_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "board_templates" ADD CONSTRAINT "board_templates_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/20260210223940_add_visibility_to_template/migration.sql b/backend/prisma/migrations/20260210223940_add_visibility_to_template/migration.sql new file mode 100644 index 0000000..dbf65a4 --- /dev/null +++ b/backend/prisma/migrations/20260210223940_add_visibility_to_template/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "board_templates" ADD COLUMN "visibility" "Visibility" NOT NULL DEFAULT 'PRIVATE'; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 05a8123..b127a00 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -107,6 +107,7 @@ model Workspace { boards Board[] memberships WorkspaceMember[] invitations WorkspaceInvitation[] + templates BoardTemplate[] @@map("workspaces") } @@ -132,18 +133,19 @@ model User { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - createdBoards Board[] @relation("BoardCreator") - boardMembers BoardMember[] - cardAssignees CardAssignee[] - comments Comment[] - attachments Attachment[] - workspaceMemberships WorkspaceMember[] - notifications Notification[] - oauthAccounts OAuthAccount[] - sentInvitations WorkspaceInvitation[] @relation("InvitationInviter") - receivedInvitations WorkspaceInvitation[] @relation("InvitationInvitee") - activities Activity[] + createdBoards Board[] @relation("BoardCreator") + boardMembers BoardMember[] + cardAssignees CardAssignee[] + comments Comment[] + attachments Attachment[] + workspaceMemberships WorkspaceMember[] + notifications Notification[] + oauthAccounts OAuthAccount[] + sentInvitations WorkspaceInvitation[] @relation("InvitationInviter") + receivedInvitations WorkspaceInvitation[] @relation("InvitationInvitee") + activities Activity[] notificationPreferences UserNotificationPreferences? + createdTemplates BoardTemplate[] @@map("users") } @@ -154,12 +156,12 @@ model User { // Per-user notification settings (email frequency, desktop notifications). model UserNotificationPreferences { - id String @id @default(uuid()) - userId String @unique - emailFrequency NotificationEmailFrequency @default(PERIODICALLY) - allowDesktopNotifications Boolean @default(false) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(uuid()) + userId String @unique + emailFrequency NotificationEmailFrequency @default(PERIODICALLY) + allowDesktopNotifications Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -526,3 +528,28 @@ model WorkspaceInvitation { @@index([status]) @@map("workspace_invitations") } + +// ---------------------------------------------------------------------------- +// BoardTemplate (CRUD - custom templates) +// ---------------------------------------------------------------------------- +// User-created board templates. Lists stored as JSON: [{ title, position, sampleCards?: [{ title, position }] }]. +// workspaceId null = global template; otherwise template is scoped to that workspace. + +model BoardTemplate { + id String @id @default(uuid()) + name String + description String + lists Json + visibility Visibility @default(PRIVATE) + workspaceId String? + creatorId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + workspace Workspace? @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + creator User @relation(fields: [creatorId], references: [id], onDelete: Cascade) + + @@index([workspaceId]) + @@index([creatorId]) + @@map("board_templates") +} From 6fed6473ce8aea9fc7db3af7421a82ec1d091738 Mon Sep 17 00:00:00 2001 From: mpJunot Date: Wed, 11 Feb 2026 00:49:05 +0100 Subject: [PATCH 2/9] feat(backend): add templates module (CRUD, visibility, createFromBoard) --- .../templates/dto/create-template.input.ts | 34 ++ .../templates/dto/template-list.input.ts | 32 ++ .../templates/dto/update-template.input.ts | 35 ++ .../templates/entities/template-list.type.ts | 22 + .../templates/entities/template.entity.ts | 33 ++ .../src/modules/templates/templates.module.ts | 11 + .../templates/templates.resolver.spec.ts | 135 +++++ .../modules/templates/templates.resolver.ts | 79 +++ .../templates/templates.service.spec.ts | 461 ++++++++++++++++++ .../modules/templates/templates.service.ts | 340 +++++++++++++ 10 files changed, 1182 insertions(+) create mode 100644 backend/src/modules/templates/dto/create-template.input.ts create mode 100644 backend/src/modules/templates/dto/template-list.input.ts create mode 100644 backend/src/modules/templates/dto/update-template.input.ts create mode 100644 backend/src/modules/templates/entities/template-list.type.ts create mode 100644 backend/src/modules/templates/entities/template.entity.ts create mode 100644 backend/src/modules/templates/templates.module.ts create mode 100644 backend/src/modules/templates/templates.resolver.spec.ts create mode 100644 backend/src/modules/templates/templates.resolver.ts create mode 100644 backend/src/modules/templates/templates.service.spec.ts create mode 100644 backend/src/modules/templates/templates.service.ts diff --git a/backend/src/modules/templates/dto/create-template.input.ts b/backend/src/modules/templates/dto/create-template.input.ts new file mode 100644 index 0000000..05ffede --- /dev/null +++ b/backend/src/modules/templates/dto/create-template.input.ts @@ -0,0 +1,34 @@ +import { InputType, Field, ID } from '@nestjs/graphql'; +import { IsNotEmpty, IsString, IsOptional, IsArray, ValidateNested, IsEnum } from 'class-validator'; +import { Type } from 'class-transformer'; +import { Visibility } from '@prisma/client'; +import { TemplateListInput } from './template-list.input'; + +@InputType() +export class CreateTemplateInput { + @Field() + @IsNotEmpty() + @IsString() + name: string; + + @Field() + @IsNotEmpty() + @IsString() + description: string; + + @Field(() => [TemplateListInput]) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => TemplateListInput) + lists: TemplateListInput[]; + + @Field(() => Visibility, { nullable: true }) + @IsOptional() + @IsEnum(Visibility) + visibility?: Visibility; + + @Field(() => ID, { nullable: true }) + @IsOptional() + @IsString() + workspaceId?: string; +} diff --git a/backend/src/modules/templates/dto/template-list.input.ts b/backend/src/modules/templates/dto/template-list.input.ts new file mode 100644 index 0000000..73673da --- /dev/null +++ b/backend/src/modules/templates/dto/template-list.input.ts @@ -0,0 +1,32 @@ +import { InputType, Field, Int, Float } from '@nestjs/graphql'; +import { IsString, IsNumber, IsOptional, IsArray, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; + +@InputType() +export class TemplateSampleCardInput { + @Field() + @IsString() + title: string; + + @Field(() => Float) + @IsNumber() + position: number; +} + +@InputType() +export class TemplateListInput { + @Field() + @IsString() + title: string; + + @Field(() => Int) + @IsNumber() + position: number; + + @Field(() => [TemplateSampleCardInput], { nullable: true }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => TemplateSampleCardInput) + sampleCards?: TemplateSampleCardInput[]; +} diff --git a/backend/src/modules/templates/dto/update-template.input.ts b/backend/src/modules/templates/dto/update-template.input.ts new file mode 100644 index 0000000..91ef889 --- /dev/null +++ b/backend/src/modules/templates/dto/update-template.input.ts @@ -0,0 +1,35 @@ +import { InputType, Field, ID } from '@nestjs/graphql'; +import { IsNotEmpty, IsString, IsOptional, IsArray, ValidateNested, IsEnum } from 'class-validator'; +import { Type } from 'class-transformer'; +import { Visibility } from '@prisma/client'; +import { TemplateListInput } from './template-list.input'; + +@InputType() +export class UpdateTemplateInput { + @Field(() => ID) + @IsNotEmpty() + @IsString() + id: string; + + @Field({ nullable: true }) + @IsOptional() + @IsString() + name?: string; + + @Field({ nullable: true }) + @IsOptional() + @IsString() + description?: string; + + @Field(() => [TemplateListInput], { nullable: true }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => TemplateListInput) + lists?: TemplateListInput[]; + + @Field(() => Visibility, { nullable: true }) + @IsOptional() + @IsEnum(Visibility) + visibility?: Visibility; +} diff --git a/backend/src/modules/templates/entities/template-list.type.ts b/backend/src/modules/templates/entities/template-list.type.ts new file mode 100644 index 0000000..eee55da --- /dev/null +++ b/backend/src/modules/templates/entities/template-list.type.ts @@ -0,0 +1,22 @@ +import { ObjectType, Field, Float, Int } from '@nestjs/graphql'; + +@ObjectType() +export class TemplateSampleCard { + @Field() + title: string; + + @Field(() => Float) + position: number; +} + +@ObjectType() +export class TemplateListType { + @Field() + title: string; + + @Field(() => Int) + position: number; + + @Field(() => [TemplateSampleCard], { nullable: true }) + sampleCards?: TemplateSampleCard[]; +} diff --git a/backend/src/modules/templates/entities/template.entity.ts b/backend/src/modules/templates/entities/template.entity.ts new file mode 100644 index 0000000..48c591d --- /dev/null +++ b/backend/src/modules/templates/entities/template.entity.ts @@ -0,0 +1,33 @@ +import { ObjectType, Field, ID } from '@nestjs/graphql'; +import { Visibility } from '@prisma/client'; +import { TemplateListType } from './template-list.type'; + +@ObjectType() +export class Template { + @Field(() => ID) + id: string; + + @Field() + name: string; + + @Field() + description: string; + + @Field(() => [TemplateListType]) + lists: TemplateListType[]; + + @Field(() => Visibility) + visibility: Visibility; + + @Field(() => ID, { nullable: true }) + workspaceId?: string; + + @Field(() => ID) + creatorId: string; + + @Field() + createdAt: Date; + + @Field() + updatedAt: Date; +} diff --git a/backend/src/modules/templates/templates.module.ts b/backend/src/modules/templates/templates.module.ts new file mode 100644 index 0000000..1191d1a --- /dev/null +++ b/backend/src/modules/templates/templates.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { TemplatesService } from './templates.service'; +import { TemplatesResolver } from './templates.resolver'; +import { PrismaModule } from '../../prisma/prisma.module'; + +@Module({ + imports: [PrismaModule], + providers: [TemplatesResolver, TemplatesService], + exports: [TemplatesService], +}) +export class TemplatesModule {} diff --git a/backend/src/modules/templates/templates.resolver.spec.ts b/backend/src/modules/templates/templates.resolver.spec.ts new file mode 100644 index 0000000..cedb9a3 --- /dev/null +++ b/backend/src/modules/templates/templates.resolver.spec.ts @@ -0,0 +1,135 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { TemplatesResolver } from './templates.resolver'; +import { TemplatesService } from './templates.service'; + +describe('TemplatesResolver', () => { + let resolver: TemplatesResolver; + + const mockTemplate = { + id: 'tpl-1', + name: 'My Template', + description: 'Description', + lists: [{ title: 'To Do', position: 0 }, { title: 'Done', position: 1 }], + creatorId: 'user-1', + createdAt: new Date(), + updatedAt: new Date(), + }; + + const mockTemplatesService = { + create: jest.fn(), + createFromBoard: jest.fn(), + findOne: jest.fn(), + findAll: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }; + + const mockUser = { id: 'user-1' }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TemplatesResolver, + { provide: TemplatesService, useValue: mockTemplatesService }, + ], + }).compile(); + + resolver = module.get(TemplatesResolver); + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(resolver).toBeDefined(); + }); + + describe('template', () => { + it('should return a template by id', async () => { + mockTemplatesService.findOne.mockResolvedValue(mockTemplate); + + const result = await resolver.template('tpl-1', mockUser); + + expect(result).toEqual(mockTemplate); + expect(mockTemplatesService.findOne).toHaveBeenCalledWith('tpl-1', mockUser.id); + }); + }); + + describe('templates', () => { + it('should return templates list', async () => { + mockTemplatesService.findAll.mockResolvedValue([mockTemplate]); + + const result = await resolver.templates(null, mockUser); + + expect(result).toHaveLength(1); + expect(mockTemplatesService.findAll).toHaveBeenCalledWith(null, mockUser.id); + }); + }); + + describe('createTemplate', () => { + it('should create a template', async () => { + mockTemplatesService.create.mockResolvedValue(mockTemplate); + + const input = { + name: 'My Template', + description: 'Description', + lists: [{ title: 'To Do', position: 0 }, { title: 'Done', position: 1 }], + }; + + const result = await resolver.createTemplate(input, mockUser); + + expect(result).toEqual(mockTemplate); + expect(mockTemplatesService.create).toHaveBeenCalledWith(input, mockUser.id); + }); + }); + + describe('createTemplateFromBoard', () => { + it('should create a template from a board', async () => { + mockTemplatesService.createFromBoard.mockResolvedValue(mockTemplate); + + const result = await resolver.createTemplateFromBoard('board-1', 'My board template', mockUser); + + expect(result).toEqual(mockTemplate); + expect(mockTemplatesService.createFromBoard).toHaveBeenCalledWith( + 'board-1', + mockUser.id, + 'My board template', + ); + }); + + it('should create a template from board without name', async () => { + mockTemplatesService.createFromBoard.mockResolvedValue(mockTemplate); + + await resolver.createTemplateFromBoard('board-1', undefined, mockUser); + + expect(mockTemplatesService.createFromBoard).toHaveBeenCalledWith( + 'board-1', + mockUser.id, + undefined, + ); + }); + }); + + describe('updateTemplate', () => { + it('should update a template', async () => { + const updated = { ...mockTemplate, name: 'Updated' }; + mockTemplatesService.update.mockResolvedValue(updated); + + const result = await resolver.updateTemplate( + { id: 'tpl-1', name: 'Updated' }, + mockUser, + ); + + expect(result.name).toBe('Updated'); + }); + }); + + describe('deleteTemplate', () => { + it('should delete a template', async () => { + mockTemplatesService.delete.mockResolvedValue(true); + + const result = await resolver.deleteTemplate('tpl-1', mockUser); + + expect(result).toBe(true); + expect(mockTemplatesService.delete).toHaveBeenCalledWith('tpl-1', mockUser.id); + }); + }); +}); diff --git a/backend/src/modules/templates/templates.resolver.ts b/backend/src/modules/templates/templates.resolver.ts new file mode 100644 index 0000000..48f77b4 --- /dev/null +++ b/backend/src/modules/templates/templates.resolver.ts @@ -0,0 +1,79 @@ +import { Resolver, Query, Mutation, Args, ID } from '@nestjs/graphql'; +import { UseGuards } from '@nestjs/common'; +import { TemplatesService } from './templates.service'; +import { Template } from './entities/template.entity'; +import { CreateTemplateInput } from './dto/create-template.input'; +import { UpdateTemplateInput } from './dto/update-template.input'; +import { GqlAuthGuard } from '../../common/guards/gql-auth.guard'; +import { CurrentUser } from '../../common/decorators/current-user.decorator'; + +@Resolver(() => Template) +@UseGuards(GqlAuthGuard) +export class TemplatesResolver { + constructor(private readonly templatesService: TemplatesService) {} + + @Query(() => Template, { + name: 'template', + description: 'Get a template by ID. User must have access (global or workspace member).', + }) + async template( + @Args('id', { type: () => ID }) id: string, + @CurrentUser() user: any, + ): Promise