style: remove em/en dashes repo-wide, add CLAUDE.md rule against them (#890)
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>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
a5154ee884
commit
ec27228a8e
@@ -9,15 +9,15 @@ import { requireCompanyId } from '@/lib/company/context'
|
||||
// touching storage (no signed URL creation per poll).
|
||||
//
|
||||
// Derived status:
|
||||
// running — extracted_at IS NULL (pipeline hasn't stamped yet)
|
||||
// succeeded — extracted_at IS NOT NULL AND extracted_data IS NOT NULL
|
||||
// unsupported — extraction_model = 'skipped:*' (HEIC, ZIP, …)
|
||||
// failed — extracted_at IS NOT NULL AND extracted_data IS NULL AND
|
||||
// running : extracted_at IS NULL (pipeline hasn't stamped yet)
|
||||
// succeeded : extracted_at IS NOT NULL AND extracted_data IS NOT NULL
|
||||
// unsupported : extraction_model = 'skipped:*' (HEIC, ZIP, …)
|
||||
// failed : extracted_at IS NOT NULL AND extracted_data IS NULL AND
|
||||
// extraction_model = 'failed:*'
|
||||
// disabled — the document-extraction extension isn't enabled (column
|
||||
// disabled : the document-extraction extension isn't enabled (column
|
||||
// stays untouched indefinitely). Client times out and shows
|
||||
// a quiet fallback. We don't distinguish this from running
|
||||
// server-side — the client decides based on elapsed time.
|
||||
// server-side: the client decides based on elapsed time.
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
|
||||
@@ -10,7 +10,7 @@ import { createServiceClient } from '@/lib/supabase/server'
|
||||
* an <iframe> or <img> tag.
|
||||
*
|
||||
* Supabase Storage signed URLs return `Content-Disposition: attachment`,
|
||||
* which browsers refuse to render inline — that triggers the
|
||||
* which browsers refuse to render inline: that triggers the
|
||||
* "Det här innehållet har blockerats" error in journal entry previews.
|
||||
*
|
||||
* Defense in depth: the user's cookie-bound client authorizes access
|
||||
@@ -31,7 +31,7 @@ const EXTENSION_MIME_MAP: Record<string, string> = {
|
||||
* `mime_type = null` or `application/octet-stream` (browsers sometimes
|
||||
* report empty File.type for files dragged from certain sources). Combined
|
||||
* with the new `X-Content-Type-Options: nosniff` header on this route,
|
||||
* that broke Chrome's PDF viewer for older rows — the plugin would load
|
||||
* that broke Chrome's PDF viewer for older rows: the plugin would load
|
||||
* via <object type="application/pdf"> but refuse to parse a response
|
||||
* served as octet-stream. Falling back to the file extension covers every
|
||||
* legacy row without a DB backfill.
|
||||
@@ -95,7 +95,7 @@ export async function GET(
|
||||
'Content-Type': resolveContentType(doc.file_name, doc.mime_type),
|
||||
'Content-Disposition': `inline; filename="${safeFileName}"`,
|
||||
'Cache-Control': 'private, max-age=300',
|
||||
// Block MIME sniffing — Content-Type is derived from DB metadata
|
||||
// Block MIME sniffing: Content-Type is derived from DB metadata
|
||||
// (with extension fallback for legacy rows), never from response
|
||||
// content. Without nosniff a tampered file_name extension could
|
||||
// serve a stored document under an attacker-chosen MIME type.
|
||||
|
||||
@@ -25,7 +25,7 @@ import { validateDocumentMagicBytes } from '@/lib/core/documents/document-servic
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
// v4 UUIDs (variant 'a' / 'b' in the 4th group) so Zod's stricter validators
|
||||
// accept them — the looser `2222...` style fails on the variant check.
|
||||
// accept them: the looser `2222...` style fails on the variant check.
|
||||
const mockUser = { id: '11111111-1111-4111-a111-111111111111' }
|
||||
const validDocId = '22222222-2222-4222-a222-222222222222'
|
||||
const companyId = '33333333-3333-4333-a333-333333333333'
|
||||
@@ -159,7 +159,7 @@ describe('GET /api/documents/[id]/integrity', () => {
|
||||
const { status, body } = await parseJsonResponse<{ data: Record<string, unknown> }>(res)
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.valid).toBe(false)
|
||||
// The whole point of the V1.2.5 / Art 25(2) hardening — internal text
|
||||
// The whole point of the V1.2.5 / Art 25(2) hardening: internal text
|
||||
// never appears in the response.
|
||||
expect(body.data.reason).toBeUndefined()
|
||||
expect(JSON.stringify(body)).not.toContain('storage/v1/object')
|
||||
@@ -194,7 +194,7 @@ describe('GET /api/documents/[id]/integrity', () => {
|
||||
it('does not import the service-role supabase client', async () => {
|
||||
// The download must go through the per-request user-scoped client so
|
||||
// RLS on storage.objects can act as defense-in-depth. Statically
|
||||
// verifying the source is the cleanest check — runtime mocking of the
|
||||
// verifying the source is the cleanest check: runtime mocking of the
|
||||
// service-client export would not catch a future regression where
|
||||
// someone added an import but conditionally used it.
|
||||
const fs = await import('node:fs/promises')
|
||||
@@ -208,7 +208,7 @@ describe('GET /api/documents/[id]/integrity', () => {
|
||||
// The route's first `from('document_attachments')` chain must include
|
||||
// `.eq('is_current_version', true)` so superseded versions cannot have
|
||||
// their bytes probed via this surface. Source-level check rather than
|
||||
// runtime spying — the proxy-based queued mock collapses every chained
|
||||
// runtime spying: the proxy-based queued mock collapses every chained
|
||||
// method into the same handler, so introspecting individual .eq calls
|
||||
// is not feasible without rebuilding the mock.
|
||||
const fs = await import('node:fs/promises')
|
||||
|
||||
@@ -12,7 +12,7 @@ const ParamsSchema = z.object({ id: z.string().uuid() })
|
||||
* GET /api/documents/:id/integrity
|
||||
*
|
||||
* Probes the actual stored bytes against the declared MIME type. Used by the
|
||||
* Bilagor modal to surface a clear "this file is corrupt — please re-upload"
|
||||
* Bilagor modal to surface a clear "this file is corrupt: please re-upload"
|
||||
* warning instead of relying on the browser's PDF viewer error UI, which
|
||||
* only fires after the user has already tried to view the file.
|
||||
*
|
||||
@@ -21,7 +21,7 @@ const ParamsSchema = z.object({ id: z.string().uuid() })
|
||||
* after those rows were written. This endpoint lets the UI detect and steer
|
||||
* the user toward replacing them.
|
||||
*
|
||||
* Response shape is intentionally minimal — { valid: boolean } only. The
|
||||
* Response shape is intentionally minimal: { valid: boolean } only. The
|
||||
* reason for an invalid result is logged server-side rather than returned
|
||||
* to the client to avoid information disclosure (V1.2.5 / GDPR Art 25(2))
|
||||
* and to keep this from being a probe surface for storage internals.
|
||||
@@ -91,8 +91,8 @@ export async function GET(
|
||||
}
|
||||
|
||||
// Only the first 16 bytes are needed for magic-byte detection (PDF/PNG
|
||||
// use ≤8, WebP needs 12). Trimming here doesn't change bandwidth — the
|
||||
// full blob is already downloaded — but it makes the intent explicit and
|
||||
// use ≤8, WebP needs 12). Trimming here doesn't change bandwidth (the
|
||||
// full blob is already downloaded) but it makes the intent explicit and
|
||||
// keeps memory churn off the hot path for large PDFs.
|
||||
const headerBuffer = await blob.slice(0, 16).arrayBuffer()
|
||||
const magicError = validateDocumentMagicBytes(headerBuffer, doc.mime_type)
|
||||
|
||||
@@ -30,7 +30,7 @@ import { NextResponse } from 'next/server'
|
||||
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
// Body fields are uuid-validated (LinkDocumentSchema) — request fixtures must
|
||||
// Body fields are uuid-validated (LinkDocumentSchema): request fixtures must
|
||||
// be real UUIDs. The enqueue mock rows keep their short ids; the queued mock
|
||||
// doesn't correlate request input with mocked output.
|
||||
const JE_ID = '11111111-1111-4111-8111-111111111111'
|
||||
@@ -152,7 +152,7 @@ describe('POST /api/documents/[id]/link', () => {
|
||||
expect(mockSupabase.from).not.toHaveBeenCalledWith('transactions')
|
||||
})
|
||||
|
||||
it('tolerates a failing transaction pin — the JE link is the primary effect', async () => {
|
||||
it('tolerates a failing transaction pin: the JE link is the primary effect', async () => {
|
||||
enqueue({ data: { id: 'je-1' } }) // journal entry company check
|
||||
enqueue({ data: { id: 'doc-1', journal_entry_id: 'je-1', file_name: 'x.pdf' } }) // link update
|
||||
enqueue({ data: null, error: { message: 'rls denied' } }) // transaction pin fails
|
||||
@@ -199,7 +199,7 @@ describe('POST /api/documents/[id]/link', () => {
|
||||
})
|
||||
|
||||
it('rejects a journal entry outside the active company with DOC_LINK_ENTRY_NOT_FOUND', async () => {
|
||||
// The company-scoped lookup finds no row — same result whether the id is
|
||||
// The company-scoped lookup finds no row: same result whether the id is
|
||||
// bogus or belongs to another tenant. The document must never be updated.
|
||||
enqueue({ data: null }) // journal entry company check → no match
|
||||
const res = await POST(
|
||||
|
||||
@@ -8,13 +8,13 @@ import { LinkDocumentSchema } from '@/lib/api/schemas'
|
||||
ensureInitialized()
|
||||
|
||||
/**
|
||||
* POST /api/documents/[id]/link — link a document to a journal entry.
|
||||
* POST /api/documents/[id]/link: link a document to a journal entry.
|
||||
*
|
||||
* Body: { journal_entry_id: string, journal_entry_line_id?: string, inbox_item_id?: string, transaction_id?: string }
|
||||
*
|
||||
* When `inbox_item_id` is supplied (the "choose from inbox" flow), the inbox
|
||||
* item is stamped with the verifikat id after a successful link so it drops out
|
||||
* of the active inbox into "Bokförda" — reusing the inbox's own
|
||||
* of the active inbox into "Bokförda": reusing the inbox's own
|
||||
* created_journal_entry_id lifecycle. The document link is the legally-relevant
|
||||
* write and happens first; the inbox stamp is operational housekeeping, so a
|
||||
* stamp failure is logged but does not fail the request (the doc is correctly
|
||||
@@ -23,7 +23,7 @@ ensureInitialized()
|
||||
* When `transaction_id` is supplied (booking-flow callers that link underlag
|
||||
* right after booking a bank transaction), the doc is also pinned to the
|
||||
* transaction row (transactions.document_id) so the /transactions list shows
|
||||
* the underlag indicator. Only set when the tx has no pin yet — first linked
|
||||
* the underlag indicator. Only set when the tx has no pin yet: first linked
|
||||
* doc wins, and an existing räkenskapsinformation pin is never swapped (which
|
||||
* would trip the immutability trigger). Same best-effort posture as the inbox
|
||||
* stamp: a failure is logged but does not fail the request.
|
||||
@@ -64,13 +64,13 @@ export const POST = withRouteContext(
|
||||
.update({ created_journal_entry_id: body.journal_entry_id })
|
||||
.eq('id', body.inbox_item_id)
|
||||
.eq('company_id', companyId!)
|
||||
// Only stamp the inbox item that actually owns this document — a
|
||||
// Only stamp the inbox item that actually owns this document: a
|
||||
// mismatched pairing becomes a safe no-op rather than mis-marking an
|
||||
// unrelated item as consumed.
|
||||
.eq('document_id', id)
|
||||
.select('id')
|
||||
if (inboxError) {
|
||||
// Non-fatal — the verifikat ↔ underlag link already succeeded.
|
||||
// Non-fatal: the verifikat ↔ underlag link already succeeded.
|
||||
opLog.warn('inbox item stamp after link failed', {
|
||||
inboxItemId: body.inbox_item_id,
|
||||
reason: inboxError.message,
|
||||
@@ -96,7 +96,7 @@ export const POST = withRouteContext(
|
||||
// for multi-doc bookings and avoids the BFL immutability trigger.
|
||||
.is('document_id', null)
|
||||
if (pinError) {
|
||||
// Non-fatal — the verifikat ↔ underlag link already succeeded; the
|
||||
// Non-fatal: the verifikat ↔ underlag link already succeeded; the
|
||||
// pin is row-level UX on the /transactions list.
|
||||
opLog.warn('transaction pin after link failed', {
|
||||
transactionId: body.transaction_id,
|
||||
|
||||
@@ -72,7 +72,7 @@ export async function GET(
|
||||
/**
|
||||
* DELETE /api/documents/:id
|
||||
* Remove an uploaded document. Only permitted when the document is not yet
|
||||
* linked to a journal entry — once linked, it is räkenskapsinformation under
|
||||
* linked to a journal entry: once linked, it is räkenskapsinformation under
|
||||
* BFL 7 kap 2§ and must be retained for 7 years. For linked docs the caller
|
||||
* should use POST /api/documents/:id/versions to supersede via a new version.
|
||||
*/
|
||||
|
||||
@@ -7,15 +7,15 @@ import type { InvoiceExtractionResult } from '@/types'
|
||||
ensureInitialized()
|
||||
|
||||
/**
|
||||
* GET /api/documents/inbox-available — list invoice-inbox documents that are
|
||||
* GET /api/documents/inbox-available: list invoice-inbox documents that are
|
||||
* available to attach as underlag to a verifikat.
|
||||
*
|
||||
* Returns only *unconsumed* inbox items: those that have a file but have not
|
||||
* yet become a supplier invoice, a direct journal entry, or been matched to a
|
||||
* bank transaction — and whose underlying document is not already linked to a
|
||||
* bank transaction, and whose underlying document is not already linked to a
|
||||
* verifikation. This mirrors the inbox's own "Att göra" set, narrowed to items
|
||||
* with an attachable file. Re-pointing an already-linked document is forbidden
|
||||
* (BFL 7 kap — räkenskapsinformation is immutable), so those are excluded here
|
||||
* (BFL 7 kap: räkenskapsinformation is immutable), so those are excluded here
|
||||
* and the DB immutability trigger is the backstop.
|
||||
*
|
||||
* `invoice_inbox_items` is a core table, so a core route may read it directly
|
||||
@@ -43,7 +43,7 @@ interface DocRow {
|
||||
export const GET = withRouteContext('document.inbox_available', async (_request, ctx) => {
|
||||
const { supabase, companyId, log, requestId } = ctx
|
||||
|
||||
// 1) Eligible inbox items — company-scoped (defense in depth alongside RLS),
|
||||
// 1) Eligible inbox items: company-scoped (defense in depth alongside RLS),
|
||||
// unconsumed, with a document.
|
||||
const { data: inboxRows, error: inboxError } = await supabase
|
||||
.from('invoice_inbox_items')
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { DocumentUploadSource } from '@/types'
|
||||
ensureInitialized()
|
||||
|
||||
/**
|
||||
* POST /api/documents — upload a document to the WORM archive.
|
||||
* POST /api/documents: upload a document to the WORM archive.
|
||||
*
|
||||
* multipart/form-data:
|
||||
* file: the document file
|
||||
@@ -72,7 +72,7 @@ export const POST = withRouteContext(
|
||||
})
|
||||
}
|
||||
// Magic-byte validation rejections (validateDocumentMagicBytes) are a
|
||||
// client problem, not a storage failure — surface as 400 with an
|
||||
// client problem, not a storage failure: surface as 400 with an
|
||||
// accurate message instead of the misleading "kunde inte sparas".
|
||||
if (/kunde inte verifieras|matchar inte den angivna filtypen/i.test(message)) {
|
||||
opLog.warn('document upload rejected by content validation', { reason: message })
|
||||
@@ -91,7 +91,7 @@ export const POST = withRouteContext(
|
||||
)
|
||||
|
||||
/**
|
||||
* GET /api/documents — list documents.
|
||||
* GET /api/documents: list documents.
|
||||
*
|
||||
* Query params:
|
||||
* journal_entry_id: filter by JE
|
||||
|
||||
@@ -4,7 +4,7 @@ import { withCronContext } from '@/lib/api/with-cron-context'
|
||||
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
|
||||
/**
|
||||
* GET /api/documents/verify/cron — weekly Sunday 03:00 UTC.
|
||||
* GET /api/documents/verify/cron: weekly Sunday 03:00 UTC.
|
||||
* Spot-checks WORM archive integrity by recomputing SHA-256 for the next
|
||||
* batch of documents and writing INTEGRITY_FAILURE rows to the audit log
|
||||
* for any mismatches.
|
||||
|
||||
Reference in New Issue
Block a user