feat(dn): route to delete or keep a flagged record
Co-Authored-By: MiniMax Code <noreply@minimax.io>
This commit is contained in:
parent
77dbdeb160
commit
7c1a7f74fc
2 changed files with 118 additions and 0 deletions
|
|
@ -0,0 +1,81 @@
|
|||
import {beforeEach,describe,expect,it,vi} from 'vitest';
|
||||
|
||||
const {pbAdmin,getCurrentUser,resolveFlag}=vi.hoisted(()=>({pbAdmin:vi.fn(),getCurrentUser:vi.fn(),resolveFlag:vi.fn()}));
|
||||
|
||||
vi.mock('@/lib/pb',()=>({pbAdmin}));
|
||||
vi.mock('@/lib/auth/session',()=>({getCurrentUser}));
|
||||
vi.mock('@/lib/purchasing/deletion-decisions',()=>({resolveFlag,FlagDecisionError:class FlagDecisionError extends Error{constructor(readonly code:string,readonly status:number,message:string){super(message);}}}));
|
||||
|
||||
import {POST} from './route';
|
||||
|
||||
const RECORD='aliasaaaaaaaaaa';
|
||||
const user={id:'u1',email:'david@example.test',name:'David',role:'admin',siteIds:[],mustChangePassword:false};
|
||||
|
||||
function post(body:unknown,collection='supplier_document_aliases',recordId=RECORD){
|
||||
return POST(new Request('http://localhost/api',{method:'POST',body:JSON.stringify(body),headers:{'content-type':'application/json'}}),{params:Promise.resolve({collection,recordId})});
|
||||
}
|
||||
|
||||
describe('POST /api/deletion-flags/[collection]/[recordId]',()=>{
|
||||
beforeEach(()=>{
|
||||
vi.clearAllMocks();
|
||||
getCurrentUser.mockResolvedValue(user);
|
||||
pbAdmin.mockResolvedValue({});
|
||||
resolveFlag.mockResolvedValue({action:'keep',journalId:'journal123456789'});
|
||||
});
|
||||
|
||||
it('lets an unauthenticated caller through to the sign-in response',async()=>{
|
||||
getCurrentUser.mockResolvedValue(null);
|
||||
const res=await post({action:'keep',reason:'Checked the slip'});
|
||||
expect(res.status).toBe(401);
|
||||
expect(resolveFlag).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an action it does not know',async()=>{
|
||||
for(const action of ['archive','remove','',null,7]){
|
||||
const res=await post({action,reason:'Checked the slip'});
|
||||
expect(res.status).toBe(400);
|
||||
expect(await res.json()).toEqual({error:'invalid_request'});
|
||||
}
|
||||
expect(resolveFlag).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a record id or collection it does not know',async()=>{
|
||||
expect((await post({action:'keep',reason:'Checked the slip'},'supplier_document_aliases','alias!')).status).toBe(400);
|
||||
expect((await post({action:'keep',reason:'Checked the slip'},'mcp_operations',RECORD)).status).toBe(400);
|
||||
expect((await post({action:'keep',reason:'Checked the slip'},'supplier_document_aliases','short')).status).toBe(400);
|
||||
expect(resolveFlag).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('says a record that cannot be deleted in plain words',async()=>{
|
||||
const {FlagDecisionError}=await import('@/lib/purchasing/deletion-decisions');
|
||||
resolveFlag.mockRejectedValue(new FlagDecisionError('delete_blocked',409,'This is delivery evidence and is never deleted. Keep it, or set it aside or move the receipt from its document page.'));
|
||||
const res=await post({action:'delete',reason:'Blank page that holds no document'});
|
||||
expect(res.status).toBe(409);
|
||||
expect(await res.json()).toEqual({error:'delete_blocked',detail:'This is delivery evidence and is never deleted. Keep it, or set it aside or move the receipt from its document page.'});
|
||||
});
|
||||
|
||||
it('passes on the refusal codes the rules raise',async()=>{
|
||||
const {FlagDecisionError}=await import('@/lib/purchasing/deletion-decisions');
|
||||
const codes:Array<[string,number]>=[['not_allowed',403],['reason_required',400],['no_open_flag',409],['record_not_found',404],['delete_failed',409]];
|
||||
for(const [code,status] of codes){
|
||||
resolveFlag.mockRejectedValue(new FlagDecisionError(code,status,'words'));
|
||||
const res=await post({action:'delete',reason:'Checked the slip'});
|
||||
expect(res.status).toBe(status);
|
||||
expect(await res.json()).toMatchObject({error:code,detail:'words'});
|
||||
}
|
||||
});
|
||||
|
||||
it('records the decision and says so',async()=>{
|
||||
const res=await post({action:'keep',reason:'Checked against the slip'});
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({ok:true,action:'keep'});
|
||||
expect(resolveFlag).toHaveBeenCalledWith({},user,{collection:'supplier_document_aliases',recordId:RECORD,action:'keep',reason:'Checked against the slip'});
|
||||
});
|
||||
|
||||
it('does not blame the person when the decision itself could not be saved',async()=>{
|
||||
resolveFlag.mockRejectedValue(new Error('the database went away'));
|
||||
const res=await post({action:'delete',reason:'Checked against the slip'});
|
||||
expect(res.status).toBe(409);
|
||||
expect(await res.json()).toEqual({error:'decision_not_saved',detail:'the database went away'});
|
||||
});
|
||||
});
|
||||
37
src/app/api/deletion-flags/[collection]/[recordId]/route.ts
Normal file
37
src/app/api/deletion-flags/[collection]/[recordId]/route.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import {NextResponse} from 'next/server';
|
||||
import {pbAdmin} from '@/lib/pb';
|
||||
import {requireApiUser} from '@/lib/auth/guard';
|
||||
import {RequestDeletionSchema} from '@/lib/purchasing/deletion-requests';
|
||||
import {FlagDecisionError,resolveFlag} from '@/lib/purchasing/deletion-decisions';
|
||||
|
||||
export const dynamic='force-dynamic';
|
||||
export const runtime='nodejs';
|
||||
|
||||
/** Exactly the collections an agent may flag, so the route cannot be talked into naming another. */
|
||||
const COLLECTIONS=new Set<string>(RequestDeletionSchema.shape.collection.options);
|
||||
const idPattern=/^[a-z0-9]{15}$/i;
|
||||
|
||||
/**
|
||||
* Records a person's decision on a deletion flag.
|
||||
*
|
||||
* This route is the only door into `resolveFlag`, and it is behind the person's
|
||||
* own session: agents have no MCP tool for a decision, so nothing but a person
|
||||
* acting for themselves can delete a record. The rules answer with words rather
|
||||
* than codes, so the page can show a person exactly what went wrong.
|
||||
*/
|
||||
export async function POST(request:Request,{params}:{params:Promise<{collection:string;recordId:string}>}){
|
||||
const user=await requireApiUser('financial');
|
||||
if(user instanceof Response)return user;
|
||||
const {collection,recordId}=await params;
|
||||
const body=await request.json().catch(()=>null) as {action?:unknown;reason?:unknown}|null;
|
||||
if(!idPattern.test(recordId)||!COLLECTIONS.has(collection)||(body?.action!=='delete'&&body?.action!=='keep')){
|
||||
return NextResponse.json({error:'invalid_request'},{status:400});
|
||||
}
|
||||
try{
|
||||
const {action}=await resolveFlag(await pbAdmin(),user,{collection,recordId,action:body.action,reason:typeof body?.reason==='string'?body.reason:''});
|
||||
return NextResponse.json({ok:true,action});
|
||||
}catch(error){
|
||||
if(error instanceof FlagDecisionError)return NextResponse.json({error:error.code,detail:error.message},{status:error.status});
|
||||
return NextResponse.json({error:'decision_not_saved',detail:error instanceof Error?error.message:'The decision was not saved.'},{status:409});
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue