diff --git a/src/api/routers/integrationsDiscovery.ts b/src/api/routers/integrationsDiscovery.ts index 6b31b3b9..643ec268 100644 --- a/src/api/routers/integrationsDiscovery.ts +++ b/src/api/routers/integrationsDiscovery.ts @@ -223,6 +223,35 @@ export const integrationsDiscoveryRouter = router({ return { successes, errors }; }), + createTrelloCustomField: protectedProcedure + .input( + trelloCredsInput.extend({ + boardId: z + .string() + .regex(/^[a-zA-Z0-9]+$/) + .max(32), + name: z.string().min(1).max(100), + type: z.enum(['number', 'text', 'checkbox', 'date', 'list']), + }), + ) + .mutation(async ({ ctx, input }) => { + logger.debug('integrationsDiscovery.createTrelloCustomField called', { + orgId: ctx.effectiveOrgId, + boardId: input.boardId, + name: input.name, + type: input.type, + }); + return withResolvedTrelloCreds( + input, + ctx.effectiveOrgId, + 'Failed to create Trello custom field', + (creds) => + withTrelloCredentials(creds, () => + trelloClient.createBoardCustomField(input.boardId, input.name, input.type), + ), + ); + }), + jiraProjects: protectedProcedure.input(jiraCredsInput).mutation(async ({ ctx, input }) => { logger.debug('integrationsDiscovery.jiraProjects called', { orgId: ctx.effectiveOrgId }); return withResolvedJiraCreds( diff --git a/src/trello/client.ts b/src/trello/client.ts index 4fb35d62..7fb7cff0 100644 --- a/src/trello/client.ts +++ b/src/trello/client.ts @@ -484,6 +484,27 @@ export const trelloClient = { })); }, + async createBoardCustomField( + boardId: string, + name: string, + type: string, + ): Promise<{ id: string; name: string; type: string }> { + logger.debug('Creating board custom field', { boardId, name, type }); + const field = await trelloFetch<{ id?: string; name?: string; type?: string }>( + '/customFields', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: { idModel: boardId, modelType: 'board', name, type, pos: 'bottom' }, + }, + ); + return { + id: field.id || '', + name: field.name || '', + type: field.type || '', + }; + }, + // ===== Member / Actions ===== async getMe(): Promise<{ id: string; fullName: string; username: string }> { diff --git a/tests/unit/api/routers/integrationsDiscovery.test.ts b/tests/unit/api/routers/integrationsDiscovery.test.ts index e725e52d..dbdad52d 100644 --- a/tests/unit/api/routers/integrationsDiscovery.test.ts +++ b/tests/unit/api/routers/integrationsDiscovery.test.ts @@ -27,6 +27,7 @@ const mockTrelloGetBoards = vi.fn(); const mockTrelloGetBoardLists = vi.fn(); const mockTrelloGetBoardLabels = vi.fn(); const mockTrelloGetBoardCustomFields = vi.fn(); +const mockTrelloCreateBoardCustomField = vi.fn(); vi.mock('../../../../src/trello/client.js', () => ({ withTrelloCredentials: (...args: unknown[]) => { @@ -39,6 +40,7 @@ vi.mock('../../../../src/trello/client.js', () => ({ getBoardLists: (...args: unknown[]) => mockTrelloGetBoardLists(...args), getBoardLabels: (...args: unknown[]) => mockTrelloGetBoardLabels(...args), getBoardCustomFields: (...args: unknown[]) => mockTrelloGetBoardCustomFields(...args), + createBoardCustomField: (...args: unknown[]) => mockTrelloCreateBoardCustomField(...args), }, })); @@ -481,4 +483,139 @@ describe('integrationsDiscoveryRouter', () => { ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); }); }); + + // ── createTrelloCustomField ────────────────────────────────────────── + + describe('createTrelloCustomField', () => { + it('returns id, name, and type on success', async () => { + setupDbCredentials([ + { orgId: 'org-1', value: 'api-key' }, + { orgId: 'org-1', value: 'token' }, + ]); + mockTrelloCreateBoardCustomField.mockResolvedValue({ + id: 'cf-123', + name: 'Cost', + type: 'number', + }); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.createTrelloCustomField({ + ...trelloCredsInput, + boardId: 'boardabc', + name: 'Cost', + type: 'number', + }); + + expect(result).toEqual({ + id: 'cf-123', + name: 'Cost', + type: 'number', + }); + expect(mockTrelloCreateBoardCustomField).toHaveBeenCalledWith('boardabc', 'Cost', 'number'); + }); + + it('throws UNAUTHORIZED when not authenticated', async () => { + const caller = createCaller({ user: null, effectiveOrgId: null }); + await expect( + caller.createTrelloCustomField({ + ...trelloCredsInput, + boardId: 'boardabc', + name: 'Cost', + type: 'number', + }), + ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + }); + + it('throws NOT_FOUND when credential does not exist', async () => { + mockDbWhere.mockResolvedValueOnce([]); + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + + await expect( + caller.createTrelloCustomField({ + ...trelloCredsInput, + boardId: 'boardabc', + name: 'Cost', + type: 'number', + }), + ).rejects.toMatchObject({ code: 'NOT_FOUND' }); + }); + + it('validates boardId with alphanumeric regex', async () => { + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect( + caller.createTrelloCustomField({ + ...trelloCredsInput, + boardId: 'board-with-hyphens', + name: 'Cost', + type: 'number', + }), + ).rejects.toThrow(); + }); + + it('validates boardId max length of 32', async () => { + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect( + caller.createTrelloCustomField({ + ...trelloCredsInput, + boardId: 'a'.repeat(33), + name: 'Cost', + type: 'number', + }), + ).rejects.toThrow(); + }); + + it('validates name min length of 1', async () => { + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect( + caller.createTrelloCustomField({ + ...trelloCredsInput, + boardId: 'boardabc', + name: '', + type: 'number', + }), + ).rejects.toThrow(); + }); + + it('validates name max length of 100', async () => { + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect( + caller.createTrelloCustomField({ + ...trelloCredsInput, + boardId: 'boardabc', + name: 'a'.repeat(101), + type: 'number', + }), + ).rejects.toThrow(); + }); + + it('validates type is one of the allowed enum values', async () => { + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect( + caller.createTrelloCustomField({ + ...trelloCredsInput, + boardId: 'boardabc', + name: 'Cost', + type: 'invalid-type', + }), + ).rejects.toThrow(); + }); + + it('wraps API failure in BAD_REQUEST', async () => { + setupDbCredentials([ + { orgId: 'org-1', value: 'api-key' }, + { orgId: 'org-1', value: 'token' }, + ]); + mockTrelloCreateBoardCustomField.mockRejectedValue(new Error('Board not found')); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect( + caller.createTrelloCustomField({ + ...trelloCredsInput, + boardId: 'boardabc', + name: 'Cost', + type: 'number', + }), + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + }); + }); }); diff --git a/tests/unit/trello/client.test.ts b/tests/unit/trello/client.test.ts index b2acd15f..5fa91dbd 100644 --- a/tests/unit/trello/client.test.ts +++ b/tests/unit/trello/client.test.ts @@ -705,4 +705,83 @@ describe('trelloClient', () => { ).rejects.toThrow('Trello API error 403'); }); }); + + describe('createBoardCustomField', () => { + it('POSTs to /customFields with boardId, name, type, and pos', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ id: 'cf-new', name: 'Cost', type: 'number' }), { + status: 200, + }), + ); + + const result = await withTrelloCredentials(creds, () => + trelloClient.createBoardCustomField('board-abc', 'Cost', 'number'), + ); + + expect(result).toEqual({ id: 'cf-new', name: 'Cost', type: 'number' }); + const [url, options] = fetchSpy.mock.calls[0]; + expect(url).toContain('/1/customFields'); + expect(url).toContain('key=test-key'); + expect(url).toContain('token=test-token'); + expect(options?.method).toBe('POST'); + expect(options?.headers).toEqual({ 'Content-Type': 'application/json' }); + expect(options?.body).toBe( + JSON.stringify({ + idModel: 'board-abc', + modelType: 'board', + name: 'Cost', + type: 'number', + pos: 'bottom', + }), + ); + }); + + it('handles all supported custom field types', async () => { + const types = ['number', 'text', 'checkbox', 'date', 'list']; + + for (const type of types) { + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response(JSON.stringify({ id: `cf-${type}`, name: 'Test Field', type }), { + status: 200, + }), + ); + + const result = await withTrelloCredentials(creds, () => + trelloClient.createBoardCustomField('board-1', 'Test Field', type), + ); + + expect(result.type).toBe(type); + const [, options] = fetchSpy.mock.calls[0]; + expect(JSON.parse(options?.body as string).type).toBe(type); + } + }); + + it('normalizes missing response fields to empty strings', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify({}), { status: 200 }), + ); + + const result = await withTrelloCredentials(creds, () => + trelloClient.createBoardCustomField('board-abc', 'Cost', 'number'), + ); + + expect(result).toEqual({ id: '', name: '', type: '' }); + }); + + it('throws on non-OK response', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('Forbidden', { status: 403 })); + + await expect( + withTrelloCredentials(creds, () => + trelloClient.createBoardCustomField('board-abc', 'Cost', 'number'), + ), + ).rejects.toThrow('Trello API error 403'); + }); + + it('throws when called outside scope', async () => { + await expect( + trelloClient.createBoardCustomField('board-abc', 'Cost', 'number'), + ).rejects.toThrow('No Trello credentials in scope'); + }); + }); });