Spaces:
Runtime error
Runtime error
Download apps/api/src/services/expense/slip-queue/slip-queue.controller.ts from ClaraSu/llm-router: direct link, hf CLI and curl.
- Browser
- Download file 9.45 kB
-
https://huggingface.co/spaces/ClaraSu/llm-router/resolve/main/apps/api/src/services/expense/slip-queue/slip-queue.controller.ts
- Command line
-
hf download hf://spaces/ClaraSu/llm-router/apps/api/src/services/expense/slip-queue/slip-queue.controller.ts
-
curl -L -o slip-queue.controller.ts https://huggingface.co/spaces/ClaraSu/llm-router/resolve/main/apps/api/src/services/expense/slip-queue/slip-queue.controller.ts
9.45 kB
| import type { Request, RequestHandler } from 'express' | |
| import type { AuthenticatedRequest } from '@llm-router/auth-client' | |
| import { container } from '../loader.js' | |
| import type { ParsedExpense, SlipStatus, SlipQueueCursor } from '../ports/index.js' | |
| function tenantOf(req: Request): string { | |
| return (req as AuthenticatedRequest).auth!.tenantId | |
| } | |
| // POST /v1/slip-queue β { images: string[] } | |
| // Response: { succeeded: SlipQueueItem[], failed: Array<{ error: string }> } β a | |
| // per-image compression/upload failure doesn't fail the whole batch (see | |
| // SlipQueueService.enqueue); the caller can inspect `failed` for partial-failure info. | |
| export const postSlipQueue: RequestHandler = async (req, res) => { | |
| const { images } = req.body as { images?: unknown } | |
| if (!Array.isArray(images) || images.length === 0) { | |
| res.status(400).json({ error: 'images must be a non-empty array of base64 strings' }) | |
| return | |
| } | |
| if (images.length > 50) { | |
| res.status(400).json({ error: 'Max 50 images per request' }) | |
| return | |
| } | |
| const result = await container.slipQueueService.enqueue(tenantOf(req), images as string[]) | |
| res.status(201).json(result) | |
| } | |
| const VALID_STATUSES: SlipStatus[] = ['pending', 'processing', 'success', 'needs_review', 'failed', 'confirmed', 'duplicate'] | |
| const DEFAULT_PAGE_SIZE = 30 | |
| const MAX_PAGE_SIZE = 100 | |
| // GET /v1/slip-queue?status=<SlipStatus>&limit=<n>&after_created_at=<iso>&after_id=<uuid> | |
| // One tab's page at a time β status is required, there's no "everything" | |
| // mode anymore. Keyset-paginated (see SlipQueueRepository.listByStatus) | |
| // rather than offset-based, so pages stay correct as new slips land mid-scroll. | |
| export const getSlipQueue: RequestHandler = async (req, res) => { | |
| const query = req.query as Record<string, string | undefined> | |
| const status = query['status'] | |
| if (!status || !VALID_STATUSES.includes(status as SlipStatus)) { | |
| res.status(400).json({ error: `status must be one of: ${VALID_STATUSES.join(', ')}` }) | |
| return | |
| } | |
| const limitParam = Number(query['limit'] ?? DEFAULT_PAGE_SIZE) | |
| const limit = Math.min(Math.max(Number.isFinite(limitParam) ? limitParam : DEFAULT_PAGE_SIZE, 1), MAX_PAGE_SIZE) | |
| const after = query['after_created_at'] && query['after_id'] | |
| ? { createdAt: query['after_created_at'], id: query['after_id'] } | |
| : undefined | |
| const opts: { limit: number; after?: SlipQueueCursor } = after ? { limit, after } : { limit } | |
| const page = await container.slipQueueService.listByStatus(tenantOf(req), status as SlipStatus, opts) | |
| res.json(page) | |
| } | |
| // GET /v1/slip-queue/counts β lightweight per-status tallies for the tab | |
| // badges, polled independently of whichever tab's page is currently loaded. | |
| export const getSlipQueueCounts: RequestHandler = async (req, res) => { | |
| const counts = await container.slipQueueService.countsByStatus(tenantOf(req)) | |
| res.json(counts) | |
| } | |
| // POST /v1/slip-queue/process β called by GitHub Actions cron (INTERNAL_SECRET auth via the global auth | |
| // middleware, which bypasses JWT verification entirely β req.auth is never populated here). Sweeps pending | |
| // slips across every tenant; each row is updated back through its own tenant scope (see SlipQueueService.processAll). | |
| export const processSlipQueue: RequestHandler = async (req, res) => { | |
| const limit = Number((req.query as Record<string, string | undefined>)['limit'] ?? 20) | |
| const result = await container.slipQueueService.processAll(limit) | |
| res.json(result) | |
| } | |
| // POST /v1/slip-queue/:id/retry | |
| export const retrySlipQueueItem: RequestHandler = async (req, res) => { | |
| const tenantId = tenantOf(req) | |
| const id = String(req.params['id'] ?? '') | |
| const item = await container.slipQueueService.get(tenantId, id) | |
| if (!item) { res.status(404).json({ error: 'Slip not found' }); return } | |
| if (item.status !== 'failed') { res.status(400).json({ error: 'Only failed slips can be retried' }); return } | |
| const updated = await container.slipQueueService.resetForRetry(tenantId, id) | |
| res.json(updated) | |
| } | |
| // POST /v1/slip-queue/:id/confirm β { category_id: string, overrides?: Partial<CreateExpense>, | |
| // description?: string, shares?: [{person_id, amount}] } | |
| // | |
| // `shares` carries the already-resolved per-person amounts the frontend's SplitBillPanel | |
| // computed (equal-split or manual) β the same shape bill-shares.routes.ts accepts today. | |
| // This handler performs the same two-call, non-transactional sequence the manual/upload | |
| // flows already use (create expense, then create the split): a split failure must not | |
| // undo the already-created expense or block confirmation, since retrying "Confirm" would | |
| // otherwise create a duplicate expense. | |
| export const confirmSlipQueueItem: RequestHandler = async (req, res) => { | |
| const tenantId = tenantOf(req) | |
| const id = String(req.params['id'] ?? '') | |
| const item = await container.slipQueueService.get(tenantId, id) | |
| if (!item) { res.status(404).json({ error: 'Slip not found' }); return } | |
| if (item.status !== 'success') { res.status(400).json({ error: 'Only parsed slips can be confirmed' }); return } | |
| if (!item.parsed_result) { res.status(400).json({ error: 'No parsed result to confirm' }); return } | |
| const body = req.body as { | |
| category_id: string | |
| date?: string | |
| merchant?: string | |
| currency?: string | |
| description?: string | |
| shares?: Array<{ person_id: string; amount: number }> | |
| } | |
| if (!body.category_id) { res.status(400).json({ error: 'category_id is required' }); return } | |
| const today = new Date().toISOString().slice(0, 10) | |
| let expense | |
| try { | |
| expense = await container.expenseService.create(tenantId, { | |
| amount: item.parsed_result.amount, | |
| currency: body.currency ?? item.parsed_result.currency ?? 'USD', | |
| category_id: body.category_id, | |
| date: body.date ?? item.parsed_result.date ?? today, | |
| ...(body.merchant ?? item.parsed_result.merchant ? { merchant: body.merchant ?? item.parsed_result.merchant ?? undefined } : {}), | |
| ...(body.description?.trim() ? { description: body.description.trim() } : {}), | |
| slip_image_key: item.image_key, | |
| reference: item.parsed_result.reference, | |
| }) | |
| } catch (err: unknown) { | |
| const status = (err as NodeJS.ErrnoException & { status?: number })?.status | |
| if (err instanceof Error && status) { | |
| res.status(status).json({ error: err.message }) | |
| return | |
| } | |
| throw err | |
| } | |
| await container.slipQueueService.setConfirmed(tenantId, id) | |
| const validShares = (body.shares ?? []).filter(s => s.person_id && Number(s.amount) > 0) | |
| if (validShares.length > 0) { | |
| try { | |
| await container.billShareService.split(tenantId, { | |
| expense_id: expense.id, | |
| total: expense.amount, | |
| shares: validShares, | |
| }) | |
| } catch (splitErr) { | |
| console.error('Slip confirmed, but splitting it failed:', splitErr) | |
| } | |
| } | |
| res.status(201).json(expense) | |
| } | |
| // POST /v1/slip-queue/:id/apply-match β resolves a needs_review slip via | |
| // either of slip-queue-match-review-ui's two option boxes: | |
| // - "use matched template": body is {} β reuses the slip's own already- | |
| // persisted match_confidence/match_template_id/parsed_result. | |
| // - "set up a new template": body carries the freshly-taught template's | |
| // match result β { parsed_result, match_confidence, match_template_id } | |
| // β which overwrites the stale below-threshold one. | |
| // Either way the slip lands on 'success', same as today's confident-auto- | |
| // match path, so the existing Review & Confirm flow takes over from there. | |
| export const applyMatchSlipQueueItem: RequestHandler = async (req, res) => { | |
| const tenantId = tenantOf(req) | |
| const id = String(req.params['id'] ?? '') | |
| const item = await container.slipQueueService.get(tenantId, id) | |
| if (!item) { res.status(404).json({ error: 'Slip not found' }); return } | |
| if (item.status !== 'needs_review') { res.status(400).json({ error: 'Only needs_review slips can have a match applied' }); return } | |
| const body = req.body as { | |
| parsed_result?: ParsedExpense | |
| match_confidence?: number | |
| match_template_id?: string | null | |
| } | |
| const hasOverride = body.parsed_result !== undefined && body.match_confidence !== undefined | |
| const fields = hasOverride | |
| ? { parsed_result: body.parsed_result!, match_confidence: body.match_confidence!, match_template_id: body.match_template_id ?? null } | |
| : { parsed_result: item.parsed_result, match_confidence: item.match_confidence, match_template_id: item.match_template_id } | |
| if (!hasOverride && !fields.match_template_id) { | |
| res.status(400).json({ error: 'No matched template to apply β teach a new one instead' }) | |
| return | |
| } | |
| const updated = await container.slipQueueService.applyMatch(tenantId, id, { | |
| parsed_result: fields.parsed_result, | |
| match_confidence: fields.match_confidence ?? 0, | |
| match_template_id: fields.match_template_id, | |
| }) | |
| res.json(updated) | |
| } | |
| // DELETE /v1/slip-queue/:id | |
| export const deleteSlipQueueItem: RequestHandler = async (req, res) => { | |
| const tenantId = tenantOf(req) | |
| const id = String(req.params['id'] ?? '') | |
| const item = await container.slipQueueService.get(tenantId, id) | |
| if (!item) { res.status(404).json({ error: 'Slip not found' }); return } | |
| if (item.status === 'confirmed') { | |
| res.status(400).json({ error: 'Cannot discard a confirmed slip β its image is still used by the confirmed expense' }) | |
| return | |
| } | |
| await container.slipQueueService.delete(tenantId, id) | |
| res.status(204).send() | |
| } | |