diff --git a/backend/jest.config.js b/backend/jest.config.js index 9b1198b..acaa5eb 100644 --- a/backend/jest.config.js +++ b/backend/jest.config.js @@ -1,6 +1,9 @@ module.exports = { moduleFileExtensions: ['js', 'json', 'ts'], rootDir: 'src', + moduleNameMapper: { + '^@/(.*)$': '/$1', + }, testRegex: '.*\\.spec\\.ts$', transform: { '^.+\\.(t|j)s$': 'ts-jest', 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") +} diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 2298842..18b1a3c 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -23,6 +23,7 @@ import { AttachmentsModule } from './modules/attachments/attachments.module'; import { UploadModule } from './modules/upload/upload.module'; import { ActivityModule } from './modules/activity/activity.module'; import { NotificationsModule } from './modules/notifications/notifications.module'; +import { TemplatesModule } from './modules/templates/templates.module'; import { SubscriptionsModule } from './common/subscriptions/subscriptions.module'; import { validateWsConnection } from './common/subscriptions/ws-auth'; import { GqlAuthGuard } from './common/guards/gql-auth.guard'; @@ -110,6 +111,7 @@ import { HealthController } from './common/controllers/health.controller'; UploadModule, ActivityModule, NotificationsModule, + TemplatesModule, ], controllers: [HealthController], providers: [ diff --git a/backend/src/graphql/schema.gql b/backend/src/graphql/schema.gql index b4abe4b..56f0c2d 100644 --- a/backend/src/graphql/schema.gql +++ b/backend/src/graphql/schema.gql @@ -115,6 +115,15 @@ type BoardMemberWithUser { userId: ID! } +type BoardTemplate { + description: String! + id: String! + + """List titles in order""" + listTitles: [String!]! + name: String! +} + type Card { assignees: [MemberUser!] background: String @@ -173,6 +182,19 @@ type CommentDeletedEvent { commentId: ID! } +input CopyBoardInput { + background: String + description: String + + """ID of the board to copy (lists, cards, labels, checklists).""" + sourceBoardId: ID! + + """Title for the new board.""" + title: String! + visibility: Visibility + workspaceId: ID +} + input CreateAttachmentInput { cardId: ID! filename: String! @@ -183,6 +205,9 @@ input CreateAttachmentInput { input CreateBoardInput { background: String description: String + + """Predefined template: blank, kanban, sprint, project""" + templateId: String title: String! visibility: Visibility workspaceId: ID @@ -227,6 +252,14 @@ input CreateListInput { title: String! } +input CreateTemplateInput { + description: String! + lists: [TemplateListInput!]! + name: String! + visibility: Visibility + workspaceId: ID +} + input CreateUserInput { avatar: String description: String @@ -356,6 +389,11 @@ type Mutation { """ cancelInvitation(invitationId: ID!): Boolean! + """ + Copy a board (lists, cards, labels, checklists). New board has current user as ADMIN. + """ + copyBoard(input: CopyBoardInput!): Board! + """Create an attachment on a card.""" createAttachment(input: CreateAttachmentInput!): Attachment! @@ -383,6 +421,16 @@ type Mutation { """ createList(input: CreateListInput!): List! + """ + Create a custom board template. Optional workspaceId to scope to a workspace. + """ + createTemplate(input: CreateTemplateInput!): Template! + + """ + Create a template from an existing board (lists and cards become template structure). + """ + createTemplateFromBoard(boardId: ID!, name: String): Template! + """Create a new user (requires authentication)""" createUser(input: CreateUserInput!): User! @@ -413,6 +461,9 @@ type Mutation { """Delete a list. Cards are automatically deleted via cascade.""" deleteList(id: ID!): Boolean! + """Delete a template. Only creator or workspace admin.""" + deleteTemplate(id: ID!): Boolean! + """Delete a user by ID (requires authentication)""" deleteUser(id: ID!): Boolean! @@ -536,6 +587,9 @@ type Mutation { """Update current user notification preferences.""" updateMyNotificationPreferences(input: UpdateNotificationPreferencesInput!): NotificationPreferences! + """Update a template. Only creator or workspace admin.""" + updateTemplate(input: UpdateTemplateInput!): Template! + """Update an existing user (requires authentication)""" updateUser(id: ID!, input: UpdateUserInput!): User! @@ -647,6 +701,9 @@ type Query { """List all labels for a board. User must have access to the board.""" boardLabels(boardId: ID!): [Label!]! + """List predefined board templates (blank, kanban, sprint, project).""" + boardTemplates: [BoardTemplate!]! + """Get a card by ID. User must have access to the board.""" card(id: ID!): Card! @@ -692,6 +749,16 @@ type Query { """Get all workspaces where the current user is a member""" myWorkspaces: [Workspace!]! + """ + Get a template by ID. User must have access (global or workspace member). + """ + template(id: ID!): Template! + + """ + List templates. If workspaceId is provided, returns global + workspace templates; otherwise global only. + """ + templates(workspaceId: ID): [Template!]! + """Get a user by ID (requires authentication)""" user(id: ID!): User @@ -824,6 +891,40 @@ type Subscription { workspaceMembersUpdated(workspaceId: ID!): Boolean! } +type Template { + createdAt: DateTime! + creatorId: ID! + description: String! + id: ID! + lists: [TemplateListType!]! + name: String! + updatedAt: DateTime! + visibility: Visibility! + workspaceId: ID +} + +input TemplateListInput { + position: Int! + sampleCards: [TemplateSampleCardInput!] + title: String! +} + +type TemplateListType { + position: Int! + sampleCards: [TemplateSampleCard!] + title: String! +} + +type TemplateSampleCard { + position: Float! + title: String! +} + +input TemplateSampleCardInput { + position: Float! + title: String! +} + input UnassignMemberFromCardInput { cardId: ID! userId: ID! @@ -903,6 +1004,14 @@ input UpdateNotificationPreferencesInput { emailFrequency: NotificationEmailFrequency } +input UpdateTemplateInput { + description: String + id: ID! + lists: [TemplateListInput!] + name: String + visibility: Visibility +} + input UpdateUserInput { avatar: String description: String diff --git a/backend/src/modules/boards/board-templates.ts b/backend/src/modules/boards/board-templates.ts new file mode 100644 index 0000000..f2bcf79 --- /dev/null +++ b/backend/src/modules/boards/board-templates.ts @@ -0,0 +1,72 @@ +/** + * Predefined board templates (Trello-style). + * Each template defines list titles and optional sample cards per list. + */ + +export type BoardTemplateList = { + title: string; + position: number; + sampleCards?: { title: string; position: number }[]; +}; + +export type BoardTemplate = { + id: string; + name: string; + description: string; + lists: BoardTemplateList[]; +}; + +export const BOARD_TEMPLATES: BoardTemplate[] = [ + { + id: 'blank', + name: 'Blank', + description: 'Empty board with To Do, Doing, Done', + lists: [ + { title: 'To Do', position: 0 }, + { title: 'Doing', position: 1 }, + { title: 'Done', position: 2 }, + ], + }, + { + id: 'kanban', + name: 'Kanban', + description: 'Classic Kanban: To Do, In Progress, Done', + lists: [ + { title: 'To Do', position: 0, sampleCards: [{ title: 'Get started', position: 0 }] }, + { title: 'In Progress', position: 1 }, + { title: 'Done', position: 2 }, + ], + }, + { + id: 'sprint', + name: 'Sprint', + description: 'Agile sprint: Backlog, To Do, In Progress, In Review, Done', + lists: [ + { title: 'Backlog', position: 0 }, + { title: 'To Do', position: 1 }, + { title: 'In Progress', position: 2 }, + { title: 'In Review', position: 3 }, + { title: 'Done', position: 4 }, + ], + }, + { + id: 'project', + name: 'Project', + description: 'Project tracking: To Do, In Progress, Blocked, Done', + lists: [ + { title: 'To Do', position: 0 }, + { title: 'In Progress', position: 1 }, + { title: 'Blocked', position: 2 }, + { title: 'Done', position: 3 }, + ], + }, +]; + +const TEMPLATES_BY_ID = new Map(BOARD_TEMPLATES.map((t) => [t.id, t])); + +export function getBoardTemplate(templateId: string | null | undefined): BoardTemplate { + if (!templateId) { + return BOARD_TEMPLATES[0]; // blank + } + return TEMPLATES_BY_ID.get(templateId) ?? BOARD_TEMPLATES[0]; +} diff --git a/backend/src/modules/boards/boards.module.ts b/backend/src/modules/boards/boards.module.ts index 33c3ac2..bad19a6 100644 --- a/backend/src/modules/boards/boards.module.ts +++ b/backend/src/modules/boards/boards.module.ts @@ -5,9 +5,10 @@ import { BoardSubscriptionResolver } from './board-subscription.resolver'; import { PrismaModule } from '../../prisma/prisma.module'; import { ActivityModule } from '../activity/activity.module'; import { NotificationsModule } from '../notifications/notifications.module'; +import { TemplatesModule } from '../templates/templates.module'; @Module({ - imports: [PrismaModule, ActivityModule, NotificationsModule], + imports: [PrismaModule, ActivityModule, NotificationsModule, TemplatesModule], providers: [BoardsResolver, BoardSubscriptionResolver, BoardsService], exports: [BoardsService], }) diff --git a/backend/src/modules/boards/boards.resolver.spec.ts b/backend/src/modules/boards/boards.resolver.spec.ts index f6484f5..a1ad46f 100644 --- a/backend/src/modules/boards/boards.resolver.spec.ts +++ b/backend/src/modules/boards/boards.resolver.spec.ts @@ -12,6 +12,7 @@ describe('BoardsResolver', () => { const mockBoardsService = { create: jest.fn(), + copy: jest.fn(), findOne: jest.fn(), findByWorkspace: jest.fn(), update: jest.fn(), @@ -144,6 +145,36 @@ describe('BoardsResolver', () => { }); }); + describe('copyBoard', () => { + it('should copy a board', async () => { + const input = { + sourceBoardId: 'board-1', + title: 'Copied Board', + workspaceId: 'workspace-1', + }; + + const copiedBoard = { ...mockBoard, id: 'board-2', title: 'Copied Board' }; + mockBoardsService.copy.mockResolvedValue(copiedBoard); + + const result = await resolver.copyBoard(input, mockUser); + + expect(result).toEqual(copiedBoard); + expect(service.copy).toHaveBeenCalledWith(input, mockUser.id); + }); + }); + + describe('boardTemplates', () => { + it('should return predefined board templates', () => { + const result = resolver.boardTemplates(); + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + expect(result[0]).toHaveProperty('id'); + expect(result[0]).toHaveProperty('name'); + expect(result[0]).toHaveProperty('description'); + expect(result[0]).toHaveProperty('listTitles'); + }); + }); + describe('board', () => { it('should return a board by id', async () => { mockBoardsService.findOne.mockResolvedValue(mockBoard); diff --git a/backend/src/modules/boards/boards.resolver.ts b/backend/src/modules/boards/boards.resolver.ts index 624af7b..a285595 100644 --- a/backend/src/modules/boards/boards.resolver.ts +++ b/backend/src/modules/boards/boards.resolver.ts @@ -4,7 +4,10 @@ import { PubSub } from 'graphql-subscriptions'; import { BoardsService } from './boards.service'; import { Board } from './entities/board.entity'; import { BoardMemberWithUser } from './entities/board-member.entity'; +import { BoardTemplate } from './entities/board-template.entity'; +import { BOARD_TEMPLATES } from './board-templates'; import { CreateBoardInput } from './dto/create-board.input'; +import { CopyBoardInput } from './dto/copy-board.input'; import { UpdateBoardInput } from './dto/update-board.input'; import { AddBoardMemberInput } from './dto/add-board-member.input'; import { UpdateBoardMemberRoleInput } from './dto/update-board-member-role.input'; @@ -51,6 +54,29 @@ export class BoardsResolver { return this.boardsService.create(input, user.id); } + @Mutation(() => Board, { + description: 'Copy a board (lists, cards, labels, checklists). New board has current user as ADMIN.', + }) + async copyBoard( + @Args('input') input: CopyBoardInput, + @CurrentUser() user: any, + ): Promise { + return this.boardsService.copy(input, user.id); + } + + @Query(() => [BoardTemplate], { + name: 'boardTemplates', + description: 'List predefined board templates (blank, kanban, sprint, project).', + }) + boardTemplates(): BoardTemplate[] { + return BOARD_TEMPLATES.map((t) => ({ + id: t.id, + name: t.name, + description: t.description, + listTitles: t.lists.map((l) => l.title), + })); + } + @Query(() => Board, { name: 'board', description: 'Get a board by ID. Access based on visibility and membership.', diff --git a/backend/src/modules/boards/boards.service.spec.ts b/backend/src/modules/boards/boards.service.spec.ts index b15c826..a8f7550 100644 --- a/backend/src/modules/boards/boards.service.spec.ts +++ b/backend/src/modules/boards/boards.service.spec.ts @@ -3,6 +3,7 @@ import { NotFoundException, ForbiddenException, ConflictException, BadRequestExc import { BoardsService } from './boards.service'; import { PrismaService } from '../../prisma/prisma.service'; import { NotificationsService } from '../notifications/notifications.service'; +import { TemplatesService } from '../templates/templates.service'; import { Role, Visibility } from '@prisma/client'; describe('BoardsService', () => { @@ -37,6 +38,10 @@ describe('BoardsService', () => { create: jest.fn().mockResolvedValue({ id: 'notif-1', userId: 'user-1', type: 'BOARD_INVITATION', read: false, createdAt: new Date() }), }; + const mockTemplatesService = { + getTemplateForBoard: jest.fn().mockResolvedValue(null), + }; + const mockUser = { id: 'user-1', email: 'test@example.com', @@ -77,6 +82,10 @@ describe('BoardsService', () => { provide: NotificationsService, useValue: mockNotificationsService, }, + { + provide: TemplatesService, + useValue: mockTemplatesService, + }, ], }).compile(); diff --git a/backend/src/modules/boards/boards.service.ts b/backend/src/modules/boards/boards.service.ts index 8786518..dee9ae2 100644 --- a/backend/src/modules/boards/boards.service.ts +++ b/backend/src/modules/boards/boards.service.ts @@ -3,16 +3,22 @@ import { NotificationType, Role } from '@prisma/client'; import { PrismaService } from '../../prisma/prisma.service'; import { NotificationsService } from '../notifications/notifications.service'; import { CreateBoardInput } from './dto/create-board.input'; +import { CopyBoardInput } from './dto/copy-board.input'; +import { getBoardTemplate } from './board-templates'; import { UpdateBoardInput } from './dto/update-board.input'; +import { TemplatesService } from '../templates/templates.service'; import { AddBoardMemberInput } from './dto/add-board-member.input'; import { Board } from './entities/board.entity'; import { BoardMemberWithUser } from './entities/board-member.entity'; +const SYSTEM_TEMPLATE_IDS = new Set(['blank', 'kanban', 'sprint', 'project']); + @Injectable() export class BoardsService { constructor( private prisma: PrismaService, private notificationsService: NotificationsService, + private templatesService: TemplatesService, ) {} /** @@ -51,14 +57,28 @@ export class BoardsService { } } - // Default columns for new boards (To Do, Doing, Done) - const defaultLists = [ - { title: 'To Do', position: 0 }, - { title: 'Doing', position: 1 }, - { title: 'Done', position: 2 }, - ]; + let templateLists: { title: string; position: number; sampleCards?: { title: string; position: number }[] }[]; + if (input.templateId && !SYSTEM_TEMPLATE_IDS.has(input.templateId)) { + const custom = await this.templatesService.getTemplateForBoard(input.templateId, userId); + templateLists = custom?.lists ?? getBoardTemplate(input.templateId).lists; + } else { + templateLists = getBoardTemplate(input.templateId).lists; + } + const listsCreate = templateLists.map((list) => ({ + title: list.title, + position: list.position, + ...(list.sampleCards?.length + ? { + cards: { + create: list.sampleCards.map((c) => ({ + title: c.title, + position: c.position, + })), + }, + } + : {}), + })); - // Create board with creator as ADMIN and default lists const board = await this.prisma.board.create({ data: { title: input.title, @@ -74,7 +94,7 @@ export class BoardsService { }, }, lists: { - create: defaultLists, + create: listsCreate, }, }, }); @@ -82,6 +102,138 @@ export class BoardsService { return board; } + /** + * Copy a board: create a new board with the same lists, cards, labels, and checklists. + * Does not copy members (except current user as ADMIN), comments, attachments, or assignees. + */ + async copy(input: CopyBoardInput, userId: string): Promise { + const source = await this.prisma.board.findUnique({ + where: { id: input.sourceBoardId }, + include: { + members: true, + workspace: { include: { memberships: true } }, + labels: true, + lists: { + where: { isArchived: false }, + orderBy: { position: 'asc' }, + include: { + cards: { + where: { isArchived: false }, + orderBy: { position: 'asc' }, + include: { + labels: true, + checklists: { include: { items: true } }, + }, + }, + }, + }, + }, + }); + + if (!source) { + throw new NotFoundException('Board not found'); + } + + await this.checkBoardAccess(source, userId); + + const workspaceId = input.workspaceId ?? source.workspaceId; + if (workspaceId) { + const membership = await this.prisma.workspaceMember.findUnique({ + where: { + workspaceId_userId: { workspaceId, userId }, + }, + }); + if (!membership) { + throw new ForbiddenException('You are not a member of this workspace'); + } + if (membership.role === Role.OBSERVER) { + throw new ForbiddenException('Observers cannot create boards'); + } + } + + const newBoard = await this.prisma.$transaction(async (tx) => { + const board = await tx.board.create({ + data: { + title: input.title, + description: input.description ?? source.description, + workspaceId, + visibility: input.visibility ?? source.visibility, + background: input.background ?? source.background, + creatorId: userId, + members: { + create: { userId, role: Role.ADMIN }, + }, + }, + }); + + const labelIdMap = new Map(); + for (const label of source.labels) { + const created = await tx.label.create({ + data: { + boardId: board.id, + name: label.name, + color: label.color, + }, + }); + labelIdMap.set(label.id, created.id); + } + + for (const list of source.lists) { + const newList = await tx.list.create({ + data: { + boardId: board.id, + title: list.title, + position: list.position, + }, + }); + + for (const card of list.cards) { + const newCard = await tx.card.create({ + data: { + listId: newList.id, + title: card.title, + description: card.description, + background: card.background, + startDate: card.startDate, + dueDate: card.dueDate, + position: card.position, + completed: card.completed, + }, + }); + + for (const cl of card.labels) { + const newLabelId = labelIdMap.get(cl.labelId); + if (newLabelId) { + await tx.cardLabel.create({ + data: { cardId: newCard.id, labelId: newLabelId }, + }); + } + } + + for (const checklist of card.checklists) { + const newChecklist = await tx.checklist.create({ + data: { cardId: newCard.id, title: checklist.title }, + }); + for (const item of checklist.items) { + await tx.checklistItem.create({ + data: { + checklistId: newChecklist.id, + content: item.content, + checked: item.checked, + position: item.position, + }, + }); + } + } + } + } + + return board; + }); + + return newBoard; + } + /** * Find board by ID * - User must be a member of the board OR workspace diff --git a/backend/src/modules/boards/dto/copy-board.input.ts b/backend/src/modules/boards/dto/copy-board.input.ts new file mode 100644 index 0000000..ff634f7 --- /dev/null +++ b/backend/src/modules/boards/dto/copy-board.input.ts @@ -0,0 +1,38 @@ +import { InputType, Field, ID } from '@nestjs/graphql'; +import { IsNotEmpty, IsString, IsOptional, IsEnum, IsUUID } from 'class-validator'; +import { Visibility } from '@prisma/client'; + +@InputType() +export class CopyBoardInput { + @Field(() => ID, { + description: 'ID of the board to copy (lists, cards, labels, checklists).', + }) + @IsNotEmpty() + @IsUUID() + sourceBoardId: string; + + @Field({ description: 'Title for the new board.' }) + @IsNotEmpty() + @IsString() + title: string; + + @Field(() => ID, { nullable: true }) + @IsOptional() + @IsUUID() + workspaceId?: string; + + @Field({ nullable: true }) + @IsOptional() + @IsString() + description?: string; + + @Field(() => Visibility, { nullable: true }) + @IsOptional() + @IsEnum(Visibility) + visibility?: Visibility; + + @Field({ nullable: true }) + @IsOptional() + @IsString() + background?: string; +} diff --git a/backend/src/modules/boards/dto/create-board.input.ts b/backend/src/modules/boards/dto/create-board.input.ts index 8db7706..d6bbc46 100644 --- a/backend/src/modules/boards/dto/create-board.input.ts +++ b/backend/src/modules/boards/dto/create-board.input.ts @@ -28,4 +28,9 @@ export class CreateBoardInput { @IsOptional() @IsString() background?: string; + + @Field({ nullable: true, description: 'Predefined template: blank, kanban, sprint, project' }) + @IsOptional() + @IsString() + templateId?: string; } diff --git a/backend/src/modules/boards/entities/board-template.entity.ts b/backend/src/modules/boards/entities/board-template.entity.ts new file mode 100644 index 0000000..1c3f31a --- /dev/null +++ b/backend/src/modules/boards/entities/board-template.entity.ts @@ -0,0 +1,16 @@ +import { ObjectType, Field } from '@nestjs/graphql'; + +@ObjectType() +export class BoardTemplate { + @Field() + id: string; + + @Field() + name: string; + + @Field() + description: string; + + @Field(() => [String], { description: 'List titles in order' }) + listTitles: string[]; +} diff --git a/backend/src/modules/invitations/workspace-members-subscription.resolver.spec.ts b/backend/src/modules/invitations/workspace-members-subscription.resolver.spec.ts new file mode 100644 index 0000000..a93180d --- /dev/null +++ b/backend/src/modules/invitations/workspace-members-subscription.resolver.spec.ts @@ -0,0 +1,78 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { WorkspaceMembersSubscriptionResolver } from './workspace-members-subscription.resolver'; +import { PUB_SUB } from '../../common/subscriptions/pubsub.provider'; +import { GqlAuthGuard } from '../../common/guards/gql-auth.guard'; +import { + TRIGGER_WORKSPACE_MEMBERS_UPDATED, + TRIGGER_WORKSPACE_INVITATIONS_UPDATED, + TRIGGER_MY_INVITATIONS_UPDATED, +} from './workspace-members-subscription.resolver'; + +describe('WorkspaceMembersSubscriptionResolver', () => { + let resolver: WorkspaceMembersSubscriptionResolver; + let pubSub: { asyncIterableIterator: jest.Mock }; + + beforeEach(async () => { + const mockIterator = { [Symbol.asyncIterator]: jest.fn() }; + pubSub = { + asyncIterableIterator: jest.fn().mockReturnValue(mockIterator), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + WorkspaceMembersSubscriptionResolver, + { + provide: PUB_SUB, + useValue: pubSub, + }, + ], + }) + .overrideGuard(GqlAuthGuard) + .useValue({ canActivate: () => true }) + .compile(); + + resolver = module.get( + WorkspaceMembersSubscriptionResolver, + ); + }); + + it('should be defined', () => { + expect(resolver).toBeDefined(); + }); + + describe('workspaceMembersUpdated', () => { + it('should return async iterable for TRIGGER_WORKSPACE_MEMBERS_UPDATED', () => { + const result = resolver.workspaceMembersUpdated('ws-1'); + + expect(pubSub.asyncIterableIterator).toHaveBeenCalledWith( + TRIGGER_WORKSPACE_MEMBERS_UPDATED, + ); + expect(result).toBeDefined(); + expect(typeof result[Symbol.asyncIterator]).toBe('function'); + }); + }); + + describe('workspaceInvitationsUpdated', () => { + it('should return async iterable for TRIGGER_WORKSPACE_INVITATIONS_UPDATED', () => { + const result = resolver.workspaceInvitationsUpdated('ws-1'); + + expect(pubSub.asyncIterableIterator).toHaveBeenCalledWith( + TRIGGER_WORKSPACE_INVITATIONS_UPDATED, + ); + expect(result).toBeDefined(); + expect(typeof result[Symbol.asyncIterator]).toBe('function'); + }); + }); + + describe('myInvitationsUpdated', () => { + it('should return async iterable for TRIGGER_MY_INVITATIONS_UPDATED', () => { + const result = resolver.myInvitationsUpdated('user-1'); + + expect(pubSub.asyncIterableIterator).toHaveBeenCalledWith( + TRIGGER_MY_INVITATIONS_UPDATED, + ); + expect(result).toBeDefined(); + expect(typeof result[Symbol.asyncIterator]).toBe('function'); + }); + }); +}); 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