diff --git a/backend/src/common/interceptors/logging.interceptor.ts b/backend/src/common/interceptors/logging.interceptor.ts index 153ea1c..a138810 100644 --- a/backend/src/common/interceptors/logging.interceptor.ts +++ b/backend/src/common/interceptors/logging.interceptor.ts @@ -31,7 +31,7 @@ export class LoggingInterceptor implements NestInterceptor { this.logger.log( `→ ${method} ${url} | Operation: ${parentType}.${operationName}`, ); - if (userAgent) this.logger.debug(`User-Agent: ${userAgent}`); + this.logger.debug(`User-Agent: ${userAgent}`); return next.handle().pipe( tap({ diff --git a/backend/src/graphql/schema.gql b/backend/src/graphql/schema.gql index 56f0c2d..b087492 100644 --- a/backend/src/graphql/schema.gql +++ b/backend/src/graphql/schema.gql @@ -880,6 +880,11 @@ type Subscription { """Real-time notifications for the current user (WebSocket).""" notificationReceived: Notification! + """ + Subscribe to workspace boards list changes (create/delete/copy). Refetch workspace boards when this fires. + """ + workspaceBoardsChanged(workspaceId: ID!): Boolean! + """ Subscribe to workspace pending invitations changes (invite, cancel, accept, reject). Invalidate workspace invitations query when received. """ diff --git a/backend/src/modules/boards/board-subscription.resolver.spec.ts b/backend/src/modules/boards/board-subscription.resolver.spec.ts index 690af3b..8d27bf8 100644 --- a/backend/src/modules/boards/board-subscription.resolver.spec.ts +++ b/backend/src/modules/boards/board-subscription.resolver.spec.ts @@ -4,6 +4,11 @@ import { PUB_SUB } from '../../common/subscriptions/pubsub.provider'; import { TRIGGER_CARD_UPDATED, TRIGGER_LIST_UPDATED, + TRIGGER_BOARD_UPDATED, + TRIGGER_BOARD_MEMBERS_UPDATED, + TRIGGER_CARD_DELETED, + TRIGGER_LIST_DELETED, + TRIGGER_WORKSPACE_BOARDS_CHANGED, type CardUpdatedPayload, type ListUpdatedPayload, } from './board-subscription.resolver'; @@ -80,6 +85,46 @@ describe('BoardSubscriptionResolver', () => { }); }); + describe('boardUpdated', () => { + it('should return async iterable for TRIGGER_BOARD_UPDATED', () => { + const result = resolver.boardUpdated('board-1'); + expect(pubSub.asyncIterableIterator).toHaveBeenCalledWith(TRIGGER_BOARD_UPDATED); + expect(result).toBeDefined(); + }); + }); + + describe('boardMembersUpdated', () => { + it('should return async iterable for TRIGGER_BOARD_MEMBERS_UPDATED', () => { + const result = resolver.boardMembersUpdated('board-1'); + expect(pubSub.asyncIterableIterator).toHaveBeenCalledWith(TRIGGER_BOARD_MEMBERS_UPDATED); + expect(result).toBeDefined(); + }); + }); + + describe('cardDeleted', () => { + it('should return async iterable for TRIGGER_CARD_DELETED', () => { + const result = resolver.cardDeleted('board-1'); + expect(pubSub.asyncIterableIterator).toHaveBeenCalledWith(TRIGGER_CARD_DELETED); + expect(result).toBeDefined(); + }); + }); + + describe('listDeleted', () => { + it('should return async iterable for TRIGGER_LIST_DELETED', () => { + const result = resolver.listDeleted('board-1'); + expect(pubSub.asyncIterableIterator).toHaveBeenCalledWith(TRIGGER_LIST_DELETED); + expect(result).toBeDefined(); + }); + }); + + describe('workspaceBoardsChanged', () => { + it('should return async iterable for TRIGGER_WORKSPACE_BOARDS_CHANGED', () => { + const result = resolver.workspaceBoardsChanged('workspace-1'); + expect(pubSub.asyncIterableIterator).toHaveBeenCalledWith(TRIGGER_WORKSPACE_BOARDS_CHANGED); + expect(result).toBeDefined(); + }); + }); + describe('cardUpdatedByCardId', () => { it('should return async iterable for TRIGGER_CARD_UPDATED', () => { const result = resolver.cardUpdatedByCardId('card-1'); diff --git a/backend/src/modules/boards/board-subscription.resolver.ts b/backend/src/modules/boards/board-subscription.resolver.ts index faf34dd..9ffb1ab 100644 --- a/backend/src/modules/boards/board-subscription.resolver.ts +++ b/backend/src/modules/boards/board-subscription.resolver.ts @@ -34,6 +34,12 @@ export const TRIGGER_LIST_DELETED = 'listDeleted'; /** Payload when board members change (add/remove/role). */ export type BoardMembersUpdatedPayload = { boardId: string }; +/** Payload when the list of boards in a workspace changes (create/delete/copy). */ +export type WorkspaceBoardsChangedPayload = { workspaceId: string }; + +/** Trigger when boards are created, deleted or copied in a workspace (for real-time list refresh). */ +export const TRIGGER_WORKSPACE_BOARDS_CHANGED = 'workspaceBoardsChanged'; + @Resolver() @UseGuards(GqlAuthGuard) export class BoardSubscriptionResolver { @@ -149,4 +155,20 @@ export class BoardSubscriptionResolver { void boardId; return this.pubSub.asyncIterableIterator(TRIGGER_LIST_DELETED); } + + /** + * Subscribe to workspace boards list changes (board created, deleted, copied). + * Clients should refetch workspaceBoards(workspaceId) when this fires. + */ + @Subscription(() => Boolean, { + name: 'workspaceBoardsChanged', + description: 'Subscribe to workspace boards list changes (create/delete/copy). Refetch workspace boards when this fires.', + filter: (payload: WorkspaceBoardsChangedPayload, variables: { workspaceId: string }) => + payload.workspaceId === variables.workspaceId, + resolve: () => true, + }) + workspaceBoardsChanged(@Args('workspaceId', { type: () => ID }) workspaceId: string) { + void workspaceId; + return this.pubSub.asyncIterableIterator(TRIGGER_WORKSPACE_BOARDS_CHANGED); + } } diff --git a/backend/src/modules/boards/boards.resolver.spec.ts b/backend/src/modules/boards/boards.resolver.spec.ts index a1ad46f..235d1d4 100644 --- a/backend/src/modules/boards/boards.resolver.spec.ts +++ b/backend/src/modules/boards/boards.resolver.spec.ts @@ -5,6 +5,7 @@ import { ActivityService } from '../activity/activity.service'; import { PrismaService } from '../../prisma/prisma.service'; import { Visibility, Role } from '@prisma/client'; import { PUB_SUB } from '../../common/subscriptions/pubsub.provider'; +import { TRIGGER_WORKSPACE_BOARDS_CHANGED } from './board-subscription.resolver'; describe('BoardsResolver', () => { let resolver: BoardsResolver; @@ -129,7 +130,7 @@ describe('BoardsResolver', () => { }); describe('createBoard', () => { - it('should create a board', async () => { + it('should create a board and publish workspaceBoardsChanged when workspaceId is set', async () => { const input = { title: 'New Board', description: 'Description', @@ -142,11 +143,27 @@ describe('BoardsResolver', () => { expect(result).toEqual(mockBoard); expect(service.create).toHaveBeenCalledWith(input, mockUser.id); + expect(mockPubSub.publish).toHaveBeenCalledWith(TRIGGER_WORKSPACE_BOARDS_CHANGED, { + workspaceId: mockBoard.workspaceId, + }); + }); + + it('should create a board without publishing workspaceBoardsChanged when workspaceId is null', async () => { + const input = { title: 'Personal Board' }; + const boardNoWorkspace = { ...mockBoard, workspaceId: null }; + mockBoardsService.create.mockResolvedValue(boardNoWorkspace); + + await resolver.createBoard(input, mockUser); + + const workspacePublishCalls = (mockPubSub.publish as jest.Mock).mock.calls.filter( + (c) => c[0] === TRIGGER_WORKSPACE_BOARDS_CHANGED, + ); + expect(workspacePublishCalls).toHaveLength(0); }); }); describe('copyBoard', () => { - it('should copy a board', async () => { + it('should copy a board and publish workspaceBoardsChanged when workspaceId is set', async () => { const input = { sourceBoardId: 'board-1', title: 'Copied Board', @@ -160,6 +177,9 @@ describe('BoardsResolver', () => { expect(result).toEqual(copiedBoard); expect(service.copy).toHaveBeenCalledWith(input, mockUser.id); + expect(mockPubSub.publish).toHaveBeenCalledWith(TRIGGER_WORKSPACE_BOARDS_CHANGED, { + workspaceId: copiedBoard.workspaceId, + }); }); }); @@ -199,7 +219,7 @@ describe('BoardsResolver', () => { }); describe('updateBoard', () => { - it('should update a board', async () => { + it('should update a board and publish boardUpdated and workspaceBoardsChanged when workspaceId is set', async () => { const input = { id: mockBoard.id, title: 'Updated Board', @@ -216,22 +236,41 @@ describe('BoardsResolver', () => { expect(result).toEqual(updatedBoard); expect(service.update).toHaveBeenCalledWith(input, mockUser.id); + expect(mockPubSub.publish).toHaveBeenCalledWith(TRIGGER_WORKSPACE_BOARDS_CHANGED, { + workspaceId: updatedBoard.workspaceId, + }); }); }); describe('deleteBoard', () => { - it('should delete a board', async () => { + it('should delete a board and publish workspaceBoardsChanged when board had workspaceId', async () => { + mockPrismaService.board.findUnique.mockResolvedValue({ workspaceId: 'workspace-1' }); mockBoardsService.delete.mockResolvedValue(true); const result = await resolver.deleteBoard(mockBoard.id, mockUser); expect(result).toBe(true); expect(service.delete).toHaveBeenCalledWith(mockBoard.id, mockUser.id); + expect(mockPubSub.publish).toHaveBeenCalledWith(TRIGGER_WORKSPACE_BOARDS_CHANGED, { + workspaceId: 'workspace-1', + }); + }); + + it('should delete a board without publishing workspaceBoardsChanged when board had no workspace', async () => { + mockPrismaService.board.findUnique.mockResolvedValue({ workspaceId: null }); + mockBoardsService.delete.mockResolvedValue(true); + + await resolver.deleteBoard(mockBoard.id, mockUser); + + const workspacePublishCalls = (mockPubSub.publish as jest.Mock).mock.calls.filter( + (c) => c[0] === TRIGGER_WORKSPACE_BOARDS_CHANGED, + ); + expect(workspacePublishCalls).toHaveLength(0); }); }); describe('archiveBoard', () => { - it('should archive a board', async () => { + it('should archive a board and publish workspaceBoardsChanged when workspaceId is set', async () => { const archivedBoard = { ...mockBoard, isArchived: true, @@ -243,11 +282,14 @@ describe('BoardsResolver', () => { expect(result).toEqual(archivedBoard); expect(service.archive).toHaveBeenCalledWith(mockBoard.id, mockUser.id); + expect(mockPubSub.publish).toHaveBeenCalledWith(TRIGGER_WORKSPACE_BOARDS_CHANGED, { + workspaceId: archivedBoard.workspaceId, + }); }); }); describe('unarchiveBoard', () => { - it('should unarchive a board', async () => { + it('should unarchive a board and publish workspaceBoardsChanged when workspaceId is set', async () => { const unarchivedBoard = { ...mockBoard, isArchived: false, @@ -259,6 +301,9 @@ describe('BoardsResolver', () => { expect(result).toEqual(unarchivedBoard); expect(service.unarchive).toHaveBeenCalledWith(mockBoard.id, mockUser.id); + expect(mockPubSub.publish).toHaveBeenCalledWith(TRIGGER_WORKSPACE_BOARDS_CHANGED, { + workspaceId: unarchivedBoard.workspaceId, + }); }); }); diff --git a/backend/src/modules/boards/boards.resolver.ts b/backend/src/modules/boards/boards.resolver.ts index a285595..4e936a3 100644 --- a/backend/src/modules/boards/boards.resolver.ts +++ b/backend/src/modules/boards/boards.resolver.ts @@ -18,7 +18,11 @@ import { List } from '../lists/entities/list.entity'; import { ActivityService } from '../activity/activity.service'; import { ActivityType } from '@prisma/client'; import { PUB_SUB } from '../../common/subscriptions/pubsub.provider'; -import { TRIGGER_BOARD_UPDATED, TRIGGER_BOARD_MEMBERS_UPDATED } from './board-subscription.resolver'; +import { + TRIGGER_BOARD_UPDATED, + TRIGGER_BOARD_MEMBERS_UPDATED, + TRIGGER_WORKSPACE_BOARDS_CHANGED, +} from './board-subscription.resolver'; @Resolver(() => Board) @UseGuards(GqlAuthGuard) @@ -51,7 +55,13 @@ export class BoardsResolver { @Args('input') input: CreateBoardInput, @CurrentUser() user: any, ): Promise { - return this.boardsService.create(input, user.id); + const board = await this.boardsService.create(input, user.id); + if (board.workspaceId) { + await this.pubSub.publish(TRIGGER_WORKSPACE_BOARDS_CHANGED, { + workspaceId: board.workspaceId, + }); + } + return board; } @Mutation(() => Board, { @@ -61,7 +71,13 @@ export class BoardsResolver { @Args('input') input: CopyBoardInput, @CurrentUser() user: any, ): Promise { - return this.boardsService.copy(input, user.id); + const board = await this.boardsService.copy(input, user.id); + if (board.workspaceId) { + await this.pubSub.publish(TRIGGER_WORKSPACE_BOARDS_CHANGED, { + workspaceId: board.workspaceId, + }); + } + return board; } @Query(() => [BoardTemplate], { @@ -111,6 +127,11 @@ export class BoardsResolver { boardUpdated: board, boardId: board.id, }); + if (board.workspaceId) { + await this.pubSub.publish(TRIGGER_WORKSPACE_BOARDS_CHANGED, { + workspaceId: board.workspaceId, + }); + } return board; } @@ -121,7 +142,17 @@ export class BoardsResolver { @Args('id', { type: () => ID }) id: string, @CurrentUser() user: any, ): Promise { - return this.boardsService.delete(id, user.id); + const existing = await this.prisma.board.findUnique({ + where: { id }, + select: { workspaceId: true }, + }); + const result = await this.boardsService.delete(id, user.id); + if (existing?.workspaceId) { + await this.pubSub.publish(TRIGGER_WORKSPACE_BOARDS_CHANGED, { + workspaceId: existing.workspaceId, + }); + } + return result; } @Mutation(() => Board, { @@ -142,6 +173,11 @@ export class BoardsResolver { boardUpdated: board, boardId: board.id, }); + if (board.workspaceId) { + await this.pubSub.publish(TRIGGER_WORKSPACE_BOARDS_CHANGED, { + workspaceId: board.workspaceId, + }); + } return board; } @@ -163,6 +199,11 @@ export class BoardsResolver { boardUpdated: board, boardId: board.id, }); + if (board.workspaceId) { + await this.pubSub.publish(TRIGGER_WORKSPACE_BOARDS_CHANGED, { + workspaceId: board.workspaceId, + }); + } return board; } diff --git a/backend/src/modules/upload/upload.controller.spec.ts b/backend/src/modules/upload/upload.controller.spec.ts index bf0c100..738a9aa 100644 --- a/backend/src/modules/upload/upload.controller.spec.ts +++ b/backend/src/modules/upload/upload.controller.spec.ts @@ -2,14 +2,9 @@ import { Test, TestingModule } from '@nestjs/testing'; import { BadRequestException, UnauthorizedException } from '@nestjs/common'; import * as fs from 'fs'; import { UploadController, createImageFileFilter } from './upload.controller'; -import { StorageService } from './storage.service'; describe('UploadController', () => { let controller: UploadController; - const mockStorageService = { - isGcsEnabled: jest.fn().mockReturnValue(false), - uploadToGcs: jest.fn(), - }; const mockRequest = (overrides: { user?: { id: string }; @@ -37,10 +32,8 @@ describe('UploadController', () => { }) as Express.Multer.File; beforeEach(async () => { - mockStorageService.isGcsEnabled.mockReturnValue(false); const module: TestingModule = await Test.createTestingModule({ controllers: [UploadController], - providers: [{ provide: StorageService, useValue: mockStorageService }], }).compile(); controller = module.get(UploadController); @@ -99,25 +92,6 @@ describe('UploadController', () => { ); }); - it('should use GCS when enabled and return GCS URL', async () => { - mockStorageService.isGcsEnabled.mockReturnValue(true); - mockStorageService.uploadToGcs.mockResolvedValue( - 'https://storage.googleapis.com/my-bucket/avatars/user-1-123.jpg', - ); - const req = mockRequest({ user: { id: 'user-1' } }); - const file = mockFile(); - - const result = await controller.uploadAvatar(file, req); - - expect(result.url).toBe('https://storage.googleapis.com/my-bucket/avatars/user-1-123.jpg'); - expect(mockStorageService.uploadToGcs).toHaveBeenCalledWith( - file.buffer, - 'avatars', - expect.stringMatching(/^user-1-\d+\.jpg$/), - 'image/jpeg', - ); - }); - it('should use .gif extension when mimetype is image/gif', async () => { const req = mockRequest({ user: { id: 'user-1' } }); const file = mockFile({ mimetype: 'image/gif' }); @@ -209,25 +183,6 @@ describe('UploadController', () => { ); }); - it('should use GCS when enabled and return GCS URL', async () => { - mockStorageService.isGcsEnabled.mockReturnValue(true); - mockStorageService.uploadToGcs.mockResolvedValue( - 'https://storage.googleapis.com/my-bucket/backgrounds/background-456-abc123.png', - ); - const req = mockRequest({ user: { id: 'user-1' } }); - const file = mockFile({ fieldname: 'background', mimetype: 'image/png' }); - - const result = await controller.uploadBackground(file, req); - - expect(result.url).toBe('https://storage.googleapis.com/my-bucket/backgrounds/background-456-abc123.png'); - expect(mockStorageService.uploadToGcs).toHaveBeenCalledWith( - file.buffer, - 'backgrounds', - expect.stringMatching(/^background-\d+-[a-z0-9]+\.png$/), - 'image/png', - ); - }); - it('should use .gif extension when mimetype is image/gif', async () => { const req = mockRequest({ user: { id: 'user-1' } }); const file = mockFile({ fieldname: 'background', mimetype: 'image/gif' }); diff --git a/backend/src/modules/upload/upload.controller.ts b/backend/src/modules/upload/upload.controller.ts index d7e88fc..c9d7e79 100644 --- a/backend/src/modules/upload/upload.controller.ts +++ b/backend/src/modules/upload/upload.controller.ts @@ -14,7 +14,6 @@ import { join } from 'path'; import { existsSync, mkdirSync, writeFileSync } from 'fs'; import { Request } from 'express'; import { GqlAuthGuard } from '../../common/guards/gql-auth.guard'; -import { StorageService } from './storage.service'; const AVATARS_DIR = 'uploads/avatars'; const BACKGROUNDS_DIR = 'uploads/backgrounds'; @@ -56,8 +55,6 @@ export function createImageFileFilter(): ( @Controller('api/upload') @UseGuards(GqlAuthGuard) export class UploadController { - constructor(private readonly storage: StorageService) {} - @Post('avatar') @UseInterceptors( FileInterceptor('avatar', { @@ -78,17 +75,6 @@ export class UploadController { } const ext = getExtension(file.mimetype); const filename = `${user.id}-${Date.now()}.${ext}`; - - if (this.storage.isGcsEnabled()) { - const url = await this.storage.uploadToGcs( - file.buffer, - 'avatars', - filename, - file.mimetype, - ); - return { url }; - } - const dest = join(process.cwd(), AVATARS_DIR); if (!existsSync(dest)) mkdirSync(dest, { recursive: true }); const filepath = join(dest, filename); @@ -117,17 +103,6 @@ export class UploadController { } const ext = getExtension(file.mimetype); const filename = `background-${Date.now()}-${Math.random().toString(36).slice(2, 10)}.${ext}`; - - if (this.storage.isGcsEnabled()) { - const url = await this.storage.uploadToGcs( - file.buffer, - 'backgrounds', - filename, - file.mimetype, - ); - return { url }; - } - const dest = join(process.cwd(), BACKGROUNDS_DIR); if (!existsSync(dest)) mkdirSync(dest, { recursive: true }); writeFileSync(join(dest, filename), file.buffer); diff --git a/frontend/app/dashboard/page.tsx b/frontend/app/dashboard/page.tsx index 4722156..9cdc794 100644 --- a/frontend/app/dashboard/page.tsx +++ b/frontend/app/dashboard/page.tsx @@ -1,8 +1,7 @@ 'use client'; import React, { useState, useMemo } from 'react'; import { useRouter } from 'next/navigation'; -import { useQueryClient } from '@tanstack/react-query'; -import { useQueries } from '@tanstack/react-query'; +import { useQueryClient, useQueries } from '@tanstack/react-query'; import { createBoard as createBoardAction, Visibility, @@ -53,6 +52,7 @@ import { workspaceBoardsQueryKey, workspaceBoardsQueryOptions, } from '@/lib/queries/workspaces'; +import { useWorkspaceBoardsSubscription } from '@/lib/hooks/use-workspace-boards-subscription'; type Board = { id: string; @@ -82,6 +82,18 @@ function mapGqlToBoard(b: { }; } +/** Subscribes to real-time board list changes for one workspace (used in a loop). */ +function WorkspaceBoardsSubscriber({ + workspaceId, + queryClient, +}: { + workspaceId: string; + queryClient: ReturnType; +}) { + useWorkspaceBoardsSubscription(workspaceId, queryClient, true); + return null; +} + export default function DashboardPage() { const router = useRouter(); const queryClient = useQueryClient(); @@ -239,6 +251,13 @@ export default function DashboardPage() { return (
+ {workspaces.map((ws) => ( + + ))}
diff --git a/frontend/app/settings/components/AvatarPicker.tsx b/frontend/app/settings/components/AvatarPicker.tsx index a4de5c6..a3e42a2 100644 --- a/frontend/app/settings/components/AvatarPicker.tsx +++ b/frontend/app/settings/components/AvatarPicker.tsx @@ -20,7 +20,6 @@ function isValidHttpUrl(s: string): boolean { } const ACCEPT = 'image/jpeg,image/png,image/gif,image/webp'; -const MAX_SIZE_MB = 2; interface AvatarPickerProps { value: string; @@ -56,10 +55,6 @@ export function AvatarPicker({ const file = e.target.files?.[0]; e.target.value = ''; if (!file) return; - if (file.size > MAX_SIZE_MB * 1024 * 1024) { - toast.error(`Image must be under ${MAX_SIZE_MB} MB`); - return; - } const mime = file.type?.toLowerCase(); if (!ACCEPT.split(',').some((t) => t.trim() === mime)) { toast.error('Use JPEG, PNG, GIF or WebP'); @@ -153,7 +148,7 @@ export function AvatarPicker({ )}

- JPEG, PNG, GIF or WebP. Max {MAX_SIZE_MB} MB. + JPEG, PNG, GIF or WebP.

diff --git a/frontend/app/workspaces/[id]/boards/page.tsx b/frontend/app/workspaces/[id]/boards/page.tsx index 0a7fd50..8339496 100644 --- a/frontend/app/workspaces/[id]/boards/page.tsx +++ b/frontend/app/workspaces/[id]/boards/page.tsx @@ -25,6 +25,7 @@ import { workspaceBoardsQueryKey, } from '@/lib/queries/workspaces'; import { useWorkspaceRole } from '@/lib/hooks/use-workspace-role'; +import { useWorkspaceBoardsSubscription } from '@/lib/hooks/use-workspace-boards-subscription'; import { useCurrentUserQuery } from '@/lib/queries/users'; const STARRED_STORAGE_KEY = 'epitrello-starred-board-ids'; @@ -202,6 +203,10 @@ export default function WorkspaceBoardsPage() { refetch, } = useWorkspaceBoardsQuery(workspaceId); + useWorkspaceBoardsSubscription(workspaceId, queryClient, !!workspaceId); + + useWorkspaceBoardsSubscription(workspaceId, queryClient, !!workspaceId); + const boards: Board[] = useMemo( () => (gqlBoards || []).map((b) => ({ diff --git a/frontend/lib/hooks/use-workspace-boards-subscription.ts b/frontend/lib/hooks/use-workspace-boards-subscription.ts new file mode 100644 index 0000000..65c55bc --- /dev/null +++ b/frontend/lib/hooks/use-workspace-boards-subscription.ts @@ -0,0 +1,69 @@ +'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 { workspaceBoardsQueryKey } 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 ''; + const url = API_URL.replace(/^http:\/\//, 'ws://').replace(/^https:\/\//, 'wss://'); + return url; +} + +/** + * Subscribe to workspace boards list changes (board created, deleted, copied, archived, unarchived). + * Invalidates the workspace boards query so the list refreshes in real time. + */ +export function useWorkspaceBoardsSubscription( + 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 WorkspaceBoardsChanged($workspaceId: ID!) { + workspaceBoardsChanged(workspaceId: $workspaceId) + }`, + variables: { workspaceId }, + } as SubscribePayload, + { + next: () => { + queryClient.invalidateQueries({ queryKey: workspaceBoardsQueryKey(workspaceId) }); + }, + error: (err) => { + if (process.env.NODE_ENV === 'development') { + console.warn('[useWorkspaceBoardsSubscription]', err); + } + }, + complete: () => {}, + }, + ); + + return () => { + unsub(); + }; + }, [workspaceId, queryClient, enabled]); +}