Fix/attributes config (#926)
* fix(git): pin LF on generated extension registry and vitest snapshots setup:extensions and vitest write these files with LF; with core.autocrlf=true git expects CRLF and flags them as phantom modifications on every dev/build run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(security): enforce MFA on mcp-oauth consent and gate viewer storno route mcp-oauth/authorize renders an HTML consent page and issues 303 redirects that withRouteContext cannot express, so it kept raw getUser() and thereby skipped the AAL2 gate: a password-only (AAL1) session could approve consent that mints a long-lived, MFA-bypassing API key. Add a route-local requireAal2() step-up on GET and POST; AAL1 sessions redirect to /mfa/verify, BankID users are exempt. Separately, POST /api/reports/vat-declaration/rc-basis-gaps/fix calls correctEntry() (storno of a posted entry) but lacked requireWrite, so viewer-role members could trigger it. Add { requireWrite: true }. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(api): route transactions endpoints through withRouteContext Migrate the transactions routes off hand-rolled supabase.auth.getUser() onto the MFA-enforcing withRouteContext wrapper; add requireWrite on mutating handlers (book, uncategorize, attach-document, ignore, batch-match, create-from-document). Behavior and response shapes preserved; tests updated to the wrapper mock pattern. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(api): route SIE import and bank reconciliation through withRouteContext Migrate import/sie and reconciliation/bank routes onto the MFA-enforcing wrapper; requireWrite on mutations (import execute, create-accounts, mappings write verbs, link/unlink/run/mark-opening-balance). Reads (status, unmatched-entries) stay ungated. Response shapes preserved; tests added/updated to the wrapper mock pattern. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(api): route salary endpoints through withRouteContext Migrate salary employees and runs routes (plus ku, payroll-config, tax-tables) onto the MFA-enforcing wrapper; requireWrite on mutations. Personnummer masking/encryption untouched; file downloads (AGI XML, payslip PDF, payment files) keep their headers. Two payment-file GETs retain requireWrite because they stamp *_file_generated_at and previously gated viewers. Tests added/updated to the wrapper mock pattern. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(api): route report endpoints through withRouteContext Migrate the read-only report routes (trial balance, balansrapport, resultatrapport, income statement, ledgers, KPI, VAT declaration, salary journal, monthly breakdown, journal register, continuity check, full archive, etc.) onto the MFA-enforcing wrapper. All read-only, no requireWrite. JSON/XLSX/PDF/ZIP response bodies and headers preserved byte-for-byte; tests updated to the wrapper mock pattern. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(api): route invoices, skatteverket, agent and extension endpoints through withRouteContext Migrate invoices, supplier-invoices, skatteverket tax-payments, and dynamic extension routes onto the MFA-enforcing wrapper with requireWrite on mutations. The two NDJSON streaming agent routes (invoke, onboarding/stream) use requireAuth() directly (the wrapper can't wrap a streaming response) so MFA is still enforced. skatteverket payment-file GET keeps requireWrite (stamps a generated-at field). Response shapes and file headers preserved; tests added/updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(api): route documents, events, team and account endpoints through withRouteContext Migrate documents, events, kpi/preferences, vat/validate, support/contact onto the MFA-enforcing wrapper with requireWrite on mutations. account/password, team/accept and team/members use requireAuth() directly (user-level or pre-membership flows with no active company context) so MFA is still enforced. events keeps its dual API-key-or-session auth. Document retention guard untouched; tests added/updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(api): route settings and pending-operations endpoints through withRouteContext Migrate settings (api-keys, oauth-clients, booking-templates, counterparty-templates, logo, company settings) and pending-operations (commit, bulk-commit, reject, edit-before-approve) onto the MFA-enforcing wrapper with requireWrite on mutations. Credential-guarding routes keep their per-user ownership filters. Response shapes preserved; tests added/updated to the wrapper mock pattern. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(guards): ratchet raw-route-auth baseline 119->1 after A1 migration Lock in the withRouteContext migration so the count cannot regress. The single remaining entry, mcp-oauth/authorize, is a documented exception (HTML consent + redirects, MFA enforced via route-local step-up). Record the campaign and requireWrite decisions in DECISIONS.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(vat): add eSKD momsdeklaration file export for "Deklarera via fil" Generate the Skatteverket eSKDUpload v6.0 XML file so users can file VAT by upload instead of typing every ruta into the form. Extract buildFiledAmounts() as the shared whole-krona source of truth (öre truncated per SFL 22 kap 1 §) so the XML file and the manual-filing PDF can never disagree. Adds the /eskd API route, an XML option in the report export menu, and the upload button on the manual-filing card. Strings in sv + en. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(vat): add 'vat_settlement' source type and update related components * fix(booking): adjust search input layout and enable autofocus * fix(vat): support 12-digit org numbers and adjust emission order for eSKD file * fix(migration): add 'vat_settlement' to journal_entries.source_type CHECK --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
63e05c4eec
commit
abe9ac9d8c
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createMockRequest, parseJsonResponse, createQueuedMockSupabase } from '@/tests/helpers'
|
||||
|
||||
const { supabase, enqueue, enqueueMany, 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),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/tax/deadline-generator', () => ({
|
||||
didTaxFieldsChange: vi.fn().mockReturnValue(false),
|
||||
regenerateTaxDeadlinesForUser: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
import { PUT } from '../route'
|
||||
|
||||
describe('PUT /api/settings', () => {
|
||||
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 request = createMockRequest('/api/settings', {
|
||||
method: 'PUT',
|
||||
body: { company_name: 'New Name' },
|
||||
})
|
||||
const response = await PUT(request, { params: Promise.resolve({}) })
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(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 request = createMockRequest('/api/settings', {
|
||||
method: 'PUT',
|
||||
body: { company_name: 'New Name' },
|
||||
})
|
||||
const response = await PUT(request, { params: Promise.resolve({}) })
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(403)
|
||||
})
|
||||
|
||||
it('updates the settings on the happy path', async () => {
|
||||
enqueueMany([
|
||||
{ data: { entity_type: 'enskild_firma', onboarding_complete: false } }, // fetch oldSettings
|
||||
{ data: { id: 's1', company_name: 'New Name' } }, // update ... returning
|
||||
])
|
||||
|
||||
const request = createMockRequest('/api/settings', {
|
||||
method: 'PUT',
|
||||
body: { company_name: 'New Name' },
|
||||
})
|
||||
const response = await PUT(request, { params: Promise.resolve({}) })
|
||||
const { status, body } = await parseJsonResponse<{ data: { company_name: string } }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.company_name).toBe('New Name')
|
||||
})
|
||||
})
|
||||
@@ -1,38 +1,27 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
|
||||
/**
|
||||
* DELETE /api/settings/api-keys/[id]: Revoke an API key (soft delete)
|
||||
*/
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
'api_key.revoke',
|
||||
async (_request, ctx, { params }) => {
|
||||
const { id } = await params
|
||||
const { supabase, companyId } = ctx
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const { error } = await supabase
|
||||
.from('api_keys')
|
||||
.update({ revoked_at: new Date().toISOString() })
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.is('revoked_at', null)
|
||||
|
||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
||||
if (!writeCheck.ok) return writeCheck.response
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
|
||||
const { error } = await supabase
|
||||
.from('api_keys')
|
||||
.update({ revoked_at: new Date().toISOString() })
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.is('revoked_at', null)
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
return NextResponse.json({ success: true })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { z } from 'zod'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
|
||||
@@ -29,32 +28,28 @@ const UpdateBookingTemplateSchema = z.object({
|
||||
* PUT /api/settings/booking-templates/[id]
|
||||
* Update a non-system template.
|
||||
*/
|
||||
export async function PUT(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
export const PUT = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
'booking_template.update',
|
||||
async (request, ctx, { params }) => {
|
||||
const { id } = await params
|
||||
const { supabase } = ctx
|
||||
|
||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
||||
if (!writeCheck.ok) return writeCheck.response
|
||||
const result = await validateBody(request, UpdateBookingTemplateSchema)
|
||||
if (!result.success) return result.response
|
||||
|
||||
const result = await validateBody(request, UpdateBookingTemplateSchema)
|
||||
if (!result.success) return result.response
|
||||
// RLS prevents updating system templates
|
||||
const { data, error } = await supabase
|
||||
.from('booking_template_library')
|
||||
.update(result.data)
|
||||
.eq('id', id)
|
||||
.eq('is_system', false)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
// RLS prevents updating system templates
|
||||
const { data, error } = await supabase
|
||||
.from('booking_template_library')
|
||||
.update(result.data)
|
||||
.eq('id', id)
|
||||
.eq('is_system', false)
|
||||
.select()
|
||||
.single()
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
if (!data) return NextResponse.json({ error: 'Template not found' }, { status: 404 })
|
||||
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
if (!data) return NextResponse.json({ error: 'Template not found' }, { status: 404 })
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
return NextResponse.json({ data })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
|
||||
/**
|
||||
* POST /api/settings/booking-templates/[id]/touch
|
||||
@@ -11,31 +10,28 @@ import { requireCompanyId } from '@/lib/company/context'
|
||||
*
|
||||
* Fire-and-forget from the client: errors are non-fatal.
|
||||
*/
|
||||
export async function POST(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
'booking_template.touch',
|
||||
async (_request, ctx, { params }) => {
|
||||
const { id } = await params
|
||||
const { supabase, companyId } = ctx
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
const { error } = await supabase
|
||||
.from('booking_template_usage')
|
||||
.upsert(
|
||||
{
|
||||
template_id: id,
|
||||
company_id: companyId,
|
||||
last_used_at: new Date().toISOString(),
|
||||
},
|
||||
{ onConflict: 'template_id,company_id' },
|
||||
)
|
||||
|
||||
const { error } = await supabase
|
||||
.from('booking_template_usage')
|
||||
.upsert(
|
||||
{
|
||||
template_id: id,
|
||||
company_id: companyId,
|
||||
last_used_at: new Date().toISOString(),
|
||||
},
|
||||
{ onConflict: 'template_id,company_id' },
|
||||
)
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: { success: true } })
|
||||
}
|
||||
return NextResponse.json({ data: { success: true } })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
@@ -1,34 +1,32 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
|
||||
/**
|
||||
* GET /api/settings/booking-templates/export
|
||||
* Export company + team templates as JSON (excludes system templates).
|
||||
* Useful for sharing templates between unrelated companies.
|
||||
*/
|
||||
export async function GET() {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
export const GET = withRouteContext(
|
||||
'booking_template.export',
|
||||
async (_request, ctx) => {
|
||||
const { supabase, companyId } = ctx
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
const { data, error } = await supabase
|
||||
.from('booking_template_library')
|
||||
.select('name, description, category, entity_type, lines')
|
||||
.eq('company_id', companyId)
|
||||
.eq('is_active', true)
|
||||
.eq('is_system', false)
|
||||
.order('category')
|
||||
.order('name')
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('booking_template_library')
|
||||
.select('name, description, category, entity_type, lines')
|
||||
.eq('company_id', companyId)
|
||||
.eq('is_active', true)
|
||||
.eq('is_system', false)
|
||||
.order('category')
|
||||
.order('name')
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
|
||||
return new NextResponse(JSON.stringify({ version: 1, templates: data }, null, 2), {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Disposition': 'attachment; filename="bokforingsmallar.json"',
|
||||
},
|
||||
})
|
||||
}
|
||||
return new NextResponse(JSON.stringify({ version: 1, templates: data }, null, 2), {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Disposition': 'attachment; filename="bokforingsmallar.json"',
|
||||
},
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { z } from 'zod'
|
||||
|
||||
const ImportLineSchema = z.object({
|
||||
@@ -35,49 +33,46 @@ const ImportPayloadSchema = z.object({
|
||||
* Import templates from JSON (exported from another company).
|
||||
* Creates company-scoped templates for the active company.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
export const POST = withRouteContext(
|
||||
'booking_template.import',
|
||||
async (request, ctx) => {
|
||||
const { supabase, user, companyId } = ctx
|
||||
|
||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
||||
if (!writeCheck.ok) return writeCheck.response
|
||||
let body: unknown
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
|
||||
}
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
const parsed = ImportPayloadSchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid import format', details: parsed.error.issues },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
let body: unknown
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
|
||||
}
|
||||
const rows = parsed.data.templates.map((t) => ({
|
||||
company_id: companyId,
|
||||
team_id: null,
|
||||
created_by: user.id,
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
category: t.category,
|
||||
entity_type: t.entity_type,
|
||||
lines: t.lines,
|
||||
is_system: false,
|
||||
}))
|
||||
|
||||
const parsed = ImportPayloadSchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid import format', details: parsed.error.issues },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
const { data, error } = await supabase
|
||||
.from('booking_template_library')
|
||||
.insert(rows)
|
||||
.select()
|
||||
|
||||
const rows = parsed.data.templates.map((t) => ({
|
||||
company_id: companyId,
|
||||
team_id: null,
|
||||
created_by: user.id,
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
category: t.category,
|
||||
entity_type: t.entity_type,
|
||||
lines: t.lines,
|
||||
is_system: false,
|
||||
}))
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('booking_template_library')
|
||||
.insert(rows)
|
||||
.select()
|
||||
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
|
||||
return NextResponse.json({ data, imported: data?.length ?? 0 }, { status: 201 })
|
||||
}
|
||||
return NextResponse.json({ data, imported: data?.length ?? 0 }, { status: 201 })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { z } from 'zod'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
|
||||
@@ -42,158 +40,155 @@ const CreateBookingTemplateSchema = z.object({
|
||||
* and name for never-used templates. Usage is tracked in
|
||||
* booking_template_usage via POST /[id]/touch.
|
||||
*/
|
||||
export async function GET() {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
export const GET = withRouteContext(
|
||||
'booking_template.list',
|
||||
async (_request, ctx) => {
|
||||
const { supabase, companyId } = ctx
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
// Resolve the team this company belongs to (if any) so team-shared
|
||||
// templates stay visible while this company is selected.
|
||||
const { data: company } = await supabase
|
||||
.from('companies')
|
||||
.select('team_id')
|
||||
.eq('id', companyId)
|
||||
.maybeSingle()
|
||||
const teamId = company?.team_id ?? null
|
||||
|
||||
// Resolve the team this company belongs to (if any) so team-shared
|
||||
// templates stay visible while this company is selected.
|
||||
const { data: company } = await supabase
|
||||
.from('companies')
|
||||
.select('team_id')
|
||||
.eq('id', companyId)
|
||||
.maybeSingle()
|
||||
const teamId = company?.team_id ?? null
|
||||
|
||||
// requireCompanyId only ever returns a real membership UUID, but assert the
|
||||
// shape before interpolating it into the .or() filter.
|
||||
if (!UUID_RE.test(companyId)) {
|
||||
return NextResponse.json({ error: 'Invalid company context' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Scope to the SELECTED company: system + this company + this company's team.
|
||||
// RLS (btl_select) is membership-wide: it returns templates from *every*
|
||||
// company the user belongs to: so the active-company narrowing must happen
|
||||
// here in the API layer (mirrors counterparty-templates). Without this, a
|
||||
// user who owns several companies sees all of their templates merged.
|
||||
// Only interpolate a team id that passes the strict UUID guard.
|
||||
const scope = [
|
||||
'is_system.eq.true',
|
||||
`company_id.eq.${companyId}`,
|
||||
...(teamId && UUID_RE.test(teamId) ? [`team_id.eq.${teamId}`] : []),
|
||||
].join(',')
|
||||
|
||||
const [templatesRes, usageRes] = await Promise.all([
|
||||
supabase
|
||||
.from('booking_template_library')
|
||||
.select('*')
|
||||
.eq('is_active', true)
|
||||
.or(scope)
|
||||
.order('category')
|
||||
.order('name'),
|
||||
supabase
|
||||
.from('booking_template_usage')
|
||||
.select('template_id, last_used_at')
|
||||
.eq('company_id', companyId),
|
||||
])
|
||||
|
||||
if (templatesRes.error) {
|
||||
return NextResponse.json({ error: templatesRes.error.message }, { status: 500 })
|
||||
}
|
||||
// usage lookup failing is non-fatal: we just fall back to default ordering
|
||||
const usageByTemplate = new Map<string, string>()
|
||||
if (!usageRes.error && usageRes.data) {
|
||||
for (const row of usageRes.data) {
|
||||
usageByTemplate.set(row.template_id, row.last_used_at)
|
||||
// The wrapper only ever resolves a real membership UUID, but assert the
|
||||
// shape before interpolating it into the .or() filter.
|
||||
if (!UUID_RE.test(companyId)) {
|
||||
return NextResponse.json({ error: 'Invalid company context' }, { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
const templates = templatesRes.data ?? []
|
||||
const decorated = templates.map((t) => ({
|
||||
...t,
|
||||
last_used_at: usageByTemplate.get(t.id) ?? null,
|
||||
}))
|
||||
// Scope to the SELECTED company: system + this company + this company's team.
|
||||
// RLS (btl_select) is membership-wide: it returns templates from *every*
|
||||
// company the user belongs to: so the active-company narrowing must happen
|
||||
// here in the API layer (mirrors counterparty-templates). Without this, a
|
||||
// user who owns several companies sees all of their templates merged.
|
||||
// Only interpolate a team id that passes the strict UUID guard.
|
||||
const scope = [
|
||||
'is_system.eq.true',
|
||||
`company_id.eq.${companyId}`,
|
||||
...(teamId && UUID_RE.test(teamId) ? [`team_id.eq.${teamId}`] : []),
|
||||
].join(',')
|
||||
|
||||
// Stable-sort: templates with last_used_at come first (most-recent first).
|
||||
// Templates without usage keep their category/name order from the query.
|
||||
// ISO 8601 timestamps are fixed-width ASCII: plain relational comparison
|
||||
// is correct and avoids any locale-dependent behaviour from localeCompare.
|
||||
decorated.sort((a, b) => {
|
||||
const aUsed = a.last_used_at
|
||||
const bUsed = b.last_used_at
|
||||
if (aUsed && bUsed) {
|
||||
if (bUsed > aUsed) return -1
|
||||
if (bUsed < aUsed) return 1
|
||||
const [templatesRes, usageRes] = await Promise.all([
|
||||
supabase
|
||||
.from('booking_template_library')
|
||||
.select('*')
|
||||
.eq('is_active', true)
|
||||
.or(scope)
|
||||
.order('category')
|
||||
.order('name'),
|
||||
supabase
|
||||
.from('booking_template_usage')
|
||||
.select('template_id, last_used_at')
|
||||
.eq('company_id', companyId),
|
||||
])
|
||||
|
||||
if (templatesRes.error) {
|
||||
return NextResponse.json({ error: templatesRes.error.message }, { status: 500 })
|
||||
}
|
||||
// usage lookup failing is non-fatal: we just fall back to default ordering
|
||||
const usageByTemplate = new Map<string, string>()
|
||||
if (!usageRes.error && usageRes.data) {
|
||||
for (const row of usageRes.data) {
|
||||
usageByTemplate.set(row.template_id, row.last_used_at)
|
||||
}
|
||||
}
|
||||
|
||||
const templates = templatesRes.data ?? []
|
||||
const decorated = templates.map((t) => ({
|
||||
...t,
|
||||
last_used_at: usageByTemplate.get(t.id) ?? null,
|
||||
}))
|
||||
|
||||
// Stable-sort: templates with last_used_at come first (most-recent first).
|
||||
// Templates without usage keep their category/name order from the query.
|
||||
// ISO 8601 timestamps are fixed-width ASCII: plain relational comparison
|
||||
// is correct and avoids any locale-dependent behaviour from localeCompare.
|
||||
decorated.sort((a, b) => {
|
||||
const aUsed = a.last_used_at
|
||||
const bUsed = b.last_used_at
|
||||
if (aUsed && bUsed) {
|
||||
if (bUsed > aUsed) return -1
|
||||
if (bUsed < aUsed) return 1
|
||||
return 0
|
||||
}
|
||||
if (aUsed) return -1
|
||||
if (bUsed) return 1
|
||||
return 0
|
||||
}
|
||||
if (aUsed) return -1
|
||||
if (bUsed) return 1
|
||||
return 0
|
||||
})
|
||||
})
|
||||
|
||||
return NextResponse.json({ data: decorated })
|
||||
}
|
||||
return NextResponse.json({ data: decorated })
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* POST /api/settings/booking-templates
|
||||
* Create a company-scoped or team-scoped template.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
export const POST = withRouteContext(
|
||||
'booking_template.create',
|
||||
async (request, ctx) => {
|
||||
const { supabase, user } = ctx
|
||||
|
||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
||||
if (!writeCheck.ok) return writeCheck.response
|
||||
const result = await validateBody(request, CreateBookingTemplateSchema)
|
||||
if (!result.success) return result.response
|
||||
|
||||
const result = await validateBody(request, CreateBookingTemplateSchema)
|
||||
if (!result.success) return result.response
|
||||
const body = result.data
|
||||
const companyId = body.team_id ? null : ctx.companyId
|
||||
|
||||
const body = result.data
|
||||
const companyId = body.team_id ? null : await requireCompanyId(supabase, user.id)
|
||||
const { data, error } = await supabase
|
||||
.from('booking_template_library')
|
||||
.insert({
|
||||
company_id: companyId,
|
||||
team_id: body.team_id ?? null,
|
||||
created_by: user.id,
|
||||
name: body.name,
|
||||
description: body.description,
|
||||
category: body.category,
|
||||
entity_type: body.entity_type,
|
||||
lines: body.lines,
|
||||
is_system: false,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('booking_template_library')
|
||||
.insert({
|
||||
company_id: companyId,
|
||||
team_id: body.team_id ?? null,
|
||||
created_by: user.id,
|
||||
name: body.name,
|
||||
description: body.description,
|
||||
category: body.category,
|
||||
entity_type: body.entity_type,
|
||||
lines: body.lines,
|
||||
is_system: false,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
|
||||
return NextResponse.json({ data }, { status: 201 })
|
||||
}
|
||||
return NextResponse.json({ data }, { status: 201 })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
/**
|
||||
* DELETE /api/settings/booking-templates
|
||||
* Soft-delete a template by id (company or team scope only, never system).
|
||||
*/
|
||||
export async function DELETE(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
export const DELETE = withRouteContext(
|
||||
'booking_template.delete',
|
||||
async (request, ctx) => {
|
||||
const { supabase } = ctx
|
||||
|
||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
||||
if (!writeCheck.ok) return writeCheck.response
|
||||
let id: string | undefined
|
||||
try {
|
||||
const body = await request.json()
|
||||
id = body?.id
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
|
||||
}
|
||||
if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 })
|
||||
|
||||
let id: string | undefined
|
||||
try {
|
||||
const body = await request.json()
|
||||
id = body?.id
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
|
||||
}
|
||||
if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 })
|
||||
// RLS prevents deleting system templates (btl_delete policy checks NOT is_system)
|
||||
const { error } = await supabase
|
||||
.from('booking_template_library')
|
||||
.update({ is_active: false })
|
||||
.eq('id', id)
|
||||
|
||||
// RLS prevents deleting system templates (btl_delete policy checks NOT is_system)
|
||||
const { error } = await supabase
|
||||
.from('booking_template_library')
|
||||
.update({ is_active: false })
|
||||
.eq('id', id)
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
|
||||
return NextResponse.json({ data: { success: true } })
|
||||
}
|
||||
return NextResponse.json({ data: { success: true } })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createMockRequest, parseJsonResponse, createQueuedMockSupabase } 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),
|
||||
}))
|
||||
|
||||
import { DELETE } from '../route'
|
||||
|
||||
describe('DELETE /api/settings/counterparty-templates', () => {
|
||||
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 request = createMockRequest('/api/settings/counterparty-templates', {
|
||||
method: 'DELETE',
|
||||
body: { id: 't1' },
|
||||
})
|
||||
const response = await DELETE(request, { params: Promise.resolve({}) })
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(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 request = createMockRequest('/api/settings/counterparty-templates', {
|
||||
method: 'DELETE',
|
||||
body: { id: 't1' },
|
||||
})
|
||||
const response = await DELETE(request, { params: Promise.resolve({}) })
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(403)
|
||||
})
|
||||
|
||||
it('soft-deletes the template on the happy path', async () => {
|
||||
enqueue({ error: null }) // update is_active: false
|
||||
|
||||
const request = createMockRequest('/api/settings/counterparty-templates', {
|
||||
method: 'DELETE',
|
||||
body: { id: 't1' },
|
||||
})
|
||||
const response = await DELETE(request, { params: Promise.resolve({}) })
|
||||
const { status, body } = await parseJsonResponse<{ data: { success: boolean } }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.success).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,53 +1,43 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
|
||||
export async function GET() {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
export const GET = withRouteContext(
|
||||
'counterparty_template.list',
|
||||
async (_request, { supabase, companyId }) => {
|
||||
const { data, error } = await supabase
|
||||
.from('categorization_templates')
|
||||
.select('*')
|
||||
.eq('company_id', companyId)
|
||||
.eq('is_active', true)
|
||||
.order('occurrence_count', { ascending: false })
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('categorization_templates')
|
||||
.select('*')
|
||||
.eq('company_id', companyId)
|
||||
.eq('is_active', true)
|
||||
.order('occurrence_count', { ascending: false })
|
||||
return NextResponse.json({ data })
|
||||
},
|
||||
)
|
||||
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
export const DELETE = withRouteContext(
|
||||
'counterparty_template.delete',
|
||||
async (request, { supabase, companyId }) => {
|
||||
let id: string | undefined
|
||||
try {
|
||||
const body = await request.json()
|
||||
id = body?.id
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
|
||||
}
|
||||
if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 })
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
const { error } = await supabase
|
||||
.from('categorization_templates')
|
||||
.update({ is_active: false })
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
|
||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
||||
if (!writeCheck.ok) return writeCheck.response
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
|
||||
let id: string | undefined
|
||||
try {
|
||||
const body = await request.json()
|
||||
id = body?.id
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
|
||||
}
|
||||
if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 })
|
||||
|
||||
const { error } = await supabase
|
||||
.from('categorization_templates')
|
||||
.update({ is_active: false })
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
|
||||
return NextResponse.json({ data: { success: true } })
|
||||
}
|
||||
return NextResponse.json({ data: { success: true } })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { parseJsonResponse, createQueuedMockSupabase } 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 serviceStorage = {
|
||||
from: vi.fn().mockReturnValue({
|
||||
list: vi.fn().mockResolvedValue({ data: [], error: null }),
|
||||
remove: vi.fn().mockResolvedValue({ data: [], error: null }),
|
||||
upload: vi.fn().mockResolvedValue({ data: {}, error: null }),
|
||||
getPublicUrl: vi.fn().mockReturnValue({ data: { publicUrl: 'https://cdn.example.com/logo.png' } }),
|
||||
}),
|
||||
}
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: vi.fn(),
|
||||
createServiceClient: () => ({ storage: serviceStorage }),
|
||||
}))
|
||||
|
||||
import { POST } from '../route'
|
||||
|
||||
function makeFormRequest(): Request {
|
||||
const fd = new FormData()
|
||||
fd.append('file', new File([new Uint8Array([1, 2, 3])], 'logo.png', { type: 'image/png' }))
|
||||
return new Request('http://localhost/api/settings/logo', { method: 'POST', body: fd })
|
||||
}
|
||||
|
||||
describe('POST /api/settings/logo', () => {
|
||||
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(makeFormRequest(), { params: Promise.resolve({}) })
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(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(makeFormRequest(), { params: Promise.resolve({}) })
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(403)
|
||||
})
|
||||
|
||||
it('uploads the logo and returns its public url on the happy path', async () => {
|
||||
enqueue({ error: null }) // company_settings update
|
||||
|
||||
const response = await POST(makeFormRequest(), { params: Promise.resolve({}) })
|
||||
const { status, body } = await parseJsonResponse<{ data: { logo_url: string } }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.logo_url).toBe('https://cdn.example.com/logo.png')
|
||||
})
|
||||
})
|
||||
+88
-101
@@ -1,107 +1,41 @@
|
||||
import { createClient, createServiceClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import { createServiceClient } from '@/lib/supabase/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
|
||||
const MAX_SIZE = 2 * 1024 * 1024 // 2MB
|
||||
const ALLOWED_TYPES = ['image/png', 'image/jpeg', 'image/svg+xml', 'image/webp']
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
export const POST = withRouteContext(
|
||||
'settings.logo.upload',
|
||||
async (request, { supabase, companyId }) => {
|
||||
const formData = await request.formData()
|
||||
const file = formData.get('file') as File | null
|
||||
|
||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
||||
if (!writeCheck.ok) return writeCheck.response
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'Ingen fil angiven' }, { status: 400 })
|
||||
}
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
if (!companyId) return NextResponse.json({ error: 'No company' }, { status: 403 })
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
return NextResponse.json({ error: 'Otillåten filtyp. Tillåtna: PNG, JPG, SVG, WebP.' }, { status: 400 })
|
||||
}
|
||||
|
||||
const formData = await request.formData()
|
||||
const file = formData.get('file') as File | null
|
||||
if (file.size > MAX_SIZE) {
|
||||
return NextResponse.json({ error: 'Filen är för stor (max 2 MB).' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'Ingen fil angiven' }, { status: 400 })
|
||||
}
|
||||
const buffer = Buffer.from(await file.arrayBuffer())
|
||||
const mimeToExt: Record<string, string> = {
|
||||
'image/png': 'png',
|
||||
'image/jpeg': 'jpg',
|
||||
'image/svg+xml': 'svg',
|
||||
'image/webp': 'webp',
|
||||
}
|
||||
const ext = mimeToExt[file.type] ?? 'png'
|
||||
const storagePath = `${companyId}/logo-${Date.now()}.${ext}`
|
||||
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
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 })
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer())
|
||||
const mimeToExt: Record<string, string> = {
|
||||
'image/png': 'png',
|
||||
'image/jpeg': 'jpg',
|
||||
'image/svg+xml': 'svg',
|
||||
'image/webp': 'webp',
|
||||
}
|
||||
const ext = mimeToExt[file.type] ?? 'png'
|
||||
const storagePath = `${companyId}/logo-${Date.now()}.${ext}`
|
||||
|
||||
const serviceClient = createServiceClient()
|
||||
|
||||
// Remove any previous logo files for this company so we don't pile up orphans.
|
||||
const { data: existing } = await serviceClient.storage
|
||||
.from('logos')
|
||||
.list(companyId)
|
||||
if (existing && existing.length > 0) {
|
||||
await serviceClient.storage
|
||||
.from('logos')
|
||||
.remove(existing.map((f) => `${companyId}/${f.name}`))
|
||||
}
|
||||
|
||||
const { error: uploadError } = await serviceClient.storage
|
||||
.from('logos')
|
||||
.upload(storagePath, buffer, {
|
||||
contentType: file.type,
|
||||
upsert: true,
|
||||
})
|
||||
|
||||
if (uploadError) {
|
||||
return NextResponse.json({ error: `Uppladdning misslyckades: ${uploadError.message}` }, { status: 500 })
|
||||
}
|
||||
|
||||
const { data: urlData } = serviceClient.storage
|
||||
.from('logos')
|
||||
.getPublicUrl(storagePath)
|
||||
|
||||
// Update company settings
|
||||
const { error: updateError } = await supabase
|
||||
.from('company_settings')
|
||||
.update({ logo_url: urlData.publicUrl })
|
||||
.eq('company_id', companyId)
|
||||
|
||||
if (updateError) {
|
||||
return NextResponse.json({ error: 'Kunde inte uppdatera inställningar' }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: { logo_url: urlData.publicUrl } })
|
||||
}
|
||||
|
||||
export async function DELETE() {
|
||||
const supabase = await createClient()
|
||||
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)
|
||||
if (!companyId) return NextResponse.json({ error: 'No company' }, { status: 403 })
|
||||
|
||||
// Get current logo path
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('logo_url')
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (settings?.logo_url) {
|
||||
const serviceClient = createServiceClient()
|
||||
|
||||
// Remove any previous logo files for this company so we don't pile up orphans.
|
||||
const { data: existing } = await serviceClient.storage
|
||||
.from('logos')
|
||||
.list(companyId)
|
||||
@@ -110,13 +44,66 @@ export async function DELETE() {
|
||||
.from('logos')
|
||||
.remove(existing.map((f) => `${companyId}/${f.name}`))
|
||||
}
|
||||
}
|
||||
|
||||
// Clear logo_url
|
||||
await supabase
|
||||
.from('company_settings')
|
||||
.update({ logo_url: null })
|
||||
.eq('company_id', companyId)
|
||||
const { error: uploadError } = await serviceClient.storage
|
||||
.from('logos')
|
||||
.upload(storagePath, buffer, {
|
||||
contentType: file.type,
|
||||
upsert: true,
|
||||
})
|
||||
|
||||
return NextResponse.json({ data: { logo_url: null } })
|
||||
}
|
||||
if (uploadError) {
|
||||
return NextResponse.json({ error: `Uppladdning misslyckades: ${uploadError.message}` }, { status: 500 })
|
||||
}
|
||||
|
||||
const { data: urlData } = serviceClient.storage
|
||||
.from('logos')
|
||||
.getPublicUrl(storagePath)
|
||||
|
||||
// Update company settings
|
||||
const { error: updateError } = await supabase
|
||||
.from('company_settings')
|
||||
.update({ logo_url: urlData.publicUrl })
|
||||
.eq('company_id', companyId)
|
||||
|
||||
if (updateError) {
|
||||
return NextResponse.json({ error: 'Kunde inte uppdatera inställningar' }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: { logo_url: urlData.publicUrl } })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
export const DELETE = withRouteContext(
|
||||
'settings.logo.delete',
|
||||
async (_request, { supabase, companyId }) => {
|
||||
// Get current logo path
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('logo_url')
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (settings?.logo_url) {
|
||||
const serviceClient = createServiceClient()
|
||||
const { data: existing } = await serviceClient.storage
|
||||
.from('logos')
|
||||
.list(companyId)
|
||||
if (existing && existing.length > 0) {
|
||||
await serviceClient.storage
|
||||
.from('logos')
|
||||
.remove(existing.map((f) => `${companyId}/${f.name}`))
|
||||
}
|
||||
}
|
||||
|
||||
// Clear logo_url
|
||||
await supabase
|
||||
.from('company_settings')
|
||||
.update({ logo_url: null })
|
||||
.eq('company_id', companyId)
|
||||
|
||||
return NextResponse.json({ data: { logo_url: null } })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createMockRouteParams, parseJsonResponse, createQueuedMockSupabase } 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 appendMock = vi.fn()
|
||||
vi.mock('@/lib/processing-history/append', () => ({
|
||||
appendProcessingHistory: (...args: unknown[]) => appendMock(...args),
|
||||
}))
|
||||
|
||||
import { DELETE } from '../route'
|
||||
|
||||
describe('DELETE /api/settings/oauth-clients/[id]', () => {
|
||||
const routeParams = createMockRouteParams({ id: 'reg-1' })
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
|
||||
requireWriteMock.mockResolvedValue({ ok: true })
|
||||
appendMock.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: null,
|
||||
supabase,
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
|
||||
const response = await DELETE(new Request('http://localhost/x', { method: 'DELETE' }), routeParams)
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(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 DELETE(new Request('http://localhost/x', { method: 'DELETE' }), routeParams)
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(403)
|
||||
})
|
||||
|
||||
it('returns 404 when the registration is unknown or already revoked', async () => {
|
||||
enqueue({ data: [] }) // update ... returning no rows
|
||||
|
||||
const response = await DELETE(new Request('http://localhost/x', { method: 'DELETE' }), routeParams)
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(404)
|
||||
})
|
||||
|
||||
it('revokes the registration and appends an audit event on the happy path', async () => {
|
||||
enqueue({
|
||||
data: [{ id: 'reg-1', redirect_uri: 'https://app.example.com/cb', client_name: 'My App' }],
|
||||
})
|
||||
|
||||
const response = await DELETE(new Request('http://localhost/x', { method: 'DELETE' }), routeParams)
|
||||
const { status, body } = await parseJsonResponse<{ success: boolean }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(appendMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -1,11 +1,6 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { appendProcessingHistory } from '@/lib/processing-history/append'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
|
||||
const log = createLogger('oauth-clients:delete')
|
||||
|
||||
/**
|
||||
* DELETE /api/settings/oauth-clients/[id]: revoke a redirect URI
|
||||
@@ -17,65 +12,56 @@ const log = createLogger('oauth-clients:delete')
|
||||
* (SOC 2 CC7.2). Returns 404 when the row is unknown or already revoked
|
||||
* so callers can surface failures rather than treating "no-op" as success.
|
||||
*/
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
'oauth_client.delete',
|
||||
async (_request, { supabase, user, companyId, log }, { params }) => {
|
||||
const { id } = await params
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const { data: rows, error } = await supabase
|
||||
.from('oauth_client_registrations')
|
||||
.update({ revoked_at: new Date().toISOString() })
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.is('revoked_at', null)
|
||||
.select('id, redirect_uri, client_name')
|
||||
|
||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
||||
if (!writeCheck.ok) return writeCheck.response
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
const { data: rows, error } = await supabase
|
||||
.from('oauth_client_registrations')
|
||||
.update({ revoked_at: new Date().toISOString() })
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.is('revoked_at', null)
|
||||
.select('id, redirect_uri, client_name')
|
||||
if (!rows || rows.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'OAuth-klient hittades inte eller är redan återkallad.' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
// Audit the revocation. Failure to append must not break the user flow:
|
||||
// the revocation has already happened in the DB. We log the appendError
|
||||
// so a systematic outage is visible in operations rather than silently
|
||||
// degraded.
|
||||
try {
|
||||
await appendProcessingHistory({
|
||||
companyId,
|
||||
correlationId: id,
|
||||
aggregateType: 'System',
|
||||
aggregateId: id,
|
||||
eventType: 'OAuthClientRevoked',
|
||||
payload: {
|
||||
client_id: id,
|
||||
// Note: redirect_uri may contain a host the user identifies with their
|
||||
// own infrastructure but is not PII per Art. 4(1). Stored to satisfy
|
||||
// SOC 2 CC7.2 "what was revoked" evidence trail.
|
||||
redirect_uri: rows[0].redirect_uri,
|
||||
},
|
||||
actor: { type: 'user', id: user.id },
|
||||
occurredAt: new Date(),
|
||||
})
|
||||
} catch (auditErr) {
|
||||
log.warn('Failed to append OAuthClientRevoked audit event', auditErr)
|
||||
}
|
||||
|
||||
if (!rows || rows.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'OAuth-klient hittades inte eller är redan återkallad.' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Audit the revocation. Failure to append must not break the user flow:
|
||||
// the revocation has already happened in the DB. We log the appendError
|
||||
// so a systematic outage is visible in operations rather than silently
|
||||
// degraded.
|
||||
try {
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
await appendProcessingHistory({
|
||||
companyId,
|
||||
correlationId: id,
|
||||
aggregateType: 'System',
|
||||
aggregateId: id,
|
||||
eventType: 'OAuthClientRevoked',
|
||||
payload: {
|
||||
client_id: id,
|
||||
// Note: redirect_uri may contain a host the user identifies with their
|
||||
// own infrastructure but is not PII per Art. 4(1). Stored to satisfy
|
||||
// SOC 2 CC7.2 "what was revoked" evidence trail.
|
||||
redirect_uri: rows[0].redirect_uri,
|
||||
},
|
||||
actor: { type: 'user', id: user.id },
|
||||
occurredAt: new Date(),
|
||||
})
|
||||
} catch (auditErr) {
|
||||
log.warn('Failed to append OAuthClientRevoked audit event', auditErr)
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
return NextResponse.json({ success: true })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createMockRequest, parseJsonResponse, createQueuedMockSupabase } 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),
|
||||
}))
|
||||
|
||||
import { POST } from '../route'
|
||||
|
||||
describe('POST /api/settings/oauth-clients', () => {
|
||||
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 request = createMockRequest('/api/settings/oauth-clients', {
|
||||
method: 'POST',
|
||||
body: { client_name: 'My App', redirect_uri: 'https://app.example.com/cb' },
|
||||
})
|
||||
const response = await POST(request, { params: Promise.resolve({}) })
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(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 request = createMockRequest('/api/settings/oauth-clients', {
|
||||
method: 'POST',
|
||||
body: { client_name: 'My App', redirect_uri: 'https://app.example.com/cb' },
|
||||
})
|
||||
const response = await POST(request, { params: Promise.resolve({}) })
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(403)
|
||||
})
|
||||
|
||||
it('registers a redirect uri scoped to the current user on the happy path', async () => {
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'reg-1',
|
||||
client_name: 'My App',
|
||||
redirect_uri: 'https://app.example.com/cb',
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
})
|
||||
|
||||
const request = createMockRequest('/api/settings/oauth-clients', {
|
||||
method: 'POST',
|
||||
body: { client_name: 'My App', redirect_uri: 'https://app.example.com/cb' },
|
||||
})
|
||||
const response = await POST(request, { params: Promise.resolve({}) })
|
||||
const { status, body } = await parseJsonResponse<{ data: { id: string } }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.id).toBe('reg-1')
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,5 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { z } from 'zod'
|
||||
|
||||
/**
|
||||
@@ -29,66 +28,62 @@ const RegistrationSchema = z.object({
|
||||
.max(500),
|
||||
})
|
||||
|
||||
export async function GET() {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
export const GET = withRouteContext(
|
||||
'oauth_client.list',
|
||||
async (_request, { supabase, user }) => {
|
||||
const { data, error } = await supabase
|
||||
.from('oauth_client_registrations')
|
||||
.select('id, client_name, redirect_uri, created_at, revoked_at')
|
||||
.eq('user_id', user.id)
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('oauth_client_registrations')
|
||||
.select('id, client_name, redirect_uri, created_at, revoked_at')
|
||||
.eq('user_id', user.id)
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
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
|
||||
|
||||
let body: z.infer<typeof RegistrationSchema>
|
||||
try {
|
||||
const json = await request.json()
|
||||
body = RegistrationSchema.parse(json)
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof z.ZodError
|
||||
? err.issues[0]?.message ?? 'Ogiltig redirect URI'
|
||||
: err instanceof SyntaxError
|
||||
? 'Ogiltig JSON i request body'
|
||||
: 'Ogiltig redirect URI'
|
||||
return NextResponse.json({ error: message }, { status: 400 })
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('oauth_client_registrations')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
client_name: body.client_name,
|
||||
redirect_uri: body.redirect_uri,
|
||||
})
|
||||
.select('id, client_name, redirect_uri, created_at')
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
// Unique-index violation on redirect_uri → 409
|
||||
if (error.code === '23505') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Den här redirect URI:n är redan registrerad.' },
|
||||
{ status: 409 }
|
||||
)
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
return NextResponse.json({ data })
|
||||
},
|
||||
)
|
||||
|
||||
export const POST = withRouteContext(
|
||||
'oauth_client.create',
|
||||
async (request, { supabase, user }) => {
|
||||
let body: z.infer<typeof RegistrationSchema>
|
||||
try {
|
||||
const json = await request.json()
|
||||
body = RegistrationSchema.parse(json)
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof z.ZodError
|
||||
? err.issues[0]?.message ?? 'Ogiltig redirect URI'
|
||||
: err instanceof SyntaxError
|
||||
? 'Ogiltig JSON i request body'
|
||||
: 'Ogiltig redirect URI'
|
||||
return NextResponse.json({ error: message }, { status: 400 })
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('oauth_client_registrations')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
client_name: body.client_name,
|
||||
redirect_uri: body.redirect_uri,
|
||||
})
|
||||
.select('id, client_name, redirect_uri, created_at')
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
// Unique-index violation on redirect_uri → 409
|
||||
if (error.code === '23505') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Den här redirect URI:n är redan registrerad.' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
+97
-115
@@ -1,137 +1,119 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { didTaxFieldsChange, regenerateTaxDeadlinesForUser } from '@/lib/tax/deadline-generator'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { UpdateSettingsSchema } from '@/lib/api/schemas'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
|
||||
export async function GET() {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('company_settings')
|
||||
.select('*')
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Fall back to companies.entity_type if company_settings.entity_type is null
|
||||
let responseData = data
|
||||
if (data && !data.entity_type) {
|
||||
const { data: company } = await supabase
|
||||
.from('companies')
|
||||
.select('entity_type')
|
||||
.eq('id', companyId)
|
||||
export const GET = withRouteContext(
|
||||
'settings.get',
|
||||
async (_request, { supabase, companyId }) => {
|
||||
const { data, error } = await supabase
|
||||
.from('company_settings')
|
||||
.select('*')
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
if (company?.entity_type) {
|
||||
responseData = { ...data, entity_type: company.entity_type }
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: responseData })
|
||||
}
|
||||
// Fall back to companies.entity_type if company_settings.entity_type is null
|
||||
let responseData = data
|
||||
if (data && !data.entity_type) {
|
||||
const { data: company } = await supabase
|
||||
.from('companies')
|
||||
.select('entity_type')
|
||||
.eq('id', companyId)
|
||||
.single()
|
||||
if (company?.entity_type) {
|
||||
responseData = { ...data, entity_type: company.entity_type }
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
const supabase = await createClient()
|
||||
return NextResponse.json({ data: responseData })
|
||||
},
|
||||
)
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
export const PUT = withRouteContext(
|
||||
'settings.update',
|
||||
async (request, { supabase, companyId }) => {
|
||||
// Fetch current settings to check for tax-relevant changes
|
||||
const { data: oldSettings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('entity_type, moms_period, f_skatt, vat_registered, vat_number, pays_salaries, fiscal_year_start_month, onboarding_complete')
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const validation = await validateBody(request, UpdateSettingsSchema)
|
||||
if (!validation.success) return validation.response
|
||||
const body = validation.data
|
||||
|
||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
||||
if (!writeCheck.ok) return writeCheck.response
|
||||
// Lock org_number after onboarding is complete (legal identifier: changing it
|
||||
// would orphan vouchers, SIE history, and tax filings). company_name remains
|
||||
// editable so users can update their display/brand name (e.g. särskilt företagsnamn).
|
||||
if (oldSettings && (oldSettings as Record<string, unknown>).onboarding_complete === true) {
|
||||
delete (body as Record<string, unknown>).org_number
|
||||
}
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
|
||||
// Fetch current settings to check for tax-relevant changes
|
||||
const { data: oldSettings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('entity_type, moms_period, f_skatt, vat_registered, vat_number, pays_salaries, fiscal_year_start_month, onboarding_complete')
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
const validation = await validateBody(request, UpdateSettingsSchema)
|
||||
if (!validation.success) return validation.response
|
||||
const body = validation.data
|
||||
|
||||
// Lock org_number after onboarding is complete (legal identifier: changing it
|
||||
// would orphan vouchers, SIE history, and tax filings). company_name remains
|
||||
// editable so users can update their display/brand name (e.g. särskilt företagsnamn).
|
||||
if (oldSettings && (oldSettings as Record<string, unknown>).onboarding_complete === true) {
|
||||
delete (body as Record<string, unknown>).org_number
|
||||
}
|
||||
|
||||
// Validate: enskild firma must use calendar year (BFL 3 kap.)
|
||||
const effectiveEntityType = body.entity_type || oldSettings?.entity_type
|
||||
const effectiveFYStartMonth = body.fiscal_year_start_month ?? oldSettings?.fiscal_year_start_month
|
||||
if (effectiveEntityType === 'enskild_firma' && effectiveFYStartMonth && effectiveFYStartMonth !== 1) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Enskild firma måste använda kalenderår (BFL 3 kap.)' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Validate: VAT-registered must have VAT number (ML 11 kap. 8§) and moms period (SFL 26 kap.)
|
||||
const effectiveVatRegistered = body.vat_registered ?? oldSettings?.vat_registered
|
||||
if (effectiveVatRegistered === true) {
|
||||
const effectiveVatNumber = body.vat_number ?? oldSettings?.vat_number
|
||||
if (!effectiveVatNumber) {
|
||||
// Validate: enskild firma must use calendar year (BFL 3 kap.)
|
||||
const effectiveEntityType = body.entity_type || oldSettings?.entity_type
|
||||
const effectiveFYStartMonth = body.fiscal_year_start_month ?? oldSettings?.fiscal_year_start_month
|
||||
if (effectiveEntityType === 'enskild_firma' && effectiveFYStartMonth && effectiveFYStartMonth !== 1) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Momsregistreringsnummer krävs när företaget är momsregistrerat (ML 11 kap. 8§)' },
|
||||
{ error: 'Enskild firma måste använda kalenderår (BFL 3 kap.)' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
const effectiveMomsPeriod = body.moms_period ?? oldSettings?.moms_period
|
||||
if (!effectiveMomsPeriod) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Momsperiod krävs när företaget är momsregistrerat (SFL 26 kap.)' },
|
||||
{ status: 400 }
|
||||
)
|
||||
|
||||
// Validate: VAT-registered must have VAT number (ML 11 kap. 8§) and moms period (SFL 26 kap.)
|
||||
const effectiveVatRegistered = body.vat_registered ?? oldSettings?.vat_registered
|
||||
if (effectiveVatRegistered === true) {
|
||||
const effectiveVatNumber = body.vat_number ?? oldSettings?.vat_number
|
||||
if (!effectiveVatNumber) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Momsregistreringsnummer krävs när företaget är momsregistrerat (ML 11 kap. 8§)' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
const effectiveMomsPeriod = body.moms_period ?? oldSettings?.moms_period
|
||||
if (!effectiveMomsPeriod) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Momsperiod krävs när företaget är momsregistrerat (SFL 26 kap.)' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('company_settings')
|
||||
.update(body)
|
||||
.eq('company_id', companyId)
|
||||
.select()
|
||||
.single()
|
||||
const { data, error } = await supabase
|
||||
.from('company_settings')
|
||||
.update(body)
|
||||
.eq('company_id', companyId)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Check if tax-relevant fields changed and regenerate deadlines
|
||||
if (oldSettings && didTaxFieldsChange(oldSettings, data)) {
|
||||
try {
|
||||
await regenerateTaxDeadlinesForUser(supabase, companyId, {
|
||||
entity_type: data.entity_type,
|
||||
moms_period: data.moms_period,
|
||||
f_skatt: data.f_skatt,
|
||||
vat_registered: data.vat_registered,
|
||||
pays_salaries: data.pays_salaries ?? false,
|
||||
fiscal_year_start_month: data.fiscal_year_start_month,
|
||||
})
|
||||
console.log('Tax deadlines regenerated after settings change')
|
||||
} catch (err) {
|
||||
console.error('Failed to regenerate tax deadlines:', err)
|
||||
// Don't fail the settings update if deadline generation fails
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
// Check if tax-relevant fields changed and regenerate deadlines
|
||||
if (oldSettings && didTaxFieldsChange(oldSettings, data)) {
|
||||
try {
|
||||
await regenerateTaxDeadlinesForUser(supabase, companyId, {
|
||||
entity_type: data.entity_type,
|
||||
moms_period: data.moms_period,
|
||||
f_skatt: data.f_skatt,
|
||||
vat_registered: data.vat_registered,
|
||||
pays_salaries: data.pays_salaries ?? false,
|
||||
fiscal_year_start_month: data.fiscal_year_start_month,
|
||||
})
|
||||
console.log('Tax deadlines regenerated after settings change')
|
||||
} catch (err) {
|
||||
console.error('Failed to regenerate tax deadlines:', err)
|
||||
// Don't fail the settings update if deadline generation fails
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user