From 8097f94d2377f9e32dca923b962fb6591c423806 Mon Sep 17 00:00:00 2001 From: "j.dev" Date: Tue, 19 May 2026 15:23:17 -0700 Subject: [PATCH 1/4] feat(CCP-4487): add workflow sharing and unsharing functionality to UI API --- external-hooks/src/api/routes/ui-api.ts | 51 ++++ external-hooks/src/api/schemas/ui.ts | 40 ++++ external-hooks/src/api/services/ui-api.ts | 136 ++++++++++- .../src/db/repository/n8n/shared-workflow.ts | 46 ++++ .../tests/api/routes/ui-api.test.ts | 75 ++++++ .../tests/api/services/ui-api.test.ts | 217 ++++++++++++++++++ external-hooks/tests/helpers/mocks.ts | 15 ++ 7 files changed, 576 insertions(+), 4 deletions(-) create mode 100644 external-hooks/src/api/schemas/ui.ts diff --git a/external-hooks/src/api/routes/ui-api.ts b/external-hooks/src/api/routes/ui-api.ts index 81d81f2..bd45050 100644 --- a/external-hooks/src/api/routes/ui-api.ts +++ b/external-hooks/src/api/routes/ui-api.ts @@ -2,6 +2,13 @@ import { Router, type Request } from 'express'; import { createOidcJwtMiddleware } from '../middlewares'; import { wrapAsyncRoute } from '../utils/errors'; import type { ApiRouteContext } from '../types/routes'; +import { createRequestSchemaValidator, parseValidatedRequest, parseValidatedResponse } from '../utils/validation'; +import { + shareWorkflowResponseSchema, + shareWorkflowSchema, + unshareWorkflowResponseSchema, + unshareWorkflowSchema, +} from '../schemas/ui'; function getRequestOrigin(req: Request) { return req.get('origin') ?? `${req.protocol}://${req.get('host')}`; @@ -96,5 +103,49 @@ export function buildUiApiRouter({ services }: ApiRouteContext) { }), ); + router.post( + '/workflows/:workflowId/share', + oidcJwtMiddleware, + createRequestSchemaValidator(shareWorkflowSchema), + wrapAsyncRoute(async (req, res) => { + const details = res.locals.oidcTokenDetails; + const parsed = parseValidatedRequest(shareWorkflowSchema, req); + const result = await services.uiApi.shareWorkflow(details.email, parsed.params.workflowId, parsed.body.email); + + const payload = parseValidatedResponse(shareWorkflowResponseSchema, { + success: true as const, + message: `Workflow '${result.workflowId}' shared with '${result.sharedWithEmail}'.`, + workflowId: result.workflowId, + sharedWithEmail: result.sharedWithEmail, + }); + + res.status(201).json(payload); + }), + ); + + router.delete( + '/workflows/:workflowId/projects/:projectId', + oidcJwtMiddleware, + createRequestSchemaValidator(unshareWorkflowSchema), + wrapAsyncRoute(async (req, res) => { + const details = res.locals.oidcTokenDetails; + const parsed = parseValidatedRequest(unshareWorkflowSchema, req); + const result = await services.uiApi.unshareWorkflow( + details.email, + parsed.params.workflowId, + parsed.params.projectId, + ); + + const payload = parseValidatedResponse(unshareWorkflowResponseSchema, { + success: true as const, + message: `Workflow '${result.workflowId}' unshared from project '${result.projectId}'.`, + workflowId: result.workflowId, + projectId: result.projectId, + }); + + res.status(200).json(payload); + }), + ); + return router; } diff --git a/external-hooks/src/api/schemas/ui.ts b/external-hooks/src/api/schemas/ui.ts new file mode 100644 index 0000000..738eacd --- /dev/null +++ b/external-hooks/src/api/schemas/ui.ts @@ -0,0 +1,40 @@ +import { z } from 'zod'; +import { trimString } from '../utils/string'; + +export const shareWorkflowSchema = z.object({ + params: z.object({ + workflowId: z.string().trim().min(1), + }), + query: z.record(z.string(), z.unknown()).optional(), + body: z + .object({ + email: z.unknown(), + }) + .transform((body) => ({ + email: trimString(body.email), + })) + .refine((body) => Boolean(body.email), { message: 'Missing email in request body.' }), +}); + +export const shareWorkflowResponseSchema = z.object({ + success: z.literal(true), + message: z.string(), + workflowId: z.string(), + sharedWithEmail: z.string(), +}); + +export const unshareWorkflowSchema = z.object({ + params: z.object({ + workflowId: z.string().trim().min(1), + projectId: z.string().trim().min(1), + }), + query: z.record(z.string(), z.unknown()).optional(), + body: z.record(z.string(), z.unknown()).optional(), +}); + +export const unshareWorkflowResponseSchema = z.object({ + success: z.literal(true), + message: z.string(), + workflowId: z.string(), + projectId: z.string(), +}); diff --git a/external-hooks/src/api/services/ui-api.ts b/external-hooks/src/api/services/ui-api.ts index b374724..9c64d72 100644 --- a/external-hooks/src/api/services/ui-api.ts +++ b/external-hooks/src/api/services/ui-api.ts @@ -1,3 +1,4 @@ +import { AppError } from '../utils/errors'; import { listN8nProjectIdsAccessibleToUser } from '../helpers/n8n-validation'; import { ProjectRelationRepository } from '../../db/repository/n8n/project-relation'; import { SharedWorkflowRepository } from '../../db/repository/n8n/shared-workflow'; @@ -9,6 +10,10 @@ export type UiWorkflowSummary = { workflowName: string; projectIds: string[]; userEmails: string[]; + projectShares: Array<{ + projectId: string; + userEmails: string[]; + }>; }; type UiApiRepositories = { @@ -16,20 +21,34 @@ type UiApiRepositories = { metadata: any; findOne: (options: { where: { email: string }; relations: string[] }) => Promise; }; - project: { getPersonalProjectForUser: (userId: string) => Promise<{ id: string } | null> }; + project: { + getPersonalProjectForUser: (userId: string) => Promise<{ id: string } | null>; + }; projectRelation: { metadata: any; findAllByUser: (userId: string) => Promise>; manager: any; }; - workflow: { metadata: any }; - sharedWorkflow: { metadata: any; manager: any }; + workflow: { + metadata: any; + findOneBy?: (where: { id: string }) => Promise<{ id: string } | null>; + }; + sharedWorkflow: { metadata: any; create?: any; save?: any; delete?: any; manager: any }; + withTransaction: any; }; function canViewAllWorkflows(roleSlug?: string | null) { return roleSlug === 'global:owner' || roleSlug === 'global:admin'; } +function canShareWorkflows(roleSlug?: string | null) { + return canViewAllWorkflows(roleSlug); +} + +function normalizeEmailSet(values: Set) { + return [...values].sort((a, b) => a.localeCompare(b)); +} + export class UiApiService { constructor(private readonly n8nRepositories: UiApiRepositories) {} @@ -46,6 +65,102 @@ export class UiApiService { }; } + async shareWorkflow(email: string | undefined, workflowId: string, targetEmail: string) { + const context = await this.buildUserContext(email); + if (!context.n8nUser) throw new AppError(401, 'Not authenticated.'); + if (!canShareWorkflows(context.n8nUser.role?.slug)) { + throw new AppError(403, 'Sharing workflows is restricted to owner and admin users.'); + } + + const workflowRows = await this.loadWorkflowRows(workflowId); + if (workflowRows.length === 0) { + throw new AppError(404, 'Workflow not found.'); + } + + if (!canViewAllWorkflows(context.n8nUser.role?.slug)) { + const hasAccess = workflowRows.some((row) => context.accessibleProjectIds.includes(row.projectId)); + if (!hasAccess) throw new AppError(403, 'Workflow is not accessible for this user.'); + } + + const targetUser = await new UserRepository(this.n8nRepositories.user).findByEmail(targetEmail); + if (!targetUser) { + throw new AppError(404, 'Target user not found.'); + } + + const targetProject = await this.n8nRepositories.project.getPersonalProjectForUser(targetUser.id); + if (!targetProject) { + throw new AppError(404, 'Target user has no personal project.'); + } + + const targetProjectAlreadyShared = workflowRows.some((row) => row.projectId === targetProject.id); + if (targetProjectAlreadyShared) { + throw new AppError(409, 'Email is already associated with this workflow.'); + } + + const sharedWorkflow = this.n8nRepositories.sharedWorkflow as { + create?: (value: Record) => Record; + save?: (value: Record) => Promise; + }; + if (!sharedWorkflow?.create || !sharedWorkflow?.save) { + throw new AppError(500, 'Shared workflow repository is unavailable.'); + } + + const newShare = sharedWorkflow.create({ + project: targetProject, + workflow: { id: workflowId }, + role: 'workflow:owner', + }); + await sharedWorkflow.save(newShare); + + return { + workflowId, + sharedWithEmail: targetEmail, + }; + } + + async unshareWorkflow(email: string | undefined, workflowId: string, projectId: string) { + const context = await this.buildUserContext(email); + if (!context.n8nUser) throw new AppError(401, 'Not authenticated.'); + if (!canShareWorkflows(context.n8nUser.role?.slug)) { + throw new AppError(403, 'Sharing workflows is restricted to owner and admin users.'); + } + + const workflowRows = await this.loadWorkflowRows(workflowId); + if (workflowRows.length === 0) { + throw new AppError(404, 'Workflow not found.'); + } + if (workflowRows.length <= 1) { + throw new AppError(409, 'Workflow must keep at least one project share.'); + } + + if (!canViewAllWorkflows(context.n8nUser.role?.slug)) { + const hasAccess = workflowRows.some((row) => context.accessibleProjectIds.includes(row.projectId)); + if (!hasAccess) throw new AppError(403, 'Workflow is not accessible for this user.'); + } + + const targetShare = workflowRows.find((row) => row.projectId === projectId); + if (!targetShare) { + throw new AppError(404, 'Project is not associated with this workflow.'); + } + + const sharedWorkflow = this.n8nRepositories.sharedWorkflow as { + delete?: (criteria: Record) => Promise; + manager?: any; + }; + if (sharedWorkflow?.delete) { + await sharedWorkflow.delete({ workflow: { id: workflowId }, project: { id: projectId } }); + return { workflowId, projectId }; + } + + if (!sharedWorkflow?.manager?.delete) { + throw new AppError(500, 'Shared workflow repository is unavailable.'); + } + + await sharedWorkflow.manager.delete('SharedWorkflow', { workflow: { id: workflowId }, project: { id: projectId } }); + + return { workflowId, projectId }; + } + private async buildUserContext(email?: string) { if (!email) { return { n8nUser: null, accessibleProjectIds: [], workflows: [] as UiWorkflowSummary[] }; @@ -105,6 +220,10 @@ export class UiApiService { const workflows: UiWorkflowSummary[] = [...workflowMap.entries()].map(([workflowId, entry]) => { const userEmails = new Set(); + const projectShares = [...entry.projectIds].map((projectId) => ({ + projectId, + userEmails: normalizeEmailSet(projectEmailMap.get(projectId) ?? new Set()), + })); for (const projectId of entry.projectIds) { for (const emailValue of projectEmailMap.get(projectId) ?? []) userEmails.add(emailValue); } @@ -113,7 +232,8 @@ export class UiApiService { workflowId, workflowName: entry.workflowName, projectIds: [...entry.projectIds], - userEmails: [...userEmails].sort((a, b) => a.localeCompare(b)), + userEmails: normalizeEmailSet(userEmails), + projectShares, }; }); @@ -123,4 +243,12 @@ export class UiApiService { workflows, }; } + + private async loadWorkflowRows(workflowId: string) { + const sharedWorkflowRepository = new SharedWorkflowRepository( + this.n8nRepositories.sharedWorkflow, + new WorkflowRepository(this.n8nRepositories.workflow), + ); + return await sharedWorkflowRepository.findRowsByWorkflowId(workflowId); + } } diff --git a/external-hooks/src/db/repository/n8n/shared-workflow.ts b/external-hooks/src/db/repository/n8n/shared-workflow.ts index 546171d..48332e3 100644 --- a/external-hooks/src/db/repository/n8n/shared-workflow.ts +++ b/external-hooks/src/db/repository/n8n/shared-workflow.ts @@ -20,6 +20,52 @@ export class SharedWorkflowRepository { return this.sharedWorkflowRepository.metadata; } + async findProjectIds(workflowId: string) { + const sharedWorkflowMetadata = this.sharedWorkflowRepository.metadata; + const sharedWorkflowTable = quoteIdentifier(sharedWorkflowMetadata.tableName); + const sharedWorkflowWorkflowColumn = quoteIdentifier(getColumnName(sharedWorkflowMetadata, 'workflowId')); + const sharedWorkflowProjectColumn = quoteIdentifier(getColumnName(sharedWorkflowMetadata, 'projectId')); + + const rows = await this.sharedWorkflowRepository.manager.query( + ` + SELECT sw.${sharedWorkflowProjectColumn} AS "projectId" + FROM ${sharedWorkflowTable} sw + WHERE sw.${sharedWorkflowWorkflowColumn} = $1 + `, + [workflowId], + ); + + return rows.map((row) => String(row.projectId)); + } + + async findRowsByWorkflowId(workflowId: string) { + const sharedWorkflowMetadata = this.sharedWorkflowRepository.metadata; + const workflowMetadata = this.workflowRepository.metadata; + + const sharedWorkflowTable = quoteIdentifier(sharedWorkflowMetadata.tableName); + const workflowTable = quoteIdentifier(workflowMetadata.tableName); + + const sharedWorkflowProjectColumn = quoteIdentifier(getColumnName(sharedWorkflowMetadata, 'projectId')); + const sharedWorkflowWorkflowColumn = quoteIdentifier(getColumnName(sharedWorkflowMetadata, 'workflowId')); + const workflowIdColumn = quoteIdentifier(getColumnName(workflowMetadata, 'id')); + const workflowNameColumn = quoteIdentifier(getColumnName(workflowMetadata, 'name')); + + const rows = await this.sharedWorkflowRepository.manager.query( + ` + SELECT + sw.${sharedWorkflowWorkflowColumn} AS "workflowId", + w.${workflowNameColumn} AS "workflowName", + sw.${sharedWorkflowProjectColumn} AS "projectId" + FROM ${sharedWorkflowTable} sw + INNER JOIN ${workflowTable} w ON w.${workflowIdColumn} = sw.${sharedWorkflowWorkflowColumn} + WHERE sw.${sharedWorkflowWorkflowColumn} = $1 + `, + [workflowId], + ); + + return rows as SharedWorkflowRow[]; + } + async findWorkflowRowsByProjectIds(projectIds?: string[]) { const sharedWorkflowMetadata = this.sharedWorkflowRepository.metadata; const workflowMetadata = this.workflowRepository.metadata; diff --git a/external-hooks/tests/api/routes/ui-api.test.ts b/external-hooks/tests/api/routes/ui-api.test.ts index 07b3788..fb53a7b 100644 --- a/external-hooks/tests/api/routes/ui-api.test.ts +++ b/external-hooks/tests/api/routes/ui-api.test.ts @@ -98,3 +98,78 @@ describe('GET /ui-api/workflows', () => { ); }); }); + +describe('POST /ui-api/workflows/:workflowId/share', () => { + it('delegates to the ui api service', async () => { + const uiApi = { + shareWorkflow: vi.fn().mockResolvedValue({ + workflowId: 'wf-1', + sharedWithEmail: 'new@example.com', + }), + }; + const router = buildUiApiRouter({ services: { uiApi } } as any); + const handler = getRouteHandler(router, 'post', '/workflows/:workflowId/share'); + const req = createMockRequest({ + params: { workflowId: 'wf-1' }, + body: { email: 'new@example.com' }, + get: vi.fn(() => undefined) as any, + }); + const res = createMockResponse({ + oidcTokenDetails: { + email: 'person@example.com', + issuer: 'https://issuer.example.com', + subject: 'sub-1', + audience: ['external-ui'], + claims: {}, + }, + }); + + await handler(req as any, res as any, vi.fn()); + + expect(uiApi.shareWorkflow).toHaveBeenCalledWith('person@example.com', 'wf-1', 'new@example.com'); + expect(res.status).toHaveBeenCalledWith(201); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + workflowId: 'wf-1', + sharedWithEmail: 'new@example.com', + }), + ); + }); +}); + +describe('DELETE /ui-api/workflows/:workflowId/projects/:projectId', () => { + it('delegates to the ui api service', async () => { + const uiApi = { + unshareWorkflow: vi.fn().mockResolvedValue({ + workflowId: 'wf-1', + projectId: 'proj-1', + }), + }; + const router = buildUiApiRouter({ services: { uiApi } } as any); + const handler = getRouteHandler(router, 'delete', '/workflows/:workflowId/projects/:projectId'); + const req = createMockRequest({ + params: { workflowId: 'wf-1', projectId: 'proj-1' }, + get: vi.fn(() => undefined) as any, + }); + const res = createMockResponse({ + oidcTokenDetails: { + email: 'person@example.com', + issuer: 'https://issuer.example.com', + subject: 'sub-1', + audience: ['external-ui'], + claims: {}, + }, + }); + + await handler(req as any, res as any, vi.fn()); + + expect(uiApi.unshareWorkflow).toHaveBeenCalledWith('person@example.com', 'wf-1', 'proj-1'); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + workflowId: 'wf-1', + projectId: 'proj-1', + }), + ); + }); +}); diff --git a/external-hooks/tests/api/services/ui-api.test.ts b/external-hooks/tests/api/services/ui-api.test.ts index 9c8e3cd..b5c8fdb 100644 --- a/external-hooks/tests/api/services/ui-api.test.ts +++ b/external-hooks/tests/api/services/ui-api.test.ts @@ -54,13 +54,230 @@ describe('UiApiService', () => { workflowName: 'First workflow', projectIds: [VALID_PROJECT_ID], userEmails: ['owner@example.com'], + projectShares: [{ projectId: VALID_PROJECT_ID, userEmails: ['owner@example.com'] }], }, { workflowId: 'wf-2', workflowName: 'Second workflow', projectIds: ['team-proj'], userEmails: ['teammate@example.com'], + projectShares: [{ projectId: 'team-proj', userEmails: ['teammate@example.com'] }], }, ]); }); + + it('shares a workflow with a new email', async () => { + const n8nRepos = createMockN8nRepositories(); + n8nRepos.user.findOne.mockImplementation(async ({ where: { email } }) => { + if (email === 'owner@example.com') { + return { + id: 'user-123', + email: 'owner@example.com', + role: { slug: 'global:admin', displayName: 'Admin' }, + }; + } + if (email === 'new@example.com') { + return { + id: 'user-456', + email: 'new@example.com', + role: { slug: 'global:member', displayName: 'Member' }, + }; + } + return null; + }); + n8nRepos.project.getPersonalProjectForUser.mockImplementation(async (userId: string) => { + if (userId === 'user-123') return { id: VALID_PROJECT_ID }; + if (userId === 'user-456') return { id: 'target-proj' }; + return null; + }); + n8nRepos.projectRelation.findAllByUser.mockResolvedValue([ + { projectId: VALID_PROJECT_ID }, + { projectId: 'team-proj' }, + ]); + const workflowRows = [ + { workflowId: 'wf-1', workflowName: 'First workflow', projectId: VALID_PROJECT_ID }, + { workflowId: 'wf-1', workflowName: 'First workflow', projectId: 'team-proj' }, + ]; + n8nRepos.sharedWorkflow.manager.query.mockImplementation(async (_sql, params) => { + if (Array.isArray(params?.[0])) return workflowRows; + if (params?.[0] === 'wf-1') return workflowRows; + return []; + }); + n8nRepos.projectRelation.manager.query.mockResolvedValue([ + { projectId: VALID_PROJECT_ID, email: 'owner@example.com' }, + { projectId: 'team-proj', email: 'teammate@example.com' }, + ]); + n8nRepos.workflow.findOneBy.mockResolvedValue({ id: 'wf-1' }); + + const service = new UiApiService(n8nRepos as any); + const result = await service.shareWorkflow('owner@example.com', 'wf-1', 'new@example.com'); + + expect(n8nRepos.sharedWorkflow.save).toHaveBeenCalled(); + expect(result).toEqual({ workflowId: 'wf-1', sharedWithEmail: 'new@example.com' }); + }); + + it('rejects sharing an already associated email', async () => { + const n8nRepos = createMockN8nRepositories(); + n8nRepos.user.findOne.mockImplementation(async ({ where: { email } }) => { + if (email === 'owner@example.com') { + return { + id: 'user-123', + email: 'owner@example.com', + role: { slug: 'global:admin', displayName: 'Admin' }, + }; + } + if (email === 'teammate@example.com') { + return { + id: 'user-456', + email: 'teammate@example.com', + role: { slug: 'global:member', displayName: 'Member' }, + }; + } + return null; + }); + n8nRepos.project.getPersonalProjectForUser.mockResolvedValue({ id: VALID_PROJECT_ID }); + n8nRepos.projectRelation.findAllByUser.mockResolvedValue([ + { projectId: VALID_PROJECT_ID }, + { projectId: 'team-proj' }, + ]); + const workflowRows = [ + { workflowId: 'wf-1', workflowName: 'First workflow', projectId: VALID_PROJECT_ID }, + { workflowId: 'wf-1', workflowName: 'First workflow', projectId: 'team-proj' }, + ]; + n8nRepos.sharedWorkflow.manager.query.mockImplementation(async (_sql, params) => { + if (Array.isArray(params?.[0])) return workflowRows; + if (params?.[0] === 'wf-1') return workflowRows; + return []; + }); + n8nRepos.projectRelation.manager.query.mockResolvedValue([ + { projectId: VALID_PROJECT_ID, email: 'owner@example.com' }, + { projectId: 'team-proj', email: 'teammate@example.com' }, + ]); + + const service = new UiApiService(n8nRepos as any); + await expect(service.shareWorkflow('owner@example.com', 'wf-1', 'teammate@example.com')).rejects.toMatchObject({ + message: 'Email is already associated with this workflow.', + }); + expect(n8nRepos.sharedWorkflow.save).not.toHaveBeenCalled(); + }); + + it('shares workflows with a single project', async () => { + const n8nRepos = createMockN8nRepositories(); + n8nRepos.user.findOne.mockImplementation(async ({ where: { email } }) => { + if (email === 'owner@example.com') { + return { + id: 'user-123', + email: 'owner@example.com', + role: { slug: 'global:admin', displayName: 'Admin' }, + }; + } + if (email === 'new@example.com') { + return { + id: 'user-456', + email: 'new@example.com', + role: { slug: 'global:member', displayName: 'Member' }, + }; + } + return null; + }); + n8nRepos.project.getPersonalProjectForUser.mockResolvedValue({ id: VALID_PROJECT_ID }); + n8nRepos.project.getPersonalProjectForUser.mockImplementation(async (userId: string) => { + if (userId === 'user-123') return { id: VALID_PROJECT_ID }; + if (userId === 'user-456') return { id: 'target-proj' }; + return null; + }); + n8nRepos.projectRelation.findAllByUser.mockResolvedValue([{ projectId: VALID_PROJECT_ID }]); + const workflowRows = [{ workflowId: 'wf-1', workflowName: 'First workflow', projectId: VALID_PROJECT_ID }]; + n8nRepos.sharedWorkflow.manager.query.mockImplementation(async (_sql, params) => { + if (Array.isArray(params?.[0])) return workflowRows; + if (params?.[0] === 'wf-1') return workflowRows; + return []; + }); + n8nRepos.projectRelation.manager.query.mockResolvedValue([ + { projectId: VALID_PROJECT_ID, email: 'owner@example.com' }, + ]); + n8nRepos.workflow.findOneBy.mockResolvedValue({ id: 'wf-1' }); + + const service = new UiApiService(n8nRepos as any); + const result = await service.shareWorkflow('owner@example.com', 'wf-1', 'new@example.com'); + + expect(n8nRepos.sharedWorkflow.save).toHaveBeenCalled(); + expect(result).toEqual({ workflowId: 'wf-1', sharedWithEmail: 'new@example.com' }); + }); + + it('rejects sharing for non-admin users', async () => { + const n8nRepos = createMockN8nRepositories(); + n8nRepos.user.findOne.mockResolvedValue({ + id: 'user-123', + email: 'member@example.com', + role: { slug: 'global:member', displayName: 'Member' }, + }); + n8nRepos.project.getPersonalProjectForUser.mockResolvedValue({ id: VALID_PROJECT_ID }); + n8nRepos.projectRelation.findAllByUser.mockResolvedValue([ + { projectId: VALID_PROJECT_ID }, + { projectId: 'team-proj' }, + ]); + + const service = new UiApiService(n8nRepos as any); + await expect(service.shareWorkflow('member@example.com', 'wf-1', 'new@example.com')).rejects.toMatchObject({ + message: 'Sharing workflows is restricted to owner and admin users.', + }); + expect(n8nRepos.sharedWorkflow.save).not.toHaveBeenCalled(); + }); + + it('rejects sharing for unknown emails', async () => { + const n8nRepos = createMockN8nRepositories(); + n8nRepos.user.findOne.mockImplementation(async ({ where: { email } }) => { + if (email === 'owner@example.com') { + return { + id: 'user-123', + email: 'owner@example.com', + role: { slug: 'global:admin', displayName: 'Admin' }, + }; + } + return null; + }); + n8nRepos.project.getPersonalProjectForUser.mockResolvedValue({ id: VALID_PROJECT_ID }); + n8nRepos.projectRelation.findAllByUser.mockResolvedValue([ + { projectId: VALID_PROJECT_ID }, + { projectId: 'team-proj' }, + ]); + n8nRepos.sharedWorkflow.manager.query.mockResolvedValue([ + { workflowId: 'wf-1', workflowName: 'First workflow', projectId: VALID_PROJECT_ID }, + { workflowId: 'wf-1', workflowName: 'First workflow', projectId: 'team-proj' }, + ]); + + const service = new UiApiService(n8nRepos as any); + await expect(service.shareWorkflow('owner@example.com', 'wf-1', 'missing@example.com')).rejects.toMatchObject({ + message: 'Target user not found.', + }); + expect(n8nRepos.sharedWorkflow.save).not.toHaveBeenCalled(); + }); + + it('unshares a workflow project', async () => { + const n8nRepos = createMockN8nRepositories(); + n8nRepos.user.findOne.mockResolvedValue({ + id: 'user-123', + email: 'owner@example.com', + role: { slug: 'global:admin', displayName: 'Admin' }, + }); + n8nRepos.project.getPersonalProjectForUser.mockResolvedValue({ id: VALID_PROJECT_ID }); + n8nRepos.projectRelation.findAllByUser.mockResolvedValue([ + { projectId: VALID_PROJECT_ID }, + { projectId: 'team-proj' }, + ]); + n8nRepos.sharedWorkflow.manager.query.mockResolvedValue([ + { workflowId: 'wf-1', workflowName: 'First workflow', projectId: VALID_PROJECT_ID }, + { workflowId: 'wf-1', workflowName: 'First workflow', projectId: 'team-proj' }, + ]); + + const service = new UiApiService(n8nRepos as any); + const result = await service.unshareWorkflow('owner@example.com', 'wf-1', 'team-proj'); + + expect(n8nRepos.sharedWorkflow.delete).toHaveBeenCalledWith({ + workflow: { id: 'wf-1' }, + project: { id: 'team-proj' }, + }); + expect(result).toEqual({ workflowId: 'wf-1', projectId: 'team-proj' }); + }); }); diff --git a/external-hooks/tests/helpers/mocks.ts b/external-hooks/tests/helpers/mocks.ts index d468e26..ec0eb03 100644 --- a/external-hooks/tests/helpers/mocks.ts +++ b/external-hooks/tests/helpers/mocks.ts @@ -199,6 +199,13 @@ export function createMockN8nRepositories() { const makeManager = () => ({ query: vi.fn().mockResolvedValue([]), + transaction: vi.fn(async (callback: (em: any) => Promise) => { + const em = { + create: vi.fn((_entityName, payload) => payload), + save: vi.fn(async (value) => value), + }; + return await callback(em); + }), connection: { getMetadata: vi.fn((entityName: keyof typeof metadata) => metadata[entityName]), }, @@ -221,6 +228,14 @@ export function createMockN8nRepositories() { credential: { findOneBy: vi.fn() }, sharedWorkflow: { findProjectIds: vi.fn().mockResolvedValue([VALID_PROJECT_ID]), + findRowsByWorkflowId: vi + .fn() + .mockResolvedValue([ + { workflowId: VALID_WORKFLOW_ID, workflowName: 'Test workflow', projectId: VALID_PROJECT_ID }, + ]), + create: vi.fn((_value) => _value), + save: vi.fn(async (value) => value), + delete: vi.fn(async () => undefined), metadata: metadata.SharedWorkflow, manager: makeManager(), }, From 612afebdd47882caf9b8b71685617ee9e5ef5c14 Mon Sep 17 00:00:00 2001 From: "j.dev" Date: Tue, 19 May 2026 15:23:25 -0700 Subject: [PATCH 2/4] feat(CCP-4487): implement workflow sharing and unsharing in UI --- external-ui/src/pages/workflows.tsx | 209 ++++++++++++++---- external-ui/src/services/backend/workflows.ts | 32 +++ 2 files changed, 201 insertions(+), 40 deletions(-) diff --git a/external-ui/src/pages/workflows.tsx b/external-ui/src/pages/workflows.tsx index e4b420d..d36cb98 100644 --- a/external-ui/src/pages/workflows.tsx +++ b/external-ui/src/pages/workflows.tsx @@ -1,18 +1,58 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useMemo, useState } from 'react'; import { Link } from 'react-router'; -import { useQuery } from '@tanstack/react-query'; import { useAuth } from '../auth/auth-context'; -import { getWorkflows } from '../services/backend/workflows'; +import { getWorkflows, shareWorkflow, unshareWorkflow } from '../services/backend/workflows'; export function Workflows() { const { user, login } = useAuth(); + const queryClient = useQueryClient(); + const [sharingWorkflowId, setSharingWorkflowId] = useState(null); + const [shareEmail, setShareEmail] = useState(''); + const workflowsQuery = useQuery({ queryKey: ['workflows', user?.access_token ?? ''], queryFn: ({ signal }) => getWorkflows({ signal }), enabled: Boolean(user?.access_token), }); + const shareMutation = useMutation({ + mutationFn: ({ workflowId, email }: { workflowId: string; email: string }) => shareWorkflow(workflowId, email), + onSuccess: async () => { + setSharingWorkflowId(null); + setShareEmail(''); + await queryClient.invalidateQueries({ queryKey: ['workflows', user?.access_token ?? ''] }); + }, + }); + + const unshareMutation = useMutation({ + mutationFn: ({ workflowId, projectId }: { workflowId: string; projectId: string }) => + unshareWorkflow(workflowId, projectId), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ['workflows', user?.access_token ?? ''] }); + }, + }); + const workflows = workflowsQuery.data?.workflows ?? []; + const currentRoleSlug = workflowsQuery.data?.n8nUser?.role?.slug ?? null; + const canShareWorkflows = currentRoleSlug === 'global:owner' || currentRoleSlug === 'global:admin'; const workflowsError = workflowsQuery.error instanceof Error ? workflowsQuery.error.message : null; + const sharingWorkflow = useMemo( + () => workflows.find((workflow) => workflow.workflowId === sharingWorkflowId) ?? null, + [sharingWorkflowId, workflows], + ); + + function openShareDialog(workflowId: string) { + setSharingWorkflowId(workflowId); + setShareEmail(''); + shareMutation.reset(); + } + + function closeShareDialog() { + setSharingWorkflowId(null); + setShareEmail(''); + shareMutation.reset(); + } return (
@@ -44,50 +84,139 @@ export function Workflows() {
) : (
- {workflows.map((workflow) => ( -
-
-
-

{workflow.workflowName}

-

{workflow.workflowId}

-
-
- {workflow.projectIds.length} project{workflow.projectIds.length === 1 ? '' : 's'} + {workflows.map((workflow) => { + const canRemoveProjects = workflow.projectShares.length > 1; + return ( +
+
+
+

{workflow.workflowName}

+

{workflow.workflowId}

+
+
+
+ {workflow.projectIds.length} project{workflow.projectIds.length === 1 ? '' : 's'} +
+ {canShareWorkflows ? ( + + ) : null} +
-
-
-
-

Projects

-
    - {workflow.projectIds.map((projectId) => ( -
  • - {projectId} -
  • - ))} -
+
+
+
Project
+
User emails
+
Action
+
+ {workflow.projectShares.map((projectShare) => ( +
+
+ {projectShare.projectId} +
+
+ {projectShare.userEmails.length ? ( + projectShare.userEmails.map((email) => ( + + {email} + + )) + ) : ( + No users + )} +
+
+ {canShareWorkflows && canRemoveProjects ? ( + + ) : null} +
+
+ ))}
+
+ ); + })} +
+ )} -
-

- User emails -

-
    - {workflow.userEmails.map((email) => ( -
  • - {email} -
  • - ))} -
-
+ {sharingWorkflow && canShareWorkflows ? ( +
+
+
+

Share workflow

+

{sharingWorkflow.workflowName}

+
+ +
{ + event.preventDefault(); + shareMutation.mutate({ workflowId: sharingWorkflow.workflowId, email: shareEmail.trim() }); + }} + > + + + {shareMutation.error instanceof Error ? ( +

{shareMutation.error.message}

+ ) : null} + +
+ +
- - ))} +
+
- )} + ) : null} {user ? (
diff --git a/external-ui/src/services/backend/workflows.ts b/external-ui/src/services/backend/workflows.ts index 362ffa8..3c01e8b 100644 --- a/external-ui/src/services/backend/workflows.ts +++ b/external-ui/src/services/backend/workflows.ts @@ -18,9 +18,41 @@ export type WorkflowsResponse = { workflowName: string; projectIds: string[]; userEmails: string[]; + projectShares: Array<{ + projectId: string; + userEmails: string[]; + }>; }>; }; +export type ShareWorkflowResponse = { + success: boolean; + message: string; + workflowId: string; + sharedWithEmail: string; +}; + +export type UnshareWorkflowResponse = { + success: boolean; + message: string; + workflowId: string; + projectId: string; +}; + export async function getWorkflows(params?: { signal?: AbortSignal }) { return instance.get('/ui-api/workflows', { signal: params?.signal }).then((res) => res.data); } + +export async function shareWorkflow(workflowId: string, email: string) { + return instance + .post(`/ui-api/workflows/${encodeURIComponent(workflowId)}/share`, { email }) + .then((res) => res.data); +} + +export async function unshareWorkflow(workflowId: string, projectId: string) { + return instance + .delete( + `/ui-api/workflows/${encodeURIComponent(workflowId)}/projects/${encodeURIComponent(projectId)}`, + ) + .then((res) => res.data); +} From b0e7ae44b3ec923a5b87054ea701b6012a1170f3 Mon Sep 17 00:00:00 2001 From: "j.dev" Date: Tue, 19 May 2026 15:50:33 -0700 Subject: [PATCH 3/4] refactor(CCP-4487): extract reusable SQL query logic in n8n repositories --- .../src/db/repository/n8n/project-relation.ts | 24 +++-- .../src/db/repository/n8n/shared-workflow.ts | 91 ++++++++----------- 2 files changed, 55 insertions(+), 60 deletions(-) diff --git a/external-hooks/src/db/repository/n8n/project-relation.ts b/external-hooks/src/db/repository/n8n/project-relation.ts index 8fb3f4e..1becc3c 100644 --- a/external-hooks/src/db/repository/n8n/project-relation.ts +++ b/external-hooks/src/db/repository/n8n/project-relation.ts @@ -12,7 +12,7 @@ export class ProjectRelationRepository { return this.projectRelationRepository.metadata; } - async listUserEmailsByProjectIds(projectIds: string[]) { + private buildUserEmailLookup() { const projectRelationMetadata = this.projectRelationRepository.metadata; const userMetadata = this.userRepository.metadata; @@ -24,17 +24,29 @@ export class ProjectRelationRepository { const userIdColumn = quoteIdentifier(getColumnName(userMetadata, 'id')); const userEmailColumn = quoteIdentifier(getColumnName(userMetadata, 'email')); - const rows = await this.projectRelationRepository.manager.query( - ` + return { + projectRelationProjectColumn, + selectSql: ` SELECT pr.${projectRelationProjectColumn} AS "projectId", u.${userEmailColumn} AS "email" FROM ${projectRelationTable} pr INNER JOIN ${userTable} u ON u.${userIdColumn} = pr.${projectRelationUserColumn} - WHERE pr.${projectRelationProjectColumn} = ANY($1) `, - [projectIds], - ); + }; + } + + private async queryUserEmails(sql: string, params?: unknown[]) { + return await this.projectRelationRepository.manager.query(sql, params); + } + + async listUserEmailsByProjectIds(projectIds: string[]) { + if (!projectIds.length) return [] as Array<{ projectId: string; email: string }>; + + const { projectRelationProjectColumn, selectSql } = this.buildUserEmailLookup(); + const rows = await this.queryUserEmails(`${selectSql} WHERE pr.${projectRelationProjectColumn} = ANY($1)`, [ + projectIds, + ]); return rows as Array<{ projectId: string; email: string }>; } diff --git a/external-hooks/src/db/repository/n8n/shared-workflow.ts b/external-hooks/src/db/repository/n8n/shared-workflow.ts index 48332e3..4704ba6 100644 --- a/external-hooks/src/db/repository/n8n/shared-workflow.ts +++ b/external-hooks/src/db/repository/n8n/shared-workflow.ts @@ -7,6 +7,8 @@ type SharedWorkflowRow = { projectId: string; }; +type SharedWorkflowQueryResult = Array>; + export class SharedWorkflowRepository { constructor( private readonly sharedWorkflowRepository: { @@ -20,25 +22,7 @@ export class SharedWorkflowRepository { return this.sharedWorkflowRepository.metadata; } - async findProjectIds(workflowId: string) { - const sharedWorkflowMetadata = this.sharedWorkflowRepository.metadata; - const sharedWorkflowTable = quoteIdentifier(sharedWorkflowMetadata.tableName); - const sharedWorkflowWorkflowColumn = quoteIdentifier(getColumnName(sharedWorkflowMetadata, 'workflowId')); - const sharedWorkflowProjectColumn = quoteIdentifier(getColumnName(sharedWorkflowMetadata, 'projectId')); - - const rows = await this.sharedWorkflowRepository.manager.query( - ` - SELECT sw.${sharedWorkflowProjectColumn} AS "projectId" - FROM ${sharedWorkflowTable} sw - WHERE sw.${sharedWorkflowWorkflowColumn} = $1 - `, - [workflowId], - ); - - return rows.map((row) => String(row.projectId)); - } - - async findRowsByWorkflowId(workflowId: string) { + private buildWorkflowRowSelect() { const sharedWorkflowMetadata = this.sharedWorkflowRepository.metadata; const workflowMetadata = this.workflowRepository.metadata; @@ -50,57 +34,56 @@ export class SharedWorkflowRepository { const workflowIdColumn = quoteIdentifier(getColumnName(workflowMetadata, 'id')); const workflowNameColumn = quoteIdentifier(getColumnName(workflowMetadata, 'name')); - const rows = await this.sharedWorkflowRepository.manager.query( - ` + return { + sharedWorkflowProjectColumn, + sharedWorkflowWorkflowColumn, + selectSql: ` SELECT sw.${sharedWorkflowWorkflowColumn} AS "workflowId", w.${workflowNameColumn} AS "workflowName", sw.${sharedWorkflowProjectColumn} AS "projectId" FROM ${sharedWorkflowTable} sw INNER JOIN ${workflowTable} w ON w.${workflowIdColumn} = sw.${sharedWorkflowWorkflowColumn} + `, + }; + } + + private async queryWorkflowRows(sql: string, params?: unknown[]): Promise { + return await this.sharedWorkflowRepository.manager.query(sql, params); + } + + async findProjectIds(workflowId: string) { + const sharedWorkflowMetadata = this.sharedWorkflowRepository.metadata; + const sharedWorkflowTable = quoteIdentifier(sharedWorkflowMetadata.tableName); + const sharedWorkflowWorkflowColumn = quoteIdentifier(getColumnName(sharedWorkflowMetadata, 'workflowId')); + const sharedWorkflowProjectColumn = quoteIdentifier(getColumnName(sharedWorkflowMetadata, 'projectId')); + + const rows = await this.queryWorkflowRows( + ` + SELECT sw.${sharedWorkflowProjectColumn} AS "projectId" + FROM ${sharedWorkflowTable} sw WHERE sw.${sharedWorkflowWorkflowColumn} = $1 `, [workflowId], ); - return rows as SharedWorkflowRow[]; + return rows.map((row) => String(row.projectId)); } - async findWorkflowRowsByProjectIds(projectIds?: string[]) { - const sharedWorkflowMetadata = this.sharedWorkflowRepository.metadata; - const workflowMetadata = this.workflowRepository.metadata; - - const sharedWorkflowTable = quoteIdentifier(sharedWorkflowMetadata.tableName); - const workflowTable = quoteIdentifier(workflowMetadata.tableName); + async findRowsByWorkflowId(workflowId: string) { + const { sharedWorkflowWorkflowColumn, selectSql } = this.buildWorkflowRowSelect(); + const rows = await this.queryWorkflowRows(`${selectSql} WHERE sw.${sharedWorkflowWorkflowColumn} = $1`, [ + workflowId, + ]); - const sharedWorkflowProjectColumn = quoteIdentifier(getColumnName(sharedWorkflowMetadata, 'projectId')); - const sharedWorkflowWorkflowColumn = quoteIdentifier(getColumnName(sharedWorkflowMetadata, 'workflowId')); - const workflowIdColumn = quoteIdentifier(getColumnName(workflowMetadata, 'id')); - const workflowNameColumn = quoteIdentifier(getColumnName(workflowMetadata, 'name')); + return rows as SharedWorkflowRow[]; + } + async findWorkflowRowsByProjectIds(projectIds?: string[]) { + const { sharedWorkflowProjectColumn, selectSql } = this.buildWorkflowRowSelect(); const rows = projectIds?.length - ? await this.sharedWorkflowRepository.manager.query( - ` - SELECT - sw.${sharedWorkflowWorkflowColumn} AS "workflowId", - w.${workflowNameColumn} AS "workflowName", - sw.${sharedWorkflowProjectColumn} AS "projectId" - FROM ${sharedWorkflowTable} sw - INNER JOIN ${workflowTable} w ON w.${workflowIdColumn} = sw.${sharedWorkflowWorkflowColumn} - WHERE sw.${sharedWorkflowProjectColumn} = ANY($1) - `, - [projectIds], - ) - : await this.sharedWorkflowRepository.manager.query( - ` - SELECT - sw.${sharedWorkflowWorkflowColumn} AS "workflowId", - w.${workflowNameColumn} AS "workflowName", - sw.${sharedWorkflowProjectColumn} AS "projectId" - FROM ${sharedWorkflowTable} sw - INNER JOIN ${workflowTable} w ON w.${workflowIdColumn} = sw.${sharedWorkflowWorkflowColumn} - `, - ); + ? await this.queryWorkflowRows(`${selectSql} WHERE sw.${sharedWorkflowProjectColumn} = ANY($1)`, [projectIds]) + : await this.queryWorkflowRows(selectSql); return rows as SharedWorkflowRow[]; } From 13b982f7a39ea8bbb3a8fae3196031a8c61f3ce9 Mon Sep 17 00:00:00 2001 From: "j.dev" Date: Tue, 19 May 2026 15:50:40 -0700 Subject: [PATCH 4/4] feat(CCP-4487): enhance service and routes --- external-hooks/src/api/routes/ui-api.ts | 64 ++--- external-hooks/src/api/services/ui-api.ts | 327 +++++++++++++--------- 2 files changed, 233 insertions(+), 158 deletions(-) diff --git a/external-hooks/src/api/routes/ui-api.ts b/external-hooks/src/api/routes/ui-api.ts index bd45050..7bac62c 100644 --- a/external-hooks/src/api/routes/ui-api.ts +++ b/external-hooks/src/api/routes/ui-api.ts @@ -2,6 +2,7 @@ import { Router, type Request } from 'express'; import { createOidcJwtMiddleware } from '../middlewares'; import { wrapAsyncRoute } from '../utils/errors'; import type { ApiRouteContext } from '../types/routes'; +import type { OidcTokenDetails } from '../types/oidc'; import { createRequestSchemaValidator, parseValidatedRequest, parseValidatedResponse } from '../utils/validation'; import { shareWorkflowResponseSchema, @@ -14,6 +15,34 @@ function getRequestOrigin(req: Request) { return req.get('origin') ?? `${req.protocol}://${req.get('host')}`; } +function serializeRole(role: { slug: string; displayName: string } | null | undefined) { + return role ? { slug: role.slug, displayName: role.displayName } : null; +} + +function serializeN8nUser( + user: { id: string; email: string; role: { slug: string; displayName: string } | null } | null, +) { + return user ? { id: user.id, email: user.email, role: serializeRole(user.role) } : null; +} + +function serializeOidcDetails(details: OidcTokenDetails | undefined) { + if (!details) return null; + return { + issuer: details.issuer, + subject: details.subject, + audience: details.audience, + azp: details.azp, + email: details.email, + preferredUsername: details.preferredUsername, + name: details.name, + scope: details.scope, + expiresAt: details.expiresAt, + issuedAt: details.issuedAt, + notBefore: details.notBefore, + claims: details.claims, + }; +} + export function buildUiApiRouter({ services }: ApiRouteContext) { const router = Router(); const oidcJwtMiddleware = createOidcJwtMiddleware({ @@ -49,29 +78,8 @@ export function buildUiApiRouter({ services }: ApiRouteContext) { ok: true, route: '/ui-api/whoami', method: req.method, - oidc: details - ? { - issuer: details.issuer, - subject: details.subject, - audience: details.audience, - azp: details.azp, - email: details.email, - preferredUsername: details.preferredUsername, - name: details.name, - scope: details.scope, - expiresAt: details.expiresAt, - issuedAt: details.issuedAt, - notBefore: details.notBefore, - claims: details.claims, - } - : null, - n8nUser: n8nUser - ? { - id: n8nUser.id, - email: n8nUser.email, - role: n8nUser.role ? { slug: n8nUser.role.slug, displayName: n8nUser.role.displayName } : null, - } - : null, + oidc: serializeOidcDetails(details), + n8nUser: serializeN8nUser(n8nUser), userAgent: req.get('user-agent') ?? null, }); }), @@ -88,15 +96,7 @@ export function buildUiApiRouter({ services }: ApiRouteContext) { ok: true, route: '/ui-api/workflows', method: req.method, - n8nUser: context.n8nUser - ? { - id: context.n8nUser.id, - email: context.n8nUser.email, - role: context.n8nUser.role - ? { slug: context.n8nUser.role.slug, displayName: context.n8nUser.role.displayName } - : null, - } - : null, + n8nUser: serializeN8nUser(context.n8nUser), accessibleProjectIds: context.accessibleProjectIds, workflows: context.workflows, }); diff --git a/external-hooks/src/api/services/ui-api.ts b/external-hooks/src/api/services/ui-api.ts index 9c64d72..2af0148 100644 --- a/external-hooks/src/api/services/ui-api.ts +++ b/external-hooks/src/api/services/ui-api.ts @@ -5,38 +5,71 @@ import { SharedWorkflowRepository } from '../../db/repository/n8n/shared-workflo import { UserRepository, type N8nUiUser } from '../../db/repository/n8n/user'; import { WorkflowRepository } from '../../db/repository/n8n/workflow'; +type UiWorkflowProjectShare = { + projectId: string; + userEmails: string[]; +}; + export type UiWorkflowSummary = { workflowId: string; workflowName: string; projectIds: string[]; userEmails: string[]; - projectShares: Array<{ - projectId: string; - userEmails: string[]; - }>; + projectShares: UiWorkflowProjectShare[]; }; -type UiApiRepositories = { - user: { - metadata: any; - findOne: (options: { where: { email: string }; relations: string[] }) => Promise; - }; - project: { - getPersonalProjectForUser: (userId: string) => Promise<{ id: string } | null>; - }; - projectRelation: { - metadata: any; - findAllByUser: (userId: string) => Promise>; - manager: any; - }; - workflow: { - metadata: any; - findOneBy?: (where: { id: string }) => Promise<{ id: string } | null>; +type UiApiContext = { + n8nUser: N8nUiUser | null; + accessibleProjectIds: string[]; + workflows: UiWorkflowSummary[]; +}; + +type N8nUserRepository = { + metadata: any; + findOne: (options: { where: { email: string }; relations: string[] }) => Promise; +}; + +type N8nProjectRepository = { + getPersonalProjectForUser: (userId: string) => Promise<{ id: string } | null>; +}; + +type N8nProjectRelationRepository = { + metadata: any; + findAllByUser: (userId: string) => Promise>; + manager: { query: (sql: string, params?: unknown[]) => Promise> }; +}; + +type N8nWorkflowRepository = { + metadata: any; + findOneBy?: (where: { id: string }) => Promise<{ id: string } | null>; +}; + +type N8nSharedWorkflowRepository = { + metadata: any; + create?: (value: Record) => Record; + save?: (value: Record) => Promise; + delete?: (criteria: Record) => Promise; + manager: { + query: (sql: string, params?: unknown[]) => Promise>>; + delete?: (entity: string, criteria: Record) => Promise; }; - sharedWorkflow: { metadata: any; create?: any; save?: any; delete?: any; manager: any }; +}; + +type UiApiRepositories = { + user: N8nUserRepository; + project: N8nProjectRepository; + projectRelation: N8nProjectRelationRepository; + workflow: N8nWorkflowRepository; + sharedWorkflow: N8nSharedWorkflowRepository; withTransaction: any; }; +type WorkflowRow = { + workflowId: string; + workflowName: string; + projectId: string; +}; + function canViewAllWorkflows(roleSlug?: string | null) { return roleSlug === 'global:owner' || roleSlug === 'global:admin'; } @@ -50,10 +83,25 @@ function normalizeEmailSet(values: Set) { } export class UiApiService { - constructor(private readonly n8nRepositories: UiApiRepositories) {} + private readonly userRepository: UserRepository; + private readonly projectRelationRepository: ProjectRelationRepository; + private readonly sharedWorkflowRepository: SharedWorkflowRepository; + + constructor(private readonly n8nRepositories: UiApiRepositories) { + this.userRepository = new UserRepository(n8nRepositories.user); + this.projectRelationRepository = new ProjectRelationRepository( + n8nRepositories.projectRelation, + n8nRepositories.user, + ); + this.sharedWorkflowRepository = new SharedWorkflowRepository( + n8nRepositories.sharedWorkflow, + new WorkflowRepository(n8nRepositories.workflow), + ); + } async getWhoami(email?: string) { - return await this.buildUserContext(email).then((context) => context.n8nUser); + const context = await this.buildUserContext(email); + return context.n8nUser; } async getWorkflows(email?: string) { @@ -66,23 +114,12 @@ export class UiApiService { } async shareWorkflow(email: string | undefined, workflowId: string, targetEmail: string) { - const context = await this.buildUserContext(email); - if (!context.n8nUser) throw new AppError(401, 'Not authenticated.'); - if (!canShareWorkflows(context.n8nUser.role?.slug)) { - throw new AppError(403, 'Sharing workflows is restricted to owner and admin users.'); - } - + const context = await this.requireManagingContext(email); const workflowRows = await this.loadWorkflowRows(workflowId); - if (workflowRows.length === 0) { - throw new AppError(404, 'Workflow not found.'); - } - - if (!canViewAllWorkflows(context.n8nUser.role?.slug)) { - const hasAccess = workflowRows.some((row) => context.accessibleProjectIds.includes(row.projectId)); - if (!hasAccess) throw new AppError(403, 'Workflow is not accessible for this user.'); - } + this.ensureWorkflowExists(workflowRows); + this.ensureWorkflowVisibleToCaller(context, workflowRows); - const targetUser = await new UserRepository(this.n8nRepositories.user).findByEmail(targetEmail); + const targetUser = await this.userRepository.findByEmail(targetEmail); if (!targetUser) { throw new AppError(404, 'Target user not found.'); } @@ -97,20 +134,7 @@ export class UiApiService { throw new AppError(409, 'Email is already associated with this workflow.'); } - const sharedWorkflow = this.n8nRepositories.sharedWorkflow as { - create?: (value: Record) => Record; - save?: (value: Record) => Promise; - }; - if (!sharedWorkflow?.create || !sharedWorkflow?.save) { - throw new AppError(500, 'Shared workflow repository is unavailable.'); - } - - const newShare = sharedWorkflow.create({ - project: targetProject, - workflow: { id: workflowId }, - role: 'workflow:owner', - }); - await sharedWorkflow.save(newShare); + await this.createWorkflowShare(workflowId, targetProject); return { workflowId, @@ -119,80 +143,95 @@ export class UiApiService { } async unshareWorkflow(email: string | undefined, workflowId: string, projectId: string) { - const context = await this.buildUserContext(email); - if (!context.n8nUser) throw new AppError(401, 'Not authenticated.'); - if (!canShareWorkflows(context.n8nUser.role?.slug)) { - throw new AppError(403, 'Sharing workflows is restricted to owner and admin users.'); - } - + const context = await this.requireManagingContext(email); const workflowRows = await this.loadWorkflowRows(workflowId); - if (workflowRows.length === 0) { - throw new AppError(404, 'Workflow not found.'); - } + this.ensureWorkflowExists(workflowRows); if (workflowRows.length <= 1) { throw new AppError(409, 'Workflow must keep at least one project share.'); } - if (!canViewAllWorkflows(context.n8nUser.role?.slug)) { - const hasAccess = workflowRows.some((row) => context.accessibleProjectIds.includes(row.projectId)); - if (!hasAccess) throw new AppError(403, 'Workflow is not accessible for this user.'); - } + this.ensureWorkflowVisibleToCaller(context, workflowRows); const targetShare = workflowRows.find((row) => row.projectId === projectId); if (!targetShare) { throw new AppError(404, 'Project is not associated with this workflow.'); } - const sharedWorkflow = this.n8nRepositories.sharedWorkflow as { - delete?: (criteria: Record) => Promise; - manager?: any; - }; - if (sharedWorkflow?.delete) { - await sharedWorkflow.delete({ workflow: { id: workflowId }, project: { id: projectId } }); - return { workflowId, projectId }; - } + await this.deleteWorkflowShare(workflowId, projectId); - if (!sharedWorkflow?.manager?.delete) { - throw new AppError(500, 'Shared workflow repository is unavailable.'); + return { workflowId, projectId }; + } + + private async requireManagingContext(email?: string) { + const context = await this.buildUserContext(email); + if (!context.n8nUser) throw new AppError(401, 'Not authenticated.'); + if (!canShareWorkflows(context.n8nUser.role?.slug)) { + throw new AppError(403, 'Sharing workflows is restricted to owner and admin users.'); } + return context; + } - await sharedWorkflow.manager.delete('SharedWorkflow', { workflow: { id: workflowId }, project: { id: projectId } }); + private ensureWorkflowExists(workflowRows: WorkflowRow[]) { + if (!workflowRows.length) { + throw new AppError(404, 'Workflow not found.'); + } + } - return { workflowId, projectId }; + private ensureWorkflowVisibleToCaller( + context: { n8nUser: N8nUiUser | null; accessibleProjectIds: string[] }, + workflowRows: WorkflowRow[], + ) { + if (canViewAllWorkflows(context.n8nUser?.role?.slug)) return; + const hasAccess = workflowRows.some((row) => context.accessibleProjectIds.includes(row.projectId)); + if (!hasAccess) { + throw new AppError(403, 'Workflow is not accessible for this user.'); + } } - private async buildUserContext(email?: string) { + private async buildUserContext(email?: string): Promise { if (!email) { return { n8nUser: null, accessibleProjectIds: [], workflows: [] as UiWorkflowSummary[] }; } - const userRepository = new UserRepository(this.n8nRepositories.user); - const projectRelationRepository = new ProjectRelationRepository( - this.n8nRepositories.projectRelation, - this.n8nRepositories.user, - ); - const sharedWorkflowRepository = new SharedWorkflowRepository( - this.n8nRepositories.sharedWorkflow, - new WorkflowRepository(this.n8nRepositories.workflow), - ); - - const n8nUser = await userRepository.findByEmail(email); + const n8nUser = await this.userRepository.findByEmail(email); if (!n8nUser) { return { n8nUser: null, accessibleProjectIds: [], workflows: [] as UiWorkflowSummary[] }; } - const [personalProject, accessibleProjectIds] = await Promise.all([ - this.n8nRepositories.project.getPersonalProjectForUser(n8nUser.id), - listN8nProjectIdsAccessibleToUser(this.n8nRepositories.project, this.n8nRepositories.projectRelation, n8nUser.id), + const [personalProject, accessibleProjectIds] = await this.loadUserProjectScope(n8nUser.id); + + const sharedWorkflowRows = await this.loadVisibleWorkflowRows(n8nUser.role?.slug, accessibleProjectIds); + const projectEmailMap = await this.loadProjectEmailMap(sharedWorkflowRows, personalProject?.id, n8nUser.email); + const workflows = this.buildWorkflowSummaries(sharedWorkflowRows, projectEmailMap); + + return { + n8nUser, + accessibleProjectIds, + workflows, + }; + } + + private async loadUserProjectScope(userId: string) { + return await Promise.all([ + this.n8nRepositories.project.getPersonalProjectForUser(userId), + listN8nProjectIdsAccessibleToUser(this.n8nRepositories.project, this.n8nRepositories.projectRelation, userId), ]); + } - const sharedWorkflowRows = canViewAllWorkflows(n8nUser.role?.slug) - ? await sharedWorkflowRepository.findWorkflowRowsByProjectIds() - : await sharedWorkflowRepository.findWorkflowRowsByProjectIds(accessibleProjectIds); + private async loadVisibleWorkflowRows(roleSlug: string | null | undefined, accessibleProjectIds: string[]) { + return canViewAllWorkflows(roleSlug) + ? await this.sharedWorkflowRepository.findWorkflowRowsByProjectIds() + : await this.sharedWorkflowRepository.findWorkflowRowsByProjectIds(accessibleProjectIds); + } - const workflowProjectIds = [...new Set(sharedWorkflowRows.map((row) => row.projectId))]; + private async loadProjectEmailMap( + workflowRows: WorkflowRow[], + personalProjectId: string | undefined, + userEmail: string, + ) { + const workflowProjectIds = [...new Set(workflowRows.map((row) => row.projectId))]; const projectEmailRows = workflowProjectIds.length - ? await projectRelationRepository.listUserEmailsByProjectIds(workflowProjectIds) + ? await this.projectRelationRepository.listUserEmailsByProjectIds(workflowProjectIds) : []; const projectEmailMap = new Map>(); @@ -203,13 +242,17 @@ export class UiApiService { projectEmailMap.get(projectId)?.add(emailValue); } - if (personalProject?.id) { - if (!projectEmailMap.has(personalProject.id)) projectEmailMap.set(personalProject.id, new Set()); - projectEmailMap.get(personalProject.id)?.add(n8nUser.email); + if (personalProjectId) { + if (!projectEmailMap.has(personalProjectId)) projectEmailMap.set(personalProjectId, new Set()); + projectEmailMap.get(personalProjectId)?.add(userEmail); } + return projectEmailMap; + } + + private buildWorkflowSummaries(workflowRows: WorkflowRow[], projectEmailMap: Map>) { const workflowMap = new Map }>(); - for (const row of sharedWorkflowRows) { + for (const row of workflowRows) { const entry = workflowMap.get(row.workflowId) ?? { workflowName: row.workflowName || row.workflowId, projectIds: new Set(), @@ -218,37 +261,69 @@ export class UiApiService { workflowMap.set(row.workflowId, entry); } - const workflows: UiWorkflowSummary[] = [...workflowMap.entries()].map(([workflowId, entry]) => { - const userEmails = new Set(); - const projectShares = [...entry.projectIds].map((projectId) => ({ - projectId, - userEmails: normalizeEmailSet(projectEmailMap.get(projectId) ?? new Set()), - })); - for (const projectId of entry.projectIds) { - for (const emailValue of projectEmailMap.get(projectId) ?? []) userEmails.add(emailValue); - } - - return { - workflowId, - workflowName: entry.workflowName, - projectIds: [...entry.projectIds], - userEmails: normalizeEmailSet(userEmails), - projectShares, - }; + return [...workflowMap.entries()].map(([workflowId, entry]) => + this.buildWorkflowSummary(workflowId, entry.workflowName, entry.projectIds, projectEmailMap), + ); + } + + private buildWorkflowSummary( + workflowId: string, + workflowName: string, + projectIds: Set, + projectEmailMap: Map>, + ): UiWorkflowSummary { + const userEmails = new Set(); + const projectShares = [...projectIds].map((projectId) => { + const emails = normalizeEmailSet(projectEmailMap.get(projectId) ?? new Set()); + for (const emailValue of emails) userEmails.add(emailValue); + return { projectId, userEmails: emails }; }); return { - n8nUser, - accessibleProjectIds, - workflows, + workflowId, + workflowName, + projectIds: [...projectIds], + userEmails: normalizeEmailSet(userEmails), + projectShares, }; } private async loadWorkflowRows(workflowId: string) { - const sharedWorkflowRepository = new SharedWorkflowRepository( - this.n8nRepositories.sharedWorkflow, - new WorkflowRepository(this.n8nRepositories.workflow), - ); - return await sharedWorkflowRepository.findRowsByWorkflowId(workflowId); + return await this.sharedWorkflowRepository.findRowsByWorkflowId(workflowId); + } + + private async createWorkflowShare(workflowId: string, project: { id: string }) { + const sharedWorkflow = this.n8nRepositories.sharedWorkflow as { + create?: (value: Record) => Record; + save?: (value: Record) => Promise; + }; + if (!sharedWorkflow?.create || !sharedWorkflow?.save) { + throw new AppError(500, 'Shared workflow repository is unavailable.'); + } + + const newShare = sharedWorkflow.create({ + project, + workflow: { id: workflowId }, + role: 'workflow:owner', + }); + await sharedWorkflow.save(newShare); + } + + private async deleteWorkflowShare(workflowId: string, projectId: string) { + const sharedWorkflow = this.n8nRepositories.sharedWorkflow as { + delete?: (criteria: Record) => Promise; + manager?: { delete: (entity: string, criteria: Record) => Promise }; + }; + + if (sharedWorkflow?.delete) { + await sharedWorkflow.delete({ workflow: { id: workflowId }, project: { id: projectId } }); + return; + } + + if (!sharedWorkflow?.manager?.delete) { + throw new AppError(500, 'Shared workflow repository is unavailable.'); + } + + await sharedWorkflow.manager.delete('SharedWorkflow', { workflow: { id: workflowId }, project: { id: projectId } }); } }