Em dashes (—) and en dashes (–) had spread across comments, docs, tests, and a few UI strings, reading as AI-generated boilerplate rather than house style. Replaced each with punctuation matching its context: colon for explanatory clauses, comma for asides, plain hyphen for numeric/legal ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for paired-dash asides. messages/en.json and messages/sv.json were fixed by hand together to keep sv/en in sync. Left untouched where the dash is the functional subject rather than decorative punctuation: date-range-parser.ts's separator regex, charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the agent system-prompt files that already instruct against em dashes, and a golden iXBRL test fixture compared byte-for-byte. Also fixes two bugs surfaced along the way: an off-by-one in ApiKeysPanel's scope-label split (a leftover from an earlier partial pass), and a charset-repair test that had lost the literal en-dash it exists to verify. Regenerated the agent atom seed migration (skills:generate) since 27 SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes, with an explicit carve-out for the functional-dash cases above. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
111 lines
4.1 KiB
TypeScript
111 lines
4.1 KiB
TypeScript
import { createClient } from '@/lib/supabase/server'
|
|
import { NextResponse } from 'next/server'
|
|
import { z } from 'zod'
|
|
import { requireCompanyId } from '@/lib/company/context'
|
|
import { requireWritePermission } from '@/lib/auth/require-write'
|
|
|
|
const RejectBodySchema = z.object({
|
|
rejection_category: z
|
|
.enum(['wrong_category', 'wrong_amount', 'duplicate', 'wrong_period', 'other'])
|
|
.optional(),
|
|
rejection_reason: z.string().max(2000).optional(),
|
|
})
|
|
|
|
/**
|
|
* POST /api/pending-operations/:id/reject
|
|
*
|
|
* Reject a pending operation. Optionally accepts a JSON body with
|
|
* `rejection_category` (fixed enum) and `rejection_reason` (free text). Both
|
|
* are stored on the row so agents can fetch them via gnubok_get_recent_rejections
|
|
* and learn from "no". The body is optional: bodyless POSTs from older
|
|
* clients still mark the op rejected with NULL category/reason.
|
|
*/
|
|
export async function POST(
|
|
request: Request,
|
|
{ params }: { params: Promise<{ id: string }> }
|
|
) {
|
|
const supabase = await createClient()
|
|
const { id } = await params
|
|
|
|
const { data: { user } } = await supabase.auth.getUser()
|
|
if (!user) {
|
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
}
|
|
|
|
const writeCheck = await requireWritePermission(supabase, user.id)
|
|
if (!writeCheck.ok) return writeCheck.response
|
|
|
|
const companyId = await requireCompanyId(supabase, user.id)
|
|
|
|
// Body is optional: accept empty/missing body without rejecting the request.
|
|
// Old clients posted no body; the UI dialog will now post a body, but we
|
|
// keep accepting both shapes to avoid coupling the API to the UI version.
|
|
let rejectionCategory: string | undefined
|
|
let rejectionReason: string | undefined
|
|
const contentLength = request.headers.get('content-length')
|
|
if (contentLength && contentLength !== '0') {
|
|
try {
|
|
const raw = await request.json()
|
|
const parsed = RejectBodySchema.safeParse(raw)
|
|
if (!parsed.success) {
|
|
return NextResponse.json(
|
|
{ error: parsed.error.issues.map((i) => i.message).join('; ') },
|
|
{ status: 400 },
|
|
)
|
|
}
|
|
rejectionCategory = parsed.data.rejection_category
|
|
rejectionReason = parsed.data.rejection_reason?.trim() || undefined
|
|
} catch {
|
|
// Body present but unparseable: fail closed.
|
|
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
|
}
|
|
}
|
|
|
|
const { data: op, error: fetchError } = await supabase
|
|
.from('pending_operations')
|
|
.select('id, status')
|
|
.eq('id', id)
|
|
.eq('company_id', companyId)
|
|
.single()
|
|
|
|
if (fetchError || !op) {
|
|
return NextResponse.json({ error: 'Pending operation not found' }, { status: 404 })
|
|
}
|
|
|
|
if (op.status !== 'pending') {
|
|
// There is no auto-commit path (removed in 20260505190027), so a non-pending
|
|
// status here means the op was resolved explicitly: almost always the user
|
|
// pressed Godkänn in the /pending (Att göra) UI in parallel, or another
|
|
// client already rejected it. Spell that out so an agent doesn't read the
|
|
// generic 409 as "the system committed it behind my back".
|
|
const explained =
|
|
op.status === 'rejected'
|
|
? 'Operation already rejected.'
|
|
: op.status === 'expired'
|
|
? 'Operation already expired and can no longer be rejected.'
|
|
: `Operation already ${op.status}: it was approved explicitly (most likely via the ` +
|
|
'Att göra / pending UI in parallel), not auto-committed. It can no longer be rejected; ' +
|
|
'reverse or correct the resulting verifikat instead.'
|
|
return NextResponse.json(
|
|
{ error: explained, status: op.status },
|
|
{ status: 409 }
|
|
)
|
|
}
|
|
|
|
const { error: updateError } = await supabase
|
|
.from('pending_operations')
|
|
.update({
|
|
status: 'rejected',
|
|
resolved_at: new Date().toISOString(),
|
|
...(rejectionCategory ? { rejection_category: rejectionCategory } : {}),
|
|
...(rejectionReason ? { rejection_reason: rejectionReason } : {}),
|
|
})
|
|
.eq('id', id)
|
|
|
|
if (updateError) {
|
|
return NextResponse.json({ error: updateError.message }, { status: 500 })
|
|
}
|
|
|
|
return NextResponse.json({ data: { id, status: 'rejected' } })
|
|
}
|