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
29 changes: 29 additions & 0 deletions backend/src/graphql/schema.gql
Original file line number Diff line number Diff line change
Expand Up @@ -771,6 +771,17 @@ input RespondInvitationInput {
}

type Subscription {
"""
Subscribe to board members changes (add/remove/role). Refetch board to get updated members.
"""
boardMembersUpdated(boardId: ID!): Boolean!

"""Subscribe to board metadata changes for a single board."""
boardUpdated(boardId: ID!): Board!

"""Subscribe to card deletions for a board."""
cardDeleted(boardId: ID!): ID!

"""Subscribe to card create/update/move/delete for a board."""
cardUpdated(boardId: ID!): Card!

Expand All @@ -788,11 +799,29 @@ type Subscription {
"""Subscribe to comment edits on a card. Filter by cardId."""
commentUpdated(cardId: ID!): Comment!

"""Subscribe to list deletions for a board."""
listDeleted(boardId: ID!): ID!

"""Subscribe to list create/update/reorder/delete for a board."""
listUpdated(boardId: ID!): List!

"""
Subscribe to current user invitations changes (new invite, accept, reject, cancel). Invalidate myInvitations query when received.
"""
myInvitationsUpdated(userId: ID!): Boolean!

"""Real-time notifications for the current user (WebSocket)."""
notificationReceived: Notification!

"""
Subscribe to workspace pending invitations changes (invite, cancel, accept, reject). Invalidate workspace invitations query when received.
"""
workspaceInvitationsUpdated(workspaceId: ID!): Boolean!

"""
Subscribe to workspace members changes (add, remove, role update). Invalidate workspace members query when received.
"""
workspaceMembersUpdated(workspaceId: ID!): Boolean!
}

input UnassignMemberFromCardInput {
Expand Down
80 changes: 80 additions & 0 deletions backend/src/modules/boards/board-subscription.resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,33 @@ import { GqlAuthGuard } from '../../common/guards/gql-auth.guard';
import { PUB_SUB } from '../../common/subscriptions/pubsub.provider';
import { Card } from '../cards/entities/card.entity';
import { List } from '../lists/entities/list.entity';
import { Board } from './entities/board.entity';

/** Event payload when a card is updated (includes boardId for filtering). */
export type CardUpdatedPayload = { cardUpdated: Card; boardId: string };

/** Event payload when a list is updated (includes boardId for filtering). */
export type ListUpdatedPayload = { listUpdated: List; boardId: string };

/** Event payload when a board is updated (metadata changes, archive/unarchive). */
export type BoardUpdatedPayload = { boardUpdated: Board; boardId: string };

/** Event payload when a card is deleted from a board. */
export type CardDeletedPayload = { cardDeletedId: string; boardId: string };

/** Event payload when a list is deleted from a board. */
export type ListDeletedPayload = { listDeletedId: string; boardId: string };

/** Trigger names for PubSub (channels per event type). */
export const TRIGGER_CARD_UPDATED = 'cardUpdated';
export const TRIGGER_LIST_UPDATED = 'listUpdated';
export const TRIGGER_BOARD_UPDATED = 'boardUpdated';
export const TRIGGER_BOARD_MEMBERS_UPDATED = 'boardMembersUpdated';
export const TRIGGER_CARD_DELETED = 'cardDeleted';
export const TRIGGER_LIST_DELETED = 'listDeleted';

/** Payload when board members change (add/remove/role). */
export type BoardMembersUpdatedPayload = { boardId: string };

@Resolver()
@UseGuards(GqlAuthGuard)
Expand Down Expand Up @@ -69,4 +86,67 @@ export class BoardSubscriptionResolver {
void cardId;
return this.pubSub.asyncIterableIterator<CardUpdatedPayload>(TRIGGER_CARD_UPDATED);
}

/**
* Subscribe to board metadata changes (title, description, background, visibility, archive).
*/
@Subscription(() => Board, {
name: 'boardUpdated',
description: 'Subscribe to board metadata changes for a single board.',
filter: (payload: BoardUpdatedPayload, variables: { boardId: string }) =>
payload.boardId === variables.boardId,
resolve: (payload: BoardUpdatedPayload) => payload.boardUpdated,
})
boardUpdated(@Args('boardId', { type: () => ID }) boardId: string) {
void boardId;
return this.pubSub.asyncIterableIterator<BoardUpdatedPayload>(TRIGGER_BOARD_UPDATED);
}

/**
* Subscribe to board members changes (add, remove, role update).
* Clients should refetch the board to get the new members list.
*/
@Subscription(() => Boolean, {
name: 'boardMembersUpdated',
description: 'Subscribe to board members changes (add/remove/role). Refetch board to get updated members.',
filter: (payload: BoardMembersUpdatedPayload, variables: { boardId: string }) =>
payload.boardId === variables.boardId,
resolve: () => true,
})
boardMembersUpdated(@Args('boardId', { type: () => ID }) boardId: string) {
void boardId;
return this.pubSub.asyncIterableIterator<BoardMembersUpdatedPayload>(TRIGGER_BOARD_MEMBERS_UPDATED);
}

/**
* Subscribe to card deletions for a board.
* Returns the deleted card id so clients can remove it from their state.
*/
@Subscription(() => ID, {
name: 'cardDeleted',
description: 'Subscribe to card deletions for a board.',
filter: (payload: CardDeletedPayload, variables: { boardId: string }) =>
payload.boardId === variables.boardId,
resolve: (payload: CardDeletedPayload) => payload.cardDeletedId,
})
cardDeleted(@Args('boardId', { type: () => ID }) boardId: string) {
void boardId;
return this.pubSub.asyncIterableIterator<CardDeletedPayload>(TRIGGER_CARD_DELETED);
}

/**
* Subscribe to list deletions for a board.
* Returns the deleted list id so clients can remove it from their state.
*/
@Subscription(() => ID, {
name: 'listDeleted',
description: 'Subscribe to list deletions for a board.',
filter: (payload: ListDeletedPayload, variables: { boardId: string }) =>
payload.boardId === variables.boardId,
resolve: (payload: ListDeletedPayload) => payload.listDeletedId,
})
listDeleted(@Args('boardId', { type: () => ID }) boardId: string) {
void boardId;
return this.pubSub.asyncIterableIterator<ListDeletedPayload>(TRIGGER_LIST_DELETED);
}
}
10 changes: 10 additions & 0 deletions backend/src/modules/boards/boards.resolver.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { BoardsService } from './boards.service';
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';

describe('BoardsResolver', () => {
let resolver: BoardsResolver;
Expand Down Expand Up @@ -35,6 +36,11 @@ describe('BoardsResolver', () => {
create: jest.fn().mockResolvedValue(undefined),
};

const mockPubSub = {
publish: jest.fn(),
asyncIterableIterator: jest.fn(),
};

const mockUser = {
id: 'user-1',
email: 'test@example.com',
Expand Down Expand Up @@ -70,6 +76,10 @@ describe('BoardsResolver', () => {
provide: ActivityService,
useValue: mockActivityService,
},
{
provide: PUB_SUB,
useValue: mockPubSub,
},
],
}).compile();

Expand Down
34 changes: 29 additions & 5 deletions backend/src/modules/boards/boards.resolver.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Resolver, Query, Mutation, Args, ID, ResolveField, Parent } from '@nestjs/graphql';
import { UseGuards } from '@nestjs/common';
import { Inject, UseGuards } from '@nestjs/common';
import { PubSub } from 'graphql-subscriptions';
import { BoardsService } from './boards.service';
import { Board } from './entities/board.entity';
import { BoardMemberWithUser } from './entities/board-member.entity';
Expand All @@ -13,6 +14,8 @@ import { PrismaService } from '../../prisma/prisma.service';
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';

@Resolver(() => Board)
@UseGuards(GqlAuthGuard)
Expand All @@ -21,6 +24,7 @@ export class BoardsResolver {
private readonly boardsService: BoardsService,
private readonly prisma: PrismaService,
private readonly activityService: ActivityService,
@Inject(PUB_SUB) private readonly pubSub: PubSub,
) { }

@ResolveField(() => [List])
Expand Down Expand Up @@ -76,7 +80,12 @@ export class BoardsResolver {
@Args('input') input: UpdateBoardInput,
@CurrentUser() user: any,
): Promise<Board> {
return this.boardsService.update(input, user.id);
const board = await this.boardsService.update(input, user.id);
await this.pubSub.publish(TRIGGER_BOARD_UPDATED, {
boardUpdated: board,
boardId: board.id,
});
return board;
}

@Mutation(() => Boolean, {
Expand All @@ -103,6 +112,10 @@ export class BoardsResolver {
boardId: board.id,
payload: { boardTitle: board.title },
});
await this.pubSub.publish(TRIGGER_BOARD_UPDATED, {
boardUpdated: board,
boardId: board.id,
});
return board;
}

Expand All @@ -120,6 +133,10 @@ export class BoardsResolver {
boardId: board.id,
payload: { boardTitle: board.title },
});
await this.pubSub.publish(TRIGGER_BOARD_UPDATED, {
boardUpdated: board,
boardId: board.id,
});
return board;
}

Expand All @@ -143,6 +160,7 @@ export class BoardsResolver {
payload: { boardTitle: board.title, memberName: addedUser.name },
});
}
await this.pubSub.publish(TRIGGER_BOARD_MEMBERS_UPDATED, { boardId: input.boardId });
return result;
}

Expand All @@ -154,7 +172,9 @@ export class BoardsResolver {
@Args('userId', { type: () => ID }) userId: string,
@CurrentUser() user: any,
): Promise<boolean> {
return this.boardsService.removeMember(boardId, userId, user.id);
const result = await this.boardsService.removeMember(boardId, userId, user.id);
await this.pubSub.publish(TRIGGER_BOARD_MEMBERS_UPDATED, { boardId });
return result;
}

@Mutation(() => Boolean, {
Expand All @@ -164,7 +184,9 @@ export class BoardsResolver {
@Args('input') input: UpdateBoardMemberRoleInput,
@CurrentUser() user: any,
): Promise<boolean> {
return this.boardsService.updateMemberRole(input.boardId, input.userId, input.role, user.id);
const result = await this.boardsService.updateMemberRole(input.boardId, input.userId, input.role, user.id);
await this.pubSub.publish(TRIGGER_BOARD_MEMBERS_UPDATED, { boardId: input.boardId });
return result;
}

@Mutation(() => Boolean, {
Expand All @@ -174,6 +196,8 @@ export class BoardsResolver {
@Args('boardId', { type: () => ID }) boardId: string,
@CurrentUser() user: any,
): Promise<boolean> {
return this.boardsService.leaveBoard(boardId, user.id);
const result = await this.boardsService.leaveBoard(boardId, user.id);
await this.pubSub.publish(TRIGGER_BOARD_MEMBERS_UPDATED, { boardId });
return result;
}
}
17 changes: 15 additions & 2 deletions backend/src/modules/cards/cards.resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { GqlAuthGuard } from '../../common/guards/gql-auth.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { CardsDataLoader } from './dataloaders/cards.dataloader';
import { PUB_SUB } from '../../common/subscriptions/pubsub.provider';
import { TRIGGER_CARD_UPDATED } from '../boards/board-subscription.resolver';
import { TRIGGER_CARD_DELETED, TRIGGER_CARD_UPDATED } from '../boards/board-subscription.resolver';
import { PrismaService } from '../../prisma/prisma.service';
import { Label } from '../labels/entities/label.entity';
import DataLoader = require('dataloader');
Expand Down Expand Up @@ -121,7 +121,20 @@ export class CardsResolver {
@Args('id', { type: () => ID }) id: string,
@CurrentUser() user: any,
): Promise<boolean> {
return this.cardsService.delete(id, user.id);
// Load card and boardId before deletion so we can broadcast the deletion event.
const existing = await this.cardsService.findOne(id, user.id);
const list = await this.prisma.list.findUnique({
where: { id: existing.listId },
select: { boardId: true },
});
const result = await this.cardsService.delete(id, user.id);
if (result && list) {
await this.pubSub.publish(TRIGGER_CARD_DELETED, {
cardDeletedId: id,
boardId: list.boardId,
});
}
return result;
}

@Mutation(() => Card, {
Expand Down
7 changes: 6 additions & 1 deletion backend/src/modules/invitations/invitations.module.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import { Module } from '@nestjs/common';
import { InvitationsService } from './invitations.service';
import { InvitationsResolver } from './invitations.resolver';
import { WorkspaceMembersSubscriptionResolver } from './workspace-members-subscription.resolver';
import { PrismaModule } from '../../prisma/prisma.module';
import { EmailModule } from '../email/email.module';
import { NotificationsModule } from '../notifications/notifications.module';

@Module({
imports: [PrismaModule, EmailModule, NotificationsModule],
providers: [InvitationsResolver, InvitationsService],
providers: [
InvitationsResolver,
InvitationsService,
WorkspaceMembersSubscriptionResolver,
],
exports: [InvitationsService],
})
export class InvitationsModule {}
15 changes: 15 additions & 0 deletions backend/src/modules/invitations/invitations.resolver.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { InviteMemberInput } from './dto/invite-member.input';
import { RespondInvitationInput } from './dto/respond-invitation.input';
import { UpdateMemberRoleInput } from './dto/update-member-role.input';
import { RemoveMemberInput } from './dto/remove-member.input';
import { PUB_SUB } from '../../common/subscriptions/pubsub.provider';

describe('InvitationsResolver', () => {
let resolver: InvitationsResolver;
Expand Down Expand Up @@ -48,6 +49,8 @@ describe('InvitationsResolver', () => {
rejectInvitation: jest.fn(),
joinWorkspaceByInviteLink: jest.fn(),
cancelInvitation: jest.fn(),
getInvitationWorkspaceId: jest.fn(),
getInvitationInviteeId: jest.fn(),
getWorkspaceInvitations: jest.fn(),
getMyInvitations: jest.fn(),
getWorkspaceMembers: jest.fn(),
Expand All @@ -56,14 +59,26 @@ describe('InvitationsResolver', () => {
leaveWorkspace: jest.fn(),
};

const mockPubSub = { publish: jest.fn().mockResolvedValue(undefined) };

beforeEach(async () => {
mockInvitationsService.getInvitationWorkspaceId.mockResolvedValue(
'workspace-123',
);
mockInvitationsService.getInvitationInviteeId.mockResolvedValue(
'invitee-123',
);
const module: TestingModule = await Test.createTestingModule({
providers: [
InvitationsResolver,
{
provide: InvitationsService,
useValue: mockInvitationsService,
},
{
provide: PUB_SUB,
useValue: mockPubSub,
},
],
}).compile();

Expand Down
Loading
Loading