* fix: article number ordering, free-text rows, invoice back-nav, onboarding resilience (#1053) Four fixes from Discord feedback in issue #1053: - Articles now order by article number with numeric-aware comparison ('2' before '10', unnumbered last, name tiebreak) in the invoice editor's article picker and as the register's default sort, via a shared lib/articles/sort.ts. Name order put article "1" last. - Invoice rows with no amounts (quantity 0, unit price 0) render as pure text rows on the PDF, the invoice detail page, and the review step via shared isTextLikeLine(), instead of printing "0 / 0,00 SEK / 0,00 SEK". Display-only; booking untouched. - The invoice editor navigates with router.replace after saving, so the detail page's back arrow returns to the list instead of reopening a fresh editor from history. - A transient query failure no longer reads as "no companies" / "onboarding not done": getActiveCompanyId throws CompanyContextError('resolution_failed') instead of returning null, the Edge middleware fails open on a degraded resolution (no onboarding redirect, no cookie clearing, no locale overwrite), and the dashboard page only redirects to /onboarding on a positively read incomplete/missing settings row. This is the likely cause of the completed onboarding wizard reappearing. Fixes #1053 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: CLAUDE.md tenancy line matches actual resolution order (prefs-first, cookie not read) The middleware stopped reading the gnubok-company-id cookie when user_preferences became authoritative (RLS parity); the stale doc line still described cookie-first order and misled review tooling. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
0e9cca2750
commit
97907a5a5c
+47
-12
@@ -174,7 +174,11 @@ export async function updateSession(request: NextRequest) {
|
||||
// Resolve the active company at most once per request: both the MFA
|
||||
// enrollment gate and the company-context block below need it, and the
|
||||
// resolution costs DB round trips.
|
||||
let resolvedCompany: { companyId: string | null; locale: string | null } | null = null
|
||||
let resolvedCompany: {
|
||||
companyId: string | null
|
||||
locale: string | null
|
||||
degraded: boolean
|
||||
} | null = null
|
||||
const resolveCompanyOnce = async () =>
|
||||
(resolvedCompany ??= await resolveCompanyForMiddleware(supabase, user.id, request))
|
||||
|
||||
@@ -206,11 +210,12 @@ export async function updateSession(request: NextRequest) {
|
||||
|
||||
// Company context resolution
|
||||
const cookieCompanyId = request.cookies.get('gnubok-company-id')?.value
|
||||
const { companyId, locale: dbLocale } = await resolveCompanyOnce()
|
||||
const { companyId, locale: dbLocale, degraded } = await resolveCompanyOnce()
|
||||
|
||||
// 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) {
|
||||
// archived), clear it so the browser stops sending it. Never on degraded
|
||||
// resolution: a transient query failure must not wipe a valid cookie.
|
||||
if (!degraded && cookieCompanyId && cookieCompanyId !== companyId) {
|
||||
supabaseResponse.cookies.set('gnubok-company-id', '', { path: '/', maxAge: 0 })
|
||||
}
|
||||
|
||||
@@ -219,7 +224,7 @@ export async function updateSession(request: NextRequest) {
|
||||
// without forcing every RSC render to query the database itself.
|
||||
const cookieLocale = request.cookies.get(LOCALE_COOKIE)?.value
|
||||
const effectiveLocale = isLocale(dbLocale) ? dbLocale : DEFAULT_LOCALE
|
||||
if (cookieLocale !== effectiveLocale) {
|
||||
if (!degraded && cookieLocale !== effectiveLocale) {
|
||||
supabaseResponse.cookies.set(LOCALE_COOKIE, effectiveLocale, {
|
||||
path: '/',
|
||||
sameSite: 'lax',
|
||||
@@ -246,6 +251,15 @@ export async function updateSession(request: NextRequest) {
|
||||
return supabaseResponse
|
||||
}
|
||||
|
||||
// Degraded resolution (a query FAILED, as opposed to returning no rows)
|
||||
// means the user's companies are unknown, not absent. Fail open: pass
|
||||
// the request through and let the layout's own resolution retry or
|
||||
// surface an error. Redirecting here showed fully onboarded users the
|
||||
// onboarding wizard again on a transient failure (issue #1053).
|
||||
if (degraded) {
|
||||
return supabaseResponse
|
||||
}
|
||||
|
||||
// Enrichment lives in the user-keyed `bankid_enrichment` table (migration
|
||||
// 20260506160000), it cannot live in extension_data, which is
|
||||
// company-scoped, and the user has no company yet on this path.
|
||||
@@ -299,13 +313,13 @@ async function resolveCompanyForMiddleware(
|
||||
supabase: ReturnType<typeof createServerClient>,
|
||||
userId: string,
|
||||
_request: NextRequest
|
||||
): Promise<{ companyId: string | null; locale: string | null }> {
|
||||
): Promise<{ companyId: string | null; locale: string | null; degraded: boolean }> {
|
||||
// 1. user_preferences (authoritative) + first membership, fetched in
|
||||
// parallel: the fallback query result doubles as validation when the
|
||||
// preferred company happens to be the first membership, which is the
|
||||
// common single-company case, so most requests pay one round trip
|
||||
// instead of two sequential ones.
|
||||
const [{ data: prefs }, { data: firstCompany }] = await Promise.all([
|
||||
const [prefsRes, firstRes] = await Promise.all([
|
||||
supabase
|
||||
.from('user_preferences')
|
||||
.select('active_company_id, locale')
|
||||
@@ -321,14 +335,28 @@ async function resolveCompanyForMiddleware(
|
||||
.maybeSingle(),
|
||||
])
|
||||
|
||||
const prefs = prefsRes.data
|
||||
const firstCompany = firstRes.data
|
||||
const locale = (prefs?.locale as string | undefined) ?? null
|
||||
|
||||
// A FAILED query (as opposed to one returning no rows) means the user's
|
||||
// companies are unknown right now, not absent: flag it so the caller
|
||||
// fails open instead of redirecting to onboarding or clearing cookies
|
||||
// (issue #1053). Middleware cannot throw usefully, hence a flag.
|
||||
if (prefsRes.error || firstRes.error) {
|
||||
console.error(
|
||||
'[middleware] company resolution query failed',
|
||||
prefsRes.error ?? firstRes.error
|
||||
)
|
||||
return { companyId: null, locale, degraded: true }
|
||||
}
|
||||
|
||||
if (prefs?.active_company_id) {
|
||||
if (prefs.active_company_id === firstCompany?.company_id) {
|
||||
return { companyId: firstCompany.company_id, locale }
|
||||
return { companyId: firstCompany.company_id, locale, degraded: false }
|
||||
}
|
||||
|
||||
const { data: membership } = await supabase
|
||||
const { data: membership, error: membershipError } = await supabase
|
||||
.from('company_members')
|
||||
.select('company_id, companies!inner(archived_at)')
|
||||
.eq('company_id', prefs.active_company_id)
|
||||
@@ -336,11 +364,18 @@ async function resolveCompanyForMiddleware(
|
||||
.is('companies.archived_at', null)
|
||||
.maybeSingle()
|
||||
|
||||
if (membership) return { companyId: membership.company_id, locale }
|
||||
// A failed validation must not silently switch the user onto their
|
||||
// first membership (wrong company for consultants): degrade instead.
|
||||
if (membershipError) {
|
||||
console.error('[middleware] company preference validation failed', membershipError)
|
||||
return { companyId: null, locale, degraded: true }
|
||||
}
|
||||
|
||||
if (membership) return { companyId: membership.company_id, locale, degraded: false }
|
||||
}
|
||||
|
||||
// 2. Fallback: first non-archived membership (already fetched above)
|
||||
if (!firstCompany) return { companyId: null, locale }
|
||||
if (!firstCompany) return { companyId: null, locale, degraded: false }
|
||||
|
||||
// Write the fallback back to user_preferences so future RLS lookups
|
||||
// see the same active company without needing this fallback scan.
|
||||
@@ -358,5 +393,5 @@ async function resolveCompanyForMiddleware(
|
||||
console.error('[middleware] active company write-back failed', writeBackError)
|
||||
}
|
||||
|
||||
return { companyId: firstCompany.company_id, locale }
|
||||
return { companyId: firstCompany.company_id, locale, degraded: false }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user