From 590840a1d2d978faf774fb9d2f3c94804a1c605d Mon Sep 17 00:00:00 2001 From: mpJunot Date: Tue, 10 Feb 2026 18:51:44 +0100 Subject: [PATCH 1/4] feat(backend): add real-time subscriptions for workspace and my invitations - Add workspaceInvitationsUpdated subscription for join requests tab - Add myInvitationsUpdated subscription when user is invited or responds - Publish events on inviteMember, accept, reject, cancelInvitation - Add getInvitationWorkspaceId and getInvitationInviteeId in service - Update resolver specs with new mocks --- backend/src/graphql/schema.gql | 29 ++++ .../modules/invitations/invitations.module.ts | 7 +- .../invitations/invitations.resolver.spec.ts | 15 ++ .../invitations/invitations.resolver.ts | 119 +++++++++++++-- .../invitations/invitations.service.spec.ts | 98 +++++++++++- .../invitations/invitations.service.ts | 139 ++++++++++++++++-- ...workspace-members-subscription.resolver.ts | 79 ++++++++++ 7 files changed, 461 insertions(+), 25 deletions(-) create mode 100644 backend/src/modules/invitations/workspace-members-subscription.resolver.ts diff --git a/backend/src/graphql/schema.gql b/backend/src/graphql/schema.gql index cf1364d..b4abe4b 100644 --- a/backend/src/graphql/schema.gql +++ b/backend/src/graphql/schema.gql @@ -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! @@ -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 { diff --git a/backend/src/modules/invitations/invitations.module.ts b/backend/src/modules/invitations/invitations.module.ts index 28c87f9..2a04586 100644 --- a/backend/src/modules/invitations/invitations.module.ts +++ b/backend/src/modules/invitations/invitations.module.ts @@ -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 {} diff --git a/backend/src/modules/invitations/invitations.resolver.spec.ts b/backend/src/modules/invitations/invitations.resolver.spec.ts index 1d635da..d0e15ff 100644 --- a/backend/src/modules/invitations/invitations.resolver.spec.ts +++ b/backend/src/modules/invitations/invitations.resolver.spec.ts @@ -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; @@ -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(), @@ -56,7 +59,15 @@ 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, @@ -64,6 +75,10 @@ describe('InvitationsResolver', () => { provide: InvitationsService, useValue: mockInvitationsService, }, + { + provide: PUB_SUB, + useValue: mockPubSub, + }, ], }).compile(); diff --git a/backend/src/modules/invitations/invitations.resolver.ts b/backend/src/modules/invitations/invitations.resolver.ts index c34e70a..df87c04 100644 --- a/backend/src/modules/invitations/invitations.resolver.ts +++ b/backend/src/modules/invitations/invitations.resolver.ts @@ -1,5 +1,7 @@ import { Resolver, Mutation, Query, Args, ID } from '@nestjs/graphql'; import { UseGuards } from '@nestjs/common'; +import { Inject } from '@nestjs/common'; +import { PubSub } from 'graphql-subscriptions'; import { InvitationsService } from './invitations.service'; import { WorkspaceInvitation } from './entities/invitation.entity'; import { WorkspaceMemberWithUser } from './entities/workspace-member.entity'; @@ -9,11 +11,20 @@ import { UpdateMemberRoleInput } from './dto/update-member-role.input'; import { RemoveMemberInput } from './dto/remove-member.input'; import { GqlAuthGuard } from '../../common/guards/gql-auth.guard'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; +import { PUB_SUB } from '../../common/subscriptions/pubsub.provider'; +import { + TRIGGER_WORKSPACE_MEMBERS_UPDATED, + TRIGGER_WORKSPACE_INVITATIONS_UPDATED, + TRIGGER_MY_INVITATIONS_UPDATED, +} from './workspace-members-subscription.resolver'; @Resolver(() => WorkspaceInvitation) @UseGuards(GqlAuthGuard) export class InvitationsResolver { - constructor(private readonly invitationsService: InvitationsService) {} + constructor( + private readonly invitationsService: InvitationsService, + @Inject(PUB_SUB) private readonly pubSub: PubSub, + ) {} @Mutation(() => WorkspaceInvitation, { description: 'Invite a member to a workspace. Only ADMIN members can invite.', @@ -22,7 +33,16 @@ export class InvitationsResolver { @Args('input') input: InviteMemberInput, @CurrentUser() user: any, ): Promise { - return this.invitationsService.inviteMember(input, user.id); + const result = await this.invitationsService.inviteMember(input, user.id); + await this.pubSub.publish(TRIGGER_WORKSPACE_INVITATIONS_UPDATED, { + workspaceId: result.workspaceId, + }); + if (result.inviteeId) { + await this.pubSub.publish(TRIGGER_MY_INVITATIONS_UPDATED, { + userId: result.inviteeId, + }); + } + return result; } @Mutation(() => WorkspaceInvitation, { @@ -32,7 +52,20 @@ export class InvitationsResolver { @Args('input') input: RespondInvitationInput, @CurrentUser() user: any, ): Promise { - return this.invitationsService.acceptInvitation(input.invitationId, user.id); + const result = await this.invitationsService.acceptInvitation( + input.invitationId, + user.id, + ); + await this.pubSub.publish(TRIGGER_WORKSPACE_MEMBERS_UPDATED, { + workspaceId: result.workspaceId, + }); + await this.pubSub.publish(TRIGGER_WORKSPACE_INVITATIONS_UPDATED, { + workspaceId: result.workspaceId, + }); + await this.pubSub.publish(TRIGGER_MY_INVITATIONS_UPDATED, { + userId: user.id, + }); + return result; } @Mutation(() => WorkspaceInvitation, { @@ -42,7 +75,22 @@ export class InvitationsResolver { @Args('input') input: RespondInvitationInput, @CurrentUser() user: any, ): Promise { - return this.invitationsService.rejectInvitation(input.invitationId, user.id); + const workspaceId = await this.invitationsService.getInvitationWorkspaceId( + input.invitationId, + ); + const result = await this.invitationsService.rejectInvitation( + input.invitationId, + user.id, + ); + if (workspaceId) { + await this.pubSub.publish(TRIGGER_WORKSPACE_INVITATIONS_UPDATED, { + workspaceId, + }); + } + await this.pubSub.publish(TRIGGER_MY_INVITATIONS_UPDATED, { + userId: user.id, + }); + return result; } @Mutation(() => Boolean, { @@ -52,7 +100,16 @@ export class InvitationsResolver { @Args('workspaceId', { type: () => ID }) workspaceId: string, @CurrentUser() user: any, ): Promise { - return this.invitationsService.joinWorkspaceByInviteLink(workspaceId, user.id); + const result = await this.invitationsService.joinWorkspaceByInviteLink( + workspaceId, + user.id, + ); + if (result) { + await this.pubSub.publish(TRIGGER_WORKSPACE_MEMBERS_UPDATED, { + workspaceId, + }); + } + return result; } @Mutation(() => Boolean, { @@ -62,7 +119,27 @@ export class InvitationsResolver { @Args('invitationId', { type: () => ID }) invitationId: string, @CurrentUser() user: any, ): Promise { - return this.invitationsService.cancelInvitation(invitationId, user.id); + const [workspaceId, inviteeId] = await Promise.all([ + this.invitationsService.getInvitationWorkspaceId(invitationId), + this.invitationsService.getInvitationInviteeId(invitationId), + ]); + const result = await this.invitationsService.cancelInvitation( + invitationId, + user.id, + ); + if (result) { + if (workspaceId) { + await this.pubSub.publish(TRIGGER_WORKSPACE_INVITATIONS_UPDATED, { + workspaceId, + }); + } + if (inviteeId) { + await this.pubSub.publish(TRIGGER_MY_INVITATIONS_UPDATED, { + userId: inviteeId, + }); + } + } + return result; } @Query(() => [WorkspaceInvitation], { @@ -102,7 +179,16 @@ export class InvitationsResolver { @Args('input') input: UpdateMemberRoleInput, @CurrentUser() user: any, ): Promise { - return this.invitationsService.updateMemberRole(input, user.id); + const result = await this.invitationsService.updateMemberRole( + input, + user.id, + ); + if (result) { + await this.pubSub.publish(TRIGGER_WORKSPACE_MEMBERS_UPDATED, { + workspaceId: input.workspaceId, + }); + } + return result; } @Mutation(() => Boolean, { @@ -112,7 +198,13 @@ export class InvitationsResolver { @Args('input') input: RemoveMemberInput, @CurrentUser() user: any, ): Promise { - return this.invitationsService.removeMember(input, user.id); + const result = await this.invitationsService.removeMember(input, user.id); + if (result) { + await this.pubSub.publish(TRIGGER_WORKSPACE_MEMBERS_UPDATED, { + workspaceId: input.workspaceId, + }); + } + return result; } @Mutation(() => Boolean, { @@ -122,6 +214,15 @@ export class InvitationsResolver { @Args('workspaceId', { type: () => ID }) workspaceId: string, @CurrentUser() user: any, ): Promise { - return this.invitationsService.leaveWorkspace(workspaceId, user.id); + const result = await this.invitationsService.leaveWorkspace( + workspaceId, + user.id, + ); + if (result) { + await this.pubSub.publish(TRIGGER_WORKSPACE_MEMBERS_UPDATED, { + workspaceId, + }); + } + return result; } } diff --git a/backend/src/modules/invitations/invitations.service.spec.ts b/backend/src/modules/invitations/invitations.service.spec.ts index 9bb5a5d..5512689 100644 --- a/backend/src/modules/invitations/invitations.service.spec.ts +++ b/backend/src/modules/invitations/invitations.service.spec.ts @@ -29,6 +29,14 @@ describe('InvitationsService', () => { create: jest.fn(), update: jest.fn(), }, + board: { + findMany: jest.fn(), + }, + boardMember: { + updateMany: jest.fn(), + count: jest.fn(), + deleteMany: jest.fn(), + }, user: { findUnique: jest.fn(), }, @@ -499,17 +507,93 @@ describe('InvitationsService', () => { const workspaceId = 'workspace-1'; const userId = 'user-1'; - it('should successfully leave workspace', async () => { + it('should set user to OBSERVER (guest) when still on at least one board', async () => { + mockPrismaService.workspaceMember.findUnique.mockResolvedValue({ + id: '1', + role: Role.MEMBER, + }); + mockPrismaService.board.findMany.mockResolvedValue([{ id: 'board-1' }]); + mockPrismaService.boardMember.count.mockResolvedValue(1); + mockPrismaService.workspaceMember.update.mockResolvedValue({ + id: '1', + role: Role.OBSERVER, + }); + mockPrismaService.boardMember.updateMany.mockResolvedValue({ count: 1 }); + + const result = await service.leaveWorkspace(workspaceId, userId); + + expect(result).toBe(true); + expect(mockPrismaService.workspaceMember.update).toHaveBeenCalledWith({ + where: { + workspaceId_userId: { workspaceId, userId }, + }, + data: { role: Role.OBSERVER }, + }); + expect(mockPrismaService.boardMember.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + userId, + boardId: { in: ['board-1'] }, + }, + data: { role: Role.OBSERVER }, + }), + ); + expect(mockPrismaService.workspaceMember.delete).not.toHaveBeenCalled(); + }); + + it('should remove user from workspace and all boards when not on any board', async () => { mockPrismaService.workspaceMember.findUnique.mockResolvedValue({ id: '1', role: Role.MEMBER, }); + mockPrismaService.board.findMany.mockResolvedValue([{ id: 'board-1' }]); + mockPrismaService.boardMember.count.mockResolvedValue(0); + mockPrismaService.boardMember.deleteMany.mockResolvedValue({ count: 0 }); mockPrismaService.workspaceMember.delete.mockResolvedValue({}); const result = await service.leaveWorkspace(workspaceId, userId); expect(result).toBe(true); - expect(mockPrismaService.workspaceMember.delete).toHaveBeenCalled(); + expect(mockPrismaService.boardMember.deleteMany).toHaveBeenCalledWith({ + where: { + userId, + boardId: { in: ['board-1'] }, + }, + }); + expect(mockPrismaService.workspaceMember.delete).toHaveBeenCalledWith({ + where: { + workspaceId_userId: { workspaceId, userId }, + }, + }); + expect(mockPrismaService.workspaceMember.update).not.toHaveBeenCalled(); + expect(mockPrismaService.boardMember.updateMany).not.toHaveBeenCalled(); + }); + + it('should remove OBSERVER (guest) from workspace and all boards when they leave', async () => { + mockPrismaService.workspaceMember.findUnique.mockResolvedValue({ + id: '1', + role: Role.OBSERVER, + }); + mockPrismaService.board.findMany.mockResolvedValue([{ id: 'board-1' }, { id: 'board-2' }]); + mockPrismaService.boardMember.deleteMany.mockResolvedValue({ count: 2 }); + mockPrismaService.workspaceMember.delete.mockResolvedValue({}); + + const result = await service.leaveWorkspace(workspaceId, userId); + + expect(result).toBe(true); + expect(mockPrismaService.boardMember.deleteMany).toHaveBeenCalledWith({ + where: { + userId, + boardId: { in: ['board-1', 'board-2'] }, + }, + }); + expect(mockPrismaService.workspaceMember.delete).toHaveBeenCalledWith({ + where: { + workspaceId_userId: { workspaceId, userId }, + }, + }); + expect(mockPrismaService.workspaceMember.update).not.toHaveBeenCalled(); + expect(mockPrismaService.boardMember.updateMany).not.toHaveBeenCalled(); }); it('should throw NotFoundException if user is not a member', async () => { @@ -591,12 +675,17 @@ describe('InvitationsService', () => { inviteeEmail: 'user@example.com', status: InvitationStatus.PENDING, inviter: { name: 'Admin User', email: 'admin@example.com' }, + workspace: { name: 'Test Workspace' }, }, ]); const result = await service.getWorkspaceInvitations(workspaceId, userId); expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + inviterName: 'Admin User', + workspaceName: 'Test Workspace', + }); expect(mockPrismaService.workspaceInvitation.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ @@ -611,6 +700,11 @@ describe('InvitationsService', () => { email: true, }, }, + workspace: { + select: { + name: true, + }, + }, }, orderBy: { createdAt: 'desc', diff --git a/backend/src/modules/invitations/invitations.service.ts b/backend/src/modules/invitations/invitations.service.ts index 8d9a321..4a8c09b 100644 --- a/backend/src/modules/invitations/invitations.service.ts +++ b/backend/src/modules/invitations/invitations.service.ts @@ -302,7 +302,7 @@ export class InvitationsService { // Check if user is ADMIN await this.checkAdminPermission(workspaceId, userId); - return this.prisma.workspaceInvitation.findMany({ + const invitations = await this.prisma.workspaceInvitation.findMany({ where: { workspaceId, status: InvitationStatus.PENDING, @@ -310,9 +310,18 @@ export class InvitationsService { }, include: { inviter: { select: { name: true, email: true } }, + workspace: { select: { name: true } }, }, orderBy: { createdAt: 'desc' }, }); + + return invitations.map((inv) => ({ + ...inv, + inviterName: + (inv.inviter?.name?.trim() as string) || inv.inviter?.email || 'Unknown', + workspaceName: + (inv.workspace?.name?.trim() as string) || 'Unnamed workspace', + })); } /** @@ -323,18 +332,26 @@ export class InvitationsService { const user = await this.prisma.user.findUnique({ where: { id: userId } }); - return this.prisma.workspaceInvitation.findMany({ + const invitations = await this.prisma.workspaceInvitation.findMany({ where: { inviteeEmail: user.email, status: InvitationStatus.PENDING, expiresAt: { gt: new Date() }, }, include: { - inviter: { select: { name: true } }, + inviter: { select: { name: true, email: true } }, workspace: { select: { name: true, logoUrl: true } }, }, orderBy: { createdAt: 'desc' }, }); + + return invitations.map((inv) => ({ + ...inv, + inviterName: + (inv.inviter?.name?.trim() as string) || inv.inviter?.email || 'Unknown', + workspaceName: + (inv.workspace?.name?.trim() as string) || 'Unnamed workspace', + })); } /** @@ -447,12 +464,14 @@ export class InvitationsService { } /** - * Leave a workspace + * Leave workspace: + * - If user is already OBSERVER (guest): remove from workspace and from all boards (full exit). + * - If user is ADMIN/MEMBER and on at least one board: become OBSERVER (guest) in workspace and on boards. + * - If user is ADMIN/MEMBER and not on any board: remove from workspace. */ async leaveWorkspace(workspaceId: string, userId: string): Promise { this.logger.log(`User ${userId} leaving workspace ${workspaceId}`); - // Check if user is a member const member = await this.prisma.workspaceMember.findUnique({ where: { workspaceId_userId: { @@ -466,7 +485,6 @@ export class InvitationsService { throw new NotFoundException('You are not a member of this workspace'); } - // Prevent last ADMIN from leaving if (member.role === Role.ADMIN) { const adminCount = await this.prisma.workspaceMember.count({ where: { @@ -482,19 +500,114 @@ export class InvitationsService { } } - await this.prisma.workspaceMember.delete({ - where: { - workspaceId_userId: { - workspaceId, + const boardsInWorkspace = await this.prisma.board.findMany({ + where: { workspaceId }, + select: { id: true }, + }); + const boardIds = boardsInWorkspace.map((b) => b.id); + + if (member.role === Role.OBSERVER) { + // Already guest: full exit — remove from all boards then from workspace + if (boardIds.length > 0) { + await this.prisma.boardMember.deleteMany({ + where: { + userId, + boardId: { in: boardIds }, + }, + }); + } + await this.prisma.workspaceMember.delete({ + where: { + workspaceId_userId: { + workspaceId, + userId, + }, + }, + }); + this.logger.log( + `User ${userId} (guest) removed from workspace ${workspaceId} and all boards`, + ); + return true; + } + + const isOnAnyBoard = + boardIds.length > 0 && + (await this.prisma.boardMember.count({ + where: { userId, + boardId: { in: boardIds }, }, - }, - }); + })) > 0; + + if (isOnAnyBoard) { + // ADMIN/MEMBER still on at least one board → become OBSERVER (guest) + await this.prisma.workspaceMember.update({ + where: { + workspaceId_userId: { + workspaceId, + userId, + }, + }, + data: { role: Role.OBSERVER }, + }); + await this.prisma.boardMember.updateMany({ + where: { + userId, + boardId: { in: boardIds }, + }, + data: { role: Role.OBSERVER }, + }); + this.logger.log( + `User ${userId} set to OBSERVER (guest) in workspace ${workspaceId} and on ${boardIds.length} board(s)`, + ); + } else { + // ADMIN/MEMBER not on any board → remove from all boards of workspace then from workspace + if (boardIds.length > 0) { + await this.prisma.boardMember.deleteMany({ + where: { + userId, + boardId: { in: boardIds }, + }, + }); + } + await this.prisma.workspaceMember.delete({ + where: { + workspaceId_userId: { + workspaceId, + userId, + }, + }, + }); + this.logger.log( + `User ${userId} removed from workspace ${workspaceId} and all its boards`, + ); + } - this.logger.log(`User left workspace successfully`); return true; } + /** + * Get workspaceId for an invitation (for publishing invitations-updated event). + */ + async getInvitationWorkspaceId(invitationId: string): Promise { + const inv = await this.prisma.workspaceInvitation.findUnique({ + where: { id: invitationId }, + select: { workspaceId: true }, + }); + return inv?.workspaceId ?? null; + } + + /** + * Get inviteeId for an invitation (for publishing my-invitations-updated event). + */ + async getInvitationInviteeId(invitationId: string): Promise { + const inv = await this.prisma.workspaceInvitation.findUnique({ + where: { id: invitationId }, + select: { inviteeId: true }, + }); + return inv?.inviteeId ?? null; + } + /** * Cancel an invitation (by inviter or admin) */ diff --git a/backend/src/modules/invitations/workspace-members-subscription.resolver.ts b/backend/src/modules/invitations/workspace-members-subscription.resolver.ts new file mode 100644 index 0000000..2d4019a --- /dev/null +++ b/backend/src/modules/invitations/workspace-members-subscription.resolver.ts @@ -0,0 +1,79 @@ +import { Resolver, Subscription, Args, ID } from '@nestjs/graphql'; +import { UseGuards } from '@nestjs/common'; +import { PubSub } from 'graphql-subscriptions'; +import { Inject } from '@nestjs/common'; +import { GqlAuthGuard } from '../../common/guards/gql-auth.guard'; +import { PUB_SUB } from '../../common/subscriptions/pubsub.provider'; + +export type WorkspaceMembersUpdatedPayload = { workspaceId: string }; + +export const TRIGGER_WORKSPACE_MEMBERS_UPDATED = 'workspaceMembersUpdated'; + +export type WorkspaceInvitationsUpdatedPayload = { workspaceId: string }; + +export const TRIGGER_WORKSPACE_INVITATIONS_UPDATED = 'workspaceInvitationsUpdated'; + +export type MyInvitationsUpdatedPayload = { userId: string }; + +export const TRIGGER_MY_INVITATIONS_UPDATED = 'myInvitationsUpdated'; + +@Resolver() +@UseGuards(GqlAuthGuard) +export class WorkspaceMembersSubscriptionResolver { + constructor(@Inject(PUB_SUB) private readonly pubSub: PubSub) {} + + @Subscription(() => Boolean, { + name: 'workspaceMembersUpdated', + description: + 'Subscribe to workspace members changes (add, remove, role update). Invalidate workspace members query when received.', + filter: ( + payload: WorkspaceMembersUpdatedPayload, + variables: { workspaceId: string }, + ) => payload.workspaceId === variables.workspaceId, + resolve: () => true, + }) + workspaceMembersUpdated( + @Args('workspaceId', { type: () => ID }) workspaceId: string, + ) { + void workspaceId; + return this.pubSub.asyncIterableIterator( + TRIGGER_WORKSPACE_MEMBERS_UPDATED, + ); + } + + @Subscription(() => Boolean, { + name: 'workspaceInvitationsUpdated', + description: + 'Subscribe to workspace pending invitations changes (invite, cancel, accept, reject). Invalidate workspace invitations query when received.', + filter: ( + payload: WorkspaceInvitationsUpdatedPayload, + variables: { workspaceId: string }, + ) => payload.workspaceId === variables.workspaceId, + resolve: () => true, + }) + workspaceInvitationsUpdated( + @Args('workspaceId', { type: () => ID }) workspaceId: string, + ) { + void workspaceId; + return this.pubSub.asyncIterableIterator( + TRIGGER_WORKSPACE_INVITATIONS_UPDATED, + ); + } + + @Subscription(() => Boolean, { + name: 'myInvitationsUpdated', + description: + 'Subscribe to current user invitations changes (new invite, accept, reject, cancel). Invalidate myInvitations query when received.', + filter: ( + payload: MyInvitationsUpdatedPayload, + variables: { userId: string }, + ) => payload.userId === variables.userId, + resolve: () => true, + }) + myInvitationsUpdated(@Args('userId', { type: () => ID }) userId: string) { + void userId; + return this.pubSub.asyncIterableIterator( + TRIGGER_MY_INVITATIONS_UPDATED, + ); + } +} From 631d0bce4ddd0016acba0e8c46e714470c40bcb3 Mon Sep 17 00:00:00 2001 From: mpJunot Date: Tue, 10 Feb 2026 18:52:00 +0100 Subject: [PATCH 2/4] fix(backend): board, card and list resolver updates --- .../boards/board-subscription.resolver.ts | 80 +++++++++++++++++++ .../modules/boards/boards.resolver.spec.ts | 10 +++ backend/src/modules/boards/boards.resolver.ts | 34 ++++++-- backend/src/modules/cards/cards.resolver.ts | 17 +++- backend/src/modules/lists/lists.resolver.ts | 12 ++- 5 files changed, 144 insertions(+), 9 deletions(-) diff --git a/backend/src/modules/boards/board-subscription.resolver.ts b/backend/src/modules/boards/board-subscription.resolver.ts index a32c5f1..faf34dd 100644 --- a/backend/src/modules/boards/board-subscription.resolver.ts +++ b/backend/src/modules/boards/board-subscription.resolver.ts @@ -6,6 +6,7 @@ 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 }; @@ -13,9 +14,25 @@ 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) @@ -69,4 +86,67 @@ export class BoardSubscriptionResolver { void cardId; return this.pubSub.asyncIterableIterator(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(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(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(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(TRIGGER_LIST_DELETED); + } } diff --git a/backend/src/modules/boards/boards.resolver.spec.ts b/backend/src/modules/boards/boards.resolver.spec.ts index 5705951..f6484f5 100644 --- a/backend/src/modules/boards/boards.resolver.spec.ts +++ b/backend/src/modules/boards/boards.resolver.spec.ts @@ -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; @@ -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', @@ -70,6 +76,10 @@ describe('BoardsResolver', () => { provide: ActivityService, useValue: mockActivityService, }, + { + provide: PUB_SUB, + useValue: mockPubSub, + }, ], }).compile(); diff --git a/backend/src/modules/boards/boards.resolver.ts b/backend/src/modules/boards/boards.resolver.ts index c3f3998..624af7b 100644 --- a/backend/src/modules/boards/boards.resolver.ts +++ b/backend/src/modules/boards/boards.resolver.ts @@ -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'; @@ -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) @@ -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]) @@ -76,7 +80,12 @@ export class BoardsResolver { @Args('input') input: UpdateBoardInput, @CurrentUser() user: any, ): Promise { - 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, { @@ -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; } @@ -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; } @@ -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; } @@ -154,7 +172,9 @@ export class BoardsResolver { @Args('userId', { type: () => ID }) userId: string, @CurrentUser() user: any, ): Promise { - 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, { @@ -164,7 +184,9 @@ export class BoardsResolver { @Args('input') input: UpdateBoardMemberRoleInput, @CurrentUser() user: any, ): Promise { - 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, { @@ -174,6 +196,8 @@ export class BoardsResolver { @Args('boardId', { type: () => ID }) boardId: string, @CurrentUser() user: any, ): Promise { - 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; } } diff --git a/backend/src/modules/cards/cards.resolver.ts b/backend/src/modules/cards/cards.resolver.ts index fe98973..a13a040 100644 --- a/backend/src/modules/cards/cards.resolver.ts +++ b/backend/src/modules/cards/cards.resolver.ts @@ -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'); @@ -121,7 +121,20 @@ export class CardsResolver { @Args('id', { type: () => ID }) id: string, @CurrentUser() user: any, ): Promise { - 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, { diff --git a/backend/src/modules/lists/lists.resolver.ts b/backend/src/modules/lists/lists.resolver.ts index 953c759..9c21a6e 100644 --- a/backend/src/modules/lists/lists.resolver.ts +++ b/backend/src/modules/lists/lists.resolver.ts @@ -9,7 +9,7 @@ import { ReorderListsInput } from './dto/reorder-lists.input'; import { GqlAuthGuard } from '../../common/guards/gql-auth.guard'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { PUB_SUB } from '../../common/subscriptions/pubsub.provider'; -import { TRIGGER_LIST_UPDATED } from '../boards/board-subscription.resolver'; +import { TRIGGER_LIST_DELETED, TRIGGER_LIST_UPDATED } from '../boards/board-subscription.resolver'; import { ActivityService } from '../activity/activity.service'; import { ActivityType } from '@prisma/client'; @@ -68,7 +68,15 @@ export class ListsResolver { @Args('id', { type: () => ID }) id: string, @CurrentUser() user: any, ): Promise { - return this.listsService.delete(id, user.id); + const list = await this.listsService.findOne(id, user.id); + const result = await this.listsService.delete(id, user.id); + if (result && list) { + await this.pubSub.publish(TRIGGER_LIST_DELETED, { + listDeletedId: id, + boardId: list.boardId, + }); + } + return result; } @Mutation(() => [List], { From c078854eb4d05a7265eced39f5b04cc26af445e3 Mon Sep 17 00:00:00 2001 From: mpJunot Date: Tue, 10 Feb 2026 18:52:14 +0100 Subject: [PATCH 3/4] feat(frontend): real-time workspace join requests and my invitations - Add useWorkspaceInvitationsSubscription for join requests tab - Add useMyInvitationsSubscription for when user is invited - Subscribe in Sidebar and invitations page for live invitation count and list - Subscribe in workspace members page for join requests tab --- frontend/app/invitations/page.tsx | 17 ++-- frontend/app/workspaces/[id]/members/page.tsx | 58 ++++++++++--- frontend/components/Sidebar.tsx | 10 ++- .../hooks/use-my-invitations-subscription.ts | 79 ++++++++++++++++++ .../use-workspace-invitations-subscription.ts | 81 +++++++++++++++++++ .../use-workspace-members-subscription.ts | 81 +++++++++++++++++++ frontend/lib/queries/workspaces.ts | 4 +- 7 files changed, 310 insertions(+), 20 deletions(-) create mode 100644 frontend/lib/hooks/use-my-invitations-subscription.ts create mode 100644 frontend/lib/hooks/use-workspace-invitations-subscription.ts create mode 100644 frontend/lib/hooks/use-workspace-members-subscription.ts diff --git a/frontend/app/invitations/page.tsx b/frontend/app/invitations/page.tsx index cfac2b9..a329200 100644 --- a/frontend/app/invitations/page.tsx +++ b/frontend/app/invitations/page.tsx @@ -26,16 +26,21 @@ import { useMyInvitationsQuery, myInvitationsQueryKey, } from '@/lib/queries/workspaces'; +import { useCurrentUserQuery } from '@/lib/queries/users'; import { toast } from '@/lib/toast'; import type { WorkspaceInvitation } from '@/lib/graphql-types'; +import { useMyInvitationsSubscription } from '@/lib/hooks/use-my-invitations-subscription'; export default function InvitationsPage() { const router = useRouter(); const queryClient = useQueryClient(); const [processing, setProcessing] = useState(null); + const { data: currentUser } = useCurrentUserQuery(); const { data: invitations, isLoading, isError } = useMyInvitationsQuery(); + useMyInvitationsSubscription(queryClient, currentUser?.id ?? null, true); + const handleAccept = async (invitation: WorkspaceInvitation) => { setProcessing(invitation.id); try { @@ -110,8 +115,8 @@ export default function InvitationsPage() { if (isLoading) { return ( -
-
+
+
@@ -120,8 +125,8 @@ export default function InvitationsPage() { if (isError) { return ( -
-
+
+

Error loading invitations

@@ -131,8 +136,8 @@ export default function InvitationsPage() { const invitationsList = invitations ?? []; return ( -
-
+
+

My Invitations diff --git a/frontend/app/workspaces/[id]/members/page.tsx b/frontend/app/workspaces/[id]/members/page.tsx index 7d8ee96..da58e71 100644 --- a/frontend/app/workspaces/[id]/members/page.tsx +++ b/frontend/app/workspaces/[id]/members/page.tsx @@ -18,6 +18,8 @@ import { workspaceMembersQueryKey, workspaceInvitationsQueryKey, } from '@/lib/queries/workspaces'; +import { useWorkspaceMembersSubscription } from '@/lib/hooks/use-workspace-members-subscription'; +import { useWorkspaceInvitationsSubscription } from '@/lib/hooks/use-workspace-invitations-subscription'; import { useWorkspaceRole } from '@/lib/hooks/use-workspace-role'; import { useCurrentUserQuery } from '@/lib/queries/users'; import { toast } from '@/lib/toast'; @@ -47,7 +49,16 @@ export default function WorkspaceMembersPage() { isLoading: loading, isError, error: membersError, - } = useWorkspaceMembersQuery(workspaceId); + } = useWorkspaceMembersQuery(workspaceId, { + refetchInterval: 15_000, + }); + + useWorkspaceMembersSubscription(workspaceId, queryClient, true); + useWorkspaceInvitationsSubscription( + workspaceId, + queryClient, + !!permissions.canViewPendingInvitations, + ); const members: WorkspaceMemberWithUser[] = useMemo( () => wsMembers ?? [], @@ -60,12 +71,20 @@ export default function WorkspaceMembersPage() { enabled: permissions.canViewPendingInvitations, }); - const memberCount = members.length; + const membersOnly = useMemo( + () => members.filter((m) => m.role !== 'OBSERVER'), + [members], + ); + const guestsOnly = useMemo( + () => members.filter((m) => m.role === 'OBSERVER'), + [members], + ); + const memberCount = membersOnly.length; const memberLimit = 10; // TODO: Get from workspace settings const requestsCount = permissions.canViewPendingInvitations ? (invitations?.length ?? 0) : 0; - const guestsCount = members.filter((m) => m.role === 'GUEST').length; + const guestsCount = guestsOnly.length; const isOnlyAdmin = !!currentUser?.id && @@ -75,9 +94,7 @@ export default function WorkspaceMembersPage() { const otherMembersToPromote = useMemo( () => members - .filter( - (m) => m.userId !== currentUser?.id && m.role !== 'ADMIN', - ) + .filter((m) => m.userId !== currentUser?.id && m.role !== 'ADMIN') .map((m) => ({ userId: m.userId, name: m.user.name ?? '', @@ -101,6 +118,20 @@ export default function WorkspaceMembersPage() { } }; + const handleRoleChange = async (userId: string, role: string) => { + try { + await updateMemberRole(workspaceId, userId, role); + await queryClient.invalidateQueries({ + queryKey: workspaceMembersQueryKey(workspaceId), + }); + toast.success('Role updated'); + } catch (error) { + const message = + error instanceof Error ? error.message : 'Failed to update role'; + toast.error(message); + } + }; + useEffect(() => { if (isError && membersError) { toast.error( @@ -127,6 +158,7 @@ export default function WorkspaceMembersPage() { queryKey: workspaceMembersQueryKey(workspaceId), }); await queryClient.invalidateQueries({ queryKey: ['workspaces'] }); + await queryClient.invalidateQueries({ queryKey: ['board'] }); toast.success('You left the workspace'); router.push('/dashboard'); } catch (error) { @@ -229,16 +261,20 @@ export default function WorkspaceMembersPage() {
(null); const [expandedWorkspaces, setExpandedWorkspaces] = useState(() => - loadExpanded() + loadExpanded(), ); const [showCreateWorkspaceModal, setShowCreateWorkspaceModal] = useState(false); @@ -111,7 +113,9 @@ export default function AppSidebar() { } }, [workspacesError, loadingWorkspaces]); + const { data: currentUser } = useCurrentUserQuery(); const { data: myInvitations } = useMyInvitationsQuery(); + useMyInvitationsSubscription(queryClient, currentUser?.id ?? null, true); const pendingInvitationsCount = myInvitations?.length ?? 0; useEffect(() => { @@ -248,8 +252,8 @@ export default function AppSidebar() { {pendingInvitationsCount > 99 ? '99+' : pendingInvitationsCount > 9 - ? '9+' - : pendingInvitationsCount} + ? '9+' + : pendingInvitationsCount} )} diff --git a/frontend/lib/hooks/use-my-invitations-subscription.ts b/frontend/lib/hooks/use-my-invitations-subscription.ts new file mode 100644 index 0000000..ba5cfe3 --- /dev/null +++ b/frontend/lib/hooks/use-my-invitations-subscription.ts @@ -0,0 +1,79 @@ +'use client'; + +import { useEffect } from 'react'; +import { createClient, type SubscribePayload } from 'graphql-ws'; +import { getAuthToken } from '../graphql-client'; +import type { QueryClient } from '@tanstack/react-query'; +import { myInvitationsQueryKey } from '@/lib/queries/workspaces'; + +const API_URL = + process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/graphql'; + +function getWsUrl(): string { + if (typeof window === 'undefined') return ''; + return API_URL.replace(/^http:\/\//, 'ws://').replace( + /^https:\/\//, + 'wss://', + ); +} + +/** + * Subscribe to "my invitations" changes (new invite, accept, reject, cancel). + * Invalidates the myInvitations query so the list and badge update in real time when you are invited. + */ +export function useMyInvitationsSubscription( + queryClient: QueryClient, + userId: string | null, + enabled = true, +): void { + useEffect(() => { + if (typeof window === 'undefined' || !userId || !enabled) return; + + const token = getAuthToken(); + if (!token) return; + + const wsUrl = getWsUrl(); + if (!wsUrl) return; + + const client = createClient({ + url: wsUrl, + connectionParams: { + Authorization: `Bearer ${token}`, + authToken: token, + }, + retryAttempts: 5, + shouldRetry: () => true, + }); + + const unsub = client.subscribe( + { + query: `subscription MyInvitationsUpdated($userId: ID!) { + myInvitationsUpdated(userId: $userId) + }`, + variables: { userId }, + } as SubscribePayload, + { + next: () => { + queryClient.invalidateQueries({ queryKey: myInvitationsQueryKey }); + }, + error: (err: unknown) => { + if (process.env.NODE_ENV === 'development') { + console.warn( + '[MyInvitations] subscription error', + err instanceof Error ? err.message : String(err), + ); + } + }, + complete: () => {}, + }, + ); + + return () => { + if (typeof unsub === 'function') { + unsub(); + } else if (unsub && typeof (unsub as { unsubscribe?: () => void }).unsubscribe === 'function') { + (unsub as { unsubscribe: () => void }).unsubscribe(); + } + }; + }, [queryClient, userId, enabled]); +} diff --git a/frontend/lib/hooks/use-workspace-invitations-subscription.ts b/frontend/lib/hooks/use-workspace-invitations-subscription.ts new file mode 100644 index 0000000..d1f62c5 --- /dev/null +++ b/frontend/lib/hooks/use-workspace-invitations-subscription.ts @@ -0,0 +1,81 @@ +'use client'; + +import { useEffect } from 'react'; +import { createClient, type SubscribePayload } from 'graphql-ws'; +import { getAuthToken } from '../graphql-client'; +import type { QueryClient } from '@tanstack/react-query'; +import { workspaceInvitationsQueryKey } from '@/lib/queries/workspaces'; + +const API_URL = + process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/graphql'; + +function getWsUrl(): string { + if (typeof window === 'undefined') return ''; + return API_URL.replace(/^http:\/\//, 'ws://').replace( + /^https:\/\//, + 'wss://', + ); +} + +/** + * Subscribe to workspace invitations (join requests) changes. + * Invalidates the workspace invitations query so the Join requests tab updates in real time. + */ +export function useWorkspaceInvitationsSubscription( + workspaceId: string | null, + queryClient: QueryClient, + enabled = true, +): void { + useEffect(() => { + if (typeof window === 'undefined' || !workspaceId || !enabled) return; + + const token = getAuthToken(); + if (!token) return; + + const wsUrl = getWsUrl(); + if (!wsUrl) return; + + const client = createClient({ + url: wsUrl, + connectionParams: { + Authorization: `Bearer ${token}`, + authToken: token, + }, + retryAttempts: 5, + shouldRetry: () => true, + }); + + const unsub = client.subscribe( + { + query: `subscription WorkspaceInvitationsUpdated($workspaceId: ID!) { + workspaceInvitationsUpdated(workspaceId: $workspaceId) + }`, + variables: { workspaceId }, + } as SubscribePayload, + { + next: () => { + queryClient.invalidateQueries({ + queryKey: workspaceInvitationsQueryKey(workspaceId), + }); + }, + error: (err: unknown) => { + if (process.env.NODE_ENV === 'development') { + console.warn( + '[WorkspaceInvitations] subscription error', + err instanceof Error ? err.message : String(err), + ); + } + }, + complete: () => {}, + }, + ); + + return () => { + if (typeof unsub === 'function') { + unsub(); + } else if (unsub && typeof (unsub as { unsubscribe?: () => void }).unsubscribe === 'function') { + (unsub as { unsubscribe: () => void }).unsubscribe(); + } + }; + }, [workspaceId, queryClient, enabled]); +} diff --git a/frontend/lib/hooks/use-workspace-members-subscription.ts b/frontend/lib/hooks/use-workspace-members-subscription.ts new file mode 100644 index 0000000..534ef97 --- /dev/null +++ b/frontend/lib/hooks/use-workspace-members-subscription.ts @@ -0,0 +1,81 @@ +'use client'; + +import { useEffect } from 'react'; +import { createClient, type SubscribePayload } from 'graphql-ws'; +import { getAuthToken } from '../graphql-client'; +import type { QueryClient } from '@tanstack/react-query'; +import { workspaceMembersQueryKey } from '@/lib/queries/workspaces'; + +const API_URL = + process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/graphql'; + +function getWsUrl(): string { + if (typeof window === 'undefined') return ''; + return API_URL.replace(/^http:\/\//, 'ws://').replace( + /^https:\/\//, + 'wss://', + ); +} + +/** + * Subscribe to workspace members changes (add, remove, role update). + * Invalidates the workspace members query so the list refetches in real time. + */ +export function useWorkspaceMembersSubscription( + workspaceId: string | null, + queryClient: QueryClient, + enabled = true, +): void { + useEffect(() => { + if (typeof window === 'undefined' || !workspaceId || !enabled) return; + + const token = getAuthToken(); + if (!token) return; + + const wsUrl = getWsUrl(); + if (!wsUrl) return; + + const client = createClient({ + url: wsUrl, + connectionParams: { + Authorization: `Bearer ${token}`, + authToken: token, + }, + retryAttempts: 5, + shouldRetry: () => true, + }); + + const unsub = client.subscribe( + { + query: `subscription WorkspaceMembersUpdated($workspaceId: ID!) { + workspaceMembersUpdated(workspaceId: $workspaceId) + }`, + variables: { workspaceId }, + } as SubscribePayload, + { + next: () => { + queryClient.invalidateQueries({ + queryKey: workspaceMembersQueryKey(workspaceId), + }); + }, + error: (err: unknown) => { + if (process.env.NODE_ENV === 'development') { + console.warn( + '[WorkspaceMembers] subscription error', + err instanceof Error ? err.message : String(err), + ); + } + }, + complete: () => {}, + }, + ); + + return () => { + if (typeof unsub === 'function') { + unsub(); + } else if (unsub && typeof (unsub as { unsubscribe?: () => void }).unsubscribe === 'function') { + (unsub as { unsubscribe: () => void }).unsubscribe(); + } + }; + }, [workspaceId, queryClient, enabled]); +} diff --git a/frontend/lib/queries/workspaces.ts b/frontend/lib/queries/workspaces.ts index 758deaa..3c3ab91 100644 --- a/frontend/lib/queries/workspaces.ts +++ b/frontend/lib/queries/workspaces.ts @@ -67,12 +67,14 @@ export const workspaceMembersQueryKey = (workspaceId: string) => ['workspace', workspaceId, 'members'] as const; export function useWorkspaceMembersQuery( - workspaceId: string + workspaceId: string, + options?: { refetchInterval?: number }, ): UseQueryResult { return useQuery({ queryKey: workspaceMembersQueryKey(workspaceId), queryFn: () => getWorkspaceMembers(workspaceId), enabled: !!workspaceId, + refetchInterval: options?.refetchInterval, }); } From ed6ba6f4d0180c69ae07fbc1cb0b66456a060e51 Mon Sep 17 00:00:00 2001 From: mpJunot Date: Tue, 10 Feb 2026 18:52:40 +0100 Subject: [PATCH 4/4] feat(frontend): workspace members UI, board subscriptions and misc updates - Add board list, members and meta subscription hooks - Update workspace members and guests tab components - Board header, share dialog and menu updates - Invitation accept/reject pages, card and notifications UI updates --- .../boards/[id]/components/BoardHeader.tsx | 5 + .../[id]/components/BoardHeader/BoardMenu.tsx | 43 ++++- .../components/BoardHeader/ShareDialog.tsx | 114 ++++++++---- frontend/app/boards/[id]/page.tsx | 6 + frontend/app/cards/page.tsx | 6 +- frontend/app/invitations/accept/page.tsx | 79 +++++++++ frontend/app/invitations/reject/page.tsx | 79 +++++++++ .../members/components/GuestsTabContent.tsx | 10 +- .../[id]/members/components/MemberItem.tsx | 165 +++++++++++------- .../members/components/MembersTabContent.tsx | 8 + frontend/components/CardItem.tsx | 14 +- frontend/components/CardModal.tsx | 4 +- .../NotificationsDropdownContent.tsx | 95 ++++++++-- frontend/components/ui/checkbox.tsx | 26 +-- .../lib/hooks/use-board-card-subscription.ts | 38 +++- .../lib/hooks/use-board-list-subscription.ts | 143 +++++++++++++++ .../hooks/use-board-members-subscription.ts | 81 +++++++++ .../lib/hooks/use-board-meta-subscription.ts | 100 +++++++++++ 18 files changed, 863 insertions(+), 153 deletions(-) create mode 100644 frontend/app/invitations/accept/page.tsx create mode 100644 frontend/app/invitations/reject/page.tsx create mode 100644 frontend/lib/hooks/use-board-list-subscription.ts create mode 100644 frontend/lib/hooks/use-board-members-subscription.ts create mode 100644 frontend/lib/hooks/use-board-meta-subscription.ts diff --git a/frontend/app/boards/[id]/components/BoardHeader.tsx b/frontend/app/boards/[id]/components/BoardHeader.tsx index a65de44..f35dafb 100644 --- a/frontend/app/boards/[id]/components/BoardHeader.tsx +++ b/frontend/app/boards/[id]/components/BoardHeader.tsx @@ -35,6 +35,10 @@ export function BoardHeader({ }: BoardHeaderProps) { const [showShareDialog, setShowShareDialog] = useState(false); const boardMembers = board.members || []; + const currentMembership = boardMembers.find( + (m) => m.userId === currentUserId, + ); + const canManageMembers = currentMembership?.role === 'ADMIN'; return (
); diff --git a/frontend/app/boards/[id]/components/BoardHeader/BoardMenu.tsx b/frontend/app/boards/[id]/components/BoardHeader/BoardMenu.tsx index 9fbbf4b..c98a124 100644 --- a/frontend/app/boards/[id]/components/BoardHeader/BoardMenu.tsx +++ b/frontend/app/boards/[id]/components/BoardHeader/BoardMenu.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; import { MoreHorizontal, @@ -87,6 +87,23 @@ export function BoardMenu({ const [showLeaveBoardDialog, setShowLeaveBoardDialog] = useState(false); const [leaveBoardAdminUserId, setLeaveBoardAdminUserId] = useState(''); const [leavingBoard, setLeavingBoard] = useState(false); + const [isStarred, setIsStarred] = useState(false); + + const STARRED_STORAGE_KEY = 'epitrello-starred-board-ids'; + + useEffect(() => { + if (typeof window === 'undefined') return; + try { + const raw = localStorage.getItem(STARRED_STORAGE_KEY); + if (!raw) return; + const parsed = JSON.parse(raw) as unknown; + if (Array.isArray(parsed) && parsed.some((id) => id === board.id)) { + setIsStarred(true); + } + } catch { + // ignore + } + }, [board.id]); const isOnlyAdmin = !!currentUserId && @@ -196,8 +213,22 @@ export function BoardMenu({ }; const handleStar = () => { - // TODO: Implement star board functionality - toast.info('Star board feature coming soon'); + if (typeof window === 'undefined') return; + try { + const raw = localStorage.getItem(STARRED_STORAGE_KEY); + const parsed = Array.isArray(raw ? JSON.parse(raw) : []) + ? (JSON.parse(raw!) as string[]) + : []; + const exists = parsed.includes(board.id); + const next = exists + ? parsed.filter((id) => id !== board.id) + : [...parsed, board.id]; + localStorage.setItem(STARRED_STORAGE_KEY, JSON.stringify(next)); + setIsStarred(!exists); + toast.success(exists ? 'Board unstarred' : 'Board starred'); + } catch { + toast.error('Failed to update board star status'); + } }; const handleWatch = () => { @@ -378,8 +409,10 @@ export function BoardMenu({ className='flex items-center gap-2' onClick={handleStar} > - - Star + + {isStarred ? 'Unstar' : 'Star'}
diff --git a/frontend/app/boards/[id]/components/BoardHeader/ShareDialog.tsx b/frontend/app/boards/[id]/components/BoardHeader/ShareDialog.tsx index f4f2adc..7095301 100644 --- a/frontend/app/boards/[id]/components/BoardHeader/ShareDialog.tsx +++ b/frontend/app/boards/[id]/components/BoardHeader/ShareDialog.tsx @@ -24,7 +24,8 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Separator } from '@/components/ui/separator'; import { getAvatarColor } from '@/lib/utils/avatar-colors'; import { getUserByEmail } from '@/lib/actions/users'; -import { addBoardMember } from '@/lib/actions/boards'; +import { addBoardMember, updateBoardMemberRole } from '@/lib/actions/boards'; +import { boardQueryKey } from '@/app/boards/[id]/queries'; import { activityInvalidateKey, activityBoardInvalidateKey, @@ -38,6 +39,8 @@ interface ShareDialogProps { boardId: string; members: BoardMember[]; onMemberAdded?: () => void; + /** True if current user is BOARD ADMIN and can manage member roles/invites. */ + canManageMembers?: boolean; } export function ShareDialog({ @@ -46,6 +49,7 @@ export function ShareDialog({ boardId, members, onMemberAdded, + canManageMembers = true, }: ShareDialogProps) { const queryClient = useQueryClient(); const [inviteEmail, setInviteEmail] = useState(''); @@ -74,6 +78,10 @@ export function ShareDialog({ }; const handleInvite = async () => { + if (!canManageMembers) { + toast.error('Only board administrators can invite members'); + return; + } const email = inviteEmail.trim(); if (!email) { toast.error('Please enter an email address'); @@ -118,34 +126,36 @@ export function ShareDialog({
- {/* Invite section */} -
- -
- setInviteEmail(e.target.value)} - className='flex-1' - /> - - + {/* Invite section (admins only) */} + {canManageMembers && ( +
+ +
+ setInviteEmail(e.target.value)} + className='flex-1' + /> + + +
-
+ )} {/* Share link */}
@@ -203,6 +213,7 @@ export function ShareDialog({ .toUpperCase() : (member.user?.email?.charAt(0) || 'U').toUpperCase(); const avatarColor = getAvatarColor(displayName); + return (
- + {canManageMembers ? ( + + ) : ( + + {member.role === 'ADMIN' ? 'Admin' : 'Member'} + + )}
); })} diff --git a/frontend/app/boards/[id]/page.tsx b/frontend/app/boards/[id]/page.tsx index b90ce60..00ce033 100644 --- a/frontend/app/boards/[id]/page.tsx +++ b/frontend/app/boards/[id]/page.tsx @@ -22,6 +22,9 @@ import { updateBoard } from '@/lib/actions/boards'; import { toast } from '@/lib/toast'; import { useCurrentUserQuery } from '@/lib/queries/users'; import { useBoardCardSubscription } from '@/lib/hooks/use-board-card-subscription'; +import { useBoardListSubscription } from '@/lib/hooks/use-board-list-subscription'; +import { useBoardMetaSubscription } from '@/lib/hooks/use-board-meta-subscription'; +import { useBoardMembersSubscription } from '@/lib/hooks/use-board-members-subscription'; export default function BoardPage({ params, @@ -35,6 +38,9 @@ export default function BoardPage({ useEventListeners(boardId, setLists, () => lists, queryClient); useBoardCardSubscription(boardId, queryClient, !!boardId && !loading); + useBoardListSubscription(boardId, queryClient, !!boardId && !loading); + useBoardMetaSubscription(boardId, queryClient, !!boardId && !loading); + useBoardMembersSubscription(boardId, queryClient, !!boardId && !loading); const canEdit = useMemo(() => { if (!board?.members || !currentUser?.id) return false; diff --git a/frontend/app/cards/page.tsx b/frontend/app/cards/page.tsx index 72b9c65..0850339 100644 --- a/frontend/app/cards/page.tsx +++ b/frontend/app/cards/page.tsx @@ -194,8 +194,9 @@ export default async function CardsPage() { const { cards, currentUserId } = await fetchAllCards(); return ( -
-
+
+
+

Cards

All cards across your boards. Sort by board, list, or due date. Filter by board, list, labels, due date, or assignee. @@ -217,6 +218,7 @@ export default async function CardsPage() { ) : ( )} +

); } diff --git a/frontend/app/invitations/accept/page.tsx b/frontend/app/invitations/accept/page.tsx new file mode 100644 index 0000000..c218cef --- /dev/null +++ b/frontend/app/invitations/accept/page.tsx @@ -0,0 +1,79 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { acceptInvitation } from '@/lib/actions/workspaces'; +import { toast } from '@/lib/toast'; +import { getAuthToken } from '@/lib/graphql-client'; + +export default function AcceptInvitationPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const id = searchParams.get('id'); + const [status, setStatus] = useState<'idle' | 'processing' | 'done' | 'error'>( + id ? 'idle' : 'error', + ); + const [message, setMessage] = useState( + id ? 'Processing invitation...' : 'Missing invitation id in URL.', + ); + + useEffect(() => { + if (!id) return; + + const token = getAuthToken(); + if (!token) { + const nextUrl = `/invitations/accept?id=${encodeURIComponent(id)}`; + router.push(`/auth/login?next=${encodeURIComponent(nextUrl)}`); + return; + } + + const run = async () => { + setStatus('processing'); + try { + await acceptInvitation(id); + toast.success('Invitation accepted'); + setStatus('done'); + setMessage('Invitation accepted. Redirecting to your invitations...'); + setTimeout(() => { + router.push('/invitations'); + }, 1200); + } catch (err) { + const text = + err instanceof Error ? err.message : 'Failed to accept invitation'; + toast.error(text); + setStatus('error'); + setMessage(text); + } + }; + + void run(); + }, [router, id]); + + return ( +
+
+
+

+ Accepting invitation +

+

{message}

+ {status === 'processing' && ( +
+
+
+ )} + {status === 'error' && ( + + )} +
+
+
+ ); +} + diff --git a/frontend/app/invitations/reject/page.tsx b/frontend/app/invitations/reject/page.tsx new file mode 100644 index 0000000..37bb580 --- /dev/null +++ b/frontend/app/invitations/reject/page.tsx @@ -0,0 +1,79 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { rejectInvitation } from '@/lib/actions/workspaces'; +import { toast } from '@/lib/toast'; +import { getAuthToken } from '@/lib/graphql-client'; + +export default function RejectInvitationPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const id = searchParams.get('id'); + const [status, setStatus] = useState<'idle' | 'processing' | 'done' | 'error'>( + id ? 'idle' : 'error', + ); + const [message, setMessage] = useState( + id ? 'Processing invitation...' : 'Missing invitation id in URL.', + ); + + useEffect(() => { + if (!id) return; + + const token = getAuthToken(); + if (!token) { + const nextUrl = `/invitations/reject?id=${encodeURIComponent(id)}`; + router.push(`/auth/login?next=${encodeURIComponent(nextUrl)}`); + return; + } + + const run = async () => { + setStatus('processing'); + try { + await rejectInvitation(id); + toast.success('Invitation rejected'); + setStatus('done'); + setMessage('Invitation rejected. Redirecting to your invitations...'); + setTimeout(() => { + router.push('/invitations'); + }, 1200); + } catch (err) { + const text = + err instanceof Error ? err.message : 'Failed to reject invitation'; + toast.error(text); + setStatus('error'); + setMessage(text); + } + }; + + void run(); + }, [router, id]); + + return ( +
+
+
+

+ Rejecting invitation +

+

{message}

+ {status === 'processing' && ( +
+
+
+ )} + {status === 'error' && ( + + )} +
+
+
+ ); +} + diff --git a/frontend/app/workspaces/[id]/members/components/GuestsTabContent.tsx b/frontend/app/workspaces/[id]/members/components/GuestsTabContent.tsx index fbca040..874e569 100644 --- a/frontend/app/workspaces/[id]/members/components/GuestsTabContent.tsx +++ b/frontend/app/workspaces/[id]/members/components/GuestsTabContent.tsx @@ -1,4 +1,3 @@ -import { useMemo } from 'react'; import { Separator } from '@/components/ui/separator'; import { ScrollArea } from '@/components/ui/scroll-area'; import type { @@ -57,13 +56,8 @@ export function GuestsTabContent({ onAssignAdmin, isOnlyAdmin, }: GuestsTabContentProps) { - // Filter members with GUEST role (if role is implemented as enum) - // For now, we'll show empty since guests might not be implemented yet - const guests = useMemo(() => { - // If guests are members with role === 'GUEST', filter them - // Otherwise return empty array - return members.filter((m) => m.role === 'GUEST'); - }, [members]); + // Parent passes only OBSERVER members (guests) for this tab + const guests = members; return (
diff --git a/frontend/app/workspaces/[id]/members/components/MemberItem.tsx b/frontend/app/workspaces/[id]/members/components/MemberItem.tsx index 51a7187..7a8e6a7 100644 --- a/frontend/app/workspaces/[id]/members/components/MemberItem.tsx +++ b/frontend/app/workspaces/[id]/members/components/MemberItem.tsx @@ -1,4 +1,7 @@ -import { Button } from '@/components/ui/button'; +'use client'; + +import { useState } from 'react'; +import { X } from 'lucide-react'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Item, @@ -8,56 +11,73 @@ import { ItemMedia, ItemTitle, } from '@/components/ui/item'; -import { X, HelpCircle } from 'lucide-react'; -import type { WorkspaceMemberWithUser } from '@/lib/actions/workspaces'; -import { getInitials, formatLastActive, getAvatarColor } from './utils'; -import { - MemberBoardsPopover, - type MemberBoardItem, -} from './MemberBoardsPopover'; +import { Button } from '@/components/ui/button'; import { - LeaveWorkspacePopover, - type MemberToPromote, -} from './LeaveWorkspacePopover'; + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { getInitials, formatLastActive, getAvatarColor } from './utils'; +import { MemberBoardsPopover } from './MemberBoardsPopover'; +import { LeaveWorkspacePopover } from './LeaveWorkspacePopover'; import { RemoveMemberPopover } from './RemoveMemberPopover'; +import type { WorkspaceMemberWithUser } from '@/lib/actions/workspaces'; +import type { MemberBoardItem } from './MemberBoardsPopover'; +import type { MemberToPromote } from './LeaveWorkspacePopover'; interface MemberItemProps { member: WorkspaceMemberWithUser; - /** Called when current user confirms "Leave Workspace" in popover. */ onLeaveWorkspace?: () => Promise; - /** When current user is the only admin, members that can be promoted. */ - otherMembersToPromote?: MemberToPromote[]; - /** Called when current user promotes another member to admin (before leaving). */ - onAssignAdmin?: (userId: string) => Promise; - /** Called when admin chooses "Remove from Workspace" in popover. */ onRemoveFromWorkspace?: (userId: string) => Promise; - /** Called when admin chooses "Remove from Workspace and Boards" in popover. */ onRemoveFromWorkspaceAndBoards?: (userId: string) => Promise; - isRemoving: boolean; - /** If false, the remove/leave button is hidden (non-admin for remove). */ + removing: string | null; canRemove?: boolean; - /** Boards this member is part of in the workspace (for "View boards" popover). */ - memberBoards?: MemberBoardItem[]; - /** If true, show "Leave..." popover instead of "Remove..." (current user). */ - isCurrentUser?: boolean; - /** When true, current user is the only admin and must assign another admin before leaving. */ + canUpdateRole?: boolean; + onRoleChange?: (userId: string, role: string) => Promise; + memberBoards: MemberBoardItem[]; + isCurrentUser: boolean; isOnlyAdmin?: boolean; + otherMembersToPromote?: MemberToPromote[]; + onAssignAdmin?: (userId: string) => Promise; } export function MemberItem({ member, onLeaveWorkspace, - otherMembersToPromote, - onAssignAdmin, onRemoveFromWorkspace, onRemoveFromWorkspaceAndBoards, - isRemoving, + removing, canRemove = true, - memberBoards = [], - isCurrentUser = false, + canUpdateRole = false, + onRoleChange, + memberBoards, + isCurrentUser, isOnlyAdmin = false, + otherMembersToPromote = [], + onAssignAdmin, }: MemberItemProps) { - const memberName = member.user.name || member.user.email || 'This member'; + const [updatingRole, setUpdatingRole] = useState(false); + const isRemoving = removing === member.userId; + const memberName = member.user?.name || member.user?.email || 'Member'; + + const handleRoleChange = async (role: string) => { + if (!onRoleChange) return; + setUpdatingRole(true); + try { + await onRoleChange(member.userId, role); + } finally { + setUpdatingRole(false); + } + }; + + const roleLabel = + member.role === 'ADMIN' + ? 'Admin' + : member.role === 'OBSERVER' + ? 'Guest' + : 'Member'; const removeLeaveTrigger = ( - {canRemove && - (isCurrentUser - ? onLeaveWorkspace && ( - - ) - : onRemoveFromWorkspace && - onRemoveFromWorkspaceAndBoards && ( - - ))} + {canUpdateRole && onRoleChange ? ( + + ) : ( + + {roleLabel} + + )} + {isCurrentUser + ? onLeaveWorkspace && ( + + ) + : canRemove && + onRemoveFromWorkspace && + onRemoveFromWorkspaceAndBoards && ( + + )} ); diff --git a/frontend/app/workspaces/[id]/members/components/MembersTabContent.tsx b/frontend/app/workspaces/[id]/members/components/MembersTabContent.tsx index 796d0c4..22e05a6 100644 --- a/frontend/app/workspaces/[id]/members/components/MembersTabContent.tsx +++ b/frontend/app/workspaces/[id]/members/components/MembersTabContent.tsx @@ -28,6 +28,10 @@ interface MembersTabContentProps { /** If false, invite section and remove buttons are hidden (non-admin). */ canInvite?: boolean; canRemove?: boolean; + /** If true, workspace admins can change member roles. */ + canUpdateRole?: boolean; + /** Called when admin changes a member's role. */ + onRoleChange?: (userId: string, role: string) => Promise; /** Workspace boards (with members) to show "View boards" per member. */ workspaceBoards?: GqlBoard[]; /** Current user id to show "Leave" instead of "Remove" for own row. */ @@ -65,6 +69,8 @@ export function MembersTabContent({ removing, canInvite = true, canRemove = true, + canUpdateRole = false, + onRoleChange, workspaceBoards, currentUserId, otherMembersToPromote, @@ -164,6 +170,8 @@ export function MembersTabContent({ onRemoveFromWorkspaceAndBoards={onRemoveFromWorkspaceAndBoards} isRemoving={removing === member.userId} canRemove={canRemove} + canUpdateRole={canUpdateRole} + onRoleChange={onRoleChange} memberBoards={getBoardsForMember( workspaceBoards, member.userId, diff --git a/frontend/components/CardItem.tsx b/frontend/components/CardItem.tsx index bc74a15..3af5471 100644 --- a/frontend/components/CardItem.tsx +++ b/frontend/components/CardItem.tsx @@ -1,7 +1,7 @@ 'use client'; import React, { useEffect, useRef, useState } from 'react'; -import { Clock, CheckSquare } from 'lucide-react'; +import { Clock, CheckSquare, TextAlignStart } from 'lucide-react'; import CardModal from './CardModal'; import { Avatar, @@ -260,6 +260,18 @@ export default function CardItem({
)} + {/* Description icon (same as CardModal) */} + {card.description?.trim() && ( +
+ +
+ )} + {/* Checklist (checked/total) */} {(() => { const total = (card.checklists ?? []).reduce( diff --git a/frontend/components/CardModal.tsx b/frontend/components/CardModal.tsx index 2d446d2..fe3dd86 100644 --- a/frontend/components/CardModal.tsx +++ b/frontend/components/CardModal.tsx @@ -1440,7 +1440,7 @@ export default function CardModal({
-
+
-
+
; + case 'CARD_DUE_SOON': + return ; + case 'COMMENT_ADDED': + return ; + case 'BOARD_INVITATION': + return ; + case 'WORKSPACE_INVITATION': + return ; + default: + return ; + } +} + +function notificationTime(createdAt: string) { + try { + return formatDistanceToNow(new Date(createdAt), { addSuffix: true }); + } catch { + return ''; + } +} + export type NotificationsDropdownContentProps = { notifications: Notification[]; unreadCount: number; @@ -85,7 +124,7 @@ export default function NotificationsDropdownContent({ }); const handleEmailFrequencyChange = async ( - value: NotificationEmailFrequency + value: NotificationEmailFrequency, ) => { try { await updateMyNotificationPreferences({ emailFrequency: value }); @@ -144,7 +183,7 @@ export default function NotificationsDropdownContent({
@@ -213,52 +252,70 @@ export default function NotificationsDropdownContent({ ) : ( - + {uniqueNotifications.map((n) => { const href = notificationHref(n); const content = ( <> + + {notificationIcon(n.type)} + {notificationMessage(n)} + + {notificationTime(n.createdAt)} + {!n.read && ( )} ); - return ( - - {href ? ( + + if (href) { + return ( + {content} - ) : ( - content - )} + + ); + } + + return ( + + {content} ); })} diff --git a/frontend/components/ui/checkbox.tsx b/frontend/components/ui/checkbox.tsx index 3e076ff..41c3455 100644 --- a/frontend/components/ui/checkbox.tsx +++ b/frontend/components/ui/checkbox.tsx @@ -1,10 +1,10 @@ -"use client" +'use client'; -import * as React from "react" -import * as CheckboxPrimitive from "@radix-ui/react-checkbox" -import { CheckIcon } from "lucide-react" +import * as React from 'react'; +import * as CheckboxPrimitive from '@radix-ui/react-checkbox'; +import { CheckIcon } from 'lucide-react'; -import { cn } from "@/lib/utils" +import { cn } from '@/lib/utils'; function Checkbox({ className, @@ -12,21 +12,21 @@ function Checkbox({ }: React.ComponentProps) { return ( - + - ) + ); } -export { Checkbox } +export { Checkbox }; diff --git a/frontend/lib/hooks/use-board-card-subscription.ts b/frontend/lib/hooks/use-board-card-subscription.ts index 1e9f3a4..61a7b3c 100644 --- a/frontend/lib/hooks/use-board-card-subscription.ts +++ b/frontend/lib/hooks/use-board-card-subscription.ts @@ -100,7 +100,8 @@ export function useBoardCardSubscription( shouldRetry: () => true, }); - const unsub = client.subscribe( + // Subscription for card updates (create/update/move/etc.) + const unsubUpdated = client.subscribe( { query: `subscription CardUpdated($boardId: ID!) { cardUpdated(boardId: $boardId) { @@ -163,8 +164,41 @@ export function useBoardCardSubscription( }, ); + // Subscription for card deletions (remove card from all lists). + const unsubDeleted = client.subscribe( + { + query: `subscription CardDeleted($boardId: ID!) { + cardDeleted(boardId: $boardId) + }`, + variables: { boardId }, + } as SubscribePayload, + { + next: (data) => { + const deletedId = (data.data as { cardDeleted?: string })?.cardDeleted; + if (!deletedId) return; + queryClient.setQueryData(boardQueryKey(boardId), (old) => { + if (!old?.lists) return old; + return { + ...old, + lists: old.lists.map((list) => ({ + ...list, + cards: (list.cards ?? []).filter((c) => c.id !== deletedId), + })), + }; + }); + }, + error: (err) => { + if (process.env.NODE_ENV === 'development') { + console.warn('[useBoardCardSubscription:deleted]', err); + } + }, + complete: () => {}, + }, + ); + return () => { - unsub(); + unsubUpdated(); + unsubDeleted(); client.dispose(); }; }, [boardId, queryClient, enabled]); diff --git a/frontend/lib/hooks/use-board-list-subscription.ts b/frontend/lib/hooks/use-board-list-subscription.ts new file mode 100644 index 0000000..73d792f --- /dev/null +++ b/frontend/lib/hooks/use-board-list-subscription.ts @@ -0,0 +1,143 @@ +'use client'; + +import { useEffect } from 'react'; +import { createClient, type SubscribePayload } from 'graphql-ws'; +import { getAuthToken } from '../graphql-client'; +import type { QueryClient } from '@tanstack/react-query'; +import { boardQueryKey } from '@/app/boards/[id]/queries'; +import type { Board, List } from '@/app/boards/[id]/types'; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/graphql'; + +function getWsUrl(): string { + if (typeof window === 'undefined') return ''; + const url = API_URL.replace(/^http:\/\//, 'ws://').replace(/^https:\/\//, 'wss://'); + return url; +} + +interface SubscriptionList { + id: string; + title: string; + position: number; + isArchived: boolean; + boardId: string; +} + +function upsertList(lists: List[] | undefined, incoming: SubscriptionList): List[] { + const base: List[] = lists ?? []; + const idx = base.findIndex((l) => l.id === incoming.id); + const nextList: List = { + id: incoming.id, + title: incoming.title, + position: incoming.position, + isArchived: incoming.isArchived, + cards: base[idx]?.cards ?? [], + }; + + if (idx === -1) { + return [...base, nextList].sort((a, b) => a.position - b.position); + } + + const next = [...base]; + next[idx] = nextList; + return next.sort((a, b) => a.position - b.position); +} + +/** + * Subscribe to real-time list updates for a board (create/update/reorder/archive/unarchive). + * This keeps the board.lists cache in sync across clients without manual refetch. + */ +export function useBoardListSubscription( + boardId: string | null, + queryClient: QueryClient, + enabled = true, +): void { + useEffect(() => { + if (typeof window === 'undefined' || !boardId || !enabled) return; + + const token = getAuthToken(); + if (!token) return; + + const wsUrl = getWsUrl(); + if (!wsUrl) return; + + const client = createClient({ + url: wsUrl, + connectionParams: { + Authorization: `Bearer ${token}`, + authToken: token, + }, + retryAttempts: 5, + shouldRetry: () => true, + }); + + const unsubUpdated = client.subscribe( + { + query: `subscription ListUpdated($boardId: ID!) { + listUpdated(boardId: $boardId) { + id + title + position + isArchived + boardId + } + }`, + variables: { boardId }, + } as SubscribePayload, + { + next: (data) => { + const list = (data.data as { listUpdated?: SubscriptionList })?.listUpdated; + if (!list) return; + queryClient.setQueryData(boardQueryKey(boardId), (old) => { + if (!old) return old; + return { + ...old, + lists: upsertList(old.lists, list), + }; + }); + }, + error: (err) => { + if (process.env.NODE_ENV === 'development') { + console.warn('[useBoardListSubscription]', err); + } + }, + complete: () => {}, + }, + ); + + const unsubDeleted = client.subscribe( + { + query: `subscription ListDeleted($boardId: ID!) { + listDeleted(boardId: $boardId) + }`, + variables: { boardId }, + } as SubscribePayload, + { + next: (data) => { + const deletedId = (data.data as { listDeleted?: string })?.listDeleted; + if (!deletedId) return; + queryClient.setQueryData(boardQueryKey(boardId), (old) => { + if (!old?.lists) return old; + return { + ...old, + lists: (old.lists ?? []).filter((l) => l.id !== deletedId), + }; + }); + }, + error: (err) => { + if (process.env.NODE_ENV === 'development') { + console.warn('[useBoardListSubscription:deleted]', err); + } + }, + complete: () => {}, + }, + ); + + return () => { + unsubUpdated(); + unsubDeleted(); + client.dispose(); + }; + }, [boardId, queryClient, enabled]); +} + diff --git a/frontend/lib/hooks/use-board-members-subscription.ts b/frontend/lib/hooks/use-board-members-subscription.ts new file mode 100644 index 0000000..5be38b7 --- /dev/null +++ b/frontend/lib/hooks/use-board-members-subscription.ts @@ -0,0 +1,81 @@ +'use client'; + +import { useEffect } from 'react'; +import { createClient, type SubscribePayload } from 'graphql-ws'; +import { getAuthToken } from '../graphql-client'; +import type { QueryClient } from '@tanstack/react-query'; +import { boardQueryKey } from '@/app/boards/[id]/queries'; + +const API_URL = + process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/graphql'; + +function getWsUrl(): string { + if (typeof window === 'undefined') return ''; + return API_URL.replace(/^http:\/\//, 'ws://').replace( + /^https:\/\//, + 'wss://', + ); +} + +/** + * Subscribe to board members changes (add, remove, role update). + * Invalidates the board query so the members list refetches in real time. + */ +export function useBoardMembersSubscription( + boardId: string | null, + queryClient: QueryClient, + enabled = true, +): void { + useEffect(() => { + if (typeof window === 'undefined' || !boardId || !enabled) return; + + const token = getAuthToken(); + if (!token) return; + + const wsUrl = getWsUrl(); + if (!wsUrl) return; + + const client = createClient({ + url: wsUrl, + connectionParams: { + Authorization: `Bearer ${token}`, + authToken: token, + }, + retryAttempts: 5, + shouldRetry: () => true, + }); + + const unsub = client.subscribe( + { + query: `subscription BoardMembersUpdated($boardId: ID!) { + boardMembersUpdated(boardId: $boardId) + }`, + variables: { boardId }, + } as SubscribePayload, + { + next: () => { + queryClient.invalidateQueries({ + queryKey: boardQueryKey(boardId), + }); + }, + error: (err: unknown) => { + if (process.env.NODE_ENV === 'development') { + console.warn( + '[BoardMembers] subscription error', + err instanceof Error ? err.message : String(err), + ); + } + }, + complete: () => {}, + }, + ); + + return () => { + if (typeof unsub === 'function') { + unsub(); + } else if (unsub && typeof (unsub as { unsubscribe?: () => void }).unsubscribe === 'function') { + (unsub as { unsubscribe: () => void }).unsubscribe(); + } + }; + }, [boardId, queryClient, enabled]); +} diff --git a/frontend/lib/hooks/use-board-meta-subscription.ts b/frontend/lib/hooks/use-board-meta-subscription.ts new file mode 100644 index 0000000..9fccfe9 --- /dev/null +++ b/frontend/lib/hooks/use-board-meta-subscription.ts @@ -0,0 +1,100 @@ +'use client'; + +import { useEffect } from 'react'; +import { createClient, type SubscribePayload } from 'graphql-ws'; +import { getAuthToken } from '../graphql-client'; +import type { QueryClient } from '@tanstack/react-query'; +import { boardQueryKey } from '@/app/boards/[id]/queries'; +import type { Board } from '@/app/boards/[id]/types'; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/graphql'; + +function getWsUrl(): string { + if (typeof window === 'undefined') return ''; + const url = API_URL.replace(/^http:\/\//, 'ws://').replace(/^https:\/\//, 'wss://'); + return url; +} + +interface SubscriptionBoard { + id: string; + title: string; + description?: string | null; + visibility: string; + background?: string | null; + isArchived: boolean; +} + +/** + * Subscribe to board metadata changes (title, description, visibility, background, archive). + * Updates the cached board header information without refetch. + */ +export function useBoardMetaSubscription( + boardId: string | null, + queryClient: QueryClient, + enabled = true, +): void { + useEffect(() => { + if (typeof window === 'undefined' || !boardId || !enabled) return; + + const token = getAuthToken(); + if (!token) return; + + const wsUrl = getWsUrl(); + if (!wsUrl) return; + + const client = createClient({ + url: wsUrl, + connectionParams: { + Authorization: `Bearer ${token}`, + authToken: token, + }, + retryAttempts: 5, + shouldRetry: () => true, + }); + + const unsub = client.subscribe( + { + query: `subscription BoardUpdated($boardId: ID!) { + boardUpdated(boardId: $boardId) { + id + title + description + visibility + background + isArchived + } + }`, + variables: { boardId }, + } as SubscribePayload, + { + next: (data) => { + const board = (data.data as { boardUpdated?: SubscriptionBoard })?.boardUpdated; + if (!board) return; + queryClient.setQueryData(boardQueryKey(boardId), (old) => { + if (!old) return old; + return { + ...old, + title: board.title, + description: board.description ?? undefined, + visibility: board.visibility as Board['visibility'], + background: board.background ?? undefined, + isArchived: board.isArchived, + }; + }); + }, + error: (err) => { + if (process.env.NODE_ENV === 'development') { + console.warn('[useBoardMetaSubscription]', err); + } + }, + complete: () => {}, + }, + ); + + return () => { + unsub(); + client.dispose(); + }; + }, [boardId, queryClient, enabled]); +} +