Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export class AdminPoliciesController {
@Get(':orgId/policies')
@ApiOperation({ summary: 'List all policies for an organization (admin)' })
async list(@Param('orgId') orgId: string) {
return this.policiesService.findAll(orgId);
return this.policiesService.findAll({ organizationId: orgId });
}

@Post(':orgId/policies')
Expand Down
8 changes: 7 additions & 1 deletion apps/api/src/openapi/operation-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ const CORE_OPERATION_METADATA: Record<string, PublicOperationMetadata> = {
PoliciesController_getAllPolicies_v1: {
summary: 'List compliance policies',
description:
'Lists compliance policies for the organization. Use this to find a policy by name, look up a policy ID, browse drafts, or get an overview of all policies for SOC 2, ISO 27001, HIPAA, and GDPR workflows. Returns id, name, status, department, and other metadata for each policy. Pass excludeContent=true to skip the heavy TipTap content fields — recommended when you only need to identify a policy. To read or edit a single policy in detail, fetch it by ID via get-compliance-policy.',
'Lists active compliance policies by default. Use includeArchived=true to include archived rows and excludeContent=true when you only need policy metadata.',
codeSamples: [
{
lang: 'bash',
Expand All @@ -64,6 +64,12 @@ const CORE_OPERATION_METADATA: Record<string, PublicOperationMetadata> = {
source:
'curl --request GET --url "https://api.trycomp.ai/v1/policies?excludeContent=true" --header "X-API-Key: $COMP_AI_API_KEY"',
},
{
lang: 'bash',
label: 'List policies including archived',
source:
'curl --request GET --url "https://api.trycomp.ai/v1/policies?includeArchived=true" --header "X-API-Key: $COMP_AI_API_KEY"',
},
],
},
PoliciesController_createPolicy_v1: {
Expand Down
7 changes: 7 additions & 0 deletions apps/api/src/policies/dto/policy-responses.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,13 @@ export class PolicyResponseDto {
})
isArchived: boolean;

@ApiProperty({
description: 'When the policy was archived by framework sync',
example: '2024-02-01T00:00:00.000Z',
nullable: true,
})
archivedAt?: Date;

@ApiProperty({
description: 'When the policy was created',
example: '2024-01-01T00:00:00.000Z',
Expand Down
41 changes: 38 additions & 3 deletions apps/api/src/policies/policies.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ jest.mock('@db', () => ({
db: {
policy: {
findFirst: jest.fn(),
findUnique: jest.fn(),
update: jest.fn(),
},
control: {
Expand All @@ -43,6 +44,10 @@ jest.mock('@db', () => ({
findFirst: jest.fn(),
update: jest.fn(),
},
frameworkControlPolicyLink: {
deleteMany: jest.fn(),
},
$transaction: jest.fn(),
},
Frequency: {
monthly: 'monthly',
Expand Down Expand Up @@ -152,8 +157,10 @@ describe('PoliciesController', () => {

const result = await controller.getAllPolicies(orgId, mockAuthContext);

expect(policiesService.findAll).toHaveBeenCalledWith(orgId, {
expect(policiesService.findAll).toHaveBeenCalledWith({
organizationId: orgId,
excludeContent: false,
includeArchived: false,
});
expect(result).toEqual({
data: mockPolicies,
Expand All @@ -167,8 +174,10 @@ describe('PoliciesController', () => {

await controller.getAllPolicies(orgId, mockAuthContext, 'true');

expect(policiesService.findAll).toHaveBeenCalledWith(orgId, {
expect(policiesService.findAll).toHaveBeenCalledWith({
organizationId: orgId,
excludeContent: true,
includeArchived: false,
});
});

Expand All @@ -177,8 +186,22 @@ describe('PoliciesController', () => {

await controller.getAllPolicies(orgId, mockAuthContext, 'false');

expect(policiesService.findAll).toHaveBeenCalledWith(orgId, {
expect(policiesService.findAll).toHaveBeenCalledWith({
organizationId: orgId,
excludeContent: false,
includeArchived: false,
});
});

it('should pass includeArchived=true to service when query param is "true"', async () => {
mockPoliciesService.findAll.mockResolvedValue([]);

await controller.getAllPolicies(orgId, mockAuthContext, undefined, 'true');

expect(policiesService.findAll).toHaveBeenCalledWith({
organizationId: orgId,
excludeContent: false,
includeArchived: true,
});
});

Expand Down Expand Up @@ -621,7 +644,19 @@ describe('PoliciesController', () => {
describe('removePolicyControl', () => {
it('should disconnect control from policy and return success', async () => {
const { db } = require('@db');
db.policy.findUnique.mockResolvedValue({ controls: [] });
db.policy.update.mockResolvedValue({});
db.$transaction.mockImplementation(async (callback: (tx: unknown) => Promise<unknown>) =>
callback({
policy: {
findUnique: db.policy.findUnique,
update: db.policy.update,
},
frameworkControlPolicyLink: {
deleteMany: db.frameworkControlPolicyLink.deleteMany,
},
}),
);

const result = await controller.removePolicyControl(
'pol_1',
Expand Down
12 changes: 11 additions & 1 deletion apps/api/src/policies/policies.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,16 +122,26 @@ export class PoliciesController {
description:
'When true, omits `content` and `draftContent` from each policy in the response. Use this when listing policies to find one by name/ID — fetch the full content via GET /v1/policies/{id} after.',
})
@ApiQuery({
name: 'includeArchived',
required: false,
type: Boolean,
description:
'When true, includes user-archived and framework-sync-archived policies in the response. Defaults to false.',
})
@ApiExtension('x-speakeasy-mcp', { name: 'list-policies' })
@ApiResponse(GET_ALL_POLICIES_RESPONSES[200])
@ApiResponse(GET_ALL_POLICIES_RESPONSES[401])
async getAllPolicies(
@OrganizationId() organizationId: string,
@AuthContext() authContext: AuthContextType,
@Query('excludeContent') excludeContent?: string,
@Query('includeArchived') includeArchived?: string,
) {
const policies = await this.policiesService.findAll(organizationId, {
const policies = await this.policiesService.findAll({
organizationId,
excludeContent: excludeContent === 'true',
includeArchived: includeArchived === 'true',
});

return {
Expand Down
17 changes: 13 additions & 4 deletions apps/api/src/policies/policies.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,23 +140,23 @@ describe('PoliciesService', () => {
});

it('includes content and draftContent in the select by default', async () => {
await service.findAll(orgId);
await service.findAll({ organizationId: orgId });

const callArgs = db.policy.findMany.mock.calls[0][0];
expect(callArgs.select.content).toBe(true);
expect(callArgs.select.draftContent).toBe(true);
});

it('includes content and draftContent when excludeContent is false', async () => {
await service.findAll(orgId, { excludeContent: false });
await service.findAll({ organizationId: orgId, excludeContent: false });

const callArgs = db.policy.findMany.mock.calls[0][0];
expect(callArgs.select.content).toBe(true);
expect(callArgs.select.draftContent).toBe(true);
});

it('omits content and draftContent from select when excludeContent is true', async () => {
await service.findAll(orgId, { excludeContent: true });
await service.findAll({ organizationId: orgId, excludeContent: true });

const callArgs = db.policy.findMany.mock.calls[0][0];
expect(callArgs.select.content).toBeUndefined();
Expand All @@ -169,13 +169,22 @@ describe('PoliciesService', () => {
});

it('scopes results to the organization regardless of excludeContent', async () => {
await service.findAll(orgId, { excludeContent: true });
await service.findAll({ organizationId: orgId, excludeContent: true });

const callArgs = db.policy.findMany.mock.calls[0][0];
expect(callArgs.where.organizationId).toBe(orgId);
expect(callArgs.where.isArchived).toBe(false);
expect(callArgs.where.archivedAt).toBeNull();
});

it('includes archived policies when includeArchived is true', async () => {
await service.findAll({ organizationId: orgId, includeArchived: true });

const callArgs = db.policy.findMany.mock.calls[0][0];
expect(callArgs.where).toEqual({ organizationId: orgId });
expect(callArgs.select.archivedAt).toBe(true);
expect(callArgs.select.isArchived).toBe(true);
});
});

describe('updateById', () => {
Expand Down
26 changes: 19 additions & 7 deletions apps/api/src/policies/policies.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ const POLICY_UPDATE_SELECT = {
signedBy: true,
reviewDate: true,
isArchived: true,
archivedAt: true,
createdAt: true,
updatedAt: true,
lastArchivedAt: true,
Expand Down Expand Up @@ -79,19 +80,26 @@ export class PoliciesService {
private readonly timelinesService: TimelinesService,
) {}

async findAll(
organizationId: string,
options?: { excludeContent?: boolean },
) {
async findAll({
organizationId,
excludeContent,
includeArchived,
}: {
organizationId: string;
excludeContent?: boolean;
includeArchived?: boolean;
}) {
try {
const policies = await db.policy.findMany({
where: { organizationId, isArchived: false, archivedAt: null },
where: includeArchived
? { organizationId }
: { organizationId, isArchived: false, archivedAt: null },
select: {
id: true,
name: true,
description: true,
status: true,
...(options?.excludeContent
...(excludeContent
? {}
: { content: true, draftContent: true }),
frequency: true,
Expand All @@ -100,6 +108,7 @@ export class PoliciesService {
signedBy: true,
reviewDate: true,
isArchived: true,
archivedAt: true,
createdAt: true,
updatedAt: true,
lastArchivedAt: true,
Expand Down Expand Up @@ -128,7 +137,8 @@ export class PoliciesService {

this.logger.log(
`Retrieved ${policies.length} policies for organization ${organizationId}` +
(options?.excludeContent ? ' (content excluded)' : ''),
(excludeContent ? ' (content excluded)' : '') +
(includeArchived ? ' (archived included)' : ''),
);
return policies;
} catch (error) {
Expand Down Expand Up @@ -240,6 +250,7 @@ export class PoliciesService {
signedBy: true,
reviewDate: true,
isArchived: true,
archivedAt: true,
createdAt: true,
updatedAt: true,
lastArchivedAt: true,
Expand Down Expand Up @@ -338,6 +349,7 @@ export class PoliciesService {
signedBy: true,
reviewDate: true,
isArchived: true,
archivedAt: true,
createdAt: true,
updatedAt: true,
lastArchivedAt: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export const GET_ALL_POLICIES_RESPONSES: Record<string, ApiResponseOptions> = {
signedBy: [],
reviewDate: '2024-12-31T00:00:00.000Z',
isArchived: false,
archivedAt: null,
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-15T00:00:00.000Z',
lastArchivedAt: null,
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/policies/schemas/policy-operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ export const POLICY_OPERATIONS: Record<string, ApiOperationOptions> = {
getAllPolicies: {
summary: 'Get all policies',
description:
'Lists all policies. Pass excludeContent=true to skip the heavy content fields (recommended unless you need every policy fully). Fetch one policy via get-policy by ID when you need the full content to edit.',
'Lists active policies by default. Pass includeArchived=true to include archived rows and excludeContent=true to skip heavy content fields. Fetch one policy by ID for full content.',
},
getPolicyById: {
summary: 'Get policy by ID',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { EditableSOAFields } from './EditableSOAFields';

vi.mock('../hooks/useSOADocument', () => ({
useSOADocument: () => ({
saveAnswer: vi.fn(),
}),
}));

vi.mock('sonner', () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));

describe('EditableSOAFields', () => {
it('shows the edit action without requiring hover', () => {
render(
<EditableSOAFields
documentId="doc_1"
questionId="q_1"
isApplicable
justification={null}
isPendingApproval={false}
organizationId="org_1"
/>,
);

expect(screen.getByRole('button', { name: 'Edit answer' })).toHaveClass('opacity-100');
});
});
Loading
Loading