37ccda5cad
* feat(api): v1 invoice :send + bulk-create action verbs (Phase 2 PR-B-2b-3 + PR-B-2c)
Combined chunk: ship the full :send pipeline and partial-success bulk
creation in one PR, plus include the dangling /reset-password middleware
fix that completes PR-455's password-recovery flow.
POST /api/v1/companies/:companyId/invoices/:id/send
Full send pipeline mirroring the internal route, hardened for the public
API surface: email-configured check, draft-only guard, cancelled /
delivery-note / credit-note / missing-moms_ruta rejections, customer
email check, company-settings fetch, F-series invoice-number allocation
(atomic at :send per ML 17 kap 24§ p.2, not at draft create), preflight
PDF render before number consumption, final PDF render, email send,
point-of-no-return status flip, BFL 5 kap journal entry, document
archival, invoice.sent event emit. Post-send failures (journal entry,
archive) surface via a `warnings` array rather than failing the response
— the invoice IS sent at that point. Dry-run validates the pipeline +
preflight PDF without allocating a number or hitting the provider.
Error codes: INVOICE_SEND_EMAIL_NOT_CONFIGURED (503),
INVOICE_SEND_NO_CUSTOMER_EMAIL / _CANCELLED /
_COMPANY_SETTINGS_MISSING (400), INVOICE_UPDATE_NOT_DRAFT (409),
INVOICE_SEND_PDF_RENDER_FAILED / _NUMBER_ASSIGN_FAILED (500),
INVOICE_SEND_PROVIDER_FAILED (502).
POST /api/v1/companies/:companyId/invoices/bulk-create
Batch create up to 50 invoices in a single call, sequential processing,
partial success: `{ results: [{ ok, request_index, data?, error? }],
summary: { total, succeeded, failed } }`. Per-item rollback on items
insert failure (delete the parent invoice row). Emits invoice.created
per success. Dry-run wraps results in a preview without inserting.
`all_or_nothing` is accepted but reserved for a future PR.
lib/supabase/middleware.ts
Add /reset-password bypass before the authenticated-user redirect so
password-recovery sessions don't bounce to '/'. This should have landed
in PR-455 — the `git add 'app/(auth)'` filter missed the middleware
file at lib/. Without this the recovery email link silently fails for
the recipient.
Tests: 14 new integration tests across both routes (happy path,
provider failure, scope rejection, draft-only guard, dry-run shape,
bulk partial-success, max-50 enforcement, validation error). Full suite
green (3207 passing, 1 unrelated pre-existing pg-real failure).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(api): address PR #458 review — :send + bulk-create hardening
Greptile P1: silent zero-row update after email delivery.
PostgREST returns { error: null } on 0-row UPDATEs. The post-email
status flip used `.eq('status', 'draft')` as an optimistic lock but
never inspected the row count, so a concurrent state change (race,
double-send from another session) would leave the DB row in 'draft'
while the response claimed 'sent' and the email was already gone.
Fix: `.select('id')` after the update and check `flipRows.length`;
on 0-row miss, push STATUS_UPDATE_FAILED warning AND change the
response status to 'draft' so the caller can reconcile.
Greptile P1: re-read error swallowed; invoice_number could vanish
from the response.
After ensureInvoiceNumber, the re-read query destructured only `data`,
silently dropping `error`. A transient connection failure would leave
`numbered` null and `finalInvoiceNumber` undefined; JSON serialization
would then omit the field, violating the documented response schema.
Fix: capture `reReadErr`, log a warning, fall back to typed.invoice_number
(which was just written by the RPC and is authoritative in-memory).
Apply the same fallback at the top-level `ok()` call.
Greptile P2 + Compliance Swarm V2.3 + Swedish-compliance kreditfaktura:
reject credit notes from :send.
The :credit endpoint creates credit notes atomically in 'sent' state
with their own number — there is no v1 path that produces a draft
credit note, so reaching :send with credited_invoice_id set is misuse
or manual DB editing. Allowing it would assign an F-series number to
a kreditfaktura (ML 17 kap 22–23§ require a distinct kreditfaktura
series and a back-reference that this route would not enforce). Fix:
reject with VALIDATION_ERROR pointing at /credit. Removes a stretch
of dead code (originalInvoiceNumber lookup, kreditfaktura filename
branch) that can never execute now.
Greptile P2: all_or_nothing: true silently treated as false.
A caller asking for atomic semantics must not get partial-success
behaviour with no runtime signal. Fix: reject with new
NOT_IMPLEMENTED error (501) plus a details.field pointer. Schema
still accepts the flag for forward compatibility once a DB-side RPC
ships. New error code added to lib/errors/structured-errors.ts.
Tests: 3 new integration cases (credit-note rejection, status-flip
no-op warning, all_or_nothing 501). Suite green: 3218 passing.
Build clean, lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(api): PR #458 follow-up — defense-in-depth + comment precision
OWASP V8.2.1 (bulk-create): use DB-returned customer.id at insert time
instead of input.customer_id. The .eq() pair already enforces company
scoping at fetch, but echoing the trusted value from the query makes
the guarantee explicit at the call site and immune to refactoring
drift. Same change in the dry-run preview shape.
Swedish-compliance wording: the credit-note rejection comment now
spells out BOTH ML 17 kap 22–23§ requirements — distinct kreditfaktura
series AND explicit back-reference to the original invoice's
löpnummer — so any future v1 path that does support credit-note send
starts from a complete spec.
Company-settings select: kept select('*') with an explanatory comment
rather than enumerating columns. The InvoicePDF template consumes the
full CompanySettings shape; a partial allow-list risks silently breaking
rendering, and the table has no sensitive columns today (API tokens,
billing data live in scoped tables). Documents the trade-off so the
next reviewer doesn't re-litigate.
Deliberately not changed:
- Math.round → Math.trunc on VAT öre: CLAUDE.md mandates Math.round
project-wide; unilateral deviation here would diverge from the
bookkeeping engine and POST /invoices.
- 207 Multi-Status on partial post-email failures: gnubok's convention
is warnings[] in the 200 envelope; a per-route status divergence
would break the response contract clients rely on.
- Wrapper membership double-check (OWASP V8.2.1 send route): the
withApiV1 wrapper sets ctx.companyId from the URL after the
membership check — recurring false positive in this swarm.
Tests + build + lint clean. 17/17 in the touched suites.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
246 lines
8.5 KiB
TypeScript
246 lines
8.5 KiB
TypeScript
import { createServerClient } from '@supabase/ssr'
|
|
import { NextResponse, type NextRequest } from 'next/server'
|
|
import { shouldEnforceMfa } from '@/lib/auth/mfa'
|
|
|
|
export async function updateSession(request: NextRequest) {
|
|
let supabaseResponse = NextResponse.next({
|
|
request,
|
|
})
|
|
|
|
const supabase = createServerClient(
|
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
|
{
|
|
cookies: {
|
|
getAll() {
|
|
return request.cookies.getAll()
|
|
},
|
|
setAll(cookiesToSet) {
|
|
cookiesToSet.forEach(({ name, value }) =>
|
|
request.cookies.set(name, value)
|
|
)
|
|
supabaseResponse = NextResponse.next({
|
|
request,
|
|
})
|
|
cookiesToSet.forEach(({ name, value, options }) =>
|
|
supabaseResponse.cookies.set(name, value, options)
|
|
)
|
|
},
|
|
},
|
|
}
|
|
)
|
|
|
|
// IMPORTANT: Avoid writing any logic between createServerClient and
|
|
// supabase.auth.getUser(). A simple mistake could make it very hard to debug
|
|
// issues with users being randomly logged out.
|
|
|
|
const {
|
|
data: { user },
|
|
error: authError,
|
|
} = await supabase.auth.getUser()
|
|
|
|
// Get the pathname
|
|
const pathname = request.nextUrl.pathname
|
|
|
|
// If the refresh token is stale/invalid, clear the session cookies
|
|
// so the browser stops sending them on every request.
|
|
// Skip on auth routes — the callback needs PKCE cookies intact.
|
|
if (authError && !user && !pathname.startsWith('/auth')) {
|
|
await supabase.auth.signOut()
|
|
}
|
|
|
|
// Invite pages — accessible to everyone, signed in or not. A user who
|
|
// already has an account and is signed in should still be able to land on
|
|
// /invite/[token] to accept the invite with one click (see
|
|
// app/invite/[token]/page.tsx). If we bounce them to '/', they never see
|
|
// the invite at all.
|
|
if (pathname.startsWith('/invite')) {
|
|
return supabaseResponse
|
|
}
|
|
|
|
// Reset-password is reachable in both auth states. The recovery flow lands
|
|
// here with a fresh session (created by the OTP exchange in /auth/callback)
|
|
// precisely so the user can call supabase.auth.updateUser({ password }). If
|
|
// we bounce authenticated users to '/', the recovery email link silently
|
|
// fails. An already-logged-in user typing /reset-password directly just gets
|
|
// the same "change password" experience as in settings — no security loss.
|
|
if (pathname.startsWith('/reset-password')) {
|
|
return supabaseResponse
|
|
}
|
|
|
|
// Public auth routes — allow access
|
|
if (
|
|
pathname.startsWith('/login') ||
|
|
pathname.startsWith('/register') ||
|
|
pathname.startsWith('/auth') ||
|
|
pathname.startsWith('/sandbox')
|
|
) {
|
|
// If user is logged in and trying to access auth pages, redirect to dashboard
|
|
if (user) {
|
|
return NextResponse.redirect(new URL('/', request.url))
|
|
}
|
|
return supabaseResponse
|
|
}
|
|
|
|
// Protected routes - require authentication
|
|
if (!user) {
|
|
const url = request.nextUrl.clone()
|
|
url.pathname = '/login'
|
|
return NextResponse.redirect(url)
|
|
}
|
|
|
|
// MFA pages — accessible to authenticated users (AAL1+), skip MFA enforcement
|
|
if (pathname.startsWith('/mfa/')) {
|
|
return supabaseResponse
|
|
}
|
|
|
|
// MFA enforcement (application-side only, not RLS)
|
|
if (shouldEnforceMfa(user)) {
|
|
const { data: aal } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel()
|
|
|
|
// User has MFA enrolled but hasn't verified this session → redirect to verify
|
|
if (aal?.nextLevel === 'aal2' && aal?.currentLevel === 'aal1') {
|
|
return NextResponse.redirect(new URL('/mfa/verify', request.url))
|
|
}
|
|
|
|
// MFA required but user has no factor enrolled yet → force enrollment
|
|
// Skip for users with no companies (still setting up)
|
|
const companyIdForMfa = await resolveCompanyForMiddleware(supabase, user.id, request)
|
|
if (companyIdForMfa) {
|
|
const { data: factors } = await supabase.auth.mfa.listFactors()
|
|
const hasVerifiedFactor = factors?.totp?.some(f => f.status === 'verified')
|
|
|
|
if (!hasVerifiedFactor) {
|
|
return NextResponse.redirect(new URL('/mfa/enroll', request.url))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Forward the pathname so server layouts can branch on it (e.g. render a
|
|
// no-company shell for /settings/account).
|
|
supabaseResponse.headers.set('x-pathname', pathname)
|
|
|
|
// Company context resolution
|
|
const cookieCompanyId = request.cookies.get('gnubok-company-id')?.value
|
|
const companyId = await resolveCompanyForMiddleware(supabase, user.id, request)
|
|
|
|
// If the cookie pointed at a company we can no longer resolve (e.g.
|
|
// archived), clear it so the browser stops sending it.
|
|
if (cookieCompanyId && cookieCompanyId !== companyId) {
|
|
supabaseResponse.cookies.set('gnubok-company-id', '', { path: '/', maxAge: 0 })
|
|
}
|
|
|
|
// Routes that stay accessible when the user has no active company.
|
|
// Needed so a user who archived their last company can still delete
|
|
// their account without being trapped on /onboarding forever.
|
|
const isNoCompanyAllowed =
|
|
pathname.startsWith('/onboarding') ||
|
|
pathname.startsWith('/select-company') ||
|
|
pathname.startsWith('/settings/account') ||
|
|
pathname.startsWith('/api/account/') ||
|
|
pathname.startsWith('/api/company')
|
|
|
|
// No companies — redirect to the picker if we have BankID enrichment for
|
|
// this user, otherwise the manual wizard. Either way, allow the escape-hatch
|
|
// routes to pass through.
|
|
if (!companyId) {
|
|
if (isNoCompanyAllowed) {
|
|
return supabaseResponse
|
|
}
|
|
|
|
const { data: enrichmentRow } = await supabase
|
|
.from('extension_data')
|
|
.select('id')
|
|
.eq('user_id', user.id)
|
|
.eq('extension_id', 'tic')
|
|
.eq('key', 'bankid_enrichment')
|
|
.maybeSingle()
|
|
|
|
const destination = enrichmentRow ? '/select-company' : '/onboarding'
|
|
return NextResponse.redirect(new URL(destination, request.url))
|
|
}
|
|
|
|
// Set company cookie on the response so downstream requests have it
|
|
supabaseResponse.cookies.set('gnubok-company-id', companyId, {
|
|
path: '/',
|
|
httpOnly: true,
|
|
secure: process.env.NODE_ENV === 'production',
|
|
sameSite: 'lax',
|
|
maxAge: 60 * 60 * 24 * 365,
|
|
})
|
|
|
|
// Allow access to onboarding (for adding new companies), select-company, and companies/new
|
|
if (pathname.startsWith('/select-company') || pathname.startsWith('/companies/new') || pathname.startsWith('/onboarding')) {
|
|
return supabaseResponse
|
|
}
|
|
|
|
return supabaseResponse
|
|
}
|
|
|
|
/**
|
|
* Resolve the active company for the authenticated user.
|
|
*
|
|
* Resolution: user_preferences → first non-archived membership.
|
|
*
|
|
* `user_preferences.active_company_id` is the authoritative source for
|
|
* the active company on both the Next.js and Postgres RLS side. The
|
|
* `gnubok-company-id` cookie is still refreshed for legacy read paths
|
|
* but it is no longer READ here, because RLS (via
|
|
* `current_active_company_id()`) cannot see cookies — so letting the
|
|
* cookie override the database would re-introduce the divergence this
|
|
* entire migration exists to fix.
|
|
*
|
|
* When we fall back to "first membership" (no user_preferences row yet),
|
|
* we also upsert user_preferences so subsequent RLS lookups agree with
|
|
* us without needing the fallback scan.
|
|
*
|
|
* Cannot use lib/company/context.ts because middleware runs on Edge.
|
|
*/
|
|
async function resolveCompanyForMiddleware(
|
|
supabase: ReturnType<typeof createServerClient>,
|
|
userId: string,
|
|
_request: NextRequest
|
|
): Promise<string | null> {
|
|
// 1. user_preferences (authoritative)
|
|
const { data: prefs } = await supabase
|
|
.from('user_preferences')
|
|
.select('active_company_id')
|
|
.eq('user_id', userId)
|
|
.maybeSingle()
|
|
|
|
if (prefs?.active_company_id) {
|
|
const { data: membership } = await supabase
|
|
.from('company_members')
|
|
.select('company_id, companies!inner(archived_at)')
|
|
.eq('company_id', prefs.active_company_id)
|
|
.eq('user_id', userId)
|
|
.is('companies.archived_at', null)
|
|
.maybeSingle()
|
|
|
|
if (membership) return membership.company_id
|
|
}
|
|
|
|
// 2. Fallback: first non-archived membership by created_at
|
|
const { data: firstCompany } = await supabase
|
|
.from('company_members')
|
|
.select('company_id, companies!inner(archived_at)')
|
|
.eq('user_id', userId)
|
|
.is('companies.archived_at', null)
|
|
.order('created_at', { ascending: true })
|
|
.limit(1)
|
|
.maybeSingle()
|
|
|
|
if (!firstCompany) return null
|
|
|
|
// Write the fallback back to user_preferences so future RLS lookups
|
|
// see the same active company without needing this fallback scan.
|
|
await supabase
|
|
.from('user_preferences')
|
|
.upsert(
|
|
{ user_id: userId, active_company_id: firstCompany.company_id },
|
|
{ onConflict: 'user_id' }
|
|
)
|
|
|
|
return firstCompany.company_id
|
|
}
|