* feat(settings): per-company opt-in for data analysis of bookkeeping outcomes (#1346) Adds company_settings.data_analysis_opt_in (default false, no grandfathering) and gates every path that reads bookkeeping outcomes across companies on it: POST /api/agent/categorize/outcome stops writing calibration samples for companies that have not opted in, and the backtest / calibration-fit scripts filter to opted-in company ids. One helper (lib/company/data-analysis.ts) is the single gate for future analysis paths. A toggle on Inställningar > Företag states plainly what is analysed (proposed vs booked account, amount, confidence; no free text, no personal data) in sv and en. The flag is UI-only by design: consent is a human action, so it is absent from the v1 REST / MCP settings pick lists. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna * fix(settings): make data-analysis consent copy true for the backtest path (#1346) Addresses adversarial review findings on PR #2007: - Findings 1-3 (consent narrower than the gated processing): the flag also gates scripts/backtest-categorize.ts, which re-runs transaction descriptions, merchant names and matched underlag through the model. The sv/en toggle help and disclosure now state that explicitly as "evaluation runs" and no longer claim that free text or underlag are excluded. The migration header and COMMENT, the lib/company/data-analysis.ts docstring, the backtest script header and the DECISIONS line say the same. Kept the gate (un-gating would put the script back to reading every company with no consent at all). A test pins that both locales name those inputs and contain no "no free text / no underlag" denial. - Finding 4 (member sees an active switch that RLS rejects): the toggle is now enabled only for owner/admin, matching the company_settings update policy; the disclosure says only administrators can change the choice. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna * fix(scripts): address round-2 review findings (#1346) 1. [minor] Opted-in company filter was an unbounded PostgREST `in` list in the URL (scripts/fit-categorize-calibration.ts, scripts/backtest-categorize.ts). Both scripts now read the opted-in ids through a shared, paginated helper (listDataAnalysisOptedInCompanyIds, fetchAllRows so the pre-fetch no longer caps at 1000) and query per chunk of 100 ids (chunkCompanyIds). The fit script pages each chunk on the id PK; the backtest merges per-chunk results and re-cuts to the N most recent overall. Early exit on zero opt-ins is kept. Pinned with tests in lib/company/__tests__/data-analysis.test.ts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna * fix(scripts): coerce a null transaction description in the backtest (#1346) The typed row from the chunked consent query made description nullable, which TransactionForSelect does not accept; fall back to the original description or an empty string, as the untyped row did implicitly before. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
33a58bec51
commit
ad8566f1ae
@@ -11,13 +11,18 @@ vi.mock('@/lib/sandbox/guard', () => ({ guardSandbox: () => guardSandbox() }))
|
||||
import { POST } from '../route'
|
||||
|
||||
const inserts: Record<string, unknown>[] = []
|
||||
function makeSupabase(membership: unknown = { user_id: 'user-1' }) {
|
||||
// company_settings.maybeSingle() feeds the consent gate; company_members
|
||||
// feeds the membership check. Default: a member of an opted-in company.
|
||||
function makeSupabase(membership: unknown = { user_id: 'user-1' }, optedIn = true) {
|
||||
return {
|
||||
from(table: string) {
|
||||
const chain = {
|
||||
select: () => chain,
|
||||
eq: () => chain,
|
||||
maybeSingle: async () => ({ data: membership }),
|
||||
maybeSingle: async () =>
|
||||
table === 'company_settings'
|
||||
? { data: { data_analysis_opt_in: optedIn }, error: null }
|
||||
: { data: membership, error: null },
|
||||
insert: async (payload: Record<string, unknown>) => {
|
||||
if (table === 'categorize_calibration_samples') inserts.push(payload)
|
||||
return { error: null }
|
||||
@@ -61,7 +66,16 @@ describe('POST /api/agent/categorize/outcome', () => {
|
||||
expect(inserts).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('logs was_correct=true when the proposed account was booked', async () => {
|
||||
it('skips companies that have not opted in to data analysis (204, no sample)', async () => {
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase: makeSupabase({ user_id: 'user-1' }, false), error: null })
|
||||
const res = await POST(
|
||||
createMockRequest('/x', { method: 'POST', body: body({ proposed_account: '5410', booked_account: '5410' }) }),
|
||||
)
|
||||
expect(res.status).toBe(204)
|
||||
expect(inserts).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('logs was_correct=true when the proposed account was booked (opted-in company)', async () => {
|
||||
const res = await POST(
|
||||
createMockRequest('/x', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { requireAuth } from '@/lib/auth/require-auth'
|
||||
import { getActiveCompanyId } from '@/lib/company/context'
|
||||
import { isDataAnalysisOptedIn } from '@/lib/company/data-analysis'
|
||||
import { guardSandbox } from '@/lib/sandbox/guard'
|
||||
|
||||
/**
|
||||
@@ -14,7 +15,10 @@ import { guardSandbox } from '@/lib/sandbox/guard'
|
||||
*
|
||||
* Telemetry only: it never posts anything and is gated on auth + membership.
|
||||
* Sandbox bookings run on seed data, so they are silently skipped (a 204) to
|
||||
* keep the corpus clean.
|
||||
* keep the corpus clean. The corpus is read across companies, so it only
|
||||
* collects from companies that opted in to data analysis
|
||||
* (company_settings.data_analysis_opt_in, #1346): everyone else gets the same
|
||||
* silent 204 and no row.
|
||||
*/
|
||||
|
||||
const Schema = z.object({
|
||||
@@ -58,6 +62,10 @@ export async function POST(request: Request): Promise<Response> {
|
||||
const blocked = await guardSandbox(supabase, companyId)
|
||||
if (blocked) return noContent()
|
||||
|
||||
// Consent gate: the corpus is analysed across companies, so a company that
|
||||
// has not opted in contributes nothing (default false, no grandfathering).
|
||||
if (!(await isDataAnalysisOptedIn(supabase, companyId))) return noContent()
|
||||
|
||||
const proposed = parsed.data.proposed_account ?? null
|
||||
|
||||
// Best-effort: a failed telemetry insert must never surface to the user.
|
||||
|
||||
@@ -116,6 +116,39 @@ describe('PUT /api/settings', () => {
|
||||
expect(deadlineMocks.regenerate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('accepts the data_analysis_opt_in consent toggle', async () => {
|
||||
enqueueMany([
|
||||
{ data: { entity_type: 'enskild_firma', onboarding_complete: true } }, // fetch oldSettings
|
||||
{ data: { id: 's1', data_analysis_opt_in: true } }, // update ... returning
|
||||
{ data: null, count: 5 }, // deadlines count (has some -> no regen)
|
||||
])
|
||||
|
||||
const request = createMockRequest('/api/settings', {
|
||||
method: 'PUT',
|
||||
body: { data_analysis_opt_in: true },
|
||||
})
|
||||
const response = await PUT(request, { params: Promise.resolve({}) })
|
||||
const { status, body } = await parseJsonResponse<{ data: { data_analysis_opt_in: boolean } }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.data_analysis_opt_in).toBe(true)
|
||||
expect(deadlineMocks.regenerate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a non-boolean data_analysis_opt_in value', async () => {
|
||||
enqueueMany([
|
||||
{ data: { onboarding_complete: true } }, // oldSettings
|
||||
])
|
||||
|
||||
const request = createMockRequest('/api/settings', {
|
||||
method: 'PUT',
|
||||
body: { data_analysis_opt_in: 'yes' },
|
||||
})
|
||||
const response = await PUT(request, { params: Promise.resolve({}) })
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
|
||||
it('round-trips share capital fields and clears them with null', async () => {
|
||||
const updates = { aktiekapital: 25000, antal_aktier: 500 }
|
||||
enqueueMany([
|
||||
|
||||
Reference in New Issue
Block a user