Skip to content
Draft
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
124 changes: 124 additions & 0 deletions packages/common/src/services/decision/deleteInstance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { db, eq } from '@op/db/client';
import {
ProcessStatus,
processInstances,
stateTransitionHistory,
} from '@op/db/schema';
import { User } from '@op/supabase/lib';
import { checkPermission, permission } from 'access-zones';

import { CommonError, NotFoundError, UnauthorizedError } from '../../utils';
import { getOrgAccessUser, getUserSession } from '../access';
import { assertOrganizationByProfileId } from '../assert';

export interface DeleteInstanceResult {
success: boolean;
action: 'deleted' | 'archived';
instanceId: string;
}

export const deleteInstance = async ({
instanceId,
user,
}: {
instanceId: string;
user: User;
}): Promise<DeleteInstanceResult> => {
try {
const [sessionUser, existingInstance] = await Promise.all([
getUserSession({ authUserId: user.id }),
db.query.processInstances.findFirst({
where: eq(processInstances.id, instanceId),
}),
]);

const { user: dbUser } = sessionUser ?? {};

if (!dbUser || !dbUser.currentProfileId) {
throw new UnauthorizedError('User must have an active profile');
}

if (!existingInstance) {
throw new NotFoundError('Process instance not found');
}

// Get organization from process instance owner profile
const organization = await assertOrganizationByProfileId(
existingInstance.ownerProfileId,
);

// Get user's organization membership and roles
const orgUser = await getOrgAccessUser({
user,
organizationId: organization.id,
});

const hasPermissions = checkPermission(
{ decisions: permission.ADMIN },
orgUser?.roles ?? [],
);

// Only the process owner or admin can delete the instance
const isProcessOwner =
existingInstance.ownerProfileId === dbUser.currentProfileId;

if (!hasPermissions && !isProcessOwner) {
throw new UnauthorizedError(
'Not authorized to delete this process instance',
);
}

// Check if any state transitions have occurred
const transitions = await db.query.stateTransitionHistory.findFirst({
where: eq(stateTransitionHistory.processInstanceId, instanceId),
});

if (transitions) {
// Transitions exist - archive instead of delete
const [archivedInstance] = await db
.update(processInstances)
.set({
status: ProcessStatus.ARCHIVED,
updatedAt: new Date().toISOString(),
})
.where(eq(processInstances.id, instanceId))
.returning();

if (!archivedInstance) {
throw new CommonError('Failed to archive process instance');
}

return {
success: true,
action: 'archived',
instanceId,
};
}

// No transitions - safe to hard delete
const [deletedInstance] = await db
.delete(processInstances)
.where(eq(processInstances.id, instanceId))
.returning();

if (!deletedInstance) {
throw new CommonError('Failed to delete process instance');
}

return {
success: true,
action: 'deleted',
instanceId,
};
} catch (error) {
if (
error instanceof UnauthorizedError ||
error instanceof NotFoundError ||
error instanceof CommonError
) {
throw error;
}
console.error('Error deleting process instance:', error);
throw new CommonError('Failed to delete process instance');
}
};
1 change: 1 addition & 0 deletions packages/common/src/services/decision/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export * from './listProcesses';
export * from './createInstance';
export * from './createInstanceFromTemplate';
export * from './updateInstance';
export * from './deleteInstance';
export * from './listInstances';
export * from './getInstance';
export * from './listDecisionProfiles';
Expand Down
160 changes: 160 additions & 0 deletions services/api/src/routers/decision/instances/deleteInstance.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { db, eq } from '@op/db/client';
import { processInstances, stateTransitionHistory } from '@op/db/schema';
import { describe, expect, it } from 'vitest';

import { appRouter } from '../..';
import { TestDecisionsDataManager } from '../../../test/helpers/TestDecisionsDataManager';
import {
createIsolatedSession,
createTestContextWithSession,
} from '../../../test/supabase-utils';
import { createCallerFactory } from '../../../trpcFactory';

const createCaller = createCallerFactory(appRouter);

async function createAuthenticatedCaller(email: string) {
const { session } = await createIsolatedSession(email);
return createCaller(await createTestContextWithSession(session));
}

describe.concurrent('deleteInstance', () => {
it('should hard delete instance when no transitions exist', async ({
task,
onTestFinished,
}) => {
const testData = new TestDecisionsDataManager(task.id, onTestFinished);

const setup = await testData.createDecisionSetup({
instanceCount: 1,
grantAccess: true,
});

const { instance } = setup.instances[0]!;
const caller = await createAuthenticatedCaller(setup.userEmail);

const result = await caller.decision.deleteInstance({
instanceId: instance.id,
});

expect(result.success).toBe(true);
expect(result.action).toBe('deleted');
expect(result.instanceId).toBe(instance.id);

// Verify the instance was actually deleted
const deletedInstance = await db.query.processInstances.findFirst({
where: eq(processInstances.id, instance.id),
});

expect(deletedInstance).toBeUndefined();
});

it('should archive instance when transitions exist', async ({
task,
onTestFinished,
}) => {
const testData = new TestDecisionsDataManager(task.id, onTestFinished);

const setup = await testData.createDecisionSetup({
instanceCount: 1,
grantAccess: true,
});

const { instance } = setup.instances[0]!;
const caller = await createAuthenticatedCaller(setup.userEmail);

// Insert a state transition history record to simulate a transition having occurred
await db.insert(stateTransitionHistory).values([
{
processInstanceId: instance.id,
fromStateId: 'initial',
toStateId: 'final',
transitionedAt: new Date(),
},
]);

const result = await caller.decision.deleteInstance({
instanceId: instance.id,
});

expect(result.success).toBe(true);
expect(result.action).toBe('archived');
expect(result.instanceId).toBe(instance.id);

// Verify the instance was archived, not deleted
const archivedInstance = await db.query.processInstances.findFirst({
where: eq(processInstances.id, instance.id),
});

expect(archivedInstance).toBeDefined();
expect(archivedInstance!.status).toBe('archived');

Check failure on line 89 in services/api/src/routers/decision/instances/deleteInstance.test.ts

View workflow job for this annotation

GitHub Actions / API Tests

src/routers/decision/instances/deleteInstance.test.ts > deleteInstance > should archive instance when transitions exist

AssertionError: expected 'draft' to be 'archived' // Object.is equality Expected: "archived" Received: "draft" ❯ src/routers/decision/instances/deleteInstance.test.ts:89:38
});

it('should require authentication', async () => {
const caller = createCaller({
session: null,
user: null,
} as never);

await expect(
caller.decision.deleteInstance({
instanceId: '00000000-0000-0000-0000-000000000000',
}),
).rejects.toThrow();
});

it('should throw error for non-existent instance', async ({
task,
onTestFinished,
}) => {
const testData = new TestDecisionsDataManager(task.id, onTestFinished);

const setup = await testData.createDecisionSetup({
instanceCount: 0,
});

const caller = await createAuthenticatedCaller(setup.userEmail);

await expect(
caller.decision.deleteInstance({
instanceId: '00000000-0000-0000-0000-000000000000',
}),
).rejects.toThrow(/not found/i);
});

it('should not allow non-owner to delete instance', async ({
task,
onTestFinished,
}) => {
const testData = new TestDecisionsDataManager(task.id, onTestFinished);

// Create instance owned by first user
const setup = await testData.createDecisionSetup({
instanceCount: 1,
grantAccess: true,
});

const { instance, profileId } = setup.instances[0]!;

// Create a second user who is not the owner (just a member)
const memberUser = await testData.createMemberUser({
organization: setup.organization,
instanceProfileIds: [profileId],
});

const memberCaller = await createAuthenticatedCaller(memberUser.email);

// Member should not be able to delete the instance
await expect(
memberCaller.decision.deleteInstance({
instanceId: instance.id,
}),
).rejects.toThrow(/not authorized/i);

// Verify the instance still exists
const existingInstance = await db.query.processInstances.findFirst({
where: eq(processInstances.id, instance.id),
});

expect(existingInstance).toBeDefined();
});
});
87 changes: 87 additions & 0 deletions services/api/src/routers/decision/instances/deleteInstance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import {
NotFoundError,
UnauthorizedError,
deleteInstance as deleteInstanceService,
} from '@op/common';
import { TRPCError } from '@trpc/server';
import type { OpenApiMeta } from 'trpc-to-openapi';
import { z } from 'zod';

import withAnalytics from '../../../middlewares/withAnalytics';
import withAuthenticated from '../../../middlewares/withAuthenticated';
import withRateLimited from '../../../middlewares/withRateLimited';
import { loggedProcedure, router } from '../../../trpcFactory';

const meta: OpenApiMeta = {
openapi: {
enabled: true,
method: 'DELETE',
path: '/decision/instance/{instanceId}',
protect: true,
tags: ['decision'],
summary: 'Delete or archive process instance',
},
};

export const deleteInstanceRouter = router({
deleteInstance: loggedProcedure
.use(withRateLimited({ windowSize: 10, maxRequests: 5 }))
.use(withAuthenticated)
.use(withAnalytics)
.meta(meta)
.input(
z.object({
instanceId: z.string().uuid(),
}),
)
.output(
z.object({
success: z.boolean(),
action: z.enum(['deleted', 'archived']),
instanceId: z.string(),
}),
)
.mutation(async ({ ctx, input }) => {
const { user, logger } = ctx;

try {
const result = await deleteInstanceService({
instanceId: input.instanceId,
user,
});

logger.info('Process instance deleted/archived', {
userId: user.id,
instanceId: input.instanceId,
action: result.action,
});

return result;
} catch (error: unknown) {
logger.error('Failed to delete process instance', {
userId: user.id,
instanceId: input.instanceId,
error,
});

if (error instanceof UnauthorizedError) {
throw new TRPCError({
message: error.message,
code: 'UNAUTHORIZED',
});
}

if (error instanceof NotFoundError) {
throw new TRPCError({
message: error.message,
code: 'NOT_FOUND',
});
}

throw new TRPCError({
message: 'Failed to delete process instance',
code: 'INTERNAL_SERVER_ERROR',
});
}
}),
});
2 changes: 2 additions & 0 deletions services/api/src/routers/decision/instances/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { mergeRouters } from '../../../trpcFactory';
import { createInstanceRouter } from './createInstance';
import { createInstanceFromTemplateRouter } from './createInstanceFromTemplate';
import { deleteInstanceRouter } from './deleteInstance';
import { getCategoriesRouter } from './getCategories';
import { getDecisionBySlugRouter } from './getDecisionBySlug';
import { getInstanceRouter } from './getInstance';
Expand All @@ -12,6 +13,7 @@ export const instancesRouter = mergeRouters(
createInstanceRouter,
createInstanceFromTemplateRouter,
updateInstanceRouter,
deleteInstanceRouter,
listInstancesRouter,
getInstanceRouter,
getCategoriesRouter,
Expand Down
Loading