diff --git a/packages/common/src/services/decision/deleteInstance.ts b/packages/common/src/services/decision/deleteInstance.ts new file mode 100644 index 000000000..966a08b5e --- /dev/null +++ b/packages/common/src/services/decision/deleteInstance.ts @@ -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 => { + 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'); + } +}; diff --git a/packages/common/src/services/decision/index.ts b/packages/common/src/services/decision/index.ts index 9c78c1fde..e6ee504ad 100644 --- a/packages/common/src/services/decision/index.ts +++ b/packages/common/src/services/decision/index.ts @@ -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'; diff --git a/services/api/src/routers/decision/instances/deleteInstance.test.ts b/services/api/src/routers/decision/instances/deleteInstance.test.ts new file mode 100644 index 000000000..00657c077 --- /dev/null +++ b/services/api/src/routers/decision/instances/deleteInstance.test.ts @@ -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'); + }); + + 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(); + }); +}); diff --git a/services/api/src/routers/decision/instances/deleteInstance.ts b/services/api/src/routers/decision/instances/deleteInstance.ts new file mode 100644 index 000000000..b8ec2ad59 --- /dev/null +++ b/services/api/src/routers/decision/instances/deleteInstance.ts @@ -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', + }); + } + }), +}); diff --git a/services/api/src/routers/decision/instances/index.ts b/services/api/src/routers/decision/instances/index.ts index 513c448e4..864fa3676 100644 --- a/services/api/src/routers/decision/instances/index.ts +++ b/services/api/src/routers/decision/instances/index.ts @@ -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'; @@ -12,6 +13,7 @@ export const instancesRouter = mergeRouters( createInstanceRouter, createInstanceFromTemplateRouter, updateInstanceRouter, + deleteInstanceRouter, listInstancesRouter, getInstanceRouter, getCategoriesRouter,