Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/src/common/interceptors/logging.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export class LoggingInterceptor implements NestInterceptor {
this.logger.log(
`→ ${method} ${url} | Operation: ${parentType}.${operationName}`,
);
if (userAgent) this.logger.debug(`User-Agent: ${userAgent}`);
this.logger.debug(`User-Agent: ${userAgent}`);

return next.handle().pipe(
tap({
Expand Down
5 changes: 5 additions & 0 deletions backend/src/graphql/schema.gql
Original file line number Diff line number Diff line change
Expand Up @@ -880,6 +880,11 @@ type Subscription {
"""Real-time notifications for the current user (WebSocket)."""
notificationReceived: Notification!

"""
Subscribe to workspace boards list changes (create/delete/copy). Refetch workspace boards when this fires.
"""
workspaceBoardsChanged(workspaceId: ID!): Boolean!

"""
Subscribe to workspace pending invitations changes (invite, cancel, accept, reject). Invalidate workspace invitations query when received.
"""
Expand Down
45 changes: 45 additions & 0 deletions backend/src/modules/boards/board-subscription.resolver.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ import { PUB_SUB } from '../../common/subscriptions/pubsub.provider';
import {
TRIGGER_CARD_UPDATED,
TRIGGER_LIST_UPDATED,
TRIGGER_BOARD_UPDATED,
TRIGGER_BOARD_MEMBERS_UPDATED,
TRIGGER_CARD_DELETED,
TRIGGER_LIST_DELETED,
TRIGGER_WORKSPACE_BOARDS_CHANGED,
type CardUpdatedPayload,
type ListUpdatedPayload,
} from './board-subscription.resolver';
Expand Down Expand Up @@ -80,6 +85,46 @@ describe('BoardSubscriptionResolver', () => {
});
});

describe('boardUpdated', () => {
it('should return async iterable for TRIGGER_BOARD_UPDATED', () => {
const result = resolver.boardUpdated('board-1');
expect(pubSub.asyncIterableIterator).toHaveBeenCalledWith(TRIGGER_BOARD_UPDATED);
expect(result).toBeDefined();
});
});

describe('boardMembersUpdated', () => {
it('should return async iterable for TRIGGER_BOARD_MEMBERS_UPDATED', () => {
const result = resolver.boardMembersUpdated('board-1');
expect(pubSub.asyncIterableIterator).toHaveBeenCalledWith(TRIGGER_BOARD_MEMBERS_UPDATED);
expect(result).toBeDefined();
});
});

describe('cardDeleted', () => {
it('should return async iterable for TRIGGER_CARD_DELETED', () => {
const result = resolver.cardDeleted('board-1');
expect(pubSub.asyncIterableIterator).toHaveBeenCalledWith(TRIGGER_CARD_DELETED);
expect(result).toBeDefined();
});
});

describe('listDeleted', () => {
it('should return async iterable for TRIGGER_LIST_DELETED', () => {
const result = resolver.listDeleted('board-1');
expect(pubSub.asyncIterableIterator).toHaveBeenCalledWith(TRIGGER_LIST_DELETED);
expect(result).toBeDefined();
});
});

describe('workspaceBoardsChanged', () => {
it('should return async iterable for TRIGGER_WORKSPACE_BOARDS_CHANGED', () => {
const result = resolver.workspaceBoardsChanged('workspace-1');
expect(pubSub.asyncIterableIterator).toHaveBeenCalledWith(TRIGGER_WORKSPACE_BOARDS_CHANGED);
expect(result).toBeDefined();
});
});

describe('cardUpdatedByCardId', () => {
it('should return async iterable for TRIGGER_CARD_UPDATED', () => {
const result = resolver.cardUpdatedByCardId('card-1');
Expand Down
22 changes: 22 additions & 0 deletions backend/src/modules/boards/board-subscription.resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ export const TRIGGER_LIST_DELETED = 'listDeleted';
/** Payload when board members change (add/remove/role). */
export type BoardMembersUpdatedPayload = { boardId: string };

/** Payload when the list of boards in a workspace changes (create/delete/copy). */
export type WorkspaceBoardsChangedPayload = { workspaceId: string };

/** Trigger when boards are created, deleted or copied in a workspace (for real-time list refresh). */
export const TRIGGER_WORKSPACE_BOARDS_CHANGED = 'workspaceBoardsChanged';

@Resolver()
@UseGuards(GqlAuthGuard)
export class BoardSubscriptionResolver {
Expand Down Expand Up @@ -149,4 +155,20 @@ export class BoardSubscriptionResolver {
void boardId;
return this.pubSub.asyncIterableIterator<ListDeletedPayload>(TRIGGER_LIST_DELETED);
}

/**
* Subscribe to workspace boards list changes (board created, deleted, copied).
* Clients should refetch workspaceBoards(workspaceId) when this fires.
*/
@Subscription(() => Boolean, {
name: 'workspaceBoardsChanged',
description: 'Subscribe to workspace boards list changes (create/delete/copy). Refetch workspace boards when this fires.',
filter: (payload: WorkspaceBoardsChangedPayload, variables: { workspaceId: string }) =>
payload.workspaceId === variables.workspaceId,
resolve: () => true,
})
workspaceBoardsChanged(@Args('workspaceId', { type: () => ID }) workspaceId: string) {
void workspaceId;
return this.pubSub.asyncIterableIterator<WorkspaceBoardsChangedPayload>(TRIGGER_WORKSPACE_BOARDS_CHANGED);
}
}
57 changes: 51 additions & 6 deletions backend/src/modules/boards/boards.resolver.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { ActivityService } from '../activity/activity.service';
import { PrismaService } from '../../prisma/prisma.service';
import { Visibility, Role } from '@prisma/client';
import { PUB_SUB } from '../../common/subscriptions/pubsub.provider';
import { TRIGGER_WORKSPACE_BOARDS_CHANGED } from './board-subscription.resolver';

describe('BoardsResolver', () => {
let resolver: BoardsResolver;
Expand Down Expand Up @@ -129,7 +130,7 @@ describe('BoardsResolver', () => {
});

describe('createBoard', () => {
it('should create a board', async () => {
it('should create a board and publish workspaceBoardsChanged when workspaceId is set', async () => {
const input = {
title: 'New Board',
description: 'Description',
Expand All @@ -142,11 +143,27 @@ describe('BoardsResolver', () => {

expect(result).toEqual(mockBoard);
expect(service.create).toHaveBeenCalledWith(input, mockUser.id);
expect(mockPubSub.publish).toHaveBeenCalledWith(TRIGGER_WORKSPACE_BOARDS_CHANGED, {
workspaceId: mockBoard.workspaceId,
});
});

it('should create a board without publishing workspaceBoardsChanged when workspaceId is null', async () => {
const input = { title: 'Personal Board' };
const boardNoWorkspace = { ...mockBoard, workspaceId: null };
mockBoardsService.create.mockResolvedValue(boardNoWorkspace);

await resolver.createBoard(input, mockUser);

const workspacePublishCalls = (mockPubSub.publish as jest.Mock).mock.calls.filter(
(c) => c[0] === TRIGGER_WORKSPACE_BOARDS_CHANGED,
);
expect(workspacePublishCalls).toHaveLength(0);
});
});

describe('copyBoard', () => {
it('should copy a board', async () => {
it('should copy a board and publish workspaceBoardsChanged when workspaceId is set', async () => {
const input = {
sourceBoardId: 'board-1',
title: 'Copied Board',
Expand All @@ -160,6 +177,9 @@ describe('BoardsResolver', () => {

expect(result).toEqual(copiedBoard);
expect(service.copy).toHaveBeenCalledWith(input, mockUser.id);
expect(mockPubSub.publish).toHaveBeenCalledWith(TRIGGER_WORKSPACE_BOARDS_CHANGED, {
workspaceId: copiedBoard.workspaceId,
});
});
});

Expand Down Expand Up @@ -199,7 +219,7 @@ describe('BoardsResolver', () => {
});

describe('updateBoard', () => {
it('should update a board', async () => {
it('should update a board and publish boardUpdated and workspaceBoardsChanged when workspaceId is set', async () => {
const input = {
id: mockBoard.id,
title: 'Updated Board',
Expand All @@ -216,22 +236,41 @@ describe('BoardsResolver', () => {

expect(result).toEqual(updatedBoard);
expect(service.update).toHaveBeenCalledWith(input, mockUser.id);
expect(mockPubSub.publish).toHaveBeenCalledWith(TRIGGER_WORKSPACE_BOARDS_CHANGED, {
workspaceId: updatedBoard.workspaceId,
});
});
});

describe('deleteBoard', () => {
it('should delete a board', async () => {
it('should delete a board and publish workspaceBoardsChanged when board had workspaceId', async () => {
mockPrismaService.board.findUnique.mockResolvedValue({ workspaceId: 'workspace-1' });
mockBoardsService.delete.mockResolvedValue(true);

const result = await resolver.deleteBoard(mockBoard.id, mockUser);

expect(result).toBe(true);
expect(service.delete).toHaveBeenCalledWith(mockBoard.id, mockUser.id);
expect(mockPubSub.publish).toHaveBeenCalledWith(TRIGGER_WORKSPACE_BOARDS_CHANGED, {
workspaceId: 'workspace-1',
});
});

it('should delete a board without publishing workspaceBoardsChanged when board had no workspace', async () => {
mockPrismaService.board.findUnique.mockResolvedValue({ workspaceId: null });
mockBoardsService.delete.mockResolvedValue(true);

await resolver.deleteBoard(mockBoard.id, mockUser);

const workspacePublishCalls = (mockPubSub.publish as jest.Mock).mock.calls.filter(
(c) => c[0] === TRIGGER_WORKSPACE_BOARDS_CHANGED,
);
expect(workspacePublishCalls).toHaveLength(0);
});
});

describe('archiveBoard', () => {
it('should archive a board', async () => {
it('should archive a board and publish workspaceBoardsChanged when workspaceId is set', async () => {
const archivedBoard = {
...mockBoard,
isArchived: true,
Expand All @@ -243,11 +282,14 @@ describe('BoardsResolver', () => {

expect(result).toEqual(archivedBoard);
expect(service.archive).toHaveBeenCalledWith(mockBoard.id, mockUser.id);
expect(mockPubSub.publish).toHaveBeenCalledWith(TRIGGER_WORKSPACE_BOARDS_CHANGED, {
workspaceId: archivedBoard.workspaceId,
});
});
});

describe('unarchiveBoard', () => {
it('should unarchive a board', async () => {
it('should unarchive a board and publish workspaceBoardsChanged when workspaceId is set', async () => {
const unarchivedBoard = {
...mockBoard,
isArchived: false,
Expand All @@ -259,6 +301,9 @@ describe('BoardsResolver', () => {

expect(result).toEqual(unarchivedBoard);
expect(service.unarchive).toHaveBeenCalledWith(mockBoard.id, mockUser.id);
expect(mockPubSub.publish).toHaveBeenCalledWith(TRIGGER_WORKSPACE_BOARDS_CHANGED, {
workspaceId: unarchivedBoard.workspaceId,
});
});
});

Expand Down
49 changes: 45 additions & 4 deletions backend/src/modules/boards/boards.resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@ import { List } from '../lists/entities/list.entity';
import { ActivityService } from '../activity/activity.service';
import { ActivityType } from '@prisma/client';
import { PUB_SUB } from '../../common/subscriptions/pubsub.provider';
import { TRIGGER_BOARD_UPDATED, TRIGGER_BOARD_MEMBERS_UPDATED } from './board-subscription.resolver';
import {
TRIGGER_BOARD_UPDATED,
TRIGGER_BOARD_MEMBERS_UPDATED,
TRIGGER_WORKSPACE_BOARDS_CHANGED,
} from './board-subscription.resolver';

@Resolver(() => Board)
@UseGuards(GqlAuthGuard)
Expand Down Expand Up @@ -51,7 +55,13 @@ export class BoardsResolver {
@Args('input') input: CreateBoardInput,
@CurrentUser() user: any,
): Promise<Board> {
return this.boardsService.create(input, user.id);
const board = await this.boardsService.create(input, user.id);
if (board.workspaceId) {
await this.pubSub.publish(TRIGGER_WORKSPACE_BOARDS_CHANGED, {
workspaceId: board.workspaceId,
});
}
return board;
}

@Mutation(() => Board, {
Expand All @@ -61,7 +71,13 @@ export class BoardsResolver {
@Args('input') input: CopyBoardInput,
@CurrentUser() user: any,
): Promise<Board> {
return this.boardsService.copy(input, user.id);
const board = await this.boardsService.copy(input, user.id);
if (board.workspaceId) {
await this.pubSub.publish(TRIGGER_WORKSPACE_BOARDS_CHANGED, {
workspaceId: board.workspaceId,
});
}
return board;
}

@Query(() => [BoardTemplate], {
Expand Down Expand Up @@ -111,6 +127,11 @@ export class BoardsResolver {
boardUpdated: board,
boardId: board.id,
});
if (board.workspaceId) {
await this.pubSub.publish(TRIGGER_WORKSPACE_BOARDS_CHANGED, {
workspaceId: board.workspaceId,
});
}
return board;
}

Expand All @@ -121,7 +142,17 @@ export class BoardsResolver {
@Args('id', { type: () => ID }) id: string,
@CurrentUser() user: any,
): Promise<boolean> {
return this.boardsService.delete(id, user.id);
const existing = await this.prisma.board.findUnique({
where: { id },
select: { workspaceId: true },
});
const result = await this.boardsService.delete(id, user.id);
if (existing?.workspaceId) {
await this.pubSub.publish(TRIGGER_WORKSPACE_BOARDS_CHANGED, {
workspaceId: existing.workspaceId,
});
}
return result;
}

@Mutation(() => Board, {
Expand All @@ -142,6 +173,11 @@ export class BoardsResolver {
boardUpdated: board,
boardId: board.id,
});
if (board.workspaceId) {
await this.pubSub.publish(TRIGGER_WORKSPACE_BOARDS_CHANGED, {
workspaceId: board.workspaceId,
});
}
return board;
}

Expand All @@ -163,6 +199,11 @@ export class BoardsResolver {
boardUpdated: board,
boardId: board.id,
});
if (board.workspaceId) {
await this.pubSub.publish(TRIGGER_WORKSPACE_BOARDS_CHANGED, {
workspaceId: board.workspaceId,
});
}
return board;
}

Expand Down
Loading
Loading