Bug/gh issues fiz (#1103)
* refactor: optimize page loading and data fetching * fix: resolve recurring production runtime errors * feat: add MCP company and customer updates * fix: handle year-end tax adjustments * feat: harden annual report compliance * fix: expand invoice logo and font support * fix: sanitize API route error responses * fix: sanitize user-facing error messages * feat: persist onboarding and tax assessment notices * fix: reduce cloud backup audit churn * feat: refine invoice editor layout * fix: show saved tax adjustments in INK2 * fix: complete annual report API mappings * docs: record operational safeguards and decisions * fix: harden annual report review findings * fix: adjust column span for description based on VAT registration * New css class name
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
|
||||
/**
|
||||
* DELETE /api/settings/api-keys/[id]: Revoke an API key (soft delete)
|
||||
@@ -18,7 +19,7 @@ export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
.is('revoked_at', null)
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import type { ApiKeyMode, ApiKeyScope } from '@/lib/auth/api-keys'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
|
||||
/** GET /api/settings/api-keys: list the company's API keys (key value never returned). */
|
||||
export const GET = withRouteContext(
|
||||
@@ -123,7 +124,7 @@ export const POST = withRouteContext(
|
||||
log.error('api_key insert failed', error)
|
||||
return errorResponseFromCode('API_KEY_CREATE_FAILED', log, {
|
||||
requestId,
|
||||
details: { reason: error.message },
|
||||
details: { reason: getUserErrorMessage(error) },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { z } from 'zod'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
|
||||
const BookingTemplateLineSchema = z.object({
|
||||
account: z.string().regex(/^\d{4}$/),
|
||||
@@ -46,7 +47,7 @@ export const PUT = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
if (error) return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
|
||||
if (!data) return NextResponse.json({ error: 'Template not found' }, { status: 404 })
|
||||
|
||||
return NextResponse.json({ data })
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
|
||||
/**
|
||||
* POST /api/settings/booking-templates/[id]/touch
|
||||
@@ -28,7 +29,7 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
)
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: { success: true } })
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
|
||||
/**
|
||||
* GET /api/settings/booking-templates/export
|
||||
@@ -20,7 +21,7 @@ export const GET = withRouteContext(
|
||||
.order('category')
|
||||
.order('name')
|
||||
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
if (error) return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
|
||||
|
||||
return new NextResponse(JSON.stringify({ version: 1, templates: data }, null, 2), {
|
||||
headers: {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { z } from 'zod'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
|
||||
const ImportLineSchema = z.object({
|
||||
account: z.string().regex(/^\d{4}$/),
|
||||
@@ -70,7 +71,7 @@ export const POST = withRouteContext(
|
||||
.insert(rows)
|
||||
.select()
|
||||
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
if (error) return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
|
||||
|
||||
return NextResponse.json({ data, imported: data?.length ?? 0 }, { status: 201 })
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { z } from 'zod'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
|
||||
// The GET scope below builds a PostgREST .or() filter by string interpolation.
|
||||
// Guard every interpolated id against a strict UUID shape so a tainted value
|
||||
@@ -87,7 +88,7 @@ export const GET = withRouteContext(
|
||||
])
|
||||
|
||||
if (templatesRes.error) {
|
||||
return NextResponse.json({ error: templatesRes.error.message }, { status: 500 })
|
||||
return NextResponse.json({ error: getUserErrorMessage(templatesRes.error) }, { status: 500 })
|
||||
}
|
||||
// usage lookup failing is non-fatal: we just fall back to default ordering
|
||||
const usageByTemplate = new Map<string, string>()
|
||||
@@ -155,7 +156,7 @@ export const POST = withRouteContext(
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
if (error) return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
|
||||
|
||||
return NextResponse.json({ data }, { status: 201 })
|
||||
},
|
||||
@@ -186,7 +187,7 @@ export const DELETE = withRouteContext(
|
||||
.update({ is_active: false })
|
||||
.eq('id', id)
|
||||
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
if (error) return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
|
||||
|
||||
return NextResponse.json({ data: { success: true } })
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
|
||||
export const GET = withRouteContext(
|
||||
'counterparty_template.list',
|
||||
@@ -11,7 +12,7 @@ export const GET = withRouteContext(
|
||||
.eq('is_active', true)
|
||||
.order('occurrence_count', { ascending: false })
|
||||
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
if (error) return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
|
||||
|
||||
return NextResponse.json({ data })
|
||||
},
|
||||
@@ -35,7 +36,7 @@ export const DELETE = withRouteContext(
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
if (error) return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
|
||||
|
||||
return NextResponse.json({ data: { success: true } })
|
||||
},
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createQueuedMockSupabase, parseJsonResponse } from '@/tests/helpers'
|
||||
|
||||
const { supabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
|
||||
const requireAuthMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
const requireWriteMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
|
||||
}))
|
||||
|
||||
const storageBucket = {
|
||||
upload: vi.fn().mockResolvedValue({ data: {}, error: null }),
|
||||
list: vi.fn().mockResolvedValue({ data: [], error: null }),
|
||||
remove: vi.fn().mockResolvedValue({ data: [], error: null }),
|
||||
}
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: vi.fn(),
|
||||
createServiceClient: () => ({
|
||||
storage: { from: vi.fn().mockReturnValue(storageBucket) },
|
||||
}),
|
||||
}))
|
||||
|
||||
import { DELETE, POST } from '../route'
|
||||
|
||||
function makeFontRequest(
|
||||
size = 8,
|
||||
signature: number[] = [0x00, 0x01, 0x00, 0x00],
|
||||
name = 'brand.ttf',
|
||||
): Request {
|
||||
const bytes = new Uint8Array(size)
|
||||
bytes.set(signature.slice(0, size))
|
||||
const formData = new FormData()
|
||||
formData.append('file', new File([bytes], name, { type: 'font/ttf' }))
|
||||
return new Request('http://localhost/api/settings/invoice-font', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
}
|
||||
|
||||
describe('POST /api/settings/invoice-font', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
|
||||
requireWriteMock.mockResolvedValue({ ok: true })
|
||||
})
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: null,
|
||||
supabase,
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
|
||||
const response = await POST(makeFontRequest(), { params: Promise.resolve({}) })
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 403 for a viewer without write permission', async () => {
|
||||
requireWriteMock.mockResolvedValue({
|
||||
ok: false,
|
||||
response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }),
|
||||
})
|
||||
|
||||
const response = await POST(makeFontRequest(), { params: Promise.resolve({}) })
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
})
|
||||
|
||||
it('returns 400 for a file with an invalid font signature', async () => {
|
||||
const response = await POST(
|
||||
makeFontRequest(8, [0x25, 0x50, 0x44, 0x46]),
|
||||
{ params: Promise.resolve({}) },
|
||||
)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 400 when the font exceeds 5 MB', async () => {
|
||||
const response = await POST(
|
||||
makeFontRequest(5 * 1024 * 1024 + 1),
|
||||
{ params: Promise.resolve({}) },
|
||||
)
|
||||
const { body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(body.error).toContain('5 MB')
|
||||
})
|
||||
|
||||
it('returns 404 when company settings do not exist', async () => {
|
||||
enqueue({ error: { code: 'PGRST116', message: 'No rows returned' } })
|
||||
|
||||
const response = await POST(makeFontRequest(), { params: Promise.resolve({}) })
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
expect(storageBucket.remove).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uploads a valid TTF and selects it for invoice PDFs', async () => {
|
||||
enqueue({ error: null })
|
||||
|
||||
const response = await POST(makeFontRequest(), { params: Promise.resolve({}) })
|
||||
const { body } = await parseJsonResponse<{
|
||||
data: { invoice_font_family: string; invoice_custom_font_name: string }
|
||||
}>(response)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.data).toMatchObject({
|
||||
invoice_font_family: 'Custom',
|
||||
invoice_custom_font_name: 'brand.ttf',
|
||||
})
|
||||
expect(storageBucket.upload).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/^company-1\/invoice-font-\d+\.ttf$/),
|
||||
expect.any(Buffer),
|
||||
expect.objectContaining({ contentType: 'font/ttf' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('uploads a valid WOFF with its normalized content type', async () => {
|
||||
enqueue({ error: null })
|
||||
|
||||
const response = await POST(
|
||||
makeFontRequest(8, [0x77, 0x4f, 0x46, 0x46], 'brand.woff'),
|
||||
{ params: Promise.resolve({}) },
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(storageBucket.upload).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/^company-1\/invoice-font-\d+\.woff$/),
|
||||
expect.any(Buffer),
|
||||
expect.objectContaining({ contentType: 'font/woff' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE /api/settings/invoice-font', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
|
||||
requireWriteMock.mockResolvedValue({ ok: true })
|
||||
})
|
||||
|
||||
it('removes the custom font and restores Helvetica', async () => {
|
||||
enqueue({ error: null })
|
||||
|
||||
const response = await DELETE(
|
||||
new Request('http://localhost/api/settings/invoice-font', { method: 'DELETE' }),
|
||||
{ params: Promise.resolve({}) },
|
||||
)
|
||||
const { body } = await parseJsonResponse<{
|
||||
data: { invoice_font_family: string; invoice_custom_font_path: null }
|
||||
}>(response)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.data).toEqual({
|
||||
invoice_font_family: 'Helvetica',
|
||||
invoice_custom_font_path: null,
|
||||
invoice_custom_font_name: null,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,151 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { createServiceClient } from '@/lib/supabase/server'
|
||||
import {
|
||||
INVOICE_FONT_UPLOAD_MAX_BYTES,
|
||||
INVOICE_FONT_UPLOAD_MAX_MB,
|
||||
} from '@/lib/invoices/branding-constants'
|
||||
import {
|
||||
detectInvoiceFontFileFormat,
|
||||
getInvoiceFontContentType,
|
||||
} from '@/lib/invoices/font-files'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
|
||||
const ALLOWED_EXTENSIONS = new Set(['ttf', 'woff'])
|
||||
|
||||
function safeDisplayName(fileName: string, extension: string): string {
|
||||
const baseName = fileName.split(/[\\/]/).pop() ?? `invoice-font.${extension}`
|
||||
const cleaned = baseName.replace(/[\u0000-\u001f\u007f]/g, '').trim()
|
||||
return (cleaned || `invoice-font.${extension}`).slice(0, 200)
|
||||
}
|
||||
|
||||
export const POST = withRouteContext(
|
||||
'settings.invoice-font.upload',
|
||||
async (request, { supabase, companyId }) => {
|
||||
const formData = await request.formData()
|
||||
const file = formData.get('file')
|
||||
if (!(file instanceof File)) {
|
||||
return NextResponse.json({ error: 'Ingen typsnittsfil angiven.' }, { status: 400 })
|
||||
}
|
||||
|
||||
const extension = file.name.split('.').pop()?.toLowerCase() ?? ''
|
||||
if (!ALLOWED_EXTENSIONS.has(extension)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Otillåten filtyp. Tillåtna format är TTF och WOFF.' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
if (file.size > INVOICE_FONT_UPLOAD_MAX_BYTES) {
|
||||
return NextResponse.json(
|
||||
{ error: `Filen är för stor (max ${INVOICE_FONT_UPLOAD_MAX_MB} MB).` },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const bytes = Buffer.from(await file.arrayBuffer())
|
||||
const format = detectInvoiceFontFileFormat(bytes)
|
||||
if (!format || format !== extension) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Filen är inte ett giltigt TTF- eller WOFF-typsnitt.' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const fileName = `invoice-font-${Date.now()}.${format}`
|
||||
const storagePath = `${companyId}/${fileName}`
|
||||
const serviceClient = createServiceClient()
|
||||
const bucket = serviceClient.storage.from('invoice-fonts')
|
||||
|
||||
const { error: uploadError } = await bucket.upload(storagePath, bytes, {
|
||||
contentType: getInvoiceFontContentType(format),
|
||||
upsert: false,
|
||||
})
|
||||
if (uploadError) {
|
||||
return NextResponse.json(
|
||||
{ error: `Uppladdning misslyckades: ${getUserErrorMessage(uploadError)}` },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
|
||||
const displayName = safeDisplayName(file.name, format)
|
||||
const { error: updateError } = await supabase
|
||||
.from('company_settings')
|
||||
.update({
|
||||
invoice_font_family: 'Custom',
|
||||
invoice_custom_font_path: storagePath,
|
||||
invoice_custom_font_name: displayName,
|
||||
})
|
||||
.eq('company_id', companyId)
|
||||
.select('company_id')
|
||||
.single()
|
||||
|
||||
if (updateError) {
|
||||
await bucket.remove([storagePath])
|
||||
if (updateError.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Inställningarna hittades inte.' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: getUserErrorMessage(updateError) },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
|
||||
const { data: existing } = await bucket.list(companyId)
|
||||
const obsoletePaths = (existing ?? [])
|
||||
.filter((stored) => stored.name !== fileName)
|
||||
.map((stored) => `${companyId}/${stored.name}`)
|
||||
if (obsoletePaths.length > 0) await bucket.remove(obsoletePaths)
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
invoice_font_family: 'Custom',
|
||||
invoice_custom_font_path: storagePath,
|
||||
invoice_custom_font_name: displayName,
|
||||
},
|
||||
})
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
export const DELETE = withRouteContext(
|
||||
'settings.invoice-font.delete',
|
||||
async (_request, { supabase, companyId }) => {
|
||||
const { error: updateError } = await supabase
|
||||
.from('company_settings')
|
||||
.update({
|
||||
invoice_font_family: 'Helvetica',
|
||||
invoice_custom_font_path: null,
|
||||
invoice_custom_font_name: null,
|
||||
})
|
||||
.eq('company_id', companyId)
|
||||
.select('company_id')
|
||||
.single()
|
||||
|
||||
if (updateError) {
|
||||
if (updateError.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Inställningarna hittades inte.' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: getUserErrorMessage(updateError) },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
|
||||
const serviceClient = createServiceClient()
|
||||
const bucket = serviceClient.storage.from('invoice-fonts')
|
||||
const { data: existing } = await bucket.list(companyId)
|
||||
if (existing && existing.length > 0) {
|
||||
await bucket.remove(existing.map((stored) => `${companyId}/${stored.name}`))
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
invoice_font_family: 'Helvetica',
|
||||
invoice_custom_font_path: null,
|
||||
invoice_custom_font_name: null,
|
||||
},
|
||||
})
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
@@ -34,9 +34,13 @@ vi.mock('@/lib/supabase/server', () => ({
|
||||
|
||||
import { POST } from '../route'
|
||||
|
||||
function makeFormRequest(): Request {
|
||||
function makeFormRequest(
|
||||
size = 3,
|
||||
type = 'image/png',
|
||||
name = 'logo.png',
|
||||
): Request {
|
||||
const fd = new FormData()
|
||||
fd.append('file', new File([new Uint8Array([1, 2, 3])], 'logo.png', { type: 'image/png' }))
|
||||
fd.append('file', new File([new Uint8Array(size)], name, { type }))
|
||||
return new Request('http://localhost/api/settings/logo', { method: 'POST', body: fd })
|
||||
}
|
||||
|
||||
@@ -73,6 +77,39 @@ describe('POST /api/settings/logo', () => {
|
||||
expect(status).toBe(403)
|
||||
})
|
||||
|
||||
it('returns 400 for an unsupported file type', async () => {
|
||||
const response = await POST(
|
||||
makeFormRequest(3, 'application/pdf', 'logo.pdf'),
|
||||
{ params: Promise.resolve({}) },
|
||||
)
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 400 when the logo exceeds 10 MB', async () => {
|
||||
const response = await POST(
|
||||
makeFormRequest(10 * 1024 * 1024 + 1),
|
||||
{ params: Promise.resolve({}) },
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error).toContain('10 MB')
|
||||
})
|
||||
|
||||
it('accepts a logo larger than the previous 2 MB limit', async () => {
|
||||
enqueue({ error: null }) // company_settings update
|
||||
|
||||
const response = await POST(
|
||||
makeFormRequest(2 * 1024 * 1024 + 1),
|
||||
{ params: Promise.resolve({}) },
|
||||
)
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
})
|
||||
|
||||
it('uploads the logo and returns its public url on the happy path', async () => {
|
||||
enqueue({ error: null }) // company_settings update
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createServiceClient } from '@/lib/supabase/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { LOGO_UPLOAD_MAX_BYTES, LOGO_UPLOAD_MAX_MB } from '@/lib/invoices/branding-constants'
|
||||
|
||||
const MAX_SIZE = 2 * 1024 * 1024 // 2MB
|
||||
const ALLOWED_TYPES = ['image/png', 'image/jpeg', 'image/svg+xml', 'image/webp']
|
||||
|
||||
export const POST = withRouteContext(
|
||||
@@ -19,8 +20,8 @@ export const POST = withRouteContext(
|
||||
return NextResponse.json({ error: 'Otillåten filtyp. Tillåtna: PNG, JPG, SVG, WebP.' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (file.size > MAX_SIZE) {
|
||||
return NextResponse.json({ error: 'Filen är för stor (max 2 MB).' }, { status: 400 })
|
||||
if (file.size > LOGO_UPLOAD_MAX_BYTES) {
|
||||
return NextResponse.json({ error: `Filen är för stor (max ${LOGO_UPLOAD_MAX_MB} MB).` }, { status: 400 })
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer())
|
||||
@@ -53,7 +54,7 @@ export const POST = withRouteContext(
|
||||
})
|
||||
|
||||
if (uploadError) {
|
||||
return NextResponse.json({ error: `Uppladdning misslyckades: ${uploadError.message}` }, { status: 500 })
|
||||
return NextResponse.json({ error: `Uppladdning misslyckades: ${getUserErrorMessage(uploadError)}` }, { status: 500 })
|
||||
}
|
||||
|
||||
const { data: urlData } = serviceClient.storage
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { appendProcessingHistory } from '@/lib/processing-history/append'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
|
||||
/**
|
||||
* DELETE /api/settings/oauth-clients/[id]: revoke a redirect URI
|
||||
@@ -26,7 +27,7 @@ export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
.select('id, redirect_uri, client_name')
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
|
||||
}
|
||||
|
||||
if (!rows || rows.length === 0) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { z } from 'zod'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
|
||||
/**
|
||||
* GET /api/settings/oauth-clients: list the current user's registered
|
||||
@@ -38,7 +39,7 @@ export const GET = withRouteContext(
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
@@ -80,7 +81,7 @@ export const POST = withRouteContext(
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from '@/lib/tax/deadline-generator'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { UpdateSettingsSchema } from '@/lib/api/schemas'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
|
||||
export const GET = withRouteContext(
|
||||
'settings.get',
|
||||
@@ -20,7 +21,7 @@ export const GET = withRouteContext(
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
|
||||
}
|
||||
|
||||
// Fall back to companies.entity_type if company_settings.entity_type is null
|
||||
@@ -99,7 +100,7 @@ export const PUT = withRouteContext(
|
||||
// Fail closed: a failed check must not let the basis change through
|
||||
// and orphan open vacation-ledger rows.
|
||||
if (openRowsError) {
|
||||
return NextResponse.json({ error: openRowsError.message }, { status: 500 })
|
||||
return NextResponse.json({ error: getUserErrorMessage(openRowsError) }, { status: 500 })
|
||||
}
|
||||
if ((openRows ?? 0) > 0) {
|
||||
return NextResponse.json(
|
||||
@@ -182,7 +183,7 @@ export const PUT = withRouteContext(
|
||||
if (error.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Inställningarna hittades inte.' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
|
||||
}
|
||||
|
||||
// Regenerate when the save touches tax-relevant fields: the statutory
|
||||
|
||||
Reference in New Issue
Block a user