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

import { CommonError, NotFoundError } from '../../utils';
import { getProfileAccessUser } from '../access';

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

export const deleteInstance = async ({
instanceId,
user,
}: {
instanceId: string;
user: User;
}): Promise<DeleteInstanceResult> => {
// Fetch existing instance
const existingInstance = await db._query.processInstances.findFirst({
where: (table, { eq }) => eq(table.id, instanceId),
});

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

const { profileId } = existingInstance;
if (!profileId) {
throw new CommonError(
'Decision instance does not have an associated profile',
);
}

// Check user has admin access on the decision instance's profile and check for transitions in parallel
const [profileUser, transitions] = await Promise.all([
getProfileAccessUser({ user, profileId }),
db._query.stateTransitionHistory.findFirst({
where: (table, { eq }) => eq(table.processInstanceId, instanceId),
}),
]);

assertAccess({ profile: permission.ADMIN }, profileUser?.roles ?? []);

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

if (!cancelledInstance) {
throw new CommonError('Failed to cancel process instance');
}

return {
success: true,
action: 'cancelled',
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,
};
};
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 @@ -10,6 +10,7 @@ export * from './createInstance';
export * from './createInstanceFromTemplate';
export * from './updateInstance';
export * from './updateDecisionInstance';
export * from './deleteInstance';
export * from './listInstances';
export * from './getInstance';
export * from './listDecisionProfiles';
Expand Down
258 changes: 258 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,258 @@
import { db, eq } from '@op/db/client';
import {
ProcessStatus,
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: (table, { eq }) => eq(table.id, instance.id),
});

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

it('should cancel 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('cancelled');
expect(result.instanceId).toBe(instance.id);

// Verify the instance was cancelled, not deleted
const cancelledInstance = await db._query.processInstances.findFirst({
where: (table, { eq }) => eq(table.id, instance.id),
});

expect(cancelledInstance).toBeDefined();
expect(cancelledInstance!.status).toBe('cancelled');
});

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(/authenticate/i);
});

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 user with member profile access 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 with MEMBER access on the decision profile (not admin)
const memberUser = await testData.createMemberUser({
organization: setup.organization,
instanceProfileIds: [profileId], // grants MEMBER role on profile
});

const memberCaller = await createAuthenticatedCaller(memberUser.email);

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

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

expect(existingInstance).toBeDefined();
});

it('should allow user with admin profile access 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
const adminUser = await testData.createMemberUser({
organization: setup.organization,
// Don't pass instanceProfileIds - we'll grant admin access manually
});

// Grant admin access on the decision profile (not just member)
await testData.grantProfileAccess(
profileId,
adminUser.authUserId,
adminUser.email,
true, // isAdmin = true
);

const adminCaller = await createAuthenticatedCaller(adminUser.email);

// User with admin profile access should be able to delete the instance
const result = await adminCaller.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: (table, { eq }) => eq(table.id, instance.id),
});

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

it('should handle already cancelled instance gracefully', 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);

// Pre-cancel the instance and add a transition so delete would try to cancel again
await db
.update(processInstances)
.set({ status: ProcessStatus.CANCELLED })
.where(eq(processInstances.id, instance.id));

await db.insert(stateTransitionHistory).values([
{
processInstanceId: instance.id,
fromStateId: 'initial',
toStateId: 'final',
transitionedAt: new Date(),
},
]);

// Calling delete on an already cancelled instance should still succeed
const result = await caller.decision.deleteInstance({
instanceId: instance.id,
});

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

// Verify the instance is still cancelled
const cancelledInstance = await db._query.processInstances.findFirst({
where: (table, { eq }) => eq(table.id, instance.id),
});

expect(cancelledInstance).toBeDefined();
expect(cancelledInstance!.status).toBe('cancelled');
});
});
28 changes: 28 additions & 0 deletions services/api/src/routers/decision/instances/deleteInstance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { deleteInstance as deleteInstanceService } from '@op/common';
import { z } from 'zod';

import { commonAuthedProcedure, router } from '../../../trpcFactory';

export const deleteInstanceRouter = router({
deleteInstance: commonAuthedProcedure()
.input(
z.object({
instanceId: z.string().uuid(),
}),
)
.output(
z.object({
success: z.boolean(),
action: z.enum(['deleted', 'cancelled']),
instanceId: z.string(),
}),
)
.mutation(async ({ ctx, input }) => {
const { user } = ctx;

return deleteInstanceService({
instanceId: input.instanceId,
user,
});
}),
});
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, getLegacyInstanceRouter } from './getInstance';
Expand All @@ -14,6 +15,7 @@ export const instancesRouter = mergeRouters(
createInstanceFromTemplateRouter,
updateInstanceRouter,
updateDecisionInstanceRouter,
deleteInstanceRouter,
listInstancesRouter,
getInstanceRouter,
getLegacyInstanceRouter,
Expand Down
Loading