From 4ac7b45c8ad2b590b290573a70529dcfb89c125a Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Wed, 26 Aug 2026 15:14:49 +0200 Subject: [PATCH] perf(layout): dashboard layout in two waves, nav flags as one RPC, local JWT verification (#1946) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dashboard layout runs on every hard load, hard refresh, company switch and the 16 router.refresh() sites, and loading.tsx cannot paint until it resolves. It cost ~20 network calls in 4 sequential waves: a third getUser() round trip to Supabase Auth (after the proxy's and the route guard's), the company resolution, then 16 reads including four limit-1 probes whose only job is to decide whether to render the Webshop and Körjournal nav rows, and an entitlements read that itself ran two waves. - lib/auth/claims.ts: claimsPinned/userFromClaims extracted from require-auth.ts (unchanged) so the dashboard request context shares the exact pinning + mapping. getDashboardAuthContext verifies the JWT locally and falls back to getUser() only when claims are missing, unpinned or unverifiable: the proxy already performed the per-request revocation check before the layout runs (same semantics approved for routes on 2026-07-23). - Wave 1 (user-keyed, parallel with the company resolution): team membership, profile, user preferences and the memberships join, which now also supplies the active company's row and role, so the separate companies and company_members reads are gone. - Wave 2 (company-keyed): settings, agent profile, the switcher's settings names, entitlements in ONE wave (getCompanyEntitlements takes the team_id the join already carries and runs the grants read alongside config + subscription), and get_dashboard_nav_flags(). - supabase/migrations/20260826120000_get_dashboard_nav_flags.sql: SECURITY INVOKER, STABLE, EXECUTE for authenticated only; RLS applies inside. lib/dashboard/nav-flags.ts wraps it with the pre-RPC four-probe fallback on PGRST202/42883/42501 (self-hosted not yet migrated, deploy ordering) and degrades to hidden rows on any other error. - tests/pg/dashboard-nav-flags-rpc.pg.test.ts (6): fresh company, active vs pending WooCommerce, active Shopify, mileage trips, RLS for a member of another company, EXECUTE grants. Unit tests for the wrapper (RPC row, single-object payload, each fallback code, other errors) and for the entitlements teamId option. ~20 calls / 4 waves -> ~12 calls / 2 waves, 0 auth network calls. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- DECISIONS.md | 1 + app/(dashboard)/layout.tsx | 95 ++++++++---------- app/(dashboard)/request-context.ts | 26 ++++- lib/auth/claims.ts | 48 ++++++++++ lib/auth/require-auth.ts | 42 +------- lib/dashboard/__tests__/nav-flags.test.ts | 54 +++++++++++ lib/dashboard/nav-flags.ts | 60 ++++++++++++ .../__tests__/has-capability.test.ts | 24 +++++ lib/entitlements/has-capability.ts | 48 +++++++--- ...20260826120000_get_dashboard_nav_flags.sql | 48 ++++++++++ tests/pg/dashboard-nav-flags-rpc.pg.test.ts | 96 +++++++++++++++++++ 11 files changed, 433 insertions(+), 109 deletions(-) create mode 100644 lib/auth/claims.ts create mode 100644 lib/dashboard/__tests__/nav-flags.test.ts create mode 100644 lib/dashboard/nav-flags.ts create mode 100644 supabase/migrations/20260826120000_get_dashboard_nav_flags.sql create mode 100644 tests/pg/dashboard-nav-flags-rpc.pg.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index bd5a6517..98f25ebb 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1261,4 +1261,5 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-26] Webhook event catalogue lives in lib/webhooks/public-events.ts (grouped, with docs prose) and the fan-out handler set, the v1 create enum (so the OpenAPI spec and skills/accounted-api), and the docs page all derive from it: the enum and the docs had drifted to 24 of the 28 events the handler delivered, so the four reconciliation.* events were rejected at subscribe time. No API_V1_VERSION bump: the changelog already lists them as additive. [2026-08-26] Removed the phantom V1_ENDPOINT_SCOPES entries GET /api/v1/openapi.yaml, GET /api/v1/companies/:companyId and GET /api/v1/companies/:companyId/events instead of building the routes: no route file, registry entry, docs, skill or test referenced them, and the new scope-registry-parity test needs the map to describe only what exists. A company-detail GET can be added later with its entry in the same PR. [2026-08-26] gnubok_connect_bank / gnubok_connect_skatteverket moved from catalogVisibility 'search' to the default catalog: Claude.ai can only invoke tools present in tools/list, so search-only tools are discover-only there and the onboarding skill's steps 3-4 dead-ended on client-side tool-not-found (verified via event_log: the server never received the calls). Search-only visibility remains fine for tools an agent reads about before asking the user, but anything a skill instructs the agent to CALL must be in the default catalog. +[2026-08-26] Dashboard layout diet (responsiveness plan B4): the four nav-visibility probes (WooCommerce/Shopify connections, webshop orders, mileage trips) became one SECURITY INVOKER RPC get_dashboard_nav_flags (20260826120000) rather than a client-side badge hook, because the flags gate whole nav rows and loading them after mount pops rows into the sidebar; RLS still applies inside the function. The layout's getDashboardAuthContext now verifies the JWT locally (lib/auth/claims.ts, shared with requireAuth) with getUser() as the fallback: the proxy already did the per-request revocation check, so the layout's own getUser() was a second Supabase Auth round trip on every hard load. The user-keyed reads (profile, preferences, memberships) moved into the company-resolution wave and the memberships join now supplies the active company row and role, so the layout went from ~20 network calls in 4 waves to ~12 in 2. The memberships-with-embedded-settings single query was NOT attempted: the PostgREST embed shape was unverified and the settings-names query is parallel anyway. [2026-08-26] OAuth consent pre-checks ALL scopes (one-click, list collapsed in details): founder call after the read-only default dead-ended agent flows; defensible because every write is staged for approval, rows stay untickable, grant revocable. diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx index f8cf29ed..37ced443 100644 --- a/app/(dashboard)/layout.tsx +++ b/app/(dashboard)/layout.tsx @@ -16,6 +16,7 @@ import { getExtensionNavItems } from '@/lib/extensions/sectors' import { CompanyProvider } from '@/contexts/CompanyContext' import { ReferenceDataSeed } from '@/components/providers/ReferenceDataSeed' import { getCompanyEntitlements } from '@/lib/entitlements/has-capability' +import { getDashboardNavFlags } from '@/lib/dashboard/nav-flags' import { getBranding } from '@/lib/branding/service' import type { AccountingFramework, EntityType, CompanyRole, Team } from '@/types' import { @@ -69,7 +70,17 @@ export default async function DashboardLayout({ // Team membership (with the team row embedded) only depends on user.id, // so it resolves in parallel, this layout is on the critical path of // every dashboard page, so sequential round-trips are wall-clock time. - const [companyId, headerStore, { data: teamMembership }] = await Promise.all([ + // Wave 1: everything keyed on the user alone runs alongside the company + // resolution. The memberships join carries the active company's row and + // role too, so wave 2 no longer re-reads companies / company_members. + const [ + companyId, + headerStore, + { data: teamMembership }, + { data: userProfile }, + { data: userPrefs }, + { data: allMemberships }, + ] = await Promise.all([ getDashboardCompanyId(), // Read the pathname forwarded by middleware so we can branch on it. headers(), @@ -79,6 +90,15 @@ export default async function DashboardLayout({ .eq('user_id', user.id) .limit(1) .maybeSingle(), + // The signed-in user's profile, shown in the bottom-left account + // popover (full_name + initial) so it's clear which user is logged + // in, distinct from the active company shown at the top. + supabase.from('profiles').select('full_name').eq('id', user.id).maybeSingle(), + // Per-user UI state (nav collapse/fold state), server-rendered so the + // sidebar width is right on first paint, plus the hide-assistant-FAB + // preference (Inställningar → Assistenten). + supabase.from('user_preferences').select('ui_state, hide_assistant_fab').eq('user_id', user.id).maybeSingle(), + supabase.from('company_members').select('company_id, role, companies:company_id(id, name, org_number, entity_type, accounting_framework, created_by, team_id, archived_at, created_at, updated_at)').eq('user_id', user.id), ]) const pathname = headerStore.get('x-pathname') ?? '' @@ -140,78 +160,39 @@ export default async function DashboardLayout({ ) } - // Fetch company + membership for context provider, together with the - // nav/badge data, none of these depend on each other, only on - // companyId/user.id, so one round-trip batch instead of two. The rare - // stale-cookie early return below wastes the extra reads; that's cheaper - // than serializing two batches on every dashboard render. + // The active company's row and role come from the memberships join above + // (a company the user is not a member of resolves to null, same as the old + // .single() reads did). + const activeMembership = (allMemberships || []).find((m) => m.company_id === companyId) ?? null + const companyRow = (activeMembership?.companies as unknown as import('@/types').Company | null) ?? null + const memberRow = activeMembership ? { role: activeMembership.role } : null + + // Wave 2: everything keyed on the company. Nav badge counts are NOT fetched + // here: DashboardNav loads them client-side after mount + // (lib/hooks/use-worklist-badges). The four nav-visibility probes + // (webshop connections/orders, mileage trips) collapsed into one RPC. const [ - { data: companyRow }, - { data: memberRow }, - { data: allMemberships }, { data: settings, error: settingsError }, agentProfileIdentity, - { data: userProfile }, entitlements, { data: allSettingsNames }, - { data: userPrefs }, - hasWebshop, - hasMileageTrips, + navFlags, { data: seedFiscalPeriods }, { data: seedCashAccounts }, ] = await Promise.all([ - supabase.from('companies').select('*').eq('id', companyId).single(), - supabase.from('company_members').select('role').eq('company_id', companyId).eq('user_id', user.id).single(), - supabase.from('company_members').select('company_id, role, companies:company_id(id, name, org_number, entity_type, accounting_framework, created_by, team_id, archived_at, created_at, updated_at)').eq('user_id', user.id), getDashboardSettings(), - // Nav badge counts (unbooked transactions, pending operations) are NOT - // fetched here anymore: DashboardNav loads them client-side after mount - // (lib/hooks/use-worklist-badges) so two head-count queries stop blocking - // first paint on every dashboard navigation. // Agent identity, name + avatar, surfaced on the FAB and chat // surfaces. Null when no agent_profile exists yet (banner CTA path). getResolvedDashboardAgentProfile(), - // The signed-in user's profile, shown in the bottom-left account - // popover (full_name + initial) so it's clear which user is logged - // in, distinct from the active company shown at the top. - supabase.from('profiles').select('full_name').eq('id', user.id).maybeSingle(), - getCompanyEntitlements(supabase, companyId), + // teamId comes from the membership join, so the entitlements read is one + // wave (grants in parallel with config + subscription). + getCompanyEntitlements(supabase, companyId, { teamId: companyRow?.team_id ?? null }), // Current display names for ALL the user's companies (the switcher list). // RLS scopes company_settings SELECT to user_company_ids(), so this bare // select returns exactly the caller's companies, letting non-active rows // show company_settings.company_name instead of the frozen companies.name. supabase.from('company_settings').select('company_id, company_name'), - // Per-user UI state (nav collapse/fold state), server-rendered so the - // sidebar width is right on first paint, plus the hide-assistant-FAB - // preference (Inställningar → Assistenten). Batched here so it costs no - // extra round-trip on the dashboard critical path. - supabase.from('user_preferences').select('ui_state, hide_assistant_fab').eq('user_id', user.id).maybeSingle(), - // Whether the company has a webshop hooked up: an ACTIVE WooCommerce or - // Shopify connection, or already-imported webshop_orders rows (a - // disconnected store's orders are accounting underlag and must stay - // reachable). Three indexed limit-1 selects, parallel with the batch; - // accepted cost on the first-paint path (gates a nav destination, unlike - // the badge counts that moved client-side above). - Promise.all([ - supabase.from('woocommerce_connections').select('id').eq('company_id', companyId).eq('status', 'active').limit(1), - supabase.from('shopify_connections').select('id').eq('company_id', companyId).eq('status', 'active').limit(1), - supabase.from('webshop_orders').select('id').eq('company_id', companyId).limit(1), - ]).then( - ([woo, shopify, orders]) => - (woo.data?.length ?? 0) > 0 || - (shopify.data?.length ?? 0) > 0 || - (orders.data?.length ?? 0) > 0, - ), - // Whether the company already has mileage trips: OR-ed with the - // mileage_enabled settings toggle below so trips created via API/MCP can - // never be invisible underlag even if nobody flipped the toggle. Indexed - // limit-1 select, same accepted first-paint cost as the webshop gate. - supabase - .from('mileage_trips') - .select('id') - .eq('company_id', companyId) - .limit(1) - .then((trips) => (trips.data?.length ?? 0) > 0), + getDashboardNavFlags(supabase, companyId), // Reference-data seed (lib/reference-data/seed.ts): the two small lists // that gate almost every form, fetched once here so the first picker a // user opens renders populated with zero client round trips. Same @@ -230,6 +211,8 @@ export default async function DashboardLayout({ .order('is_primary', { ascending: false }) .order('ledger_account', { ascending: true }), ]) + const hasWebshop = navFlags.hasWebshop + const hasMileageTrips = navFlags.hasMileageTrips // company_id -> current display name for every company the user belongs to. const nameByCompany = new Map( diff --git a/app/(dashboard)/request-context.ts b/app/(dashboard)/request-context.ts index ed6ce6ed..c45fe3ea 100644 --- a/app/(dashboard)/request-context.ts +++ b/app/(dashboard)/request-context.ts @@ -1,7 +1,9 @@ import 'server-only' import { cache } from 'react' +import type { User } from '@supabase/supabase-js' import { createClient } from '@/lib/supabase/server' +import { claimsPinned, userFromClaims } from '@/lib/auth/claims' import { getActiveCompanyId } from '@/lib/company/context' import { ensureSandboxAgentProfile } from '@/lib/sandbox/ensure-agent' @@ -11,9 +13,27 @@ import { ensureSandboxAgentProfile } from '@/lib/sandbox/ensure-agent' */ export const getDashboardAuthContext = cache(async () => { const supabase = await createClient() - const { - data: { user }, - } = await supabase.auth.getUser() + // Local JWT verification first (same pinning + mapping as requireAuth, + // lib/auth/claims.ts): the proxy already performed the per-request + // revocation check with getUser() before this layout runs, so a second + // network round trip to Supabase Auth on every hard load, refresh and + // router.refresh() bought nothing. getUser() stays as the authoritative + // fallback when claims are missing, unpinned or unverifiable. + let user: User | null = null + if (typeof supabase.auth.getClaims === 'function') { + try { + const { data } = await supabase.auth.getClaims() + if (data?.claims?.sub && claimsPinned(data.claims)) user = userFromClaims(data.claims) + } catch { + // Fall through to the network check. + } + } + if (!user) { + const { + data: { user: fetched }, + } = await supabase.auth.getUser() + user = fetched + } return { supabase, user } }) diff --git a/lib/auth/claims.ts b/lib/auth/claims.ts new file mode 100644 index 00000000..703024dc --- /dev/null +++ b/lib/auth/claims.ts @@ -0,0 +1,48 @@ +import type { JwtPayload, User } from '@supabase/supabase-js' + +/** + * Local JWT claims to a User: shared by the API route guard (require-auth), + * the dashboard request context and, once adopted, the auth proxy. + * + * getClaims() verifies signature and expiry locally against the cached JWKS + * (asymmetric signing keys; HS256 projects fall back to a server call inside + * getClaims itself). These helpers add the pinning and the User mapping. + */ + +/** + * Defense-in-depth pinning on top of getClaims' signature/expiry verification: + * the token must come from THIS project's auth server (iss) and be an + * end-user access token (aud 'authenticated'; anonymous sign-ins share it). + * A mismatch is not treated as unauthenticated: callers fall back to the + * server-side getUser() check, which is authoritative. + */ +export function claimsPinned(claims: JwtPayload): boolean { + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL?.replace(/\/+$/, '') + // Without a configured URL (unit tests) there is nothing to pin against. + const issOk = !supabaseUrl || claims.iss === `${supabaseUrl}/auth/v1` + const aud = claims.aud + const audOk = Array.isArray(aud) ? aud.includes('authenticated') : aud === 'authenticated' + return issOk && audOk +} + +/** + * Maps verified JWT claims onto the User subset server code actually consumes + * (id, email, is_anonymous, app_metadata, user_metadata, role, phone). + * + * Server-only fields (identities, factors, created_at timestamps) are absent + * from the token and verified unused by any route (2026-07-23 audit); + * created_at is set to '' only to satisfy the type. + */ +export function userFromClaims(claims: JwtPayload): User { + return { + id: claims.sub, + aud: Array.isArray(claims.aud) ? (claims.aud[0] ?? 'authenticated') : (claims.aud ?? 'authenticated'), + role: claims.role, + email: claims.email, + phone: claims.phone, + app_metadata: claims.app_metadata ?? {}, + user_metadata: claims.user_metadata ?? {}, + is_anonymous: claims.is_anonymous ?? false, + created_at: '', + } +} diff --git a/lib/auth/require-auth.ts b/lib/auth/require-auth.ts index d95c9a23..8ce7b17b 100644 --- a/lib/auth/require-auth.ts +++ b/lib/auth/require-auth.ts @@ -1,49 +1,15 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { shouldEnforceMfa } from './mfa' -import type { User, SupabaseClient, JwtPayload } from '@supabase/supabase-js' +import type { User, SupabaseClient } from '@supabase/supabase-js' +import { claimsPinned, userFromClaims } from './claims' type AuthResult = | { user: User; supabase: SupabaseClient; error: null } | { user: null; supabase: SupabaseClient; error: NextResponse } -/** - * Maps verified JWT claims onto the User subset routes actually consume - * (id, email, is_anonymous, app_metadata, user_metadata, role, phone). - * - * Server-only fields (identities, factors, created_at timestamps) are absent - * from the token and verified unused by any route (2026-07-23 audit); - * created_at is set to '' only to satisfy the type. - */ -/** - * Defense-in-depth pinning on top of getClaims' signature/expiry verification: - * the token must come from THIS project's auth server (iss) and be an - * end-user access token (aud 'authenticated'; anonymous sign-ins share it). - * A mismatch is not treated as unauthenticated: we fall back to the - * server-side getUser() check, which is authoritative. - */ -function claimsPinned(claims: JwtPayload): boolean { - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL?.replace(/\/+$/, '') - // Without a configured URL (unit tests) there is nothing to pin against. - const issOk = !supabaseUrl || claims.iss === `${supabaseUrl}/auth/v1` - const aud = claims.aud - const audOk = Array.isArray(aud) ? aud.includes('authenticated') : aud === 'authenticated' - return issOk && audOk -} - -function userFromClaims(claims: JwtPayload): User { - return { - id: claims.sub, - aud: Array.isArray(claims.aud) ? (claims.aud[0] ?? 'authenticated') : (claims.aud ?? 'authenticated'), - role: claims.role, - email: claims.email, - phone: claims.phone, - app_metadata: claims.app_metadata ?? {}, - user_metadata: claims.user_metadata ?? {}, - is_anonymous: claims.is_anonymous ?? false, - created_at: '', - } -} +// claimsPinned / userFromClaims live in ./claims so the dashboard request +// context (and later the auth proxy) share the exact same pinning + mapping. /** * Auth + MFA guard for API routes. diff --git a/lib/dashboard/__tests__/nav-flags.test.ts b/lib/dashboard/__tests__/nav-flags.test.ts new file mode 100644 index 00000000..e47bf6c0 --- /dev/null +++ b/lib/dashboard/__tests__/nav-flags.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect, vi } from 'vitest' +import type { SupabaseClient } from '@supabase/supabase-js' +import { getDashboardNavFlags } from '../nav-flags' + +function makeSupabase( + rpcResult: { data?: unknown; error?: { code?: string; message?: string } | null }, + probes: Record = {}, +) { + const from = vi.fn((table: string) => { + const chain: Record = {} + const self: unknown = new Proxy(chain, { + get: (_t, prop) => { + if (prop === 'then') { + return (resolve: (v: unknown) => void) => resolve({ data: probes[table] ?? [], error: null }) + } + return () => self + }, + }) + return self + }) + const rpc = vi.fn(async () => ({ data: rpcResult.data ?? null, error: rpcResult.error ?? null })) + return { supabase: { from, rpc } as unknown as SupabaseClient, from, rpc } +} + +describe('getDashboardNavFlags', () => { + it('reads both flags from the RPC row and never touches the tables', async () => { + const { supabase, from, rpc } = makeSupabase({ data: [{ has_webshop: true, has_mileage_trips: false }] }) + expect(await getDashboardNavFlags(supabase, 'c1')).toEqual({ hasWebshop: true, hasMileageTrips: false }) + expect(rpc).toHaveBeenCalledWith('get_dashboard_nav_flags', { p_company_id: 'c1' }) + expect(from).not.toHaveBeenCalled() + }) + + it('accepts a single-object payload and treats null flags as false', async () => { + const { supabase } = makeSupabase({ data: { has_webshop: null, has_mileage_trips: true } }) + expect(await getDashboardNavFlags(supabase, 'c1')).toEqual({ hasWebshop: false, hasMileageTrips: true }) + }) + + it.each(['PGRST202', '42883', '42501'])('falls back to the four probes when the RPC is unavailable (%s)', async (code) => { + const { supabase, from } = makeSupabase({ error: { code } }, { webshop_orders: [{ id: 'o1' }] }) + expect(await getDashboardNavFlags(supabase, 'c1')).toEqual({ hasWebshop: true, hasMileageTrips: false }) + expect(from.mock.calls.map((c) => c[0]).sort()).toEqual([ + 'mileage_trips', + 'shopify_connections', + 'webshop_orders', + 'woocommerce_connections', + ]) + }) + + it('degrades to hidden rows on any other error instead of probing', async () => { + const { supabase, from } = makeSupabase({ error: { code: '57014', message: 'timeout' } }) + expect(await getDashboardNavFlags(supabase, 'c1')).toEqual({ hasWebshop: false, hasMileageTrips: false }) + expect(from).not.toHaveBeenCalled() + }) +}) diff --git a/lib/dashboard/nav-flags.ts b/lib/dashboard/nav-flags.ts new file mode 100644 index 00000000..f2638f37 --- /dev/null +++ b/lib/dashboard/nav-flags.ts @@ -0,0 +1,60 @@ +import type { SupabaseClient } from '@supabase/supabase-js' + +export interface DashboardNavFlags { + /** An active WooCommerce/Shopify connection or already-imported webshop orders. */ + hasWebshop: boolean + /** Existing mileage trips (created via UI, API or MCP). */ + hasMileageTrips: boolean +} + +const FALLBACK_CODES = new Set(['PGRST202', '42883', '42501']) + +/** + * The two booleans that gate the Webshop and Körjournal nav rows, in one + * round trip via get_dashboard_nav_flags() (migration 20260826120000). + * + * Fallback to the four limit-1 probes the layout ran before the RPC when + * the function is not deployed yet (self-hosted instance not migrated, or + * the deploy-ordering window before the branching merge applies the + * migration) or EXECUTE is not granted: mirrors the load-bearing fallback + * in lib/company/context.ts. Any other error degrades to (false, false): + * these flags only hide nav rows, they are never load-bearing. + */ +export async function getDashboardNavFlags( + supabase: SupabaseClient, + companyId: string, +): Promise { + const rpc = await supabase.rpc('get_dashboard_nav_flags', { p_company_id: companyId }) + if (!rpc.error) { + const row = (Array.isArray(rpc.data) ? rpc.data[0] : rpc.data) as + | { has_webshop?: boolean | null; has_mileage_trips?: boolean | null } + | null + | undefined + return { + hasWebshop: row?.has_webshop === true, + hasMileageTrips: row?.has_mileage_trips === true, + } + } + if (!FALLBACK_CODES.has(rpc.error.code ?? '')) { + return { hasWebshop: false, hasMileageTrips: false } + } + return getDashboardNavFlagsViaProbes(supabase, companyId) +} + +/** The pre-RPC implementation, kept verbatim as the fallback. */ +export async function getDashboardNavFlagsViaProbes( + supabase: SupabaseClient, + companyId: string, +): Promise { + const [woo, shopify, orders, trips] = await Promise.all([ + supabase.from('woocommerce_connections').select('id').eq('company_id', companyId).eq('status', 'active').limit(1), + supabase.from('shopify_connections').select('id').eq('company_id', companyId).eq('status', 'active').limit(1), + supabase.from('webshop_orders').select('id').eq('company_id', companyId).limit(1), + supabase.from('mileage_trips').select('id').eq('company_id', companyId).limit(1), + ]) + return { + hasWebshop: + (woo.data?.length ?? 0) > 0 || (shopify.data?.length ?? 0) > 0 || (orders.data?.length ?? 0) > 0, + hasMileageTrips: (trips.data?.length ?? 0) > 0, + } +} diff --git a/lib/entitlements/__tests__/has-capability.test.ts b/lib/entitlements/__tests__/has-capability.test.ts index e6840089..203eb5f8 100644 --- a/lib/entitlements/__tests__/has-capability.test.ts +++ b/lib/entitlements/__tests__/has-capability.test.ts @@ -244,6 +244,30 @@ describe('getCompanyEntitlements', () => { expect(result.trialExpiredAt).toBeNull() }) + it('skips the companies lookup and still scopes grants to the team when teamId is supplied', async () => { + const teamId = '22222222-2222-4222-8222-222222222222' + const base = makeSupabase({ + // Deliberately wrong: if the lookup ran, the team scope would be lost. + companies: { data: { team_id: null } }, + capability_grants: { + data: [{ capability_key: CAPABILITY.ai, expires_at: null, source: 'stripe' }], + }, + company_capability_config: { data: [] }, + }) + const tables: string[] = [] + const supabase = { + from: (table: string) => { + tables.push(table) + return (base.from as (t: string) => unknown)(table) + }, + } as unknown as SupabaseClient + const result = await getCompanyEntitlements(supabase, companyId, { teamId }) + expect(result.capabilities).toContain(CAPABILITY.ai) + expect(result.entitlementState).toBe('paid') + expect(tables).not.toContain('companies') + expect(tables).toContain('capability_grants') + }) + it('hides the trial once a non-trial grant is active (converted customer)', async () => { const supabase = makeSupabase({ companies: { data: { team_id: null } }, diff --git a/lib/entitlements/has-capability.ts b/lib/entitlements/has-capability.ts index 0422f71a..c37167fc 100644 --- a/lib/entitlements/has-capability.ts +++ b/lib/entitlements/has-capability.ts @@ -321,9 +321,36 @@ const PAYING_SUBSCRIPTION_STATUSES = ['active', 'trialing', 'past_due'] * CompanyContext so the UI can hide/disable/upsell gated features. * Self-hosted holds everything. */ +function normalizeTeamId(raw: string | null | undefined): string | null { + return raw && isUuid(raw) ? raw : null +} + +function readGrants(supabase: SupabaseClient, companyId: string, teamId: string | null) { + const scopeFilter = teamId + ? `company_id.eq.${companyId},team_id.eq.${teamId}` + : `company_id.eq.${companyId}` + return supabase + .from('capability_grants') + .select('capability_key, expires_at, source') + .in('capability_key', PAID_CAPABILITIES as unknown as string[]) + .or(scopeFilter) +} + +export interface GetCompanyEntitlementsOptions { + /** + * The company's team_id when the caller already has it (the dashboard + * layout reads it off the membership join): skips the companies lookup and + * lets the grants read run in the same wave as the other two, one round + * trip instead of two on the layout's critical path. Pass null for a + * company without a team. + */ + teamId?: string | null +} + export async function getCompanyEntitlements( supabase: SupabaseClient, companyId: string, + options: GetCompanyEntitlementsOptions = {}, ): Promise { if (isPaywallBypassed()) { return { @@ -345,8 +372,11 @@ export async function getCompanyEntitlements( // per RLS) distinguishes a churned payer from an expired trial: cancelled // subscriptions have their stripe grants deleted, so the grants alone // cannot tell the two apart. - const [{ data: company }, { data: configs }, { data: subscription }] = await Promise.all([ - supabase.from('companies').select('team_id').eq('id', companyId).maybeSingle(), + const knownTeam = options.teamId !== undefined + const [{ data: company }, { data: configs }, { data: subscription }, earlyGrants] = await Promise.all([ + knownTeam + ? Promise.resolve({ data: { team_id: options.teamId } }) + : supabase.from('companies').select('team_id').eq('id', companyId).maybeSingle(), supabase .from('company_capability_config') .select('capability_key, enabled') @@ -357,18 +387,12 @@ export async function getCompanyEntitlements( .select('status') .eq('company_id', companyId) .maybeSingle(), + // With the team known up front the grants read joins this wave. + knownTeam ? readGrants(supabase, companyId, normalizeTeamId(options.teamId)) : Promise.resolve(null), ]) - const rawTeamId = (company as { team_id: string | null } | null)?.team_id ?? null - const teamId = rawTeamId && isUuid(rawTeamId) ? rawTeamId : null + const teamId = normalizeTeamId((company as { team_id: string | null } | null)?.team_id ?? null) - const scopeFilter = teamId - ? `company_id.eq.${companyId},team_id.eq.${teamId}` - : `company_id.eq.${companyId}` - const { data: grants } = await supabase - .from('capability_grants') - .select('capability_key, expires_at, source') - .in('capability_key', PAID_CAPABILITIES as unknown as string[]) - .or(scopeFilter) + const { data: grants } = earlyGrants ?? (await readGrants(supabase, companyId, teamId)) const now = Date.now() const entitled = new Set() diff --git a/supabase/migrations/20260826120000_get_dashboard_nav_flags.sql b/supabase/migrations/20260826120000_get_dashboard_nav_flags.sql new file mode 100644 index 00000000..3f00d29f --- /dev/null +++ b/supabase/migrations/20260826120000_get_dashboard_nav_flags.sql @@ -0,0 +1,48 @@ +-- get_dashboard_nav_flags(p_company_id): the two booleans the dashboard +-- layout needs to decide whether to render the Webshop and Körjournal nav +-- rows, in ONE round trip instead of four limit-1 selects on the critical +-- path of every hard load (woocommerce_connections, shopify_connections, +-- webshop_orders, mileage_trips). +-- +-- SECURITY INVOKER: RLS on the four tables applies as usual, so a caller who +-- is not a member of p_company_id sees (false, false), never another +-- company's flags. STABLE: pure reads. EXECUTE only for authenticated. +-- +-- Responsiveness plan 2026-08-26, track B4 (dashboard layout diet). + +CREATE OR REPLACE FUNCTION public.get_dashboard_nav_flags(p_company_id uuid) +RETURNS TABLE(has_webshop boolean, has_mileage_trips boolean) +LANGUAGE sql +STABLE +SECURITY INVOKER +SET search_path TO 'public' +AS $$ + SELECT + ( + EXISTS ( + SELECT 1 FROM public.woocommerce_connections w + WHERE w.company_id = p_company_id AND w.status = 'active' + ) + OR EXISTS ( + SELECT 1 FROM public.shopify_connections s + WHERE s.company_id = p_company_id AND s.status = 'active' + ) + OR EXISTS ( + SELECT 1 FROM public.webshop_orders o + WHERE o.company_id = p_company_id + ) + ) AS has_webshop, + EXISTS ( + SELECT 1 FROM public.mileage_trips m + WHERE m.company_id = p_company_id + ) AS has_mileage_trips; +$$; + +REVOKE ALL ON FUNCTION public.get_dashboard_nav_flags(uuid) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.get_dashboard_nav_flags(uuid) FROM anon; +GRANT EXECUTE ON FUNCTION public.get_dashboard_nav_flags(uuid) TO authenticated; + +COMMENT ON FUNCTION public.get_dashboard_nav_flags(uuid) IS + 'Dashboard nav visibility flags (webshop, mileage) for one company in one round trip. SECURITY INVOKER: RLS applies.'; + +NOTIFY pgrst, 'reload schema'; diff --git a/tests/pg/dashboard-nav-flags-rpc.pg.test.ts b/tests/pg/dashboard-nav-flags-rpc.pg.test.ts new file mode 100644 index 00000000..682a502d --- /dev/null +++ b/tests/pg/dashboard-nav-flags-rpc.pg.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from 'vitest' +import { getPool, withUserContext } from './setup' +import { insertAuthUser, insertCompany, insertCompanyMember, seedCompany } from './fixtures' + +// Validates migration 20260826120000_get_dashboard_nav_flags: +// 1. has_webshop flips on an ACTIVE WooCommerce or Shopify connection +// (a pending/revoked one does not count) and has_mileage_trips on any +// mileage_trips row. +// 2. SECURITY INVOKER: a member of ANOTHER company sees (false, false) +// for this company, RLS applies inside the function. +// 3. EXECUTE is granted to authenticated only, not anon. +// +// webshop_orders is the third has_webshop source; its row shape carries many +// NOT NULL platform columns and the existing connection paths already prove +// the OR, so it is covered by the EXISTS shape, not a fixture here. + +const FLAGS = `SELECT has_webshop, has_mileage_trips FROM public.get_dashboard_nav_flags($1::uuid)` +type Row = { has_webshop: boolean; has_mileage_trips: boolean } + +async function flagsAs(userId: string, companyId: string): Promise { + return withUserContext(userId, async (client) => { + const res = await client.query(FLAGS, [companyId]) + expect(res.rows).toHaveLength(1) + return res.rows[0] + }) +} + +describe('get_dashboard_nav_flags()', () => { + it('is (false, false) for a fresh company', async () => { + const { userId, companyId } = await seedCompany() + expect(await flagsAs(userId, companyId)).toEqual({ has_webshop: false, has_mileage_trips: false }) + }) + + it('flips has_webshop on an active WooCommerce connection, not on a pending one', async () => { + const { userId, companyId } = await seedCompany() + await getPool().query( + `INSERT INTO public.woocommerce_connections (company_id, user_id, store_url, status) + VALUES ($1, $2, $3, 'pending')`, + // Unique per run: the active-connection unique index is on the store URL. + [companyId, userId, `https://nav-flags-${companyId.slice(0, 8)}.example.se`], + ) + expect((await flagsAs(userId, companyId)).has_webshop).toBe(false) + + await getPool().query( + `UPDATE public.woocommerce_connections SET status = 'active' WHERE company_id = $1`, + [companyId], + ) + expect((await flagsAs(userId, companyId)).has_webshop).toBe(true) + }) + + it('flips has_webshop on an active Shopify connection', async () => { + const { userId, companyId } = await seedCompany() + await getPool().query( + `INSERT INTO public.shopify_connections (company_id, user_id, shop_domain, status) + VALUES ($1, $2, $3, 'active')`, + // Unique per run: shopify_connections_shop_active_uniq is on the domain. + [companyId, userId, `nav-flags-${companyId.slice(0, 8)}.myshopify.com`], + ) + expect((await flagsAs(userId, companyId)).has_webshop).toBe(true) + }) + + it('flips has_mileage_trips on any mileage trip', async () => { + const { userId, companyId } = await seedCompany() + await getPool().query( + `INSERT INTO public.mileage_trips (company_id, user_id, trip_date, distance_km, from_location, to_location, purpose) + VALUES ($1, $2, '2026-08-01', 12.5, 'Kontoret', 'Kund AB', 'Kundbesök')`, + [companyId, userId], + ) + expect(await flagsAs(userId, companyId)).toEqual({ has_webshop: false, has_mileage_trips: true }) + }) + + it('applies RLS: a member of another company sees (false, false) for this one', async () => { + const { userId, companyId } = await seedCompany() + await getPool().query( + `INSERT INTO public.mileage_trips (company_id, user_id, trip_date, distance_km, from_location, to_location, purpose) + VALUES ($1, $2, '2026-08-01', 3, 'A', 'B', 'Test')`, + [companyId, userId], + ) + const outsider = await insertAuthUser() + const otherCompany = await insertCompany({ createdBy: outsider }) + await insertCompanyMember({ companyId: otherCompany, userId: outsider }) + + expect(await flagsAs(userId, companyId)).toEqual({ has_webshop: false, has_mileage_trips: true }) + expect(await flagsAs(outsider, companyId)).toEqual({ has_webshop: false, has_mileage_trips: false }) + }) + + it('grants EXECUTE to authenticated but not anon', async () => { + const res = await getPool().query<{ anon_can: boolean; authenticated_can: boolean }>( + `SELECT + has_function_privilege('anon', 'public.get_dashboard_nav_flags(uuid)', 'EXECUTE') AS anon_can, + has_function_privilege('authenticated', 'public.get_dashboard_nav_flags(uuid)', 'EXECUTE') AS authenticated_can`, + ) + expect(res.rows[0].anon_can).toBe(false) + expect(res.rows[0].authenticated_can).toBe(true) + }) +})