Delete features (#218)
* Implement company and account deletion features - Add event types for company and account deletion to CoreEvent. - Enhance Supabase middleware to handle company context resolution and cookie management for archived companies. - Create API routes for deleting accounts and companies, including necessary validations and event emissions. - Implement tests for account and company deletion endpoints to ensure proper functionality and error handling. - Add retention notice component to inform users about bookkeeping data retention during destructive actions. - Create database migrations to support soft deletion of companies and anonymization of user accounts, ensuring compliance with retention laws. * feat: enhance account deletion process and update user notifications * Add service client for onboarding completion check and update escape hatch visibility * Enhance invite flow and email handling for company members * Refactor company context and RLS policies for active company isolation - Update `switchCompany` to remove unnecessary revalidation as client handles navigation. - Revise `getActiveCompanyId` to prioritize `user_preferences` and validate against non-archived memberships. - Modify `setActiveCompany` to ensure `user_preferences` is the authoritative source while maintaining cookie compatibility. - Enhance middleware to resolve active company using `user_preferences` and fallback to first non-archived membership. - Introduce new API route `/api/company/current` to fetch the active company ID for cross-tab synchronization. - Implement `CompanyTabSync` component for real-time active company enforcement across tabs. - Create migration for RLS policies to enforce single-active-company isolation using `current_active_company_id()`. * feat: implement viewer role enforcement for write permissions - Added `useCanWrite` hook to determine if the current user has write permissions based on their role in the active company. - Updated various components (JournalEntryForm, CustomerForm, DeadlineForm, etc.) to disable write actions and show a lock icon with a tooltip for users without write permissions. - Introduced `requireWritePermission` function to enforce write permissions at the API level, returning a 403 response for viewers. - Created tests to verify the behavior of the viewer role and write permissions. - Added database migration to enforce read-only access for viewers at the database level.
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
import { eventBus } from '@/lib/events'
|
||||
|
||||
vi.mock('@/lib/init', () => ({
|
||||
ensureInitialized: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: vi.fn(),
|
||||
createServiceClient: vi.fn(),
|
||||
}))
|
||||
|
||||
import { createClient, createServiceClient } from '@/lib/supabase/server'
|
||||
import { POST } from '../route'
|
||||
|
||||
const mockCreateClient = vi.mocked(createClient)
|
||||
const mockCreateServiceClient = vi.mocked(createServiceClient)
|
||||
|
||||
function mockAuth(
|
||||
user: { id: string; email: string | null } | null,
|
||||
rpcResult: { data?: unknown; error?: unknown } = { data: null, error: null }
|
||||
) {
|
||||
const signOut = vi.fn().mockResolvedValue({ error: null })
|
||||
const rpc = vi.fn().mockResolvedValue(rpcResult)
|
||||
|
||||
mockCreateClient.mockResolvedValue({
|
||||
auth: {
|
||||
getUser: vi.fn().mockResolvedValue({ data: { user } }),
|
||||
signOut,
|
||||
},
|
||||
rpc,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any)
|
||||
|
||||
return { rpc, signOut }
|
||||
}
|
||||
|
||||
function mockServiceClient(blockers: { id: string; name: string }[] = []) {
|
||||
const updateUserById = vi.fn().mockResolvedValue({ data: {}, error: null })
|
||||
const adminSignOut = vi.fn().mockResolvedValue({ data: null, error: null })
|
||||
|
||||
const chain = {
|
||||
select: vi.fn().mockReturnThis(),
|
||||
eq: vi.fn().mockReturnThis(),
|
||||
is: vi.fn().mockResolvedValue({
|
||||
data: blockers.map((b) => ({ companies: { id: b.id, name: b.name } })),
|
||||
error: null,
|
||||
}),
|
||||
}
|
||||
|
||||
mockCreateServiceClient.mockReturnValue({
|
||||
from: vi.fn().mockReturnValue(chain),
|
||||
auth: {
|
||||
admin: {
|
||||
updateUserById,
|
||||
signOut: adminSignOut,
|
||||
},
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any)
|
||||
|
||||
return { updateUserById, adminSignOut }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
eventBus.clear()
|
||||
})
|
||||
|
||||
describe('POST /api/account/delete', () => {
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
mockAuth(null)
|
||||
|
||||
const req = createMockRequest('/api/account/delete', {
|
||||
method: 'POST',
|
||||
body: { confirm_email: 'u@example.com' },
|
||||
})
|
||||
const { status } = await parseJsonResponse(await POST(req))
|
||||
expect(status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 400 when confirm_email does not match', async () => {
|
||||
mockAuth({ id: 'user-1', email: 'right@example.com' })
|
||||
|
||||
const req = createMockRequest('/api/account/delete', {
|
||||
method: 'POST',
|
||||
body: { confirm_email: 'wrong@example.com' },
|
||||
})
|
||||
const { status, body } = await parseJsonResponse(await POST(req))
|
||||
expect(status).toBe(400)
|
||||
expect(body).toHaveProperty('error')
|
||||
})
|
||||
|
||||
it('returns 409 with blockers when RPC raises P0001', async () => {
|
||||
mockAuth(
|
||||
{ id: 'user-1', email: 'u@example.com' },
|
||||
{ data: null, error: { code: 'P0001', message: 'blocked' } }
|
||||
)
|
||||
mockServiceClient([{ id: 'c1', name: 'Acme AB' }])
|
||||
|
||||
const req = createMockRequest('/api/account/delete', {
|
||||
method: 'POST',
|
||||
body: { confirm_email: 'u@example.com' },
|
||||
})
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
error: string
|
||||
blockers: { id: string; name: string }[]
|
||||
}>(await POST(req))
|
||||
|
||||
expect(status).toBe(409)
|
||||
expect(body.blockers).toEqual([{ id: 'c1', name: 'Acme AB' }])
|
||||
})
|
||||
|
||||
it('anonymizes, bans, signs out, and emits event on happy path', async () => {
|
||||
const { rpc } = mockAuth({ id: 'user-1', email: 'u@example.com' })
|
||||
const { updateUserById, adminSignOut } = mockServiceClient()
|
||||
|
||||
const emitted: unknown[] = []
|
||||
eventBus.on('account.deleted', (payload) => {
|
||||
emitted.push(payload)
|
||||
})
|
||||
|
||||
const req = createMockRequest('/api/account/delete', {
|
||||
method: 'POST',
|
||||
body: { confirm_email: 'u@example.com' },
|
||||
})
|
||||
const { status, body } = await parseJsonResponse<{ success: boolean }>(
|
||||
await POST(req)
|
||||
)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
|
||||
expect(rpc).toHaveBeenCalledWith('anonymize_user_account', {
|
||||
target_user_id: 'user-1',
|
||||
})
|
||||
expect(updateUserById).toHaveBeenCalledWith(
|
||||
'user-1',
|
||||
expect.objectContaining({
|
||||
user_metadata: {},
|
||||
app_metadata: {},
|
||||
ban_duration: expect.any(String),
|
||||
})
|
||||
)
|
||||
// Email must NOT be scrubbed — retaining it is what blocks re-signup
|
||||
// with the same address. Recovery goes through support instead.
|
||||
const updatePayload = updateUserById.mock.calls[0][1]
|
||||
expect(updatePayload).not.toHaveProperty('email')
|
||||
expect(adminSignOut).toHaveBeenCalledWith('user-1', 'global')
|
||||
expect(emitted).toHaveLength(1)
|
||||
expect(emitted[0]).toMatchObject({ userId: 'user-1' })
|
||||
})
|
||||
})
|
||||
+106
-25
@@ -1,56 +1,137 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { createClient, createServiceClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
|
||||
const log = createLogger('api/account/delete')
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
const DeleteAccountSchema = z.object({
|
||||
confirm: z.literal('RADERA'),
|
||||
confirm_email: z.string().email(),
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /api/account/delete
|
||||
*
|
||||
* Anonymizes the calling user's account. The auth.users row is retained
|
||||
* (banned for ~100 years) as a tombstone so FKs into BFL-retained
|
||||
* bookkeeping data (companies.created_by, audit_log.user_id, etc.) stay
|
||||
* valid. Memberships are removed, profile PII is stripped, and a global
|
||||
* signout forces all sessions to end.
|
||||
*
|
||||
* Precondition: the user must own zero non-archived companies. The RPC
|
||||
* enforces this at the DB level and raises SQLSTATE P0001 with a message
|
||||
* if the precondition fails — we return 409 in that case.
|
||||
*/
|
||||
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 })
|
||||
}
|
||||
|
||||
let body: unknown
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
|
||||
}
|
||||
const result = await validateBody(request, DeleteAccountSchema)
|
||||
if (!result.success) return result.response
|
||||
const { confirm_email } = result.data
|
||||
|
||||
const parsed = DeleteAccountSchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
if (!user.email || confirm_email.trim().toLowerCase() !== user.email.toLowerCase()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Skriv RADERA för att bekräfta kontoborttagning' },
|
||||
{ error: 'E-postadressen stämmer inte överens med ditt konto.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
// RPC disables protective triggers, deletes from auth.users (CASCADE
|
||||
// cleans up all public tables), then re-enables triggers — all in one tx.
|
||||
const { error } = await supabase.rpc('delete_user_account', {
|
||||
target_user_id: user.id,
|
||||
})
|
||||
// Anonymize in the DB. Runs as SECURITY DEFINER and checks auth.uid()
|
||||
// internally, so we don't need service role here.
|
||||
const { error: rpcError } = await supabase.rpc('anonymize_user_account', {
|
||||
target_user_id: user.id,
|
||||
})
|
||||
|
||||
if (rpcError) {
|
||||
// P0001 = precondition violation from our RPC: user still owns active
|
||||
// companies. Re-fetch the blockers and return 409 so the UI can show
|
||||
// the list inline.
|
||||
if (rpcError.code === 'P0001') {
|
||||
const service = createServiceClient()
|
||||
const { data: blockers } = await service
|
||||
.from('company_members')
|
||||
.select('company_id, companies!inner(id, name, archived_at)')
|
||||
.eq('user_id', user.id)
|
||||
.eq('role', 'owner')
|
||||
.is('companies.archived_at', null)
|
||||
|
||||
const list = (blockers ?? []).map((b) => {
|
||||
const company = (b.companies as unknown) as { id: string; name: string }
|
||||
return { id: company.id, name: company.name }
|
||||
})
|
||||
|
||||
if (error) {
|
||||
console.error('Failed to delete user:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Kunde inte radera kontot. Försök igen.' },
|
||||
{ status: 500 }
|
||||
{
|
||||
error: 'Du måste radera eller överlåta dina företag innan du kan radera kontot.',
|
||||
blockers: list,
|
||||
},
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Account deletion error:', error)
|
||||
log.error('anonymize_user_account failed', { userId: user.id, error: rpcError.message })
|
||||
return NextResponse.json(
|
||||
{ error: 'Kunde inte radera kontot. Försök igen.' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Wipe PII in auth.users metadata and ban the tombstone row ~100 years.
|
||||
// DB functions can't reach supabase.auth.admin, so we do it here.
|
||||
//
|
||||
// Note: auth.users.email is intentionally NOT scrubbed. The original
|
||||
// address is retained as a legitimate-interest tombstone so that:
|
||||
// (1) re-signup with the same email is blocked by Supabase's unique
|
||||
// constraint — deletion must feel permanent, not trivially
|
||||
// reversible by re-registering
|
||||
// (2) support can verify identity when a former user asks to recover
|
||||
// BFL-retained räkenskapsinformation
|
||||
// This must be documented in the privacy policy under legitimate
|
||||
// interest (GDPR Art. 6(1)(f)). The email is never read by the app
|
||||
// after this point — login is impossible (row is banned) and the
|
||||
// profile is anonymized, so no UI ever surfaces it.
|
||||
//
|
||||
// user_metadata / app_metadata ARE wiped — they may contain display
|
||||
// name, avatar, or provider info that isn't needed for recovery.
|
||||
// The admin API replaces (not merges) these, so passing {} clears them.
|
||||
const service = createServiceClient()
|
||||
try {
|
||||
await service.auth.admin.updateUserById(user.id, {
|
||||
user_metadata: {},
|
||||
app_metadata: {},
|
||||
ban_duration: '876000h',
|
||||
})
|
||||
} catch (err) {
|
||||
log.error('Failed to wipe metadata and ban anonymized user', { userId: user.id, err })
|
||||
}
|
||||
|
||||
try {
|
||||
await service.auth.admin.signOut(user.id, 'global')
|
||||
} catch (err) {
|
||||
log.error('Failed to global sign out anonymized user', { userId: user.id, err })
|
||||
}
|
||||
|
||||
const deletedAt = new Date().toISOString()
|
||||
await eventBus.emit({
|
||||
type: 'account.deleted',
|
||||
payload: { userId: user.id, deletedAt },
|
||||
})
|
||||
|
||||
// Best-effort: clear the caller's session cookie too.
|
||||
await supabase.auth.signOut().catch(() => {})
|
||||
|
||||
// Request body is consumed; avoid unused-var lint.
|
||||
void request
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { UpdateAccountSchema } from '@/lib/api/schemas'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
@@ -16,6 +17,9 @@ export async function DELETE(
|
||||
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)
|
||||
|
||||
// Fetch the account to check if it's a system account
|
||||
@@ -75,6 +79,9 @@ export async function PUT(
|
||||
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)
|
||||
|
||||
const validation = await validateBody(request, UpdateAccountSchema)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getBASReference } from '@/lib/bookkeeping/bas-reference'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
|
||||
/**
|
||||
* POST /api/bookkeeping/accounts/activate
|
||||
@@ -18,6 +19,9 @@ export async function POST(request: Request) {
|
||||
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)
|
||||
|
||||
const body = await request.json()
|
||||
|
||||
@@ -4,6 +4,7 @@ import { NextResponse } from 'next/server'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { CreateAccountSchema } from '@/lib/api/schemas'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
@@ -52,6 +53,9 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
||||
if (!writeCheck.ok) return writeCheck.response
|
||||
|
||||
const validation = await validateBody(request, CreateAccountSchema)
|
||||
if (!validation.success) return validation.response
|
||||
const body = validation.data
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { closePeriod } from '@/lib/core/bookkeeping/period-service'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
@@ -15,6 +16,9 @@ export async function POST(
|
||||
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)
|
||||
|
||||
try {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
executeCurrencyRevaluation,
|
||||
} from '@/lib/bookkeeping/currency-revaluation'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
|
||||
/**
|
||||
* GET: Preview currency revaluation for a fiscal period
|
||||
@@ -61,6 +62,9 @@ export async function POST(
|
||||
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)
|
||||
|
||||
try {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { lockPeriod } from '@/lib/core/bookkeeping/period-service'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
@@ -15,6 +16,9 @@ export async function POST(
|
||||
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)
|
||||
|
||||
try {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { validatePeriodDuration } from '@/lib/bookkeeping/validate-period-duration'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import { z } from 'zod'
|
||||
|
||||
const UpdateFiscalPeriodSchema = z.object({
|
||||
@@ -23,6 +24,9 @@ export async function PATCH(
|
||||
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)
|
||||
|
||||
const validation = await validateBody(request, UpdateFiscalPeriodSchema)
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
executeYearEndClosing,
|
||||
} from '@/lib/core/bookkeeping/year-end-service'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
|
||||
/**
|
||||
* GET: Validate readiness and preview year-end closing
|
||||
@@ -54,6 +55,9 @@ export async function POST(
|
||||
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)
|
||||
|
||||
try {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { validatePeriodDuration } from '@/lib/bookkeeping/validate-period-durati
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { CreateFiscalPeriodSchema } 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()
|
||||
@@ -36,6 +37,9 @@ export async function POST(request: Request) {
|
||||
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)
|
||||
|
||||
const validation = await validateBody(request, CreateFiscalPeriodSchema)
|
||||
|
||||
@@ -17,6 +17,11 @@ vi.mock('@/lib/init', () => ({
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
|
||||
const mockCorrectEntry = vi.fn()
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ensureInitialized } from '@/lib/init'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { CorrectJournalEntrySchema } from '@/lib/api/schemas'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
@@ -20,6 +21,9 @@ export async function POST(
|
||||
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)
|
||||
|
||||
const validation = await validateBody(request, CorrectJournalEntrySchema)
|
||||
|
||||
@@ -18,6 +18,11 @@ vi.mock('@/lib/init', () => ({
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
|
||||
const mockReverseEntry = vi.fn()
|
||||
|
||||
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
|
||||
import { reverseEntry } from '@/lib/bookkeeping/engine'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
@@ -18,6 +19,9 @@ export async function POST(
|
||||
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)
|
||||
|
||||
try {
|
||||
|
||||
@@ -18,6 +18,11 @@ vi.mock('@/lib/init', () => ({
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
|
||||
const mockCreateJournalEntry = vi.fn()
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ensureInitialized } from '@/lib/init'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { CreateJournalEntrySchema } from '@/lib/api/schemas'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
@@ -79,6 +80,9 @@ export async function POST(request: Request) {
|
||||
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)
|
||||
|
||||
const validation = await validateBody(request, CreateJournalEntrySchema)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { CreateMappingRuleSchema } 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()
|
||||
@@ -36,6 +37,9 @@ export async function POST(request: Request) {
|
||||
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)
|
||||
|
||||
const result = await validateBody(request, CreateMappingRuleSchema)
|
||||
|
||||
@@ -15,6 +15,11 @@ vi.mock('@/lib/supabase/server', () => ({
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
|
||||
import { GET, POST } from '../route'
|
||||
|
||||
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
|
||||
import { validateBody, validateQuery } from '@/lib/api/validate'
|
||||
import { VoucherGapQuerySchema, SaveGapExplanationSchema } from '@/lib/api/schemas'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
@@ -111,6 +112,9 @@ export async function POST(request: Request) {
|
||||
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)
|
||||
|
||||
const validation = await validateBody(request, SaveGapExplanationSchema)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 type { UpdateCalendarFeedInput } from '@/types'
|
||||
|
||||
/**
|
||||
@@ -60,6 +61,9 @@ export async function POST() {
|
||||
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)
|
||||
|
||||
// Check if feed already exists
|
||||
@@ -117,6 +121,9 @@ export async function PUT(request: Request) {
|
||||
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)
|
||||
|
||||
const body: UpdateCalendarFeedInput = await request.json()
|
||||
@@ -156,6 +163,9 @@ export async function DELETE() {
|
||||
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)
|
||||
|
||||
// Generate a new token by updating with a new UUID
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createMockRequest, createMockRouteParams, parseJsonResponse } from '@/tests/helpers'
|
||||
import { eventBus } from '@/lib/events'
|
||||
|
||||
vi.mock('@/lib/init', () => ({
|
||||
ensureInitialized: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: vi.fn(),
|
||||
createServiceClient: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: vi.fn(),
|
||||
}))
|
||||
|
||||
import { createServiceClient } from '@/lib/supabase/server'
|
||||
import { requireAuth } from '@/lib/auth/require-auth'
|
||||
import { POST } from '../route'
|
||||
|
||||
const mockRequireAuth = vi.mocked(requireAuth)
|
||||
const mockCreateServiceClient = vi.mocked(createServiceClient)
|
||||
|
||||
type Row = { data: unknown; error: unknown }
|
||||
|
||||
function mockService(rowsByTable: Record<string, Row[]>) {
|
||||
const insertSpy = vi.fn().mockResolvedValue({ data: null, error: null })
|
||||
const updateSpies: Record<string, ReturnType<typeof vi.fn>> = {}
|
||||
|
||||
const from = vi.fn().mockImplementation((table: string) => {
|
||||
const queue = rowsByTable[table] ?? []
|
||||
const next = queue.shift() ?? { data: null, error: null }
|
||||
|
||||
const updateFn = vi.fn().mockReturnValue({
|
||||
eq: vi.fn().mockReturnValue({
|
||||
eq: vi.fn().mockResolvedValue({ data: null, error: null }),
|
||||
then: (resolve: (v: unknown) => void) => resolve({ data: null, error: null }),
|
||||
}),
|
||||
})
|
||||
updateSpies[table] = updateFn
|
||||
|
||||
return {
|
||||
select: vi.fn().mockReturnValue({
|
||||
eq: vi.fn().mockReturnValue({
|
||||
eq: vi.fn().mockReturnValue({
|
||||
maybeSingle: vi.fn().mockResolvedValue(next),
|
||||
}),
|
||||
maybeSingle: vi.fn().mockResolvedValue(next),
|
||||
}),
|
||||
}),
|
||||
update: updateFn,
|
||||
insert: insertSpy,
|
||||
}
|
||||
})
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockCreateServiceClient.mockReturnValue({ from } as any)
|
||||
return { from, insertSpy, updateSpies }
|
||||
}
|
||||
|
||||
function mockAuth(userId: string | null) {
|
||||
if (userId) {
|
||||
mockRequireAuth.mockResolvedValue({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
user: { id: userId, email: 'u@example.com' } as any,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
supabase: {} as any,
|
||||
error: null,
|
||||
})
|
||||
} else {
|
||||
mockRequireAuth.mockResolvedValue({
|
||||
user: null,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
supabase: {} as any,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
error: { status: 401 } as any,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
eventBus.clear()
|
||||
})
|
||||
|
||||
describe('POST /api/company/[id]/delete', () => {
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
mockAuth(null)
|
||||
const req = createMockRequest('/api/company/c1/delete', {
|
||||
method: 'POST',
|
||||
body: { confirm_name: 'Acme' },
|
||||
})
|
||||
const res = await POST(req, createMockRouteParams({ id: 'c1' }))
|
||||
// requireAuth already returned a NextResponse error
|
||||
expect(res).toBeDefined()
|
||||
})
|
||||
|
||||
it('returns 400 when confirm_name does not match', async () => {
|
||||
mockAuth('user-1')
|
||||
mockService({
|
||||
companies: [{ data: { id: 'c1', name: 'Acme AB', archived_at: null }, error: null }],
|
||||
company_members: [{ data: { role: 'owner' }, error: null }],
|
||||
})
|
||||
|
||||
const req = createMockRequest('/api/company/c1/delete', {
|
||||
method: 'POST',
|
||||
body: { confirm_name: 'Wrong Name' },
|
||||
})
|
||||
const { status, body } = await parseJsonResponse(
|
||||
await POST(req, createMockRouteParams({ id: 'c1' }))
|
||||
)
|
||||
expect(status).toBe(400)
|
||||
expect(body).toHaveProperty('error')
|
||||
})
|
||||
|
||||
it('returns 403 when caller is member but not owner', async () => {
|
||||
mockAuth('user-1')
|
||||
mockService({
|
||||
companies: [{ data: { id: 'c1', name: 'Acme AB', archived_at: null }, error: null }],
|
||||
company_members: [{ data: { role: 'member' }, error: null }],
|
||||
})
|
||||
|
||||
const req = createMockRequest('/api/company/c1/delete', {
|
||||
method: 'POST',
|
||||
body: { confirm_name: 'Acme AB' },
|
||||
})
|
||||
const { status } = await parseJsonResponse(
|
||||
await POST(req, createMockRouteParams({ id: 'c1' }))
|
||||
)
|
||||
expect(status).toBe(403)
|
||||
})
|
||||
|
||||
it('returns 404 when company is already archived', async () => {
|
||||
mockAuth('user-1')
|
||||
mockService({
|
||||
companies: [
|
||||
{
|
||||
data: {
|
||||
id: 'c1',
|
||||
name: 'Acme AB',
|
||||
archived_at: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const req = createMockRequest('/api/company/c1/delete', {
|
||||
method: 'POST',
|
||||
body: { confirm_name: 'Acme AB' },
|
||||
})
|
||||
const { status } = await parseJsonResponse(
|
||||
await POST(req, createMockRouteParams({ id: 'c1' }))
|
||||
)
|
||||
expect(status).toBe(404)
|
||||
})
|
||||
|
||||
it('archives company, writes audit log, emits event on happy path', async () => {
|
||||
mockAuth('user-1')
|
||||
const { insertSpy, updateSpies } = mockService({
|
||||
companies: [{ data: { id: 'c1', name: 'Acme AB', archived_at: null }, error: null }],
|
||||
company_members: [{ data: { role: 'owner' }, error: null }],
|
||||
})
|
||||
|
||||
const emitted: unknown[] = []
|
||||
eventBus.on('company.deleted', (payload) => {
|
||||
emitted.push(payload)
|
||||
})
|
||||
|
||||
const req = createMockRequest('/api/company/c1/delete', {
|
||||
method: 'POST',
|
||||
body: { confirm_name: 'Acme AB' },
|
||||
})
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: { companyId: string; archivedAt: string }
|
||||
}>(await POST(req, createMockRouteParams({ id: 'c1' })))
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.companyId).toBe('c1')
|
||||
expect(body.data.archivedAt).toBeTruthy()
|
||||
|
||||
// Verified the companies.update was called (archived_at + archived_by)
|
||||
expect(updateSpies.companies).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
archived_at: expect.any(String),
|
||||
archived_by: 'user-1',
|
||||
})
|
||||
)
|
||||
|
||||
// Audit log row written
|
||||
expect(insertSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'DELETE',
|
||||
table_name: 'companies',
|
||||
record_id: 'c1',
|
||||
user_id: 'user-1',
|
||||
})
|
||||
)
|
||||
|
||||
// Event emitted
|
||||
expect(emitted).toHaveLength(1)
|
||||
expect(emitted[0]).toMatchObject({ companyId: 'c1', userId: 'user-1' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,156 @@
|
||||
import { createServiceClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { requireAuth } from '@/lib/auth/require-auth'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
|
||||
const log = createLogger('api/company/delete')
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
const DeleteCompanySchema = z.object({
|
||||
confirm_name: z.string().min(1),
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /api/company/[id]/delete
|
||||
*
|
||||
* Soft-delete a company. Sets archived_at + archived_by. The underlying
|
||||
* bookkeeping data is retained for 7 years per BFL 7 kap. 2§. All reads
|
||||
* flow through user_company_ids() which filters archived rows, so the
|
||||
* company disappears from the user's UI immediately.
|
||||
*
|
||||
* Rules:
|
||||
* - Only callers with role='owner' in company_members may delete.
|
||||
* - The body must include confirm_name matching companies.name exactly.
|
||||
* - Already-archived companies return 404 (treated as not found).
|
||||
*/
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id: companyId } = await params
|
||||
|
||||
const auth = await requireAuth()
|
||||
if (auth.error) return auth.error
|
||||
const { user, supabase } = auth
|
||||
|
||||
const result = await validateBody(request, DeleteCompanySchema)
|
||||
if (!result.success) return result.response
|
||||
const { confirm_name } = result.data
|
||||
|
||||
// Service client for writes that must bypass RLS (audit_log insert,
|
||||
// user_preferences clearing). All queries still filter by company_id
|
||||
// and user.id for defense in depth.
|
||||
const service = createServiceClient()
|
||||
|
||||
// 1. Fetch company — must exist and be active. Query via service client
|
||||
// because we need the raw row regardless of RLS visibility, but we still
|
||||
// enforce membership below.
|
||||
const { data: company, error: fetchError } = await service
|
||||
.from('companies')
|
||||
.select('id, name, archived_at')
|
||||
.eq('id', companyId)
|
||||
.maybeSingle()
|
||||
|
||||
if (fetchError) {
|
||||
log.error('Failed to fetch company', { companyId, error: fetchError.message })
|
||||
return NextResponse.json({ error: 'Kunde inte hämta företag.' }, { status: 500 })
|
||||
}
|
||||
|
||||
if (!company || company.archived_at) {
|
||||
return NextResponse.json({ error: 'Företaget hittades inte.' }, { status: 404 })
|
||||
}
|
||||
|
||||
// 2. Caller must be an owner of this company
|
||||
const { data: membership, error: membershipError } = await service
|
||||
.from('company_members')
|
||||
.select('role')
|
||||
.eq('company_id', companyId)
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle()
|
||||
|
||||
if (membershipError) {
|
||||
log.error('Failed to fetch membership', { companyId, error: membershipError.message })
|
||||
return NextResponse.json({ error: 'Kunde inte verifiera behörighet.' }, { status: 500 })
|
||||
}
|
||||
|
||||
if (!membership) {
|
||||
return NextResponse.json({ error: 'Företaget hittades inte.' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (membership.role !== 'owner') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Endast ägaren kan radera ett företag.' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// 3. Confirm name matches exactly (case-sensitive trim)
|
||||
if (confirm_name.trim() !== company.name.trim()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Företagsnamnet stämmer inte överens.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// 4. Soft delete
|
||||
const archivedAt = new Date().toISOString()
|
||||
const { error: updateError } = await service
|
||||
.from('companies')
|
||||
.update({ archived_at: archivedAt, archived_by: user.id })
|
||||
.eq('id', companyId)
|
||||
|
||||
if (updateError) {
|
||||
log.error('Failed to archive company', { companyId, error: updateError.message })
|
||||
return NextResponse.json({ error: 'Kunde inte radera företaget.' }, { status: 500 })
|
||||
}
|
||||
|
||||
// 5. Clear user_preferences.active_company_id if it pointed here so the
|
||||
// middleware falls through to another membership next request.
|
||||
await service
|
||||
.from('user_preferences')
|
||||
.update({ active_company_id: null })
|
||||
.eq('user_id', user.id)
|
||||
.eq('active_company_id', companyId)
|
||||
|
||||
// 6. Write audit log row. companies has no auto-audit trigger, so do it
|
||||
// explicitly. Service client bypasses audit_log RLS (no INSERT policy).
|
||||
await service.from('audit_log').insert({
|
||||
user_id: user.id,
|
||||
company_id: companyId,
|
||||
action: 'DELETE',
|
||||
table_name: 'companies',
|
||||
record_id: companyId,
|
||||
actor_id: user.id,
|
||||
old_state: { archived_at: null },
|
||||
new_state: { archived_at: archivedAt, archived_by: user.id },
|
||||
description: `Company archived: ${company.name}`,
|
||||
})
|
||||
|
||||
// 7. Emit event
|
||||
await eventBus.emit({
|
||||
type: 'company.deleted',
|
||||
payload: { companyId, userId: user.id, archivedAt },
|
||||
})
|
||||
|
||||
// 8. Build response and clear company cookie if it matched
|
||||
const response = NextResponse.json({ data: { companyId, archivedAt } })
|
||||
|
||||
const cookieCompanyId = request.headers.get('cookie')?.match(/gnubok-company-id=([^;]+)/)?.[1]
|
||||
if (cookieCompanyId === companyId) {
|
||||
response.cookies.set('gnubok-company-id', '', {
|
||||
path: '/',
|
||||
maxAge: 0,
|
||||
})
|
||||
}
|
||||
|
||||
// Ignore supabase server-client cookie warnings; the response is what
|
||||
// the browser sees. RLS session isn't relevant here.
|
||||
void supabase
|
||||
|
||||
return response
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { getActiveCompanyId } from '@/lib/company/context'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* GET /api/company/current
|
||||
*
|
||||
* Returns the active company id for the authenticated user. Used by the
|
||||
* client-side CompanyTabSync listener to detect cross-tab divergence (e.g.
|
||||
* when a tab was hidden/backgrounded during a switch in another tab) and
|
||||
* force a hard reload on mismatch.
|
||||
*
|
||||
* Never cached — the whole point is that the response reflects the current
|
||||
* authoritative value in user_preferences.
|
||||
*/
|
||||
export async function GET() {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{
|
||||
status: 401,
|
||||
headers: { 'Cache-Control': 'private, no-store' },
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const companyId = await getActiveCompanyId(supabase, user.id)
|
||||
|
||||
return NextResponse.json(
|
||||
{ companyId },
|
||||
{ headers: { 'Cache-Control': 'private, no-store' } },
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
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'
|
||||
|
||||
/**
|
||||
* DELETE /api/company/members/[id]
|
||||
@@ -16,6 +17,9 @@ export async function DELETE(
|
||||
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)
|
||||
const { id: memberId } = await params
|
||||
const serviceClient = await createServiceClient()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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'
|
||||
|
||||
/**
|
||||
* DELETE /api/company/members/invite/[id]
|
||||
@@ -15,6 +16,9 @@ export async function DELETE(
|
||||
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)
|
||||
const { id: inviteId } = await params
|
||||
const serviceClient = await createServiceClient()
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { createClient, createServiceClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import { generateInviteToken, getInviteExpiry } from '@/lib/auth/invite-tokens'
|
||||
import { getEmailService } from '@/lib/email/service'
|
||||
import {
|
||||
@@ -9,6 +11,12 @@ import {
|
||||
generateInviteEmailText,
|
||||
} from '@/lib/email/invite-templates'
|
||||
|
||||
// Loads the email extension so getEmailService() returns the Resend
|
||||
// implementation instead of the noop default. Without this, the invite email
|
||||
// is silently skipped in dev whenever this route is hit before any other
|
||||
// init'd route in the process.
|
||||
ensureInitialized()
|
||||
|
||||
/**
|
||||
* POST /api/company/members/invite
|
||||
* Invite a user to the current company (e.g., a client as viewer).
|
||||
@@ -19,6 +27,9 @@ export async function POST(request: Request) {
|
||||
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)
|
||||
const serviceClient = await createServiceClient()
|
||||
|
||||
@@ -136,6 +147,12 @@ export async function POST(request: Request) {
|
||||
inviteUrl,
|
||||
}
|
||||
|
||||
console.log('[company/members/invite] sending email', {
|
||||
to: email,
|
||||
company: emailData.companyName,
|
||||
from: user.email,
|
||||
})
|
||||
|
||||
const result = await emailService.sendEmail({
|
||||
to: email,
|
||||
subject: generateInviteEmailSubject(emailData),
|
||||
@@ -143,9 +160,18 @@ export async function POST(request: Request) {
|
||||
text: generateInviteEmailText(emailData),
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
if (result.success) {
|
||||
console.log('[company/members/invite] email sent', {
|
||||
to: email,
|
||||
messageId: result.messageId,
|
||||
})
|
||||
} else {
|
||||
console.error('[company/members/invite] email send failed:', result.error)
|
||||
}
|
||||
} else {
|
||||
console.warn('[company/members/invite] email service not configured — skipping send', {
|
||||
to: email,
|
||||
})
|
||||
}
|
||||
|
||||
// In development, return the invite URL directly (no email service)
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* GET /api/company?owned=true&archived=false
|
||||
*
|
||||
* Returns companies the caller has access to, filtered by query:
|
||||
* - owned=true → only companies where caller's role is 'owner'
|
||||
* - archived=false → only non-archived companies (default)
|
||||
*
|
||||
* Used by the account danger zone to show a blockers list before
|
||||
* allowing account deletion.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const url = new URL(request.url)
|
||||
const ownedOnly = url.searchParams.get('owned') === 'true'
|
||||
const includeArchived = url.searchParams.get('archived') === 'true'
|
||||
|
||||
let query = supabase
|
||||
.from('company_members')
|
||||
.select('role, companies!inner(id, name, archived_at)')
|
||||
.eq('user_id', user.id)
|
||||
|
||||
if (ownedOnly) query = query.eq('role', 'owner')
|
||||
if (!includeArchived) query = query.is('companies.archived_at', null)
|
||||
|
||||
const { data, error } = await query
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
const companies = (data ?? []).map((row) => {
|
||||
const company = (row.companies as unknown) as {
|
||||
id: string
|
||||
name: string
|
||||
archived_at: string | null
|
||||
}
|
||||
return {
|
||||
id: company.id,
|
||||
name: company.name,
|
||||
archived_at: company.archived_at,
|
||||
role: row.role as string,
|
||||
}
|
||||
})
|
||||
|
||||
return NextResponse.json({ data: companies })
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { validateBody } from '@/lib/api/validate'
|
||||
import { UpdateCustomerSchema } from '@/lib/api/schemas'
|
||||
import { validateVatNumber } from '@/lib/vat/vies-client'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
|
||||
const log = createLogger('api/customers/[id]')
|
||||
@@ -70,6 +71,9 @@ export async function PATCH(
|
||||
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)
|
||||
|
||||
const result = await validateBody(request, UpdateCustomerSchema)
|
||||
@@ -172,6 +176,9 @@ export async function DELETE(
|
||||
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)
|
||||
|
||||
const { error, count } = await supabase
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Representative viewer-403 test.
|
||||
*
|
||||
* Verifies that POST /api/customers returns 403 when the caller's
|
||||
* requireWritePermission check returns an error response. This is a
|
||||
* canary test — if it breaks, the wiring between mutating routes and
|
||||
* requireWritePermission has drifted.
|
||||
*
|
||||
* The full per-role behavior of requireWritePermission itself is
|
||||
* covered in lib/auth/__tests__/require-write.test.ts.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
|
||||
const mockAuthGetUser = vi.fn()
|
||||
const mockFrom = vi.fn()
|
||||
const mockSupabase = {
|
||||
auth: { getUser: mockAuthGetUser },
|
||||
from: mockFrom,
|
||||
}
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => Promise.resolve(mockSupabase),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/init', () => ({
|
||||
ensureInitialized: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
const requireWritePermissionMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: (...args: unknown[]) => requireWritePermissionMock(...args),
|
||||
}))
|
||||
|
||||
import { POST } from '../route'
|
||||
|
||||
describe('POST /api/customers — viewer role gate', () => {
|
||||
const mockUser = { id: 'user-1', email: 'viewer@test.se' }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockAuthGetUser.mockResolvedValue({ data: { user: mockUser } })
|
||||
})
|
||||
|
||||
it('returns 403 with Swedish message when requireWritePermission rejects', async () => {
|
||||
requireWritePermissionMock.mockResolvedValue({
|
||||
ok: false,
|
||||
response: NextResponse.json(
|
||||
{ error: 'Du har endast läsbehörighet i detta företag.' },
|
||||
{ status: 403 },
|
||||
),
|
||||
})
|
||||
|
||||
const request = createMockRequest('/api/customers', {
|
||||
method: 'POST',
|
||||
body: { name: 'Test customer', customer_type: 'company' },
|
||||
})
|
||||
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(403)
|
||||
expect(body.error).toContain('läsbehörighet')
|
||||
})
|
||||
|
||||
it('calls requireWritePermission with the authenticated user id', async () => {
|
||||
requireWritePermissionMock.mockResolvedValue({
|
||||
ok: false,
|
||||
response: NextResponse.json({ error: 'blocked' }, { status: 403 }),
|
||||
})
|
||||
|
||||
const request = createMockRequest('/api/customers', {
|
||||
method: 'POST',
|
||||
body: { name: 'Test customer', customer_type: 'company' },
|
||||
})
|
||||
|
||||
await POST(request)
|
||||
|
||||
expect(requireWritePermissionMock).toHaveBeenCalledWith(mockSupabase, 'user-1')
|
||||
})
|
||||
})
|
||||
@@ -6,6 +6,7 @@ import { validateBody } from '@/lib/api/validate'
|
||||
import { CreateCustomerSchema } from '@/lib/api/schemas'
|
||||
import { validateVatNumber } from '@/lib/vat/vies-client'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import type { Customer } from '@/types'
|
||||
|
||||
@@ -46,6 +47,9 @@ export async function POST(request: Request) {
|
||||
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)
|
||||
|
||||
const result = await validateBody(request, CreateCustomerSchema)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
|
||||
/**
|
||||
* POST /api/deadlines/[id]/complete
|
||||
@@ -21,6 +22,9 @@ export async function POST(
|
||||
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)
|
||||
|
||||
// First, get current deadline state
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 type { CreateDeadlineInput } from '@/types'
|
||||
|
||||
/**
|
||||
@@ -60,6 +61,9 @@ export async function PUT(
|
||||
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)
|
||||
|
||||
const body: Partial<CreateDeadlineInput> = await request.json()
|
||||
@@ -124,6 +128,9 @@ export async function DELETE(
|
||||
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)
|
||||
|
||||
const { error } = await supabase
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { updateDeadlineStatus, isValidTransition } from '@/lib/deadlines/status-engine'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import type { DeadlineStatus } from '@/types'
|
||||
|
||||
/**
|
||||
@@ -20,6 +21,9 @@ export async function PATCH(
|
||||
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)
|
||||
|
||||
const { id } = await params
|
||||
|
||||
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { CreateDeadlineSchema } from '@/lib/api/schemas'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
|
||||
/**
|
||||
* GET /api/deadlines
|
||||
@@ -82,6 +83,9 @@ export async function POST(request: Request) {
|
||||
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)
|
||||
|
||||
const validation = await validateBody(request, CreateDeadlineSchema)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { linkToJournalEntry } from '@/lib/core/documents/document-service'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
@@ -26,6 +27,9 @@ export async function POST(
|
||||
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)
|
||||
|
||||
const { id } = await params
|
||||
|
||||
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { verifyIntegrity } from '@/lib/core/documents/document-service'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
@@ -22,6 +23,9 @@ export async function POST(
|
||||
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)
|
||||
|
||||
const { id } = await params
|
||||
|
||||
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { createNewVersion, validateDocumentFile } from '@/lib/core/documents/document-service'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
@@ -25,6 +26,9 @@ export async function POST(
|
||||
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)
|
||||
|
||||
const { id } = await params
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { runDocumentMatchingSweep } from '@/lib/documents/batch-match'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
@@ -11,6 +12,9 @@ export async function POST(request: Request) {
|
||||
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)
|
||||
|
||||
// Optional: pass specific inbox item IDs to match
|
||||
|
||||
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { uploadDocument, validateDocumentFile } from '@/lib/core/documents/document-service'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
@@ -25,6 +26,9 @@ export async function POST(request: Request) {
|
||||
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)
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
@@ -56,6 +57,9 @@ export async function POST(
|
||||
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)
|
||||
|
||||
const body = await request.json()
|
||||
@@ -101,6 +105,9 @@ export async function DELETE(
|
||||
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)
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
@@ -41,6 +42,9 @@ export async function PATCH(
|
||||
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)
|
||||
|
||||
const body = await request.json()
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ensureInitialized } from '@/lib/init'
|
||||
import { ingestTransactions, type RawTransaction } from '@/lib/transactions/ingest'
|
||||
import { generateExternalId } from '@/lib/import/bank-file/parser'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import type { ParsedBankTransaction, BankFileFormatId } from '@/lib/import/bank-file/types'
|
||||
import type { Transaction } from '@/types'
|
||||
|
||||
@@ -34,6 +35,9 @@ export async function POST(request: Request) {
|
||||
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)
|
||||
|
||||
const body: ExecuteRequest = await request.json()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
|
||||
/**
|
||||
* GET /api/import/sie/[id]
|
||||
@@ -65,6 +66,9 @@ export async function DELETE(
|
||||
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)
|
||||
|
||||
// Check current status before deleting
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 type { SIEAccount } from '@/lib/import/types'
|
||||
|
||||
/**
|
||||
@@ -64,6 +65,9 @@ export async function POST(request: Request) {
|
||||
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)
|
||||
|
||||
try {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import { parseSIEFile, detectEncoding, decodeBuffer } from '@/lib/import/sie-parser'
|
||||
import { suggestMappings } from '@/lib/import/account-mapper'
|
||||
import { executeSIEImport, checkDuplicateImport } from '@/lib/import/sie-import'
|
||||
@@ -27,6 +28,9 @@ export async function POST(request: Request) {
|
||||
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)
|
||||
|
||||
try {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { saveMappings } from '@/lib/import/sie-import'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import type { AccountMapping } from '@/lib/import/types'
|
||||
|
||||
/**
|
||||
@@ -49,6 +50,9 @@ export async function POST(request: Request) {
|
||||
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)
|
||||
|
||||
const body = await request.json()
|
||||
@@ -84,6 +88,9 @@ export async function PUT(request: Request) {
|
||||
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)
|
||||
|
||||
const body = await request.json()
|
||||
@@ -133,6 +140,9 @@ export async function DELETE(request: Request) {
|
||||
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)
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import type { Invoice } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
@@ -26,6 +27,9 @@ export async function POST(
|
||||
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)
|
||||
|
||||
// Fetch proforma with items
|
||||
|
||||
@@ -19,6 +19,11 @@ vi.mock('@/lib/init', () => ({
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
|
||||
const mockCreateInvoicePaymentJournalEntry = vi.fn()
|
||||
|
||||
@@ -8,6 +8,7 @@ import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
|
||||
import { MarkInvoicePaidSchema } from '@/lib/api/schemas'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import type { CreateJournalEntryInput, EntityType, Invoice } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
@@ -36,6 +37,9 @@ export async function POST(
|
||||
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)
|
||||
|
||||
// Fetch invoice
|
||||
|
||||
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
|
||||
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import type { EntityType, Invoice } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
@@ -27,6 +28,9 @@ export async function POST(
|
||||
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)
|
||||
|
||||
// Fetch invoice
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
|
||||
/**
|
||||
* DELETE /api/invoices/[id]
|
||||
@@ -22,6 +23,9 @@ export async function DELETE(
|
||||
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)
|
||||
|
||||
// Fetch invoice to verify ownership and status
|
||||
|
||||
@@ -24,6 +24,10 @@ vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
|
||||
const mockRenderToBuffer = vi.fn()
|
||||
vi.mock('@react-pdf/renderer', () => ({
|
||||
renderToBuffer: (...args: unknown[]) => mockRenderToBuffer(...args),
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
|
||||
import { uploadDocument } from '@/lib/core/documents/document-service'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import type { Invoice, InvoiceItem, Customer, CompanySettings } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
@@ -30,6 +31,9 @@ export async function POST(
|
||||
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)
|
||||
|
||||
// Check if email is configured
|
||||
|
||||
@@ -22,6 +22,10 @@ vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
|
||||
const mockGetVatRules = vi.fn()
|
||||
const mockCalculateVat = vi.fn()
|
||||
const mockGetAvailableVatRates = vi.fn()
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
createCreditNoteJournalEntry,
|
||||
} from '@/lib/bookkeeping/invoice-entries'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
@@ -58,6 +59,9 @@ export async function POST(request: Request) {
|
||||
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)
|
||||
|
||||
let rawBody: unknown
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { mergeWithDefaults } from '@/lib/reports/kpi-definitions'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import type { KPIPreferences } from '@/types'
|
||||
|
||||
const EXTENSION_ID = 'core/kpi'
|
||||
@@ -31,6 +32,9 @@ export async function PUT(request: Request) {
|
||||
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)
|
||||
|
||||
let body: unknown
|
||||
|
||||
@@ -20,6 +20,10 @@ vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
|
||||
// Mock the counterparty templates (non-critical side effect)
|
||||
vi.mock('@/lib/bookkeeping/counterparty-templates', () => ({
|
||||
upsertCounterpartyTemplate: vi.fn().mockResolvedValue(undefined),
|
||||
|
||||
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mapping'
|
||||
import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
|
||||
import { upsertCounterpartyTemplate } from '@/lib/bookkeeping/counterparty-templates'
|
||||
@@ -792,6 +793,9 @@ export async function POST(
|
||||
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)
|
||||
|
||||
// Fetch the pending operation
|
||||
|
||||
@@ -16,6 +16,10 @@ vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
|
||||
import { POST } from '../../reject/route'
|
||||
|
||||
describe('POST /api/pending-operations/:id/reject', () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
|
||||
/**
|
||||
* POST /api/pending-operations/:id/reject
|
||||
@@ -19,6 +20,9 @@ export async function POST(
|
||||
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)
|
||||
|
||||
const { data: op, error: fetchError } = await supabase
|
||||
|
||||
@@ -5,6 +5,7 @@ import { manualLink } from '@/lib/reconciliation/bank-reconciliation'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { BankLinkSchema } from '@/lib/api/schemas'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
@@ -16,6 +17,9 @@ export async function POST(request: Request) {
|
||||
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)
|
||||
|
||||
const validation = await validateBody(request, BankLinkSchema)
|
||||
|
||||
@@ -5,6 +5,7 @@ import { runReconciliation } from '@/lib/reconciliation/bank-reconciliation'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { RunReconciliationSchema } from '@/lib/api/schemas'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
@@ -16,6 +17,9 @@ export async function POST(request: Request) {
|
||||
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)
|
||||
|
||||
const validation = await validateBody(request, RunReconciliationSchema)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { unlinkReconciliation } from '@/lib/reconciliation/bank-reconciliation'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { BankUnlinkSchema } from '@/lib/api/schemas'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
@@ -13,6 +14,9 @@ export async function POST(request: Request) {
|
||||
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)
|
||||
|
||||
const validation = await validateBody(request, BankUnlinkSchema)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
|
||||
/**
|
||||
* DELETE /api/settings/api-keys/[id] — Revoke an API key (soft delete)
|
||||
@@ -17,6 +18,9 @@ export async function DELETE(
|
||||
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)
|
||||
|
||||
const { error } = await supabase
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { generateApiKey, hashApiKey, DEFAULT_SCOPES, validateScopes } from '@/lib/auth/api-keys'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import type { ApiKeyScope } from '@/lib/auth/api-keys'
|
||||
|
||||
/**
|
||||
@@ -42,6 +43,9 @@ export async function POST(request: Request) {
|
||||
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)
|
||||
|
||||
let name = 'Unnamed key'
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
|
||||
export async function GET() {
|
||||
const supabase = await createClient()
|
||||
@@ -26,6 +27,9 @@ export async function DELETE(request: Request) {
|
||||
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)
|
||||
|
||||
let id: string | undefined
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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'
|
||||
|
||||
const MAX_SIZE = 2 * 1024 * 1024 // 2MB
|
||||
const ALLOWED_TYPES = ['image/png', 'image/jpeg', 'image/svg+xml', 'image/webp']
|
||||
@@ -10,6 +11,9 @@ export async function POST(request: Request) {
|
||||
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 })
|
||||
|
||||
@@ -68,6 +72,9 @@ export async function DELETE() {
|
||||
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 })
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { didTaxFieldsChange, regenerateTaxDeadlinesForUser } from '@/lib/tax/dea
|
||||
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()
|
||||
@@ -51,6 +52,9 @@ export async function PUT(request: Request) {
|
||||
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)
|
||||
|
||||
// Fetch current settings to check for tax-relevant changes
|
||||
|
||||
@@ -21,6 +21,10 @@ vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
|
||||
import { eventBus } from '@/lib/events'
|
||||
|
||||
import { POST } from '../route'
|
||||
|
||||
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import type { SupplierInvoice } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
@@ -20,6 +21,9 @@ export async function POST(
|
||||
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)
|
||||
|
||||
const { data: invoice } = await supabase
|
||||
|
||||
@@ -4,6 +4,7 @@ import { eventBus } from '@/lib/events'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { createSupplierCreditNoteEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import type { SupplierInvoice, SupplierInvoiceItem, AccountingMethod } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
@@ -21,6 +22,9 @@ export async function POST(
|
||||
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)
|
||||
|
||||
// Fetch original invoice with supplier and items
|
||||
|
||||
@@ -22,6 +22,10 @@ vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
|
||||
const mockCreateSupplierInvoicePaymentEntry = vi.fn()
|
||||
const mockCreateSupplierInvoiceCashEntry = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/supplier-invoice-entries', () => ({
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { MarkSupplierInvoicePaidSchema } from '@/lib/api/schemas'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import type { SupplierInvoice, SupplierInvoiceItem } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
@@ -26,6 +27,9 @@ export async function POST(
|
||||
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)
|
||||
|
||||
const validation = await validateBody(request, MarkSupplierInvoicePaidSchema)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { UpdateSupplierInvoiceSchema } from '@/lib/api/schemas'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
@@ -46,6 +47,9 @@ export async function PUT(
|
||||
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)
|
||||
|
||||
// Only allow editing registered invoices
|
||||
@@ -99,6 +103,9 @@ export async function DELETE(
|
||||
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)
|
||||
|
||||
// Only allow deleting registered invoices without journal entries
|
||||
|
||||
@@ -21,6 +21,10 @@ vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
|
||||
const mockFindFiscalPeriod = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/engine', () => ({
|
||||
findFiscalPeriod: (...args: unknown[]) => mockFindFiscalPeriod(...args),
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ensureInitialized } from '@/lib/init'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { CreateSupplierInvoiceSchema } from '@/lib/api/schemas'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import type { SupplierInvoice, SupplierInvoiceItem } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
@@ -55,6 +56,9 @@ export async function POST(request: Request) {
|
||||
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)
|
||||
|
||||
const validation = await validateBody(request, CreateSupplierInvoiceSchema)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { UpdateSupplierSchema } from '@/lib/api/schemas'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
@@ -70,6 +71,9 @@ export async function PUT(
|
||||
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)
|
||||
|
||||
const result = await validateBody(request, UpdateSupplierSchema)
|
||||
@@ -125,6 +129,9 @@ export async function DELETE(
|
||||
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)
|
||||
|
||||
// Check for linked invoices
|
||||
|
||||
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { CreateSupplierSchema } 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()
|
||||
@@ -37,6 +38,9 @@ export async function POST(request: Request) {
|
||||
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)
|
||||
|
||||
const result = await validateBody(request, CreateSupplierSchema)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { regenerateTaxDeadlinesForUser } from '@/lib/tax/deadline-generator'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
|
||||
/**
|
||||
* POST /api/tax-deadlines/generate
|
||||
@@ -16,6 +17,9 @@ export async function POST() {
|
||||
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)
|
||||
|
||||
// Fetch company settings
|
||||
|
||||
@@ -13,6 +13,11 @@ vi.mock('@/lib/supabase/server', () => ({
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
|
||||
import { DELETE } from '../route'
|
||||
|
||||
@@ -20,6 +20,11 @@ vi.mock('@/lib/init', () => ({
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
|
||||
const mockCreateJournalEntry = vi.fn()
|
||||
|
||||
@@ -6,6 +6,7 @@ import { createJournalEntry } from '@/lib/bookkeeping/engine'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { BookTransactionSchema } from '@/lib/api/schemas'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import type { Transaction } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
@@ -23,6 +24,9 @@ export async function POST(
|
||||
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)
|
||||
|
||||
const validation = await validateBody(request, BookTransactionSchema)
|
||||
|
||||
@@ -22,6 +22,10 @@ vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
|
||||
const mockBuildMappingResultFromCategory = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/category-mapping', () => ({
|
||||
buildMappingResultFromCategory: (...args: unknown[]) =>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-ent
|
||||
import { saveUserMappingRule } from '@/lib/bookkeeping/mapping-engine'
|
||||
import { upsertCounterpartyTemplate, buildMappingResultFromCounterpartyTemplate } from '@/lib/bookkeeping/counterparty-templates'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import type { CategorizationTemplate } from '@/types'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { CategorizeTransactionSchema } from '@/lib/api/schemas'
|
||||
@@ -99,6 +100,9 @@ export async function POST(
|
||||
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)
|
||||
|
||||
// Parse and validate request body
|
||||
|
||||
@@ -42,6 +42,11 @@ vi.mock('@/lib/init', () => ({
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
|
||||
import { POST } from '../route'
|
||||
|
||||
@@ -11,6 +11,7 @@ import { logMatchEvent } from '@/lib/invoices/match-log'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import type { EntityType, Invoice, Transaction } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
@@ -40,6 +41,9 @@ export async function POST(
|
||||
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)
|
||||
|
||||
// Parse and validate request body
|
||||
|
||||
@@ -10,6 +10,7 @@ import { logMatchEvent } from '@/lib/invoices/match-log'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import type { SupplierInvoice, SupplierInvoiceItem, Transaction } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
@@ -32,6 +33,9 @@ export async function POST(
|
||||
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)
|
||||
|
||||
const validation = await validateBody(request, MatchSupplierInvoiceSchema)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
@@ -14,6 +15,9 @@ export async function DELETE(
|
||||
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)
|
||||
|
||||
// Fetch the transaction with ownership check
|
||||
|
||||
@@ -18,6 +18,11 @@ vi.mock('@/lib/init', () => ({
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
|
||||
const mockReverseEntry = vi.fn()
|
||||
|
||||
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
|
||||
import { reverseEntry } from '@/lib/bookkeeping/engine'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
@@ -16,6 +17,9 @@ export async function POST(
|
||||
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)
|
||||
|
||||
const { id } = await params
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getBestInvoiceMatch } from '@/lib/invoices/invoice-matching'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import type { Transaction } from '@/types'
|
||||
|
||||
/**
|
||||
@@ -17,6 +18,9 @@ export async function POST() {
|
||||
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)
|
||||
|
||||
// Fetch uncategorized income transactions without a potential match
|
||||
|
||||
Reference in New Issue
Block a user