Files
accounted/app/api/v1/companies/[companyId]/suppliers/bulk-create/route.ts
T
Jakob Wennberg f266c386f3 chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers (#2150)
* chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers

Remove 33 dead files, ~270 unreferenced exports/types, 13 dead i18n
namespaces and 4 unused dependencies; fold byte-identical helper copies
into one canonical home each (lib/utils chunk/sleep/utcDateStamp,
lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format,
lib/pdf/number-text, lib/browser/panel-request, lib/api/v1/body +
v1ValidationError rolled out to ~55 v1 routes, booking-template schemas).

No behaviour change: v1 bodies and status codes, MCP tool schemas, DB
writes and money math are untouched. Naive ore rounding was deliberately
not swapped for roundOre; see DECISIONS.md 2026-09-02 for the full list
of things left alone on purpose.

tsc, lint, 19588 unit tests and check:guards green; antipattern baseline
ratcheted (naive-ore-round 622 -> 620, hand-rolled-invariant 115 -> 113).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(transactions): import RawTransaction from @/types after the ingest re-export removal

CI's type ratchet (check:types, full tsconfig) caught the one test file
that still imported the type through lib/transactions/ingest.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 11:51:16 +02:00

298 lines
10 KiB
TypeScript

/**
* POST /api/v1/companies/{companyId}/suppliers/bulk-create
*
* Bulk-create up to 50 suppliers in one call. Each item is validated and
* inserted independently: per-item failures don't roll back successes.
* Mirrors the shape of /customers/bulk-create exactly so agents only need
* to learn one bulk pattern.
*
* Response: `{ results: [{ ok, request_index, data?, error? }], summary }`.
* Idempotent over the whole batch. Dry-runnable.
*
* Unlike customers, suppliers do not run VIES validation on create: the
* vat_number is stored as supplied without an external check.
*/
import { z } from 'zod'
import type { SupabaseClient } from '@supabase/supabase-js'
import { ok } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponseFromCode, v1ValidationError } from '@/lib/api/v1/errors'
import { readV1JsonBody } from '@/lib/api/v1/body'
import { CreateSupplierSchema } from '@/lib/api/schemas'
import { eventBus } from '@/lib/events'
import type { Logger } from '@/lib/logger'
import type { Supplier } from '@/types'
const BulkCreateRequest = z.object({
suppliers: z.array(CreateSupplierSchema).min(1).max(50),
all_or_nothing: z.boolean().optional().default(false),
})
const BulkResultItem = z.object({
ok: z.boolean(),
request_index: z.number().int().nonnegative(),
data: z.unknown().optional(),
error: z
.object({
code: z.string(),
message: z.string(),
details: z.unknown().optional(),
})
.optional(),
})
const BulkCreateResponse = z.object({
results: z.array(BulkResultItem),
summary: z.object({
total: z.number().int(),
succeeded: z.number().int(),
failed: z.number().int(),
}),
})
const SUPPLIER_RESPONSE_COLUMNS =
'id, name, supplier_type, email, phone, address_line1, address_line2, postal_code, city, country, org_number, vat_number, bankgiro, plusgiro, bank_account, iban, bic, default_expense_account, default_payment_terms, default_currency, notes, archived_at, created_at, updated_at'
registerEndpoint({
operation: 'suppliers.bulk-create',
method: 'POST',
path: '/api/v1/companies/:companyId/suppliers/bulk-create',
summary: 'Create up to 50 suppliers in one call (partial-success).',
description:
'Bulk-create endpoint mirroring /customers/bulk-create. Each supplier is validated and inserted independently: per-item failures do not roll back items that succeeded. Returns a results array plus a summary. Idempotent over the whole batch. Dry-runnable.',
useWhen:
'You\'re importing a roster of suppliers from another AP system, or seeding a fresh company with its existing vendor list. Use dry-run first to validate the batch.',
doNotUseFor:
'Updating existing suppliers: PATCH /suppliers/{id} once per supplier. Bulk uploads of > 50 suppliers: split into pages of 50. Transactional all-or-nothing imports: passing all_or_nothing: true returns 501 NOT_IMPLEMENTED.',
pitfalls: [
'Idempotency-Key is mandatory and covers the WHOLE batch. A retried bulk-create returns the cached full response: it does not retry only the failed items.',
'Passing all_or_nothing: true returns 501 NOT_IMPLEMENTED. Today only partial-success batches exist; omit the flag or pass false.',
'org_number uniqueness is enforced at the DB level: items with duplicates fail individually with SUPPLIER_DUPLICATE_ORG_NUMBER.',
'No VIES validation runs per item; vat_number is stored as supplied. Validate externally if your workflow requires it.',
],
example: {
request: {
suppliers: [
{ name: 'Office Depot AB', supplier_type: 'swedish_business', org_number: '556677-8899' },
{ name: 'Cloud Hosting GmbH', supplier_type: 'eu_business', vat_number: 'DE123456789' },
],
},
response: {
data: {
results: [
{ ok: true, request_index: 0, data: { id: '0e9c…', name: 'Office Depot AB' } },
{ ok: true, request_index: 1, data: { id: '4d2a…', name: 'Cloud Hosting GmbH' } },
],
summary: { total: 2, succeeded: 2, failed: 0 },
},
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'suppliers:write',
risk: 'low',
idempotent: true,
reversible: true,
dryRunSupported: true,
request: { body: BulkCreateRequest },
response: { success: dataEnvelope(BulkCreateResponse) },
})
interface ResultItem {
ok: boolean
request_index: number
data?: unknown
error?: { code: string; message: string; details?: unknown }
}
async function createOneSupplier(
supabase: SupabaseClient,
companyId: string,
userId: string,
index: number,
input: z.infer<typeof CreateSupplierSchema>,
dryRun: boolean,
log: Logger,
): Promise<ResultItem> {
if (dryRun) {
return {
ok: true,
request_index: index,
data: {
preview: {
id: null,
name: input.name,
supplier_type: input.supplier_type,
email: input.email ?? null,
phone: input.phone ?? null,
address_line1: input.address_line1 ?? null,
address_line2: input.address_line2 ?? null,
postal_code: input.postal_code ?? null,
city: input.city ?? null,
country: input.country ?? 'SE',
org_number: input.org_number ?? null,
vat_number: input.vat_number ?? null,
bankgiro: input.bankgiro ?? null,
plusgiro: input.plusgiro ?? null,
bank_account: input.bank_account ?? null,
iban: input.iban ?? null,
bic: input.bic ?? null,
default_expense_account: input.default_expense_account ?? null,
default_payment_terms: input.default_payment_terms ?? 30,
default_currency: input.default_currency ?? 'SEK',
notes: input.notes ?? null,
archived_at: null,
created_at: null,
updated_at: null,
},
},
}
}
const { data, error } = await supabase
.from('suppliers')
.insert({
user_id: userId,
company_id: companyId,
name: input.name,
supplier_type: input.supplier_type,
email: input.email ?? null,
phone: input.phone ?? null,
address_line1: input.address_line1 ?? null,
address_line2: input.address_line2 ?? null,
postal_code: input.postal_code ?? null,
city: input.city ?? null,
country: input.country ?? 'SE',
org_number: input.org_number ?? null,
vat_number: input.vat_number ?? null,
bankgiro: input.bankgiro ?? null,
plusgiro: input.plusgiro ?? null,
bank_account: input.bank_account ?? null,
iban: input.iban ?? null,
bic: input.bic ?? null,
default_expense_account: input.default_expense_account ?? null,
default_payment_terms: input.default_payment_terms ?? 30,
default_currency: input.default_currency ?? 'SEK',
notes: input.notes ?? null,
})
.select(SUPPLIER_RESPONSE_COLUMNS)
.single()
if (error) {
if (error.code === '23505') {
return {
ok: false,
request_index: index,
error: {
code: 'SUPPLIER_DUPLICATE_ORG_NUMBER',
message: 'A supplier with this org_number already exists in this company.',
details: { field: 'org_number' },
},
}
}
log.error('bulk-create: supplier insert failed', error, {
request_index: index,
companyId,
pgCode: error.code,
})
return {
ok: false,
request_index: index,
error: {
code: 'SUPPLIER_CREATE_FAILED',
message: 'Supplier insert failed.',
details: { pg_code: error.code },
},
}
}
try {
await eventBus.emit({
type: 'supplier.created',
payload: {
supplier: {
...(data as Record<string, unknown>),
user_id: userId,
company_id: companyId,
} as unknown as Supplier,
companyId,
userId,
},
})
} catch (err) {
log.warn('bulk-create: supplier.created emit failed', err as Error, {
request_index: index,
})
}
return { ok: true, request_index: index, data }
}
export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
'suppliers.bulk-create',
async (request, ctx) => {
if (!z.string().uuid().safeParse(ctx.companyId).success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'companyId', message: 'companyId must be a UUID.' },
})
}
const rawBodyResult = await readV1JsonBody(request, ctx)
if (!rawBodyResult.ok) return rawBodyResult.response
const rawBody = rawBodyResult.body
const parsed = BulkCreateRequest.safeParse(rawBody)
if (!parsed.success) return v1ValidationError(ctx, parsed.error)
const body = parsed.data
if (body.all_or_nothing) {
return v1ErrorResponseFromCode('NOT_IMPLEMENTED', ctx.log, {
requestId: ctx.requestId,
details: {
field: 'all_or_nothing',
message:
'all_or_nothing: true is not yet implemented. Omit the flag (or pass false) to use partial-success semantics.',
},
})
}
// Sequential processing matches /customers/bulk-create and /invoices/bulk-create.
// The 50-item cap bounds worst-case latency.
const results: ResultItem[] = []
for (let i = 0; i < body.suppliers.length; i++) {
const item = await createOneSupplier(
ctx.supabase,
ctx.companyId!,
ctx.userId,
i,
body.suppliers[i],
ctx.dryRun,
ctx.log,
)
results.push(item)
}
const summary = {
total: results.length,
succeeded: results.filter((r) => r.ok).length,
failed: results.filter((r) => !r.ok).length,
}
ctx.log.info('suppliers.bulk-create completed', {
companyId: ctx.companyId,
userId: ctx.userId,
...summary,
dryRun: ctx.dryRun,
})
if (ctx.dryRun) {
return dryRunPreview({ results, summary }, { requestId: ctx.requestId, log: ctx.log })
}
return ok({ results, summary }, { requestId: ctx.requestId })
},
{ requireIdempotencyKey: true },
)