feat(core): Create reviews page and render inbox (#34357)

This commit is contained in:
Sandra Zollner
2026-07-23 09:40:36 +00:00
committed by GitHub
parent f8a338b0c0
commit b2cbcc1b81
26 changed files with 2044 additions and 10 deletions
+1
View File
@@ -295,6 +295,7 @@ export type { EncryptionKeyResponseDto } from './encryption/encryption-key-respo
export { CreateWorkflowReviewRequestDto } from './workflow-reviews/create-workflow-review-request.dto';
export { GetWorkflowReviewEligibleReviewersQueryDto } from './workflow-reviews/get-eligible-reviewers-query.dto';
export { ListWorkflowReviewRequestsQueryDto } from './workflow-reviews/list-workflow-review-requests-query.dto';
export * from './workflow-reviews/list-workflow-review-inbox.dto';
export { UpdateOtelSettingsDto } from './otel/update-otel-settings.dto';
export { TestOtelTraceDto } from './otel/test-otel-trace.dto';
@@ -0,0 +1,39 @@
import { z } from 'zod';
import {
type WorkflowReviewRequestSummary,
workflowReviewRequestStateSchema,
} from '../../workflow-review-request-summary';
import { Z } from '../../zod-class';
/**
* Inbox list item — extends {@link WorkflowReviewRequestSummary} (the
* workflow-scoped list payload used by the review-required toggle sync) with the
* cross-project fields the inbox needs.
*/
export interface WorkflowReviewInboxItem extends WorkflowReviewRequestSummary {
projectId: string;
title: string;
workflowName: string | null;
}
/**
* Inbox query. Defaults to open requests when `state` is omitted.
* Deferred (LIGO-594): `projectId`, `reviewer`, and `author` filters.
*/
export class ListWorkflowReviewInboxQueryDto extends Z.class({
limit: z.coerce.number().int().min(1).max(100).default(15),
// Opaque base64url keyset cursor (ISO createdAt + id), not a bare UUID.
cursor: z.string().min(1).max(256).optional(),
state: workflowReviewRequestStateSchema.optional(),
}) {}
export interface ListWorkflowReviewInboxResponse {
data: WorkflowReviewInboxItem[];
nextCursor: string | null;
hasMore: boolean;
}
export interface GetWorkflowReviewInboxSummaryResponse {
hasAny: boolean;
}
@@ -11,8 +11,23 @@ describe('WorkflowReviewRequestRepository', () => {
const entityManager = mockEntityManager(WorkflowReviewRequest);
const repo = Container.get(WorkflowReviewRequestRepository);
let queryBuilder: Mocked<SelectQueryBuilder<WorkflowReviewRequest>>;
beforeEach(() => {
vi.resetAllMocks();
queryBuilder = mock<SelectQueryBuilder<WorkflowReviewRequest>>();
queryBuilder.where.mockReturnThis();
queryBuilder.andWhere.mockReturnThis();
queryBuilder.orderBy.mockReturnThis();
queryBuilder.addOrderBy.mockReturnThis();
queryBuilder.select.mockReturnThis();
queryBuilder.take.mockReturnThis();
queryBuilder.limit.mockReturnThis();
queryBuilder.getMany.mockResolvedValue([]);
queryBuilder.getRawOne.mockResolvedValue(undefined);
vi.spyOn(repo, 'createQueryBuilder').mockReturnValue(queryBuilder);
});
describe('createRequest', () => {
@@ -120,4 +135,154 @@ describe('WorkflowReviewRequestRepository', () => {
expect(queryBuilder.take).toHaveBeenCalledWith(0);
});
});
describe('findManyForInbox', () => {
it('filters by requester only when projectIds is empty', async () => {
const rows = [mock<WorkflowReviewRequest>({ id: 'req-1' })];
queryBuilder.getMany.mockResolvedValueOnce(rows);
const result = await repo.findManyForInbox({
projectIds: [],
requesterId: 'user-1',
limit: 15,
});
expect(result).toBe(rows);
expect(repo.createQueryBuilder).toHaveBeenCalledWith('review');
expect(queryBuilder.where).toHaveBeenCalledWith('review.createdById = :requesterId', {
requesterId: 'user-1',
});
expect(queryBuilder.take).toHaveBeenCalledWith(15);
});
it('skips the projectId filter when projectIds is null', async () => {
const rows = [mock<WorkflowReviewRequest>({ id: 'req-1' })];
queryBuilder.getMany.mockResolvedValueOnce(rows);
const result = await repo.findManyForInbox({
projectIds: null,
requesterId: 'user-1',
state: 'open',
limit: 15,
});
expect(result).toBe(rows);
expect(repo.createQueryBuilder).toHaveBeenCalledWith('review');
expect(queryBuilder.where).not.toHaveBeenCalled();
expect(queryBuilder.orderBy).toHaveBeenCalledWith('review.createdAt', 'DESC');
expect(queryBuilder.addOrderBy).toHaveBeenCalledWith('review.id', 'ASC');
expect(queryBuilder.andWhere).toHaveBeenCalledWith('review.state = :state', {
state: 'open',
});
expect(queryBuilder.take).toHaveBeenCalledWith(15);
});
it('matches projects OR the requester, filtering by state and ordering newest first', async () => {
const rows = [mock<WorkflowReviewRequest>({ id: 'req-1' })];
queryBuilder.getMany.mockResolvedValueOnce(rows);
const result = await repo.findManyForInbox({
projectIds: ['proj-1', 'proj-2'],
requesterId: 'user-1',
state: 'open',
limit: 15,
});
expect(result).toBe(rows);
expect(repo.createQueryBuilder).toHaveBeenCalledWith('review');
expect(queryBuilder.where).toHaveBeenCalledWith(
'(review.projectId IN (:...projectIds) OR review.createdById = :requesterId)',
{ projectIds: ['proj-1', 'proj-2'], requesterId: 'user-1' },
);
expect(queryBuilder.orderBy).toHaveBeenCalledWith('review.createdAt', 'DESC');
expect(queryBuilder.addOrderBy).toHaveBeenCalledWith('review.id', 'ASC');
expect(queryBuilder.andWhere).toHaveBeenCalledWith('review.state = :state', {
state: 'open',
});
expect(queryBuilder.take).toHaveBeenCalledWith(15);
});
it('applies the keyset boundary carried in the cursor without an anchor lookup', async () => {
const findOneSpy = vi.spyOn(repo, 'findOne');
queryBuilder.getMany.mockResolvedValueOnce([]);
const createdAt = new Date('2024-01-02T00:00:00.000Z');
await repo.findManyForInbox({
projectIds: ['proj-1'],
requesterId: 'user-1',
limit: 10,
cursor: { createdAt, id: 'req-cursor' },
});
expect(findOneSpy).not.toHaveBeenCalled();
expect(queryBuilder.andWhere).toHaveBeenCalledWith(
'(review.createdAt < :createdAt OR (review.createdAt = :createdAt AND review.id > :id))',
{ createdAt, id: 'req-cursor' },
);
});
});
describe('existsAnyForInbox', () => {
it('queries by requester only when projectIds is empty', async () => {
queryBuilder.getRawOne.mockResolvedValueOnce({ '?column?': 1 });
const result = await repo.existsAnyForInbox({ projectIds: [], requesterId: 'user-1' });
expect(result).toBe(true);
expect(repo.createQueryBuilder).toHaveBeenCalledWith('review');
expect(queryBuilder.select).toHaveBeenCalledWith('1');
expect(queryBuilder.where).toHaveBeenCalledWith('review.createdById = :requesterId', {
requesterId: 'user-1',
});
expect(queryBuilder.limit).toHaveBeenCalledWith(1);
});
it('skips the projectId filter when projectIds is null', async () => {
queryBuilder.getRawOne.mockResolvedValueOnce({ '?column?': 1 });
const result = await repo.existsAnyForInbox({
projectIds: null,
requesterId: 'user-1',
state: 'open',
});
expect(result).toBe(true);
expect(repo.createQueryBuilder).toHaveBeenCalledWith('review');
expect(queryBuilder.select).toHaveBeenCalledWith('1');
expect(queryBuilder.where).not.toHaveBeenCalled();
expect(queryBuilder.andWhere).toHaveBeenCalledWith('review.state = :state', {
state: 'open',
});
expect(queryBuilder.limit).toHaveBeenCalledWith(1);
});
it('returns true when at least one matching row exists', async () => {
queryBuilder.getRawOne.mockResolvedValueOnce({ '?column?': 1 });
const result = await repo.existsAnyForInbox({
projectIds: ['proj-1'],
requesterId: 'user-1',
state: 'open',
});
expect(result).toBe(true);
expect(queryBuilder.select).toHaveBeenCalledWith('1');
expect(queryBuilder.where).toHaveBeenCalledWith(
'(review.projectId IN (:...projectIds) OR review.createdById = :requesterId)',
{ projectIds: ['proj-1'], requesterId: 'user-1' },
);
expect(queryBuilder.andWhere).toHaveBeenCalledWith('review.state = :state', {
state: 'open',
});
expect(queryBuilder.limit).toHaveBeenCalledWith(1);
});
it('returns false when no rows match', async () => {
queryBuilder.getRawOne.mockResolvedValueOnce(undefined);
await expect(
repo.existsAnyForInbox({ projectIds: ['proj-1'], requesterId: 'user-1' }),
).resolves.toBe(false);
});
});
});
+4 -1
View File
@@ -71,7 +71,10 @@ export {
type PublishedWorkflowDataForExecution,
} from './workflow-published-version.repository';
export { WorkflowPublishHistoryRepository } from './workflow-publish-history.repository';
export { WorkflowReviewRequestRepository } from './workflow-review-request.repository';
export {
WorkflowReviewRequestRepository,
type InboxCursor,
} from './workflow-review-request.repository';
export { WorkflowReviewRequestWorkflowRepository } from './workflow-review-request-workflow.repository';
export { WorkflowReviewRequestReviewerRepository } from './workflow-review-request-reviewer.repository';
export { WorkflowReviewRequestAuthorRepository } from './workflow-review-request-author.repository';
@@ -2,6 +2,7 @@ import { Service } from '@n8n/di';
import type { EntityManager } from '@n8n/typeorm';
import { DataSource, Repository } from '@n8n/typeorm';
import { WorkflowEntity } from '../entities/workflow-entity';
import { WorkflowReviewRequestWorkflow } from '../entities/workflow-review-request-workflow.ee';
@Service()
@@ -36,4 +37,21 @@ export class WorkflowReviewRequestWorkflowRepository extends Repository<Workflow
order: { id: 'ASC' },
});
}
/** One workflow per review for now; multi-workflow "primary" selection can wait. */
async findWorkflowNamesByRequestIds(requestIds: string[]): Promise<Map<string, string>> {
if (requestIds.length === 0) {
return new Map();
}
// Join via entity so DB_TABLE_PREFIX is applied (postgres ITs).
const rows = await this.createQueryBuilder('wrw')
.innerJoin(WorkflowEntity, 'workflow', 'workflow.id = wrw.workflowId')
.select('wrw.workflowReviewRequestId', 'requestId')
.addSelect('workflow.name', 'workflowName')
.where('wrw.workflowReviewRequestId IN (:...requestIds)', { requestIds })
.getRawMany<{ requestId: string; workflowName: string }>();
return new Map(rows.map((row) => [row.requestId, row.workflowName]));
}
}
@@ -1,5 +1,5 @@
import { Service } from '@n8n/di';
import type { EntityManager } from '@n8n/typeorm';
import type { EntityManager, SelectQueryBuilder } from '@n8n/typeorm';
import { DataSource, Repository } from '@n8n/typeorm';
import { WorkflowReviewRequestWorkflow } from '../entities/workflow-review-request-workflow.ee';
@@ -9,6 +9,33 @@ import {
type WorkflowReviewRequestState,
} from '../entities/workflow-review-request.ee';
/**
* Keyset pagination boundary. The caller carries `createdAt`/`id` in the cursor
* itself so pagination never depends on the anchor row still existing.
*/
export type InboxCursor = {
createdAt: Date;
id: string;
};
export type FindManyForInboxOptions = {
/** `null` means all projects (no filter); `[]` means no publish-scoped projects. */
projectIds: string[] | null;
/** Requesters always see the reviews they created, regardless of project scope. */
requesterId: string;
state?: WorkflowReviewRequestState;
limit: number;
cursor?: InboxCursor;
};
export type ExistsAnyForInboxOptions = {
/** `null` means all projects (no filter); `[]` means no publish-scoped projects. */
projectIds: string[] | null;
/** Requesters always see the reviews they created, regardless of project scope. */
requesterId: string;
state?: WorkflowReviewRequestState;
};
@Service()
export class WorkflowReviewRequestRepository extends Repository<WorkflowReviewRequest> {
constructor(dataSource: DataSource) {
@@ -95,4 +122,70 @@ export class WorkflowReviewRequestRepository extends Repository<WorkflowReviewRe
.orderBy('request.createdAt', 'DESC')
.getOne();
}
async findManyForInbox(options: FindManyForInboxOptions): Promise<WorkflowReviewRequest[]> {
const { projectIds, requesterId, state, limit, cursor } = options;
const queryBuilder = this.createQueryBuilder('review')
.orderBy('review.createdAt', 'DESC')
.addOrderBy('review.id', 'ASC');
this.applyInboxVisibility(queryBuilder, projectIds, requesterId);
if (state !== undefined) {
queryBuilder.andWhere('review.state = :state', { state });
}
if (cursor) {
queryBuilder.andWhere(
'(review.createdAt < :createdAt OR (review.createdAt = :createdAt AND review.id > :id))',
{ createdAt: cursor.createdAt, id: cursor.id },
);
}
queryBuilder.take(limit);
return await queryBuilder.getMany();
}
async existsAnyForInbox(options: ExistsAnyForInboxOptions): Promise<boolean> {
const { projectIds, requesterId, state } = options;
const queryBuilder = this.createQueryBuilder('review').select('1');
this.applyInboxVisibility(queryBuilder, projectIds, requesterId);
if (state !== undefined) {
queryBuilder.andWhere('review.state = :state', { state });
}
const row = await queryBuilder.limit(1).getRawOne<Record<string, unknown>>();
return row !== undefined;
}
/**
* Inbox visibility: a review is visible if the caller is its requester OR it
* belongs to a project the caller can publish to. `projectIds === null`
* means global scope (no filter). An empty `projectIds` still matches the
* caller's own requests.
*/
private applyInboxVisibility(
queryBuilder: SelectQueryBuilder<WorkflowReviewRequest>,
projectIds: string[] | null,
requesterId: string,
): void {
if (projectIds === null) {
return;
}
if (projectIds.length === 0) {
queryBuilder.where('review.createdById = :requesterId', { requesterId });
return;
}
queryBuilder.where(
'(review.projectId IN (:...projectIds) OR review.createdById = :requesterId)',
{ projectIds, requesterId },
);
}
}
@@ -0,0 +1,194 @@
import { mockInstance } from '@n8n/backend-test-utils';
import { LicenseState, type Logger } from '@n8n/backend-common';
import type {
DbLockService,
SharedWorkflowRepository,
User,
UserRepository,
WorkflowReviewRequest,
WorkflowReviewRequestReviewerRepository,
} from '@n8n/db';
import {
WorkflowReviewRequestAuthorRepository,
WorkflowReviewRequestRepository,
WorkflowReviewRequestWorkflowRepository,
} from '@n8n/db';
import { mock } from 'vitest-mock-extended';
import type { CollaborationService } from '@/collaboration/collaboration.service';
import { ProjectService } from '@/services/project.service.ee';
import type { RoleService } from '@/services/role.service';
import { WorkflowReviewPolicyService } from '@/services/workflow-review-policy.service';
import type { WorkflowFinderService } from '@/workflows/workflow-finder.service';
import type { WorkflowHistoryService } from '@/workflows/workflow-history/workflow-history.service';
import { WorkflowReviewRequestService } from '../workflow-review-request.service';
describe('WorkflowReviewRequestService list', () => {
const logger = mock<Logger>();
const workflowReviewPolicyService = mockInstance(WorkflowReviewPolicyService);
const workflowFinderService = mock<WorkflowFinderService>();
const workflowHistoryService = mock<WorkflowHistoryService>();
const sharedWorkflowRepository = mock<SharedWorkflowRepository>();
const workflowReviewRequestRepository = mockInstance(WorkflowReviewRequestRepository);
const workflowReviewRequestWorkflowRepository = mockInstance(
WorkflowReviewRequestWorkflowRepository,
);
const workflowReviewRequestAuthorRepository = mockInstance(WorkflowReviewRequestAuthorRepository);
const reviewerRepository = mock<WorkflowReviewRequestReviewerRepository>();
const userRepository = mock<UserRepository>();
const roleService = mock<RoleService>();
const projectService = mockInstance(ProjectService);
const licenseState = mockInstance(LicenseState);
const dbLockService = mock<DbLockService>();
const collaborationService = mock<CollaborationService>();
let service: WorkflowReviewRequestService;
const user = mock<User>({ id: 'user-1', role: { slug: 'global:member', scopes: [] } });
beforeEach(() => {
vi.resetAllMocks();
process.env.N8N_ENV_FEAT_WORKFLOW_REVIEWS = 'true';
licenseState.isWorkflowReviewsLicensed.mockReturnValue(true);
workflowReviewPolicyService.get.mockResolvedValue({ enabled: true });
service = new WorkflowReviewRequestService(
logger,
workflowReviewPolicyService,
workflowFinderService,
workflowHistoryService,
sharedWorkflowRepository,
workflowReviewRequestRepository,
workflowReviewRequestWorkflowRepository,
workflowReviewRequestAuthorRepository,
reviewerRepository,
userRepository,
roleService,
projectService,
licenseState,
dbLockService,
collaborationService,
);
});
describe('listForInbox', () => {
function mockAccessibleProjects(projectIds: string[] = ['proj-1']) {
projectService.getProjectIdsWithScope.mockResolvedValueOnce(projectIds);
}
it('returns paginated data with hasMore and nextCursor', async () => {
mockAccessibleProjects();
const rows = [
mock<WorkflowReviewRequest>({
id: 'req-2',
projectId: 'proj-1',
title: 'Second',
decision: 'pending',
state: 'open',
createdAt: new Date('2024-01-02T00:00:00.000Z'),
updatedAt: new Date('2024-01-02T00:00:00.000Z'),
}),
mock<WorkflowReviewRequest>({
id: 'req-1',
projectId: 'proj-1',
title: 'First',
decision: 'pending',
state: 'open',
createdAt: new Date('2024-01-01T00:00:00.000Z'),
updatedAt: new Date('2024-01-01T00:00:00.000Z'),
}),
];
workflowReviewRequestRepository.findManyForInbox.mockResolvedValue(rows);
workflowReviewRequestWorkflowRepository.findWorkflowNamesByRequestIds.mockResolvedValue(
new Map([['req-2', 'Linked workflow']]),
);
const result = await service.listForInbox(user, { limit: 1 });
expect(workflowReviewRequestRepository.findManyForInbox).toHaveBeenCalledWith({
projectIds: ['proj-1'],
requesterId: 'user-1',
state: 'open',
limit: 2,
cursor: undefined,
});
expect(result.data).toHaveLength(1);
expect(result.data[0]?.workflowName).toBe('Linked workflow');
expect(result.hasMore).toBe(true);
// nextCursor encodes the last row's keyset boundary (createdAt + id).
const expectedCursor = Buffer.from('2024-01-02T00:00:00.000Z|req-2', 'utf8').toString(
'base64url',
);
expect(result.nextCursor).toBe(expectedCursor);
});
it('decodes the incoming cursor into a keyset boundary', async () => {
mockAccessibleProjects();
workflowReviewRequestRepository.findManyForInbox.mockResolvedValue([]);
workflowReviewRequestWorkflowRepository.findWorkflowNamesByRequestIds.mockResolvedValue(
new Map(),
);
const cursor = Buffer.from('2024-01-02T00:00:00.000Z|req-2', 'utf8').toString('base64url');
await service.listForInbox(user, { limit: 15, cursor });
expect(workflowReviewRequestRepository.findManyForInbox).toHaveBeenCalledWith(
expect.objectContaining({
cursor: { createdAt: new Date('2024-01-02T00:00:00.000Z'), id: 'req-2' },
}),
);
});
it('rejects a malformed cursor', async () => {
mockAccessibleProjects();
const cursor = Buffer.from('not-a-valid-cursor', 'utf8').toString('base64url');
await expect(service.listForInbox(user, { limit: 15, cursor })).rejects.toThrow(
'Invalid pagination cursor',
);
});
});
describe('resolveAccessibleProjectIds', () => {
it('returns the publish-scoped project ids for members', async () => {
projectService.getProjectIdsWithScope.mockResolvedValueOnce(['publish-proj']);
expect(await service.resolveAccessibleProjectIds(user)).toEqual(['publish-proj']);
expect(projectService.getProjectIdsWithScope).toHaveBeenCalledWith(user, [
'workflow:publish',
]);
expect(projectService.getProjectIdsWithScope).toHaveBeenCalledTimes(1);
// Requesters see their own reviews via the repository's requesterId filter,
// so no personal-project fallback is needed here.
expect(projectService.getPersonalProject).not.toHaveBeenCalled();
});
it('returns null (all projects) for users with global workflow:publish without enumerating projects', async () => {
const owner = mock<User>({
role: {
slug: 'global:owner',
scopes: [{ slug: 'workflow:publish' }],
},
});
expect(await service.resolveAccessibleProjectIds(owner)).toBeNull();
expect(projectService.getProjectIdsWithScope).not.toHaveBeenCalled();
});
it('does not short-circuit for a global project:delete role without workflow:publish', async () => {
const admin = mock<User>({
role: {
slug: 'custom:global',
scopes: [{ slug: 'project:delete' }],
},
});
projectService.getProjectIdsWithScope.mockResolvedValueOnce(['publish-proj']);
expect(await service.resolveAccessibleProjectIds(admin)).toEqual(['publish-proj']);
expect(projectService.getProjectIdsWithScope).toHaveBeenCalledWith(admin, [
'workflow:publish',
]);
});
});
});
@@ -3,7 +3,7 @@ import type {
GetWorkflowReviewEligibleReviewersQueryDto,
ListWorkflowReviewRequestsQueryDto,
} from '@n8n/api-types';
import type { Logger } from '@n8n/backend-common';
import type { LicenseState, Logger } from '@n8n/backend-common';
import type {
AuthIdentity,
DbLockService,
@@ -26,6 +26,7 @@ import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ConflictError } from '@/errors/response-errors/conflict.error';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import type { ProjectService } from '@/services/project.service.ee';
import type { RoleService } from '@/services/role.service';
import type { WorkflowReviewPolicyService } from '@/services/workflow-review-policy.service';
import type { WorkflowFinderService } from '@/workflows/workflow-finder.service';
@@ -59,6 +60,8 @@ describe('WorkflowReviewRequestService', () => {
const reviewerRepository = mock<WorkflowReviewRequestReviewerRepository>();
const userRepository = mock<UserRepository>();
const roleService = mock<RoleService>();
const projectService = mock<ProjectService>();
const licenseState = mock<LicenseState>();
const dbLockService = mock<DbLockService>();
const collaborationService = mock<CollaborationService>();
const logger = mock<Logger>();
@@ -76,12 +79,16 @@ describe('WorkflowReviewRequestService', () => {
reviewerRepository,
userRepository,
roleService,
projectService,
licenseState,
dbLockService,
collaborationService,
);
beforeEach(() => {
vi.resetAllMocks();
process.env.N8N_ENV_FEAT_WORKFLOW_REVIEWS = 'true';
licenseState.isWorkflowReviewsLicensed.mockReturnValue(true);
// Feature enabled by default; the disabled path is exercised explicitly.
workflowReviewPolicyService.get.mockResolvedValue({ enabled: true });
// By default, run the critical section against the mocked transaction.
@@ -15,13 +15,13 @@ import {
WorkflowReviewRequestWorkflowRepository,
} from '@n8n/db';
import { Container } from '@n8n/di';
import { WorkflowReviewPolicyService } from '@/services/workflow-review-policy.service';
import { createAdmin, createMember, createOwner, createUser } from '@test-integration/db/users';
import { createWorkflowHistoryItem } from '@test-integration/db/workflow-history';
import type { SuperAgentTest } from '@test-integration/types';
import * as utils from '@test-integration/utils';
import { WorkflowReviewPolicyService } from '@/services/workflow-review-policy.service';
const testServer = utils.setupTestServer({
endpointGroups: ['workflow-reviews'],
enabledFeatures: ['feat:workflowReviews'],
@@ -30,9 +30,12 @@ const testServer = utils.setupTestServer({
let owner: User;
let member: User;
let viewer: User;
let ownerProject: Project;
let teamProject: Project;
let ownerAgent: SuperAgentTest;
let memberAgent: SuperAgentTest;
let viewerAgent: SuperAgentTest;
let requestRepository: WorkflowReviewRequestRepository;
let workflowRepository: WorkflowReviewRequestWorkflowRepository;
@@ -49,6 +52,9 @@ beforeAll(() => {
});
beforeEach(async () => {
process.env.N8N_ENV_FEAT_WORKFLOW_REVIEWS = 'true';
testServer.license.enable('feat:workflowReviews');
await testDb.truncate([
'WorkflowReviewRequestAuthor',
'WorkflowReviewRequestReviewer',
@@ -68,9 +74,15 @@ beforeEach(async () => {
owner = await createOwner();
member = await createMember();
viewer = await createMember();
ownerProject = await getPersonalProject(owner);
teamProject = await createTeamProject('Reviews Project', owner);
await linkUserToProject(member, teamProject, 'project:editor');
await linkUserToProject(viewer, teamProject, 'project:viewer');
ownerAgent = testServer.authAgentFor(owner);
memberAgent = testServer.authAgentFor(member);
viewerAgent = testServer.authAgentFor(viewer);
});
/** Create a workflow owned by `owner` with a pinned history version. */
@@ -742,3 +754,227 @@ describe('GET /workflow-review-requests', () => {
testServer.license.enable('feat:workflowReviews');
});
});
async function seedInboxRequests() {
const openRequest = await requestRepository.createRequest({
projectId: teamProject.id,
title: 'Open review request',
createdById: owner.id,
state: 'open',
});
const closedRequest = await requestRepository.createRequest({
projectId: teamProject.id,
title: 'Closed review request',
createdById: owner.id,
state: 'closed',
});
return { openRequest, closedRequest };
}
describe('GET /workflow-review-requests/summary', () => {
test('returns hasAny true for instance owner', async () => {
await seedInboxRequests();
const response = await ownerAgent.get('/workflow-review-requests/summary').expect(200);
expect(response.body.data).toEqual({ hasAny: true });
});
test('returns hasAny true for project editor', async () => {
await seedInboxRequests();
const response = await memberAgent.get('/workflow-review-requests/summary').expect(200);
expect(response.body.data).toEqual({ hasAny: true });
});
test('returns hasAny false for project viewer', async () => {
await seedInboxRequests();
const response = await viewerAgent.get('/workflow-review-requests/summary').expect(200);
expect(response.body.data).toEqual({ hasAny: false });
});
test('returns hasAny true for a requester of their own review', async () => {
await requestRepository.createRequest({
projectId: teamProject.id,
title: 'Review submitted by viewer',
createdById: viewer.id,
state: 'open',
});
const response = await viewerAgent.get('/workflow-review-requests/summary').expect(200);
expect(response.body.data).toEqual({ hasAny: true });
});
test('returns 403 when feature is disabled', async () => {
await policyService.set(false);
await ownerAgent.get('/workflow-review-requests/summary').expect(403);
});
});
describe('GET /workflow-review-requests/inbox', () => {
test('returns reviews for instance owner', async () => {
const { openRequest } = await seedInboxRequests();
const response = await ownerAgent
.get('/workflow-review-requests/inbox')
.query({ state: 'open', limit: 15 })
.expect(200);
expect(response.body.data.data).toHaveLength(1);
expect(response.body.data.data[0]).toMatchObject({
id: openRequest.id,
title: 'Open review request',
state: 'open',
workflowName: null,
});
expect(response.body.data.hasMore).toBe(false);
expect(response.body.data.nextCursor).toBeNull();
});
test('returns empty list for project viewer', async () => {
await seedInboxRequests();
const response = await viewerAgent.get('/workflow-review-requests/inbox').expect(200);
expect(response.body.data.data).toEqual([]);
expect(response.body.data.hasMore).toBe(false);
});
test('returns 403 when license is disabled', async () => {
testServer.license.disable('feat:workflowReviews');
await ownerAgent.get('/workflow-review-requests/inbox').expect(403);
});
test('returns cursor pagination metadata', async () => {
await seedInboxRequests();
await requestRepository.createRequest({
projectId: teamProject.id,
title: 'Second open review',
createdById: owner.id,
state: 'open',
});
const firstPage = await ownerAgent
.get('/workflow-review-requests/inbox')
.query({ state: 'open', limit: 1 })
.expect(200);
expect(firstPage.body.data.data).toHaveLength(1);
expect(firstPage.body.data.hasMore).toBe(true);
expect(firstPage.body.data.nextCursor).toBeTruthy();
const secondPage = await ownerAgent
.get('/workflow-review-requests/inbox')
.query({
state: 'open',
limit: 1,
cursor: firstPage.body.data.nextCursor,
})
.expect(200);
expect(secondPage.body.data.data).toHaveLength(1);
expect(secondPage.body.data.data[0].id).not.toBe(firstPage.body.data.data[0].id);
});
test('includes workflow name on list items', async () => {
const workflow = await createWorkflow({ name: 'Inbox Workflow' }, teamProject);
const enrichedRequest = await requestRepository.createRequest({
projectId: teamProject.id,
title: 'Enriched review request',
createdById: owner.id,
state: 'open',
});
await workflowRepository.createWorkflowRow({
workflowReviewRequestId: enrichedRequest.id,
workflowId: workflow.id,
});
const response = await ownerAgent
.get('/workflow-review-requests/inbox')
.query({ state: 'open', limit: 15 })
.expect(200);
expect(response.body.data.data).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: enrichedRequest.id,
title: 'Enriched review request',
workflowName: 'Inbox Workflow',
}),
]),
);
});
test('hides reviews from projects the member cannot access', async () => {
const otherProject = await createTeamProject('Other Reviews Project', owner);
await requestRepository.createRequest({
projectId: otherProject.id,
title: 'Private other-project review',
createdById: owner.id,
state: 'open',
});
const memberResponse = await memberAgent.get('/workflow-review-requests/inbox').expect(200);
expect(memberResponse.body.data.data).toEqual([]);
const ownerResponse = await ownerAgent.get('/workflow-review-requests/inbox').expect(200);
expect(ownerResponse.body.data.data).toEqual(
expect.arrayContaining([
expect.objectContaining({
title: 'Private other-project review',
}),
]),
);
});
test('shows requesters their own reviews even without project access', async () => {
const otherProject = await createTeamProject('Unrelated Project', owner);
const ownRequest = await requestRepository.createRequest({
projectId: otherProject.id,
title: 'Review I submitted',
createdById: member.id,
state: 'open',
});
const response = await memberAgent.get('/workflow-review-requests/inbox').expect(200);
expect(response.body.data.data).toEqual(
expect.arrayContaining([expect.objectContaining({ id: ownRequest.id })]),
);
});
test('does not truncate pagination when the cursor row is deleted', async () => {
await seedInboxRequests();
await requestRepository.createRequest({
projectId: teamProject.id,
title: 'Second open review',
createdById: owner.id,
state: 'open',
});
const firstPage = await ownerAgent
.get('/workflow-review-requests/inbox')
.query({ state: 'open', limit: 1 })
.expect(200);
const cursor = firstPage.body.data.nextCursor as string;
const firstId = firstPage.body.data.data[0].id as string;
// Delete the anchor row before requesting the next page.
await requestRepository.delete({ id: firstId });
const secondPage = await ownerAgent
.get('/workflow-review-requests/inbox')
.query({ state: 'open', limit: 1, cursor })
.expect(200);
expect(secondPage.body.data.data).toHaveLength(1);
expect(secondPage.body.data.data[0].id).not.toBe(firstId);
});
});
@@ -21,8 +21,8 @@ beforeEach(async () => {
ownerAgent = testServer.authAgentFor(owner);
});
describe('POST /workflow-review-requests (env flag off)', () => {
test('is unreachable (404) even though the license is present', async () => {
describe('workflow-review-requests (env flag off)', () => {
test('POST is unreachable (404) even though the license is present', async () => {
await ownerAgent
.post('/workflow-review-requests')
.send({
@@ -31,6 +31,14 @@ describe('POST /workflow-review-requests (env flag off)', () => {
})
.expect(404);
});
test('GET inbox is unreachable (404)', async () => {
await ownerAgent.get('/workflow-review-requests/inbox').expect(404);
});
test('GET summary is unreachable (404)', async () => {
await ownerAgent.get('/workflow-review-requests/summary').expect(404);
});
});
describe('GET /workflow-review-requests (env flag off)', () => {
@@ -5,8 +5,12 @@ import type {
WorkflowReviewEligibleReviewersList,
WorkflowReviewRequestList,
WorkflowReviewRequestSummary,
ListWorkflowReviewInboxQueryDto,
ListWorkflowReviewInboxResponse,
GetWorkflowReviewInboxSummaryResponse,
WorkflowReviewInboxItem,
} from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
import { LicenseState, Logger } from '@n8n/backend-common';
import {
DbLock,
DbLockService,
@@ -16,15 +20,20 @@ import {
WorkflowReviewRequestRepository,
WorkflowReviewRequestReviewerRepository,
WorkflowReviewRequestWorkflowRepository,
type InboxCursor,
type User,
type WorkflowReviewRequest,
} from '@n8n/db';
import { Service } from '@n8n/di';
import { hasGlobalScope } from '@n8n/permissions';
import { CollaborationService } from '@/collaboration/collaboration.service';
import { isWorkflowReviewsFeatureAvailable } from '@/constants/workflow-reviews';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ConflictError } from '@/errors/response-errors/conflict.error';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { ProjectService } from '@/services/project.service.ee';
import { RoleService } from '@/services/role.service';
import { WorkflowReviewPolicyService } from '@/services/workflow-review-policy.service';
import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
@@ -44,6 +53,8 @@ export class WorkflowReviewRequestService {
private readonly workflowReviewRequestReviewerRepository: WorkflowReviewRequestReviewerRepository,
private readonly userRepository: UserRepository,
private readonly roleService: RoleService,
private readonly projectService: ProjectService,
private readonly licenseState: LicenseState,
private readonly dbLockService: DbLockService,
private readonly collaborationService: CollaborationService,
) {}
@@ -253,4 +264,121 @@ export class WorkflowReviewRequestService {
updatedAt: request.updatedAt.toISOString(),
};
}
/**
* Cross-project inbox.
*
* Defaults to open requests when `state` is omitted. Deferred (LIGO-594):
* `projectId`, `reviewer`, and `author` filters.
*/
async listForInbox(
user: User,
query: ListWorkflowReviewInboxQueryDto,
): Promise<ListWorkflowReviewInboxResponse> {
await this.assertFeatureAvailable();
const projectIds = await this.resolveAccessibleProjectIds(user);
const { limit } = query;
const rows = await this.workflowReviewRequestRepository.findManyForInbox({
projectIds,
requesterId: user.id,
state: query.state ?? 'open',
limit: limit + 1,
cursor: query.cursor ? this.decodeInboxCursor(query.cursor) : undefined,
});
const hasMore = rows.length > limit;
const data = rows.slice(0, limit);
const lastRow = data.at(-1);
const nextCursor = hasMore && lastRow ? this.encodeInboxCursor(lastRow) : null;
const workflowNamesByRequestId =
await this.workflowReviewRequestWorkflowRepository.findWorkflowNamesByRequestIds(
data.map((row) => row.id),
);
return {
data: data.map((row) => this.toInboxItem(row, workflowNamesByRequestId.get(row.id) ?? null)),
nextCursor,
hasMore,
};
}
async getInboxSummaryForUser(user: User): Promise<GetWorkflowReviewInboxSummaryResponse> {
await this.assertFeatureAvailable();
const projectIds = await this.resolveAccessibleProjectIds(user);
// Any state — sidebar must appear so users can open the Closed tab.
const hasAny = await this.workflowReviewRequestRepository.existsAnyForInbox({
projectIds,
requesterId: user.id,
});
return { hasAny };
}
private async assertFeatureAvailable(): Promise<void> {
if (!isWorkflowReviewsFeatureAvailable(this.licenseState.isWorkflowReviewsLicensed())) {
throw new ForbiddenError('Workflow reviews are not available on this instance');
}
const policy = await this.workflowReviewPolicyService.get();
if (!policy.enabled) {
throw new ForbiddenError('Workflow reviews are disabled on this instance');
}
}
/**
* Project IDs for inbox queries. `null` means "all projects, unfiltered" —
* correct for users with `workflow:publish` scoped globally. Requesters always
* see their own reviews regardless (repository OR-matches `requesterId`), so no
* personal-project fallback is needed.
*/
async resolveAccessibleProjectIds(user: User): Promise<string[] | null> {
if (hasGlobalScope(user, 'workflow:publish')) {
return null;
}
return await this.projectService.getProjectIdsWithScope(user, ['workflow:publish']);
}
/**
* Encode the keyset boundary (createdAt + id) into an opaque cursor so the
* next page is resolved without re-reading the anchor row — a review deleted
* between requests no longer truncates the rest of the inbox.
*/
private encodeInboxCursor(row: WorkflowReviewRequest): string {
return Buffer.from(`${row.createdAt.toISOString()}|${row.id}`, 'utf8').toString('base64url');
}
private decodeInboxCursor(cursor: string): InboxCursor {
const decoded = Buffer.from(cursor, 'base64url').toString('utf8');
const separatorIndex = decoded.indexOf('|');
if (separatorIndex === -1) {
throw new BadRequestError('Invalid pagination cursor');
}
const createdAt = new Date(decoded.slice(0, separatorIndex));
const id = decoded.slice(separatorIndex + 1);
if (id.length === 0 || Number.isNaN(createdAt.getTime())) {
throw new BadRequestError('Invalid pagination cursor');
}
return { createdAt, id };
}
private toInboxItem(
entity: WorkflowReviewRequest,
workflowName: string | null,
): WorkflowReviewInboxItem {
return {
id: entity.id,
projectId: entity.projectId,
title: entity.title,
workflowName,
decision: entity.decision,
state: entity.state,
createdAt: entity.createdAt.toISOString(),
updatedAt: entity.updatedAt.toISOString(),
};
}
}
@@ -2,10 +2,13 @@ import {
CreateWorkflowReviewRequestDto,
GetWorkflowReviewEligibleReviewersQueryDto,
ListWorkflowReviewRequestsQueryDto,
type GetWorkflowReviewInboxSummaryResponse,
type ListWorkflowReviewInboxResponse,
ListWorkflowReviewInboxQueryDto,
} from '@n8n/api-types';
import { AuthenticatedRequest } from '@n8n/db';
import { Body, Get, Licensed, Post, Query, RestController } from '@n8n/decorators';
import { Response } from 'express';
import type { Response } from 'express';
import { WorkflowReviewRequestService } from './workflow-review-request.service';
@@ -46,4 +49,27 @@ export class WorkflowReviewRequestsController {
res.status(201);
return request;
}
/**
* Cross-project inbox. Lives under `/inbox` so `GET /` stays free for the
* workflow-scoped list used by review-required toggle sync (LIGO-838).
*/
@Get('/inbox')
@Licensed('feat:workflowReviews')
async listInbox(
req: AuthenticatedRequest,
_res: Response,
@Query query: ListWorkflowReviewInboxQueryDto,
): Promise<ListWorkflowReviewInboxResponse> {
return await this.workflowReviewRequestService.listForInbox(req.user, query);
}
@Get('/summary')
@Licensed('feat:workflowReviews')
async getSummary(
req: AuthenticatedRequest,
_res: Response,
): Promise<GetWorkflowReviewInboxSummaryResponse> {
return await this.workflowReviewRequestService.getInboxSummaryForUser(req.user);
}
}
@@ -87,7 +87,7 @@ const scrollRight = () => scroll(50);
<div v-if="canScrollRight" :class="$style.next" @click="scrollRight">
<N8nIcon :class="$style.positionIcon" icon="chevron-right" size="small" />
</div>
<div ref="tabs" :class="$style.tabs">
<div ref="tabs" role="tablist" :class="$style.tabs">
<div
v-for="option in options"
:id="option.value.toString()"
@@ -133,6 +133,9 @@ const scrollRight = () => scroll(50);
</RouterLink>
<div
v-else
role="tab"
tabindex="0"
:aria-selected="modelValue === option.value"
:class="{
[$style.tab]: true,
[$style.activeTab]: modelValue === option.value,
@@ -140,6 +143,8 @@ const scrollRight = () => scroll(50);
[$style.dangerTab]: option.variant === 'danger',
}"
@click="() => handleTabClick(option.value)"
@keydown.enter.prevent="() => handleTabClick(option.value)"
@keydown.space.prevent="() => handleTabClick(option.value)"
>
<N8nIcon
v-if="option.icon && option.iconPosition !== 'right'"
@@ -5090,6 +5090,16 @@
"settings.security.workflowReviews.error": "Failed to update workflow reviews settings",
"settings.security.workflowReviews.confirmDisable.headline": "Disable workflow reviews",
"settings.security.workflowReviews.confirmDisable.message": "Workflow reviews will no longer be available on this instance. Existing project-level review settings will be ignored.",
"workflowReviews.menu.title": "Reviews",
"workflowReviews.page.title": "Reviews",
"workflowReviews.disclaimer.body": "Submit workflow versions for review before publishing. When reviews are assigned to your projects, they will appear here.",
"workflowReviews.sidebar.tabs.open": "Open",
"workflowReviews.sidebar.tabs.closed": "Closed",
"workflowReviews.sidebar.empty": "No reviews in this tab",
"workflowReviews.detail.placeholder": "Full review details are coming soon.",
"workflowReviews.emptyState.body": "There are no reviews in this tab.",
"workflowReviews.noSelection.body": "Select a review to see more information.",
"workflowReviews.error.load": "Could not load workflow reviews",
"settings.security.personalSpace.title": "Personal Space",
"settings.security.personalSpace.description": "Manage workflow publishing and sharing in personal spaces.",
"settings.security.personalSpace.publishing.title": "Workflow publishing",
@@ -6,6 +6,7 @@ import { ChatModule } from '@/features/ai/chatHub/module.descriptor';
import { InstanceAiModule } from '@/features/ai/instanceAi/module.descriptor';
import { AgentsModule } from '@/features/agents/module.descriptor';
import { OtelModule } from '@/features/settings/otel/module.descriptor';
import { WorkflowReviewsModule } from '@/features/workflow-reviews/module.descriptor';
/**
* Hard-coding modules list until we have a dynamic way to load modules.
@@ -18,4 +19,5 @@ export const modules: FrontendModuleDescription[] = [
InstanceAiModule,
AgentsModule,
OtelModule,
WorkflowReviewsModule,
];
@@ -15,6 +15,8 @@ import { CHAT_VIEW } from '@/features/ai/chatHub/constants';
import { useFavoritesStore } from '@/app/stores/favorites.store';
import { useFavoriteNavItems } from '../composables/useFavoriteNavItems';
import { INSTANCE_AI_VIEW } from '@/features/ai/instanceAi/constants';
import { WORKFLOW_REVIEW_REQUESTS_VIEW } from '@/features/workflow-reviews/constants';
import { useWorkflowReviewsFeature } from '@/features/workflow-reviews/composables/useWorkflowReviewsFeature';
import { hasPermission } from '@/app/utils/rbac/permissions';
@@ -123,6 +125,15 @@ const instanceAi = computed<IMenuItem>(() => ({
preview: true,
}));
const { isWorkflowReviewsEnabled: isWorkflowReviewsNavVisible } = useWorkflowReviewsFeature();
const workflowReviews = computed<IMenuItem>(() => ({
id: 'workflow-reviews',
icon: 'message-square-text',
label: locale.baseText('workflowReviews.menu.title'),
route: { to: { name: WORKFLOW_REVIEW_REQUESTS_VIEW } },
preview: true,
}));
const chat = computed<IMenuItem>(() => ({
id: 'chat',
icon: 'message-circle',
@@ -180,6 +191,13 @@ onBeforeUnmount(() => {
:active="activeTabId === 'shared'"
data-test-id="project-shared-menu-item"
/>
<N8nMenuItem
v-if="isWorkflowReviewsNavVisible"
:item="workflowReviews"
:compact="props.collapsed"
:active="activeTabId === 'workflow-reviews'"
data-test-id="project-workflow-reviews-menu-item"
/>
<N8nMenuItem
v-if="isChatLinkAvailable"
:item="chat"
@@ -1,6 +1,8 @@
import { computed, reactive } from 'vue';
import { useRoute } from 'vue-router';
import { VIEWS } from '@/app/constants';
import { WORKFLOW_REVIEW_REQUESTS_VIEW } from '@/features/workflow-reviews/constants';
/**
* This composable holds reusable logic that detects the current page type
*/
@@ -21,9 +23,12 @@ export const useProjectPages = () => {
route.name === VIEWS.SHARED_CREDENTIALS,
);
const isWorkflowReviewsSubPage = computed(() => route.name === WORKFLOW_REVIEW_REQUESTS_VIEW);
return reactive({
isOverviewSubPage,
isSharedSubPage,
isProjectsSubPage,
isWorkflowReviewsSubPage,
});
};
@@ -327,6 +327,11 @@ export const useProjectsStore = defineStore(STORES.PROJECTS, () => {
setCurrentProject(null);
}
if (newRoute?.path?.includes('workflow-review-requests')) {
projectNavActiveId.value = 'workflow-reviews';
setCurrentProject(null);
}
if (newRoute?.path?.includes('workflow/')) {
if (currentProjectId.value) {
projectNavActiveId.value = currentProjectId.value;
@@ -0,0 +1,278 @@
<script lang="ts" setup>
import type { WorkflowReviewInboxItem, WorkflowReviewRequestState } from '@n8n/api-types';
import { computed, ref, watch } from 'vue';
import { useI18n } from '@n8n/i18n';
import {
N8nBadge,
N8nCard,
N8nHeading,
N8nIcon,
N8nLoading,
N8nTabs,
N8nText,
} from '@n8n/design-system';
import { useIntersectionObserver } from '@/app/composables/useIntersectionObserver';
import TimeAgo from '@/app/components/TimeAgo.vue';
const props = defineProps<{
items: WorkflowReviewInboxItem[];
activeState: WorkflowReviewRequestState;
selectedId: string | null;
loading: boolean;
loadingMore: boolean;
hasMore: boolean;
isEmpty: boolean;
}>();
const emit = defineEmits<{
select: [id: string];
clear: [];
'update:activeState': [state: WorkflowReviewRequestState];
loadMore: [];
}>();
const i18n = useI18n();
const listRef = ref<HTMLElement | null>(null);
const loadMoreSentinel = ref<HTMLElement | null>(null);
const tabOptions = computed(() => [
{ label: i18n.baseText('workflowReviews.sidebar.tabs.open'), value: 'open' as const },
{ label: i18n.baseText('workflowReviews.sidebar.tabs.closed'), value: 'closed' as const },
]);
const { observe: observeForLoadMore } = useIntersectionObserver({
root: listRef,
threshold: 0.01,
onIntersect: () => emit('loadMore'),
});
watch(
[loadMoreSentinel, () => props.hasMore, () => props.loadingMore, () => props.items.length],
([sentinel, hasMore, loadingMore]) => {
if (sentinel && hasMore && !loadingMore) {
observeForLoadMore(sentinel);
}
},
{ immediate: true, flush: 'post' },
);
function onTabChange(value: string | number | boolean) {
emit('update:activeState', String(value) as WorkflowReviewRequestState);
}
function onListBackgroundClick() {
if (props.selectedId) {
emit('clear');
}
}
</script>
<template>
<aside :class="$style.sidebar" data-test-id="workflow-reviews-sidebar">
<div :class="$style.columnTitle">
<N8nHeading bold tag="h2" size="xlarge" data-test-id="workflow-reviews-page-title">
{{ i18n.baseText('workflowReviews.page.title') }}
</N8nHeading>
</div>
<div :class="$style.header">
<N8nTabs
:model-value="activeState"
:options="tabOptions"
variant="modern"
data-test-id="workflow-reviews-tabs"
@update:model-value="onTabChange"
/>
</div>
<div
ref="listRef"
role="listbox"
:aria-label="i18n.baseText('workflowReviews.page.title')"
:class="$style.list"
@click.self="onListBackgroundClick"
>
<N8nLoading v-if="loading" :loading="true" :rows="4" />
<template v-else>
<N8nText
v-if="isEmpty"
color="text-light"
size="small"
data-test-id="workflow-reviews-empty"
>
{{ i18n.baseText('workflowReviews.sidebar.empty') }}
</N8nText>
<N8nCard
v-for="item in items"
:key="item.id"
:class="[$style.card, { [$style.cardSelected]: selectedId === item.id }]"
data-test-id="workflow-review-request-row"
role="option"
tabindex="0"
:aria-selected="selectedId === item.id"
@click="emit('select', item.id)"
@keydown.enter.prevent="emit('select', item.id)"
@keydown.space.prevent="emit('select', item.id)"
>
<div :class="$style.cardContent">
<N8nText bold tag="h3" :class="$style.cardTitle">
{{ item.title }}
</N8nText>
<div :class="$style.cardMeta">
<N8nBadge
v-if="item.workflowName"
theme="tertiary"
:show-border="false"
:class="$style.workflowBadge"
data-test-id="workflow-review-request-workflow-badge"
>
<span :class="$style.workflowBadgeText" :title="item.workflowName">
<N8nIcon icon="workflow" size="small" />
<span>{{ item.workflowName }}</span>
</span>
</N8nBadge>
<N8nText
size="xsmall"
color="text-light"
:class="$style.cardMetaTime"
data-test-id="workflow-review-request-created-at"
>
<TimeAgo :date="item.createdAt" />
</N8nText>
</div>
</div>
</N8nCard>
<div v-if="loadingMore" :class="$style.loadingMore">
<N8nLoading :loading="true" :rows="1" />
</div>
<div ref="loadMoreSentinel" :class="$style.sentinel" />
</template>
</div>
</aside>
</template>
<style lang="scss" module>
.sidebar {
display: flex;
flex-direction: column;
flex: 0 0 35%;
min-width: 12rem;
height: 100%;
border-right: var(--border-width) solid var(--color--foreground--tint-1);
}
.columnTitle {
display: flex;
align-items: center;
min-height: var(--spacing--2xl);
padding: 0 var(--spacing--md) var(--spacing--sm) 0;
}
.header {
display: flex;
flex-direction: column;
gap: var(--spacing--sm);
padding: 0 var(--spacing--md) var(--spacing--md) 0;
}
.list {
display: flex;
flex: 1;
flex-direction: column;
gap: var(--spacing--2xs);
overflow-y: auto;
padding: 0 var(--spacing--md) var(--spacing--md) 0;
}
.card {
cursor: pointer;
padding: var(--spacing--xs);
align-items: stretch;
border: var(--border-width) solid var(--color--foreground--tint-1);
transition: background-color 0.3s ease;
&:hover:not(.cardSelected) {
background-color: var(--background--active);
border-color: transparent;
}
&:focus-visible {
border-color: var(--focus--border-color);
}
}
.cardSelected {
background-color: var(--background--active);
border-color: transparent;
}
.cardContent {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--spacing--2xs);
min-width: 0;
width: 100%;
}
.cardTitle {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: 100%;
font-size: var(--font-size--sm);
}
.cardMeta {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--spacing--2xs);
width: 100%;
min-width: 0;
}
.cardMetaTime {
flex-shrink: 0;
white-space: nowrap;
margin-left: auto;
}
.workflowBadge {
flex: 0 1 auto;
max-width: 100%;
border: var(--border);
border-radius: var(--radius);
padding: var(--spacing--4xs) var(--spacing--2xs);
color: var(--color--text);
max-width: 12rem;
> span {
max-width: 100%;
}
}
.workflowBadgeText {
display: inline-flex;
align-items: center;
gap: var(--spacing--3xs);
max-width: 100%;
min-width: 0;
line-height: calc(var(--font-size--sm) + 1px);
> span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
}
.loadingMore {
padding: var(--spacing--sm);
}
.sentinel {
height: 1px;
}
</style>
@@ -0,0 +1 @@
export const WORKFLOW_REVIEW_REQUESTS_VIEW = 'WorkflowReviewRequestsView';
@@ -0,0 +1,34 @@
import type { FrontendModuleDescription } from '@n8n/frontend-module-sdk';
import { VIEWS } from '@/app/constants';
import { useWorkflowReviewsFeature } from './composables/useWorkflowReviewsFeature';
import { WORKFLOW_REVIEW_REQUESTS_VIEW } from './constants';
const WorkflowReviewRequestsView = async () =>
await import('./views/WorkflowReviewRequestsView.vue');
export const WorkflowReviewsModule: FrontendModuleDescription = {
id: 'workflow-reviews',
name: 'Workflow Reviews',
description: 'Cross-project workflow review inbox.',
icon: 'message-square-text',
routes: [
{
path: '/workflow-review-requests',
name: WORKFLOW_REVIEW_REQUESTS_VIEW,
component: WorkflowReviewRequestsView,
beforeEnter() {
return (
useWorkflowReviewsFeature().isWorkflowReviewsEnabled.value || {
name: VIEWS.NOT_FOUND,
}
);
},
meta: {
layout: 'default',
middleware: ['authenticated', 'custom'],
},
},
],
};
@@ -0,0 +1,206 @@
import { createPinia, setActivePinia } from 'pinia';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import * as workflowReviewsApi from './workflowReviews.api';
import { useReviewInboxStore } from './reviewInbox.store';
vi.mock('./workflowReviews.api');
describe('useReviewInboxStore', () => {
beforeEach(() => {
setActivePinia(createPinia());
vi.resetAllMocks();
});
it('probes summary and loads open reviews when hasAny is true', async () => {
vi.mocked(workflowReviewsApi.fetchWorkflowReviewInboxSummary).mockResolvedValue({
hasAny: true,
});
vi.mocked(workflowReviewsApi.fetchWorkflowReviewInbox).mockResolvedValue({
data: [
{
id: 'req-1',
projectId: 'proj-1',
title: 'Review',
workflowName: 'My workflow',
decision: 'pending',
state: 'open',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
],
nextCursor: null,
hasMore: false,
});
const store = useReviewInboxStore();
await store.probeInbox();
expect(workflowReviewsApi.fetchWorkflowReviewInboxSummary).toHaveBeenCalledTimes(1);
expect(store.hasAnyReviews).toBe(true);
expect(store.probeSettled).toBe(true);
expect(store.showSidebar).toBe(true);
expect(store.items).toHaveLength(1);
});
it('skips list fetch when summary hasAny is false', async () => {
vi.mocked(workflowReviewsApi.fetchWorkflowReviewInboxSummary).mockResolvedValue({
hasAny: false,
});
const store = useReviewInboxStore();
await store.probeInbox();
expect(workflowReviewsApi.fetchWorkflowReviewInbox).not.toHaveBeenCalled();
expect(store.showSidebar).toBe(false);
});
it('refetches when switching tabs', async () => {
vi.mocked(workflowReviewsApi.fetchWorkflowReviewInbox).mockResolvedValue({
data: [],
nextCursor: null,
hasMore: false,
});
const store = useReviewInboxStore();
await store.setActiveState('closed');
expect(store.activeState).toBe('closed');
expect(workflowReviewsApi.fetchWorkflowReviewInbox).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ state: 'closed' }),
);
});
it('ignores stale list responses', async () => {
let resolveFirst!: (value: {
data: [];
nextCursor: null;
hasMore: false;
}) => void;
const firstResponse = new Promise<{
data: [];
nextCursor: null;
hasMore: false;
}>((resolve) => {
resolveFirst = resolve;
});
vi.mocked(workflowReviewsApi.fetchWorkflowReviewInbox)
.mockImplementationOnce(async () => await firstResponse)
.mockResolvedValueOnce({
data: [
{
id: 'req-2',
projectId: 'proj-1',
title: 'Newer',
workflowName: null,
decision: 'pending',
state: 'closed',
createdAt: '2024-01-02T00:00:00.000Z',
updatedAt: '2024-01-02T00:00:00.000Z',
},
],
nextCursor: null,
hasMore: false,
});
const store = useReviewInboxStore();
const firstFetch = store.fetchList({ reset: true });
await store.setActiveState('closed');
await vi.waitFor(() => {
expect(store.items).toEqual([expect.objectContaining({ id: 'req-2', title: 'Newer' })]);
});
resolveFirst({ data: [], nextCursor: null, hasMore: false });
await firstFetch;
expect(store.items).toEqual([expect.objectContaining({ id: 'req-2', title: 'Newer' })]);
});
it('does not apply probe results after reset', async () => {
let resolveSummary!: (value: { hasAny: boolean }) => void;
const summaryPromise = new Promise<{ hasAny: boolean }>((resolve) => {
resolveSummary = resolve;
});
vi.mocked(workflowReviewsApi.fetchWorkflowReviewInboxSummary).mockImplementationOnce(
async () => await summaryPromise,
);
const store = useReviewInboxStore();
const probe = store.probeInbox();
store.reset();
resolveSummary({ hasAny: true });
await probe;
expect(store.probeSettled).toBe(false);
expect(store.hasAnyReviews).toBe(false);
expect(workflowReviewsApi.fetchWorkflowReviewInbox).not.toHaveBeenCalled();
});
it('clears loadingMore when a reset list request invalidates pagination', async () => {
let resolveMore!: (value: {
data: [];
nextCursor: null;
hasMore: false;
}) => void;
const morePromise = new Promise<{
data: [];
nextCursor: null;
hasMore: false;
}>((resolve) => {
resolveMore = resolve;
});
vi.mocked(workflowReviewsApi.fetchWorkflowReviewInbox)
.mockResolvedValueOnce({
data: [
{
id: 'req-1',
projectId: 'proj-1',
title: 'Open',
workflowName: null,
decision: 'pending',
state: 'open',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
],
nextCursor: 'req-1',
hasMore: true,
})
.mockImplementationOnce(async () => await morePromise)
.mockResolvedValueOnce({
data: [],
nextCursor: null,
hasMore: false,
});
const store = useReviewInboxStore();
await store.fetchList({ reset: true });
const loadMore = store.loadMore();
await vi.waitFor(() => {
expect(store.loadingMore).toBe(true);
});
await store.setActiveState('closed');
expect(store.loadingMore).toBe(false);
resolveMore({ data: [], nextCursor: null, hasMore: false });
await loadMore;
expect(store.loadingMore).toBe(false);
});
it('does not treat a failed list fetch as an empty inbox', async () => {
vi.mocked(workflowReviewsApi.fetchWorkflowReviewInboxSummary).mockResolvedValue({
hasAny: true,
});
vi.mocked(workflowReviewsApi.fetchWorkflowReviewInbox).mockRejectedValue(new Error('network'));
const store = useReviewInboxStore();
await expect(store.probeInbox()).rejects.toThrow('network');
expect(store.error).toEqual(new Error('network'));
expect(store.isEmpty).toBe(false);
});
});
@@ -0,0 +1,202 @@
import { defineStore } from 'pinia';
import type {
ListWorkflowReviewInboxResponse,
WorkflowReviewRequestState,
WorkflowReviewInboxItem,
} from '@n8n/api-types';
import { computed, ref } from 'vue';
import { useRootStore } from '@n8n/stores/useRootStore';
import { fetchWorkflowReviewInbox, fetchWorkflowReviewInboxSummary } from './workflowReviews.api';
const DEFAULT_LIMIT = 15;
function toError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}
export const useReviewInboxStore = defineStore('workflowReviewInbox', () => {
const rootStore = useRootStore();
const probeSettled = ref(false);
const hasAnyReviews = ref(false);
const items = ref<WorkflowReviewInboxItem[]>([]);
const selectedId = ref<string | null>(null);
const activeState = ref<WorkflowReviewRequestState>('open');
const nextCursor = ref<string | null>(null);
const hasMore = ref(false);
const loading = ref(false);
const loadingMore = ref(false);
const error = ref<Error | null>(null);
let listRequestSeq = 0;
let probeRequestSeq = 0;
const selectedItem = computed(
() => items.value.find((item) => item.id === selectedId.value) ?? null,
);
const showSidebar = computed(() => probeSettled.value && hasAnyReviews.value);
const isEmpty = computed(
() => showSidebar.value && !loading.value && error.value === null && items.value.length === 0,
);
function applyListResponse(
response: ListWorkflowReviewInboxResponse,
{ append }: { append: boolean },
): void {
items.value = append ? [...items.value, ...response.data] : response.data;
nextCursor.value = response.nextCursor;
hasMore.value = response.hasMore;
}
async function requestList(cursor?: string): Promise<ListWorkflowReviewInboxResponse> {
return await fetchWorkflowReviewInbox(rootStore.restApiContext, {
state: activeState.value,
limit: DEFAULT_LIMIT,
cursor,
});
}
async function probeInbox() {
const requestSeq = ++probeRequestSeq;
probeSettled.value = false;
error.value = null;
try {
const summary = await fetchWorkflowReviewInboxSummary(rootStore.restApiContext);
if (requestSeq !== probeRequestSeq) {
return;
}
hasAnyReviews.value = summary.hasAny;
probeSettled.value = true;
if (summary.hasAny) {
await fetchList({ reset: true });
}
} catch (e) {
if (requestSeq !== probeRequestSeq) {
return;
}
error.value = toError(e);
probeSettled.value = true;
throw e;
}
}
async function fetchList(options: { reset?: boolean } = {}) {
const requestSeq = ++listRequestSeq;
if (options.reset) {
items.value = [];
nextCursor.value = null;
hasMore.value = false;
selectedId.value = null;
// Invalidate any in-flight loadMore so pagination is not stuck.
loadingMore.value = false;
}
loading.value = true;
error.value = null;
try {
const response = await requestList();
if (requestSeq !== listRequestSeq) {
return;
}
applyListResponse(response, { append: false });
} catch (e) {
if (requestSeq !== listRequestSeq) {
return;
}
error.value = toError(e);
throw e;
} finally {
if (requestSeq === listRequestSeq) {
loading.value = false;
}
}
}
async function loadMore() {
if (loading.value || loadingMore.value || !hasMore.value || !nextCursor.value) {
return;
}
const requestSeq = ++listRequestSeq;
const cursor = nextCursor.value;
loadingMore.value = true;
error.value = null;
try {
const response = await requestList(cursor);
if (requestSeq !== listRequestSeq) {
return;
}
applyListResponse(response, { append: true });
} catch (e) {
if (requestSeq !== listRequestSeq) {
return;
}
error.value = toError(e);
throw e;
} finally {
if (requestSeq === listRequestSeq) {
loadingMore.value = false;
}
}
}
async function setActiveState(state: WorkflowReviewRequestState) {
if (activeState.value === state) return;
activeState.value = state;
await fetchList({ reset: true });
}
function selectItem(id: string) {
selectedId.value = id;
}
function clearSelection() {
selectedId.value = null;
}
function reset() {
probeRequestSeq += 1;
listRequestSeq += 1;
probeSettled.value = false;
hasAnyReviews.value = false;
items.value = [];
selectedId.value = null;
activeState.value = 'open';
nextCursor.value = null;
hasMore.value = false;
loading.value = false;
loadingMore.value = false;
error.value = null;
}
return {
probeSettled,
hasAnyReviews,
items,
selectedId,
selectedItem,
activeState,
nextCursor,
hasMore,
loading,
loadingMore,
error,
showSidebar,
isEmpty,
probeInbox,
fetchList,
loadMore,
setActiveState,
selectItem,
clearSelection,
reset,
};
});
@@ -0,0 +1,144 @@
import { createTestingPinia } from '@pinia/testing';
import { createComponentRenderer } from '@/__tests__/render';
import { mockedStore, waitAllPromises } from '@/__tests__/utils';
import { useToast } from '@/app/composables/useToast';
import { useReviewInboxStore } from '../reviewInbox.store';
import WorkflowReviewRequestsView from './WorkflowReviewRequestsView.vue';
vi.mock('@/app/composables/useToast', () => ({
useToast: vi.fn(),
}));
vi.mock('@/app/composables/useDocumentTitle', () => ({
useDocumentTitle: () => ({
set: vi.fn(),
}),
}));
const showError = vi.fn();
const renderComponent = createComponentRenderer(WorkflowReviewRequestsView, {
global: {
stubs: {
PageViewLayout: {
template: '<div data-test-id="workflow-review-requests-view"><slot /></div>',
},
WorkflowReviewRequestsSidebar: {
template: '<div data-test-id="workflow-review-requests-sidebar" />',
},
},
},
});
describe('WorkflowReviewRequestsView', () => {
let store: ReturnType<typeof mockedStore<typeof useReviewInboxStore>>;
beforeEach(() => {
createTestingPinia();
showError.mockReset();
vi.mocked(useToast).mockReturnValue({ showError } as unknown as ReturnType<typeof useToast>);
store = mockedStore(useReviewInboxStore);
store.probeSettled = false;
store.showSidebar = false;
store.selectedItem = null;
store.items = [];
store.activeState = 'open';
store.selectedId = null;
store.loading = false;
store.loadingMore = false;
store.hasMore = false;
store.isEmpty = false;
store.probeInbox.mockResolvedValue(undefined);
store.reset.mockClear();
});
it('probes the inbox on mount', async () => {
renderComponent();
await waitAllPromises();
expect(store.probeInbox).toHaveBeenCalledTimes(1);
});
it('shows loading while the inbox probe has not settled', async () => {
store.probeSettled = false;
const { container, queryByTestId } = renderComponent();
await waitAllPromises();
expect(container.querySelector('.n8n-loading')).toBeInTheDocument();
expect(queryByTestId('workflow-reviews-disclaimer')).not.toBeInTheDocument();
});
it('shows the disclaimer when settled with nothing selected', async () => {
store.probeSettled = true;
store.showSidebar = false;
store.selectedItem = null;
const { container, getByTestId, queryByTestId } = renderComponent();
await waitAllPromises();
expect(getByTestId('workflow-reviews-disclaimer')).toBeInTheDocument();
expect(container.querySelector('.n8n-loading')).not.toBeInTheDocument();
expect(queryByTestId('workflow-review-requests-sidebar')).not.toBeInTheDocument();
});
it('renders the sidebar and selected title when a review is selected', async () => {
store.probeSettled = true;
store.showSidebar = true;
store.selectedItem = {
id: 'req-1',
projectId: 'proj-1',
title: 'Needs review',
workflowName: 'My workflow',
decision: 'pending',
state: 'open',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
};
const { getByTestId } = renderComponent();
await waitAllPromises();
expect(getByTestId('workflow-review-requests-sidebar')).toBeInTheDocument();
expect(getByTestId('workflow-review-request-title')).toHaveTextContent('Needs review');
expect(getByTestId('workflow-review-request-detail-stub')).toBeInTheDocument();
});
it('shows an error toast when probing the inbox fails', async () => {
const error = new Error('network');
store.probeInbox.mockRejectedValueOnce(error);
renderComponent();
await waitAllPromises();
expect(showError).toHaveBeenCalledWith(error, 'Could not load workflow reviews');
});
it('does not toast probe errors after unmount', async () => {
let rejectProbe!: (error: Error) => void;
store.probeInbox.mockImplementationOnce(
async () =>
await new Promise<void>((_resolve, reject) => {
rejectProbe = reject;
}),
);
const { unmount } = renderComponent();
unmount();
rejectProbe(new Error('network'));
await waitAllPromises();
expect(showError).not.toHaveBeenCalled();
});
it('resets the store on unmount', async () => {
const { unmount } = renderComponent();
await waitAllPromises();
unmount();
expect(store.reset).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,183 @@
<script lang="ts" setup>
import { onMounted, onUnmounted } from 'vue';
import { storeToRefs } from 'pinia';
import type { WorkflowReviewRequestState } from '@n8n/api-types';
import { useI18n } from '@n8n/i18n';
import { N8nHeading, N8nLoading, N8nText } from '@n8n/design-system';
import PageViewLayout from '@/app/components/layouts/PageViewLayout.vue';
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
import { useToast } from '@/app/composables/useToast';
import WorkflowReviewRequestsSidebar from '../components/WorkflowReviewRequestsSidebar.vue';
import { useReviewInboxStore } from '../reviewInbox.store';
const store = useReviewInboxStore();
const {
probeSettled,
showSidebar,
selectedItem,
items,
activeState,
selectedId,
loading,
loadingMore,
hasMore,
isEmpty,
} = storeToRefs(store);
const i18n = useI18n();
const documentTitle = useDocumentTitle();
const { showError } = useToast();
documentTitle.set(i18n.baseText('workflowReviews.page.title'));
let isMounted = false;
function handleListError(error: unknown) {
if (!isMounted) return;
showError(error, i18n.baseText('workflowReviews.error.load'));
}
async function onActiveStateChange(state: WorkflowReviewRequestState) {
try {
await store.setActiveState(state);
} catch (error) {
await handleListError(error);
}
}
async function onLoadMore() {
try {
await store.loadMore();
} catch (error) {
await handleListError(error);
}
}
onMounted(async () => {
isMounted = true;
try {
await store.probeInbox();
} catch (error) {
await handleListError(error);
}
});
onUnmounted(() => {
isMounted = false;
store.reset();
});
</script>
<template>
<PageViewLayout data-test-id="workflow-review-requests-view">
<div :class="$style.content">
<WorkflowReviewRequestsSidebar
v-if="showSidebar"
:items="items"
:active-state="activeState"
:selected-id="selectedId"
:loading="loading"
:loading-more="loadingMore"
:has-more="hasMore"
:is-empty="isEmpty"
@select="store.selectItem"
@clear="store.clearSelection"
@update:active-state="onActiveStateChange"
@load-more="onLoadMore"
/>
<div :class="$style.main">
<div :class="$style.columnTitle">
<N8nHeading
v-if="showSidebar && selectedItem"
bold
tag="h2"
size="xlarge"
data-test-id="workflow-review-request-title"
>
{{ selectedItem.title }}
</N8nHeading>
<N8nHeading
v-else-if="!showSidebar"
bold
tag="h2"
size="xlarge"
data-test-id="workflow-reviews-page-title"
>
{{ i18n.baseText('workflowReviews.page.title') }}
</N8nHeading>
</div>
<div :class="$style.mainBody">
<N8nLoading v-if="!probeSettled" :loading="true" :rows="3" />
<N8nText
v-else-if="selectedItem"
color="text-light"
size="medium"
data-test-id="workflow-review-request-detail-stub"
>
{{ i18n.baseText('workflowReviews.detail.placeholder') }}
</N8nText>
<N8nText
v-else-if="!showSidebar"
color="text-light"
size="medium"
data-test-id="workflow-reviews-disclaimer"
>
{{ i18n.baseText('workflowReviews.disclaimer.body') }}
</N8nText>
<N8nText
v-else-if="isEmpty"
color="text-light"
size="medium"
data-test-id="workflow-reviews-empty-state"
>
{{ i18n.baseText('workflowReviews.emptyState.body') }}
</N8nText>
<N8nText
v-else
color="text-light"
size="medium"
data-test-id="workflow-reviews-no-selection"
>
{{ i18n.baseText('workflowReviews.noSelection.body') }}
</N8nText>
</div>
</div>
</div>
</PageViewLayout>
</template>
<style lang="scss" module>
.content {
display: flex;
width: 100%;
min-height: 0;
height: 100%;
overflow: hidden;
}
.main {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
min-height: 0;
overflow: hidden;
padding: 0 0 var(--spacing--md) var(--spacing--md);
}
.columnTitle {
display: flex;
align-items: center;
min-height: var(--spacing--2xl);
padding-bottom: var(--spacing--sm);
}
.mainBody {
flex: 1;
min-height: 0;
overflow: auto;
}
</style>
@@ -1,4 +1,6 @@
import type {
GetWorkflowReviewInboxSummaryResponse,
ListWorkflowReviewInboxResponse,
WorkflowReviewEligibleReviewersList,
WorkflowReviewRequestList,
WorkflowReviewRequestState,
@@ -16,6 +18,13 @@ export interface CreateWorkflowReviewRequestPayload {
reviewerUserIds?: string[];
}
export type FetchWorkflowReviewInboxParams = {
state?: WorkflowReviewRequestState;
limit?: number;
cursor?: string;
};
/** Workflow-scoped list used by review-required toggle sync. */
export async function fetchWorkflowReviewRequests(
context: IRestApiContext,
query: { workflowId: string; state?: WorkflowReviewRequestState; take?: number; skip?: number },
@@ -51,3 +60,17 @@ export async function createWorkflowReviewRequest(
{ ...payload },
);
}
export async function fetchWorkflowReviewInboxSummary(
context: IRestApiContext,
): Promise<GetWorkflowReviewInboxSummaryResponse> {
return await makeRestApiRequest(context, 'GET', '/workflow-review-requests/summary');
}
/** Cross-project inbox list. */
export async function fetchWorkflowReviewInbox(
context: IRestApiContext,
params: FetchWorkflowReviewInboxParams,
): Promise<ListWorkflowReviewInboxResponse> {
return await makeRestApiRequest(context, 'GET', '/workflow-review-requests/inbox', params);
}