From cf4d00f276096b92c5aa53102aa00faf0c18bd9e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 05:07:53 +0000 Subject: [PATCH 1/5] Stop reporting zero when D1 refuses an oversized IN list, and feed the empty screens /team/analytics showed 331 bookings and GMV of Rs 3,24,472 next to "Collected Rs 0" on every service line, while the database held a captured payment for each of those bookings. D1 caps a query at 100 bound parameters, the payments read asked for 331, and the swallow-and-continue helper around it turned the refusal into a confident zero. The CX read in the same call was lost the same way. It was a whole class of read, not one query: booking payments and invoices behind the accounts view, customer subscriptions and signup dates, grooming cost attribution, partner job feed names, the seven per-booking money components of unit economics, payroll approval events, and the manager dashboard roster. Every IN-list read over an unbounded set now goes through chunkedIn, which splits the ids into chunks that fit inside the cap and concatenates the results, so the answer is the one the single query was meant to give. A sweep test fails if a library builds an IN list from a result set again; lists that cannot grow past the cap are named with the reason they are exempt. The team screens were also blank on staging for want of data rather than function: no reps are mapped to the sales team, and no campaign has ever been created. scripts/uat-demo-seed.sql adds both - a sales team with lead work, SLA clocks, recorded calls, conversions into the seeded bookings and an active productivity policy, plus a live campaign with its audience snapshot, holdout and ad spend. It layers on the existing staging seed, is idempotent, and is proven by executing the real modules over it rather than by counting rows. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CE4fKzrWRZetcYKh8CaU3b --- docs/STAGING_DEPLOY.md | 13 ++ lib/accounts-business-view.ts | 6 +- lib/company-analytics.ts | 3 +- lib/customer-business-view.ts | 10 +- lib/d1-chunked-in.ts | 36 ++++ lib/grooming-cost-attribution.ts | 6 +- lib/manager-dashboard.ts | 6 +- lib/partner-job-feed.ts | 4 +- lib/people-reports.ts | 3 +- lib/unit-economics.ts | 19 ++- scripts/uat-demo-seed-gen.mjs | 186 +++++++++++++++++++++ scripts/uat-demo-seed.sql | 254 +++++++++++++++++++++++++++++ tests/d1-in-clause-fanout.test.mjs | 197 ++++++++++++++++++++++ tests/uat-demo-seed.test.mjs | 160 ++++++++++++++++++ 14 files changed, 876 insertions(+), 27 deletions(-) create mode 100644 lib/d1-chunked-in.ts create mode 100644 scripts/uat-demo-seed-gen.mjs create mode 100644 scripts/uat-demo-seed.sql create mode 100644 tests/d1-in-clause-fanout.test.mjs create mode 100644 tests/uat-demo-seed.test.mjs diff --git a/docs/STAGING_DEPLOY.md b/docs/STAGING_DEPLOY.md index b8424f796..aaafbc844 100644 --- a/docs/STAGING_DEPLOY.md +++ b/docs/STAGING_DEPLOY.md @@ -76,6 +76,19 @@ npx wrangler d1 execute pawspace-staging --remote --file=scripts/staging-seed.sq After loading, open `/team/acquisition-funnel` and hit **Refresh sweep** to compute funnel stages, ₹300 recoveries and App-Inbound leads from the seeded data — instant material for the CRM/Sales test. +**Then load the team demo pack** so the internal surfaces are not blank. It adds the sales team the +performance leaderboard measures (4 reps mapped to `sales`, 27 leads with SLA clocks, recorded calls +and conversions into the seeded bookings, an active productivity policy) and the governed campaign +command centre (a live campaign with its audience snapshot and holdout, one awaiting approval, and ad +spend rows for the CAC line). Idempotent, and it reuses the customers and bookings above, so load it +second: +```bash +npx wrangler d1 execute pawspace-staging --remote --file=scripts/uat-demo-seed.sql +``` +(Regenerate with `node scripts/uat-demo-seed-gen.mjs`.) Then open `/team/performance` and press +**Generate 30-day report** — one click turns the seeded lead work into a ranked leaderboard, which is +also the check that the whole policy → run → board pipeline is live. + **Best option — MASKED REAL data** (the actual 4-year book, safe for staging). Run the importer against `The_PawSpace_TRUTH.xlsx` locally (the workbook and the generated SQL contain customer data — never commit either; keep them off shared drives): diff --git a/lib/accounts-business-view.ts b/lib/accounts-business-view.ts index a664e146d..af3aeac28 100644 --- a/lib/accounts-business-view.ts +++ b/lib/accounts-business-view.ts @@ -1,3 +1,4 @@ +import { chunkedIn } from "./d1-chunked-in"; type Db = D1Database; type Row = Record; @@ -43,10 +44,9 @@ export async function buildAccountsBusinessView(db: Db) { const bookingIds = bookings.map(b => String(b.id)); let invoices: Row[] = [], payments: Row[] = []; if (bookingIds.length) { - const placeholders = bookingIds.map(() => "?").join(","); [invoices, payments] = await Promise.all([ - safeAll(db, `SELECT id,booking_id,customer_id,invoice_number,status,gross_amount,tax_amount,net_amount,issued_at FROM booking_invoices WHERE booking_id IN (${placeholders})`, bookingIds), - safeAll(db, `SELECT booking_id,amount,status,method FROM booking_payments WHERE booking_id IN (${placeholders})`, bookingIds), + chunkedIn(bookingIds, (chunk, placeholders) => safeAll(db, `SELECT id,booking_id,customer_id,invoice_number,status,gross_amount,tax_amount,net_amount,issued_at FROM booking_invoices WHERE booking_id IN (${placeholders})`, chunk)), + chunkedIn(bookingIds, (chunk, placeholders) => safeAll(db, `SELECT booking_id,amount,status,method FROM booking_payments WHERE booking_id IN (${placeholders})`, chunk)), ]); } const invoiceByBooking = new Map(invoices.map(i => [String(i.booking_id), i])); diff --git a/lib/company-analytics.ts b/lib/company-analytics.ts index 76c381bb5..1325b9966 100644 --- a/lib/company-analytics.ts +++ b/lib/company-analytics.ts @@ -1,9 +1,10 @@ import { attributeGroomingBookingCosts } from "./grooming-cost-attribution"; +import{chunkedIn}from"./d1-chunked-in"; type Row=Record; const rows=(r:{results?:unknown[]})=>(r.results||[]) as T[]; async function safeAll(db:D1Database,sql:string,binds:unknown[]=[]){try{let q=db.prepare(sql);if(binds.length)q=q.bind(...binds);return rows(await q.all());}catch{return [] as Row[];}} -export async function buildCompanyAnalytics(db:D1Database,input:{from?:string;to?:string;serviceCode?:string;zoneId?:string}={}){const from=input.from||"1970-01-01",to=input.to||"2999-12-31",filters:string[]=["scheduled_start>=?","scheduled_startString(item.id));let payments:Row[]=[],tickets:Row[]=[];if(ids.length){const placeholders=ids.map(()=>"?").join(",");payments=await safeAll(db,`SELECT booking_id,amount,status,gateway FROM booking_payments WHERE booking_id IN (${placeholders})`,ids);tickets=await safeAll(db,`SELECT booking_id,category,priority,status,created_at,resolved_at,reopened_count FROM customer_experience_tickets WHERE booking_id IN (${placeholders})`,ids);}const providers=await safeAll(db,"SELECT id,provider_model,status,live,quality_score,rating FROM provider_capacity_profiles");const paymentByBooking=new Map(payments.map(p=>[String(p.booking_id),p]));const completed=bookings.filter(b=>String(b.status)==="completed"),cancelled=bookings.filter(b=>String(b.status)==="cancelled"),collected=bookings.reduce((s,b)=>{const p=paymentByBooking.get(String(b.id));return s+(p&&["captured","paid"].includes(String(p.status))?Number(p.amount||0):0)},0); +export async function buildCompanyAnalytics(db:D1Database,input:{from?:string;to?:string;serviceCode?:string;zoneId?:string}={}){const from=input.from||"1970-01-01",to=input.to||"2999-12-31",filters:string[]=["scheduled_start>=?","scheduled_startString(item.id));let payments:Row[]=[],tickets:Row[]=[];if(ids.length){payments=await chunkedIn(ids,(chunk,placeholders)=>safeAll(db,`SELECT booking_id,amount,status,gateway FROM booking_payments WHERE booking_id IN (${placeholders})`,chunk));tickets=await chunkedIn(ids,(chunk,placeholders)=>safeAll(db,`SELECT booking_id,category,priority,status,created_at,resolved_at,reopened_count FROM customer_experience_tickets WHERE booking_id IN (${placeholders})`,chunk));}const providers=await safeAll(db,"SELECT id,provider_model,status,live,quality_score,rating FROM provider_capacity_profiles");const paymentByBooking=new Map(payments.map(p=>[String(p.booking_id),p]));const completed=bookings.filter(b=>String(b.status)==="completed"),cancelled=bookings.filter(b=>String(b.status)==="cancelled"),collected=bookings.reduce((s,b)=>{const p=paymentByBooking.get(String(b.id));return s+(p&&["captured","paid"].includes(String(p.status))?Number(p.amount||0):0)},0); // GMV recognizes the same bookings as the P&L (lib/pnl-reporting.ts): cancelled and draft // bookings carry a total_amount but no recognizable revenue, so counting them silently // inflated GMV above the P&L turnover for the identical period. diff --git a/lib/customer-business-view.ts b/lib/customer-business-view.ts index d81048dd5..e316f43f2 100644 --- a/lib/customer-business-view.ts +++ b/lib/customer-business-view.ts @@ -1,4 +1,5 @@ import { buildCustomer360 } from "./customer-360"; +import { chunkedIn } from "./d1-chunked-in"; type Db = D1Database; type Row = Record; @@ -25,16 +26,15 @@ export async function buildCustomerBusinessView(db: Db, customerId?: string): Pr const records = await buildCustomer360(db, customerId); if (!records.length) return []; const ids = records.map(r => r.customerId); - const placeholders = ids.map(() => "?").join(","); let activeSubByCustomer = new Map(); let createdAtByCustomer = new Map(); try { - const subs = await db.prepare(`SELECT customer_id,MIN(expires_at) nearest_expiry FROM customer_grooming_subscriptions WHERE customer_id IN (${placeholders}) AND status='active' GROUP BY customer_id`).bind(...ids).all(); - activeSubByCustomer = new Map(subs.results.map(row => [String(row.customer_id), Number(row.nearest_expiry)])); + const subs = await chunkedIn(ids, async (chunk, placeholders) => (await db.prepare(`SELECT customer_id,MIN(expires_at) nearest_expiry FROM customer_grooming_subscriptions WHERE customer_id IN (${placeholders}) AND status='active' GROUP BY customer_id`).bind(...chunk).all()).results); + activeSubByCustomer = new Map(subs.map(row => [String(row.customer_id), Number(row.nearest_expiry)])); } catch { /* table may not exist yet in some environments */ } try { - const created = await db.prepare(`SELECT id,created_at FROM canonical_customers WHERE id IN (${placeholders})`).bind(...ids).all(); - createdAtByCustomer = new Map(created.results.map(row => [String(row.id), Number(row.created_at)])); + const created = await chunkedIn(ids, async (chunk, placeholders) => (await db.prepare(`SELECT id,created_at FROM canonical_customers WHERE id IN (${placeholders})`).bind(...chunk).all()).results); + createdAtByCustomer = new Map(created.map(row => [String(row.id), Number(row.created_at)])); } catch { /* table may not exist yet in some environments */ } const now = Date.now(), day = 86_400_000; return records.map(r => { diff --git a/lib/d1-chunked-in.ts b/lib/d1-chunked-in.ts new file mode 100644 index 000000000..b9b9bc681 --- /dev/null +++ b/lib/d1-chunked-in.ts @@ -0,0 +1,36 @@ +/** + * D1 caps a single query at 100 bound parameters. A `WHERE id IN (?,?,...)` built from a result set + * therefore works in every test fixture and every young environment, then starts failing the moment + * real volume arrives - and because these reads are wrapped in swallow-and-continue helpers, the + * failure surfaces as a confident zero rather than an error. + * + * Staging showed exactly that: /team/analytics reported 331 bookings and GMV of Rs 3,24,472 next to + * "Collected Rs 0" on every single service line, because the payments read for those 331 bookings + * asked D1 for 331 bound parameters and was discarded. + * + * Every IN-list read over an unbounded set goes through here: the ids are split into chunks that fit + * inside the cap and the chunk results are concatenated, so the answer is identical to the one the + * single query was meant to give. + */ +export const D1_IN_CHUNK = 80; + +export function idChunks(ids: readonly T[], size = D1_IN_CHUNK): T[][] { + if (size < 1) throw new Error("Chunk size must be at least 1"); + const chunks: T[][] = []; + for (let index = 0; index < ids.length; index += size) chunks.push(ids.slice(index, index + size)); + return chunks; +} + +/** + * Runs `read` once per chunk and concatenates the rows. `read` receives the chunk plus the matching + * `?,?,...` placeholder string, so call sites keep their own SQL, bindings and error handling. + */ +export async function chunkedIn( + ids: readonly T[], + read: (chunk: T[], placeholders: string) => Promise, + size = D1_IN_CHUNK, +): Promise { + if (!ids.length) return []; + const results = await Promise.all(idChunks(ids, size).map((chunk) => read(chunk, chunk.map(() => "?").join(",")))); + return results.flat(); +} diff --git a/lib/grooming-cost-attribution.ts b/lib/grooming-cost-attribution.ts index c554c8561..3fa987648 100644 --- a/lib/grooming-cost-attribution.ts +++ b/lib/grooming-cost-attribution.ts @@ -1,4 +1,5 @@ import { computeGroomerMonthlyIncentive } from "./grooming-incentive-engine"; +import { chunkedIn } from "./d1-chunked-in"; type Db = D1Database; type Row = Record; @@ -25,10 +26,9 @@ type Row = Record; export async function attributeGroomingBookingCosts(db: Db, bookingIds: string[]): Promise> { const result = new Map(); if (!bookingIds.length) return result; - const placeholders = bookingIds.map(() => "?").join(","); - const rows = await db.prepare( + const rows = { results: await chunkedIn(bookingIds, async (chunk, placeholders) => (await db.prepare( `SELECT id,provider_id,total_amount,scheduled_start FROM canonical_bookings WHERE id IN (${placeholders}) AND service_code='grooming' AND status='completed'` - ).bind(...bookingIds).all(); + ).bind(...chunk).all()).results) }; const groups = new Map }>(); for (const row of rows.results) { diff --git a/lib/manager-dashboard.ts b/lib/manager-dashboard.ts index 1f5684a43..137c342ca 100644 --- a/lib/manager-dashboard.ts +++ b/lib/manager-dashboard.ts @@ -3,6 +3,7 @@ import { currentGroomerBracket, computeGroomerMonthlyIncentive } from "./groomin import { computeTrainerMonthlyIncentive } from "./trainer-incentive-engine"; import { dailyClosureReadiness } from "./rep-daily-closure-governance"; import { dailyTalkTimeSummary } from "./talk-time-governance"; +import{chunkedIn}from"./d1-chunked-in"; type Db=D1Database; type Row=Record; @@ -34,9 +35,8 @@ async function employeesInScope(db:Db,scope:Scope){ return rows.results; } if(!scope.employeeEmails.length)return[]; - const placeholders=scope.employeeEmails.map(()=>"?").join(","); - const rows=await db.prepare(`SELECT e.id,e.work_email,e.user_email,e.display_name,v.title,v.team_code FROM employees e JOIN employee_employment_versions v ON v.employee_id=e.id AND v.effective_until IS NULL WHERE lower(COALESCE(e.user_email,e.work_email)) IN (${placeholders}) ORDER BY e.display_name`).bind(...scope.employeeEmails).all(); - return rows.results; + return chunkedIn(scope.employeeEmails,async(chunk,placeholders)=>(await db.prepare(`SELECT e.id,e.work_email,e.user_email,e.display_name,v.title,v.team_code FROM employees e JOIN employee_employment_versions v ON v.employee_id=e.id AND v.effective_until IS NULL WHERE lower(COALESCE(e.user_email,e.work_email)) IN (${placeholders}) ORDER BY e.display_name`).bind(...chunk).all()).results); + } /** diff --git a/lib/partner-job-feed.ts b/lib/partner-job-feed.ts index 9f656becc..422ea145c 100644 --- a/lib/partner-job-feed.ts +++ b/lib/partner-job-feed.ts @@ -1,3 +1,4 @@ +import{chunkedIn}from"./d1-chunked-in"; // Partner Job Feed: one unified, chronological feed of a provider's confirmed customer bookings // across ALL services, aggregated read-only from the real canonical tables. Founder requirement: // "once the booking is done the same info has to be updated in the partner app". @@ -68,8 +69,7 @@ export async function listProviderJobs(db:Db,providerId:string,now=Date.now()):P const customerIds=[...new Set(bookings.map(row=>String(row.customer_id)))].filter(Boolean); const nameByCustomer=new Map(); if(customerIds.length){ - const placeholders=customerIds.map(()=>"?").join(","); - for(const row of await safeAll(db,`SELECT id,name FROM canonical_customers WHERE id IN (${placeholders})`,customerIds))nameByCustomer.set(String(row.id),firstName(row.name)); + for(const row of await chunkedIn(customerIds,(chunk,placeholders)=>safeAll(db,`SELECT id,name FROM canonical_customers WHERE id IN (${placeholders})`,chunk)))nameByCustomer.set(String(row.id),firstName(row.name)); } const reference=new Date(now),startOfToday=new Date(reference.getFullYear(),reference.getMonth(),reference.getDate()).getTime(),endOfToday=startOfToday+DAY_MS; diff --git a/lib/people-reports.ts b/lib/people-reports.ts index 39d03e3af..ec47f1bdb 100644 --- a/lib/people-reports.ts +++ b/lib/people-reports.ts @@ -3,6 +3,7 @@ import{ensureAttendanceLeaveTables}from"./attendance-leave"; import{ensurePayrollTables}from"./payroll-engine"; import{ensureIncentiveTables}from"./incentive-engine"; import{ensurePeopleFinanceTables}from"./people-finance-integration"; +import{chunkedIn}from"./d1-chunked-in"; type Db=D1Database;type Row=Record;type Scope={mode:"all"|"manager";managerEmployeeId:string|null;employeeIds:string[];employeeEmails:string[]}; const text=(v:unknown)=>String(v??"").trim(),num=(v:unknown)=>Number(v||0),has=(p:string[],key:string)=>p.includes("*")||p.includes(key); @@ -26,6 +27,6 @@ export async function peopleReports(db:Db,input:{actorEmail:string;roleCode:stri const incentiveScope=scoped(scope,"r.employee_id"),incentives=canIncentives?await all(db,`SELECT r.id result_id,r.employee_id,r.status,r.calculated_amount,r.approved_amount,r.source_fact_run_id,p.id period_id,p.period_start,p.period_end FROM employee_incentive_results r JOIN employee_incentive_periods p ON p.id=r.period_id WHERE p.period_end>=? AND p.period_start<=?${incentiveScope.sql} ORDER BY p.period_end DESC LIMIT 300`,[start,end,...incentiveScope.params]):[]; const expenseScope=scoped(scope,"l.employee_id"),expenses=canFinance?await all(db,`SELECT l.expense_id,l.employee_id,l.linkage_status,f.expense_date,f.amount,f.status,f.cost_centre FROM people_expense_links l JOIN finance_expenses f ON f.id=l.expense_id WHERE f.expense_date>=? AND f.expense_date<=?${expenseScope.sql} ORDER BY f.expense_date DESC LIMIT 300`,[startDate,endDate,...expenseScope.params]):[],statutory=canFinance&&scope.mode==="all"?await all(db,"SELECT id,payroll_run_id,period_code,sandbox_only,external_submission,created_at FROM people_statutory_exports WHERE created_at>=? AND created_at<=? ORDER BY created_at DESC LIMIT 100",[start,end]):[]; const peopleAuditScope=scoped(scope,"employee_id"),peopleAudit=canAudit?await all(db,`SELECT id,employee_id,action,actor_id,created_at FROM people_audit_events WHERE created_at>=? AND created_at<=?${peopleAuditScope.sql} ORDER BY created_at DESC LIMIT 200`,[start,end,...peopleAuditScope.params]):[];let securityAudit:Row[]=[];if(canAudit&&scope.mode==="all")securityAudit=await all(db,"SELECT id,actor_email,actor_role,action,resource_type,resource_id,outcome,created_at FROM security_audit_events WHERE created_at>=? AND created_at<=? AND (action LIKE 'people.%' OR action LIKE 'attendance.%' OR action LIKE 'payroll.%' OR action LIKE 'incentive.%' OR action LIKE 'people_finance.%') ORDER BY created_at DESC LIMIT 300",[start,end]); - const runIds=[...new Set(payroll.map(r=>text(r.run_id)).filter(Boolean))],resultIds=[...new Set(incentives.map(r=>text(r.result_id)).filter(Boolean))];let payrollApprovalEvents:Row[]=[];if(canPayroll&&runIds.length)payrollApprovalEvents=await all(db,`SELECT id,run_id,event_type,actor_id,created_at FROM payroll_approval_events WHERE run_id IN (${runIds.map(()=>"?").join(",")}) ORDER BY created_at DESC LIMIT 200`,runIds);let incentiveApprovalEvents:Row[]=[];if(canIncentives&&resultIds.length)incentiveApprovalEvents=await all(db,`SELECT id,result_id,event_type,actor_id,created_at FROM incentive_approval_events WHERE result_id IN (${resultIds.map(()=>"?").join(",")}) ORDER BY created_at DESC LIMIT 200`,resultIds); + const runIds=[...new Set(payroll.map(r=>text(r.run_id)).filter(Boolean))],resultIds=[...new Set(incentives.map(r=>text(r.result_id)).filter(Boolean))];let payrollApprovalEvents:Row[]=[];if(canPayroll&&runIds.length)payrollApprovalEvents=await chunkedIn(runIds,(chunk,placeholders)=>all(db,`SELECT id,run_id,event_type,actor_id,created_at FROM payroll_approval_events WHERE run_id IN (${placeholders}) ORDER BY created_at DESC LIMIT 200`,chunk));let incentiveApprovalEvents:Row[]=[];if(canIncentives&&resultIds.length)incentiveApprovalEvents=await chunkedIn(resultIds,(chunk,placeholders)=>all(db,`SELECT id,result_id,event_type,actor_id,created_at FROM incentive_approval_events WHERE result_id IN (${placeholders}) ORDER BY created_at DESC LIMIT 200`,chunk)); const teams=new Map();for(const e of employees){const team=text(e.team_code)||"unassigned",r=teams.get(team)||{teamCode:team,headcount:0,attendanceExceptions:0,payrollCost:0};if(text(e.employment_status)==="active")r.headcount++;teams.set(team,r);}for(const a of attendance){if(!text(a.exception_code))continue;const team=employeeTeam.get(text(a.employee_id))||"unassigned",r=teams.get(team)||{teamCode:team,headcount:0,attendanceExceptions:0,payrollCost:0};r.attendanceExceptions++;teams.set(team,r);}for(const p of payroll){const team=text(p.team_code)||employeeTeam.get(text(p.employee_id))||"unassigned",r=teams.get(team)||{teamCode:team,headcount:0,attendanceExceptions:0,payrollCost:0};r.payrollCost=Number((r.payrollCost+num(p.gross_earnings)+num(p.employer_cost)).toFixed(2));teams.set(team,r);} return{period:{start,end,startDate,endDate},scope:{mode:scope.mode,managerEmployeeId:scope.managerEmployeeId,employeeCount:employees.length},metricDefinitions,headcount:{active,inactive:employees.length-active,joiners,leavers,total:employees.length},teamRollups:[...teams.values()].sort((a,b)=>a.teamCode.localeCompare(b.teamCode)),attendance:{available:canAttendance,byStatus:counts(attendance,"status"),exceptions:attendance.filter(r=>text(r.exception_code)).length,rows:attendance,leaveByStatus:counts(leave,"status"),leaveRows:leave},payroll:{available:canPayroll,register:payroll,runTotals:runs,variance,costByCostCentre:Object.entries(payroll.reduce((a:Record,r)=>{const k=text(r.cost_centre_code)||"unassigned";a[k]=(a[k]||0)+num(r.gross_earnings)+num(r.employer_cost);return a;},{} as Record)).map(([costCentre,amount])=>({costCentre,amount:Number(amount.toFixed(2))})),approvalEvents:payrollApprovalEvents},incentives:{available:canIncentives,byStatus:counts(incentives,"status"),calculatedAmount:sum(incentives,"calculated_amount"),approvedAmount:sum(incentives,"approved_amount"),rows:incentives,approvalEvents:incentiveApprovalEvents},finance:{available:canFinance,expenseLinks:expenses,expenseAmount:sum(expenses,"amount"),statutoryExports:statutory,statutoryExternalSubmissionEnabled:false,liveBankTransmissionEnabled:false},audit:{available:canAudit,peopleEvents:peopleAudit,securityEvents:securityAudit},sourceDrilldown:{employeeIds:[...employeeIds],attendanceIds:attendance.map(r=>text(r.id)),payrollResultIds:payroll.map(r=>text(r.result_id)),incentiveResultIds:incentives.map(r=>text(r.result_id)),expenseIds:expenses.map(r=>text(r.expense_id))},truth:{managerScopeEnforced:true,sensitivePayrollPermissionGated:true,sensitiveIncentivePermissionGated:true,staticDemoCountersAccepted:false,sourceDrilldownEnabled:true,payrollAuthority:false,disciplinaryAuthority:false,productionReady:false}};} diff --git a/lib/unit-economics.ts b/lib/unit-economics.ts index 6f8050c8d..82154454d 100644 --- a/lib/unit-economics.ts +++ b/lib/unit-economics.ts @@ -12,6 +12,7 @@ * utilisation and CAC (only when marketing spend facts exist). */ +import{chunkedIn}from"./d1-chunked-in"; type Db=D1Database; type Row=Record; @@ -31,18 +32,18 @@ export async function buildUnitEconomics(db:Db,input:UnitEconomicsFilters={}){ const bookings=await safeAll(db,["canonical_bookings"],`SELECT id,customer_id,provider_id,service_code,status,total_amount,scheduled_start,scheduled_end FROM canonical_bookings WHERE ${where}`,binds); if(!bookings.length)return{from,to,cityId:input.cityId??null,services:{},company:emptyCompany(),dataCoverage:coverageNote(),truth:truthNote()}; - const ids=bookings.map(row=>String(row.id)),placeholders=ids.map(()=>"?").join(","); + const ids=bookings.map(row=>String(row.id)); const active=bookings.filter(row=>!["cancelled","draft"].includes(String(row.status))); const serviceOf=new Map(bookings.map(row=>[String(row.id),String(row.service_code)])); // Real per-booking money components (each guarded on its owning table) - const coupons=await safeAll(db,["coupon_redemptions"],`SELECT booking_id,discount_amount FROM coupon_redemptions WHERE status='consumed' AND booking_id IN (${placeholders})`,ids); - const points=await safeAll(db,["paw_points_ledger"],`SELECT booking_id,points FROM paw_points_ledger WHERE entry_type='redeemed' AND booking_id IN (${placeholders})`,ids); - const wallet=await safeAll(db,["pawspace_wallet_ledger"],`SELECT source_id booking_id,applied_value FROM pawspace_wallet_ledger WHERE entry_type='redeem' AND source_id IN (${placeholders})`,ids); - const payouts=await safeAll(db,["provider_order_payouts"],`SELECT booking_id,amount FROM provider_order_payouts WHERE booking_id IN (${placeholders})`,ids); - const refunds=await safeAll(db,["booking_refund_cases"],`SELECT booking_id,amount FROM booking_refund_cases WHERE status='processed' AND booking_id IN (${placeholders})`,ids); - const reviews=await safeAll(db,["service_reviews"],`SELECT booking_id,stars FROM service_reviews WHERE booking_id IN (${placeholders})`,ids); - const tickets=await safeAll(db,["customer_experience_tickets"],`SELECT booking_id FROM customer_experience_tickets WHERE booking_id IN (${placeholders})`,ids); + const coupons=await chunkedIn(ids,(chunk,placeholders)=>safeAll(db,["coupon_redemptions"],`SELECT booking_id,discount_amount FROM coupon_redemptions WHERE status='consumed' AND booking_id IN (${placeholders})`,chunk)); + const points=await chunkedIn(ids,(chunk,placeholders)=>safeAll(db,["paw_points_ledger"],`SELECT booking_id,points FROM paw_points_ledger WHERE entry_type='redeemed' AND booking_id IN (${placeholders})`,chunk)); + const wallet=await chunkedIn(ids,(chunk,placeholders)=>safeAll(db,["pawspace_wallet_ledger"],`SELECT source_id booking_id,applied_value FROM pawspace_wallet_ledger WHERE entry_type='redeem' AND source_id IN (${placeholders})`,chunk)); + const payouts=await chunkedIn(ids,(chunk,placeholders)=>safeAll(db,["provider_order_payouts"],`SELECT booking_id,amount FROM provider_order_payouts WHERE booking_id IN (${placeholders})`,chunk)); + const refunds=await chunkedIn(ids,(chunk,placeholders)=>safeAll(db,["booking_refund_cases"],`SELECT booking_id,amount FROM booking_refund_cases WHERE status='processed' AND booking_id IN (${placeholders})`,chunk)); + const reviews=await chunkedIn(ids,(chunk,placeholders)=>safeAll(db,["service_reviews"],`SELECT booking_id,stars FROM service_reviews WHERE booking_id IN (${placeholders})`,chunk)); + const tickets=await chunkedIn(ids,(chunk,placeholders)=>safeAll(db,["customer_experience_tickets"],`SELECT booking_id FROM customer_experience_tickets WHERE booking_id IN (${placeholders})`,chunk)); type Ladder={gmv:number;orders:number;cancelled:number;discounts:number;providerPayout:number;refunds:number;tax:null;paymentFee:null;variableCost:null;contributionKnown:number;contributionPctOfGmv:number|null;avgOrderValue:number|null;reviews:number;csatAvgStars:number|null;csatPct:number|null;complaintsPer100:number|null;repeatRatePct:number|null;revenuePerProviderDay:number|null}; const ladders:Record={}; @@ -83,7 +84,7 @@ export async function buildUnitEconomics(db:Db,input:UnitEconomicsFilters={}){ const activeCustomers=[...new Set(active.map(row=>String(row.customer_id)))]; let ltvPerActiveCustomer:number|null=null; if(activeCustomers.length){ - const lifetime=await safeAll(db,["canonical_bookings"],`SELECT customer_id,SUM(total_amount) total FROM canonical_bookings WHERE status NOT IN ('cancelled','draft') AND customer_id IN (${activeCustomers.map(()=>"?").join(",")}) GROUP BY customer_id`,activeCustomers); + const lifetime=await chunkedIn(activeCustomers,(chunk,placeholders)=>safeAll(db,["canonical_bookings"],`SELECT customer_id,SUM(total_amount) total FROM canonical_bookings WHERE status NOT IN ('cancelled','draft') AND customer_id IN (${placeholders}) GROUP BY customer_id`,chunk)); ltvPerActiveCustomer=round2(lifetime.reduce((sum,row)=>sum+Number(row.total||0),0)/activeCustomers.length); } // Roster utilisation: booked reservation hours / rostered window hours across the window diff --git a/scripts/uat-demo-seed-gen.mjs b/scripts/uat-demo-seed-gen.mjs new file mode 100644 index 000000000..1914d7fcd --- /dev/null +++ b/scripts/uat-demo-seed-gen.mjs @@ -0,0 +1,186 @@ +// Generates scripts/uat-demo-seed.sql — the demo data for the team surfaces that render empty on a +// fresh staging database even though their modules work: the sales performance leaderboard (no reps +// mapped, no lead activity) and the governed campaign command centre (no campaigns, no audience). +// +// It layers on top of scripts/staging-seed.sql and reuses that seed's customers (CUS0000…) and +// bookings (BK00000…), so load the staging seed first. Every table shape below is copied verbatim from +// the module that owns it, every insert is INSERT OR IGNORE, and every timestamp is derived from a +// fixed base, so the file is deterministic and safe to re-run. +// +// Run: node scripts/uat-demo-seed-gen.mjs +// Load: npx wrangler d1 execute pawspace-staging --remote --file=scripts/uat-demo-seed.sql +import { writeFileSync } from "node:fs"; + +const BASE = Date.UTC(2026, 7, 1), DAY = 86400000; +const q = (value) => (value === null || value === undefined ? "NULL" : typeof value === "number" ? String(value) : `'${String(value).replace(/'/g, "''")}'`); +const insert = (table, row) => `INSERT OR IGNORE INTO ${table} (${Object.keys(row).join(",")}) VALUES (${Object.values(row).map(q).join(",")});`; +const s = []; + +s.push("-- PawSpace UAT demo seed (generated). Load scripts/staging-seed.sql first. Safe to re-run."); + +// ---------------------------------------------------------------- table shapes (verbatim from lib/) +for (const ddl of [ + "CREATE TABLE IF NOT EXISTS app_users (id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT NOT NULL, role_code TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)", + "CREATE TABLE IF NOT EXISTS lead_assignment_memberships (id TEXT PRIMARY KEY,employee_email TEXT NOT NULL,team_code TEXT NOT NULL,service_codes_json TEXT NOT NULL,city_ids_json TEXT NOT NULL,language_codes_json TEXT NOT NULL,active INTEGER NOT NULL DEFAULT 1,workload_cap_override INTEGER,created_by TEXT NOT NULL,created_at INTEGER NOT NULL,updated_by TEXT NOT NULL,updated_at INTEGER NOT NULL,UNIQUE(employee_email,team_code))", + "CREATE TABLE IF NOT EXISTS lead_assignments (id TEXT PRIMARY KEY,idempotency_key TEXT NOT NULL UNIQUE,lead_id TEXT NOT NULL,employee_email TEXT,team_code TEXT NOT NULL,policy_id TEXT NOT NULL,policy_version INTEGER NOT NULL,assignment_reason TEXT NOT NULL,status TEXT NOT NULL DEFAULT 'current',fallback_queue TEXT,assigned_at INTEGER NOT NULL,accepted_at INTEGER,ended_at INTEGER,ended_reason TEXT,previous_assignment_id TEXT,detail_json TEXT NOT NULL DEFAULT '{}',created_by TEXT NOT NULL,created_at INTEGER NOT NULL)", + "CREATE TABLE IF NOT EXISTS lead_work_items (id TEXT PRIMARY KEY, customer_id TEXT NOT NULL, source TEXT NOT NULL, service TEXT NOT NULL, owner TEXT NOT NULL, manager TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active', stage TEXT NOT NULL DEFAULT 'day_1', work_day INTEGER NOT NULL DEFAULT 1, assigned_at INTEGER NOT NULL, first_action_due_at INTEGER NOT NULL, manager_alert_at INTEGER NOT NULL, first_action_at INTEGER, call_attempts INTEGER NOT NULL DEFAULT 0, whatsapp_attempts INTEGER NOT NULL DEFAULT 0, last_outcome TEXT, next_action_at INTEGER, recycle_at INTEGER, recycle_cycle INTEGER NOT NULL DEFAULT 0, opt_out INTEGER NOT NULL DEFAULT 0, converted_booking_id TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)", + "CREATE TABLE IF NOT EXISTS lead_sla_clocks (id TEXT PRIMARY KEY,idempotency_key TEXT NOT NULL UNIQUE,lead_id TEXT NOT NULL,assignment_id TEXT NOT NULL,policy_id TEXT NOT NULL,policy_version INTEGER NOT NULL,clock_type TEXT NOT NULL,cycle INTEGER NOT NULL DEFAULT 1,status TEXT NOT NULL DEFAULT 'running',started_at INTEGER NOT NULL,due_at INTEGER NOT NULL,manager_escalation_due_at INTEGER NOT NULL,reassignment_due_at INTEGER NOT NULL,met_at INTEGER,breached_at INTEGER,paused_at INTEGER,pause_reason TEXT,paused_remaining_minutes INTEGER,last_action_at INTEGER,next_action_at INTEGER,detail_json TEXT NOT NULL DEFAULT '{}',created_by TEXT NOT NULL,created_at INTEGER NOT NULL,updated_at INTEGER NOT NULL,UNIQUE(lead_id,clock_type,cycle))", + "CREATE TABLE IF NOT EXISTS lead_sla_events (id TEXT PRIMARY KEY,idempotency_key TEXT NOT NULL UNIQUE,clock_id TEXT NOT NULL,lead_id TEXT NOT NULL,event_type TEXT NOT NULL,actor_id TEXT NOT NULL,detail_json TEXT NOT NULL DEFAULT '{}',created_at INTEGER NOT NULL)", + "CREATE TABLE IF NOT EXISTS sales_productivity_policies (id TEXT PRIMARY KEY,name TEXT NOT NULL,status TEXT NOT NULL DEFAULT 'draft',version INTEGER NOT NULL DEFAULT 1,team_code TEXT NOT NULL,timezone TEXT NOT NULL,meaningful_action_types_json TEXT NOT NULL,qualified_outcomes_json TEXT NOT NULL,revenue_basis TEXT NOT NULL,require_canonical_lead_booking_link INTEGER NOT NULL DEFAULT 1,effective_from INTEGER NOT NULL,effective_until INTEGER,approval_reference TEXT,created_by TEXT NOT NULL,created_at INTEGER NOT NULL,updated_by TEXT NOT NULL,updated_at INTEGER NOT NULL)", + "CREATE TABLE IF NOT EXISTS revenue_mission_events (id TEXT PRIMARY KEY,mission_id TEXT NOT NULL,source_event_key TEXT NOT NULL,event_type TEXT NOT NULL,customer_id TEXT NOT NULL,booking_id TEXT NOT NULL,payment_id TEXT,refund_id TEXT,service_code TEXT NOT NULL,city_id TEXT NOT NULL,gross_amount REAL NOT NULL DEFAULT 0,refund_amount REAL NOT NULL DEFAULT 0,eligible_amount REAL NOT NULL DEFAULT 0,currency TEXT NOT NULL,source_at INTEGER NOT NULL,source_version TEXT NOT NULL,attribution_json TEXT NOT NULL DEFAULT '{}',created_at INTEGER NOT NULL,UNIQUE(mission_id,source_event_key))", + // The productivity fact run reads CX escalations and revenue-opportunity suppressions per rep; a + // young database may not have created those tables yet, and the run must not fail on their absence. + "CREATE TABLE IF NOT EXISTS customer_experience_tickets (id TEXT PRIMARY KEY, customer_id TEXT, booking_id TEXT, lead_id TEXT, category TEXT NOT NULL, priority TEXT NOT NULL, subject TEXT NOT NULL, detail TEXT NOT NULL, owner TEXT NOT NULL, manager TEXT NOT NULL, sla_due_at INTEGER NOT NULL, status TEXT NOT NULL DEFAULT 'open', escalation_level INTEGER NOT NULL DEFAULT 0, customer_status TEXT NOT NULL DEFAULT 'We received your request', resolution TEXT, root_cause TEXT, resolution_evidence TEXT, reopened_count INTEGER NOT NULL DEFAULT 0, created_by TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, resolved_at INTEGER)", + "CREATE TABLE IF NOT EXISTS canonical_revenue_opportunities (id TEXT PRIMARY KEY,idempotency_key TEXT NOT NULL UNIQUE,customer_id TEXT NOT NULL,opportunity_type TEXT NOT NULL,service_code TEXT,reason TEXT NOT NULL,status TEXT NOT NULL,preferred_channel TEXT NOT NULL,estimated_value REAL NOT NULL DEFAULT 0,confidence REAL NOT NULL DEFAULT 0,signal_snapshot_json TEXT NOT NULL,suppression_reasons_json TEXT NOT NULL DEFAULT '[]',policy_id TEXT NOT NULL,policy_version INTEGER NOT NULL,source_key TEXT NOT NULL,converted_booking_id TEXT,created_by TEXT NOT NULL,created_at INTEGER NOT NULL,updated_at INTEGER NOT NULL,UNIQUE(customer_id,source_key,policy_id,policy_version))", + "CREATE TABLE IF NOT EXISTS governed_marketing_campaigns (id TEXT PRIMARY KEY,name TEXT NOT NULL,objective TEXT NOT NULL,service_code TEXT,city_id TEXT,audience_rule_json TEXT NOT NULL DEFAULT '{}',budget_amount REAL NOT NULL,currency TEXT NOT NULL DEFAULT 'INR',holdout_percent INTEGER NOT NULL,status TEXT NOT NULL DEFAULT 'draft',approval_status TEXT NOT NULL DEFAULT 'approval_required',approved_by TEXT,approved_at INTEGER,start_at INTEGER,end_at INTEGER,created_by TEXT NOT NULL,created_at INTEGER NOT NULL,updated_at INTEGER NOT NULL)", + "CREATE TABLE IF NOT EXISTS marketing_audience_snapshots (id TEXT PRIMARY KEY,campaign_id TEXT NOT NULL,snapshot_at INTEGER NOT NULL,total_candidates INTEGER NOT NULL,eligible_count INTEGER NOT NULL,holdout_count INTEGER NOT NULL,suppressed_count INTEGER NOT NULL,policy_json TEXT NOT NULL,created_by TEXT NOT NULL)", + "CREATE TABLE IF NOT EXISTS marketing_audience_members (snapshot_id TEXT NOT NULL,campaign_id TEXT NOT NULL,customer_id TEXT NOT NULL,cohort TEXT NOT NULL,suppression_reason TEXT,PRIMARY KEY(snapshot_id,customer_id))", + "CREATE TABLE IF NOT EXISTS marketing_attribution_facts (id TEXT PRIMARY KEY,campaign_id TEXT NOT NULL,customer_id TEXT,lead_id TEXT,booking_id TEXT,collection_id TEXT,source TEXT NOT NULL,medium TEXT,spend_amount REAL,booked_revenue REAL,collected_revenue REAL,contribution_margin REAL,attribution_model TEXT NOT NULL DEFAULT 'unconfigured',created_at INTEGER NOT NULL,updated_at INTEGER NOT NULL)", +]) s.push(`${ddl};`); + +// ------------------------------------------------------------------------------------ sales team +const REPS = [ + { email: "asha.rao@pawspace.in", name: "Asha Rao", leads: 9, qualified: 5, converted: 3 }, + { email: "vikram.shetty@pawspace.in", name: "Vikram Shetty", leads: 7, qualified: 4, converted: 2 }, + { email: "neha.kulkarni@pawspace.in", name: "Neha Kulkarni", leads: 6, qualified: 2, converted: 2 }, + { email: "rohit.menon@pawspace.in", name: "Rohit Menon", leads: 5, qualified: 1, converted: 1 }, +]; +const AMOUNTS = [799, 1299, 699, 899, 599]; +let leadSeq = 0, bookingSeq = 0; + +s.push("-- Sales team: reps, their team membership, and the lead work the leaderboard measures."); +for (const [repIndex, rep] of REPS.entries()) { + s.push(insert("app_users", { id: `USR-SALES-${repIndex}`, email: rep.email, name: rep.name, role_code: "associate", status: "active", created_at: BASE - 120 * DAY, updated_at: BASE - 120 * DAY })); + s.push(insert("lead_assignment_memberships", { + id: `LAM-SALES-${repIndex}`, employee_email: rep.email, team_code: "sales", + service_codes_json: JSON.stringify(["grooming", "boarding", "dog_training"]), city_ids_json: JSON.stringify(["blr", "hyd"]), + language_codes_json: JSON.stringify(["en", "hi"]), active: 1, workload_cap_override: null, + created_by: "uat-demo-seed", created_at: BASE - 120 * DAY, updated_by: "uat-demo-seed", updated_at: BASE - 120 * DAY, + })); + + for (let index = 0; index < rep.leads; index += 1) { + const leadId = `LEAD-UAT-${String(leadSeq).padStart(4, "0")}`; + const assignmentId = `ASG-UAT-${String(leadSeq).padStart(4, "0")}`; + const clockId = `CLK-UAT-${String(leadSeq).padStart(4, "0")}`; + const customerId = `CUS${String(leadSeq * 3 % 220).padStart(4, "0")}`; + // Spread the work across the last three weeks so 7/30/90-day windows all show something. + const at = BASE - (3 + (leadSeq % 18)) * DAY; + const qualified = index < rep.qualified; + const converted = index < rep.converted; + const bookingId = converted ? `BK${String(bookingSeq++).padStart(5, "0")}` : null; + const amount = AMOUNTS[leadSeq % AMOUNTS.length]; + const metSla = index % 3 !== 2; + + s.push(insert("lead_work_items", { + id: leadId, customer_id: customerId, source: ["website", "whatsapp", "referral"][index % 3], service: ["grooming", "boarding", "dog_training"][index % 3], + owner: rep.email, manager: "sales.manager@pawspace.in", status: converted ? "converted" : "active", stage: "day_1", work_day: 1, + assigned_at: at, first_action_due_at: at + 600000, manager_alert_at: at + 1_800_000, first_action_at: at + 300000, + call_attempts: 1 + (index % 3), whatsapp_attempts: index % 2, last_outcome: qualified ? "qualified" : "call_back_later", + next_action_at: at + 2 * DAY, recycle_at: null, recycle_cycle: 0, opt_out: 0, converted_booking_id: bookingId, + created_at: at, updated_at: at + 3 * DAY, + })); + s.push(insert("lead_assignments", { + id: assignmentId, idempotency_key: `${assignmentId}-idem`, lead_id: leadId, employee_email: rep.email, team_code: "sales", + policy_id: "LAP-UAT-DEMO", policy_version: 1, assignment_reason: "round_robin", status: "current", fallback_queue: null, + assigned_at: at, accepted_at: at + 120000, ended_at: null, ended_reason: null, previous_assignment_id: null, + detail_json: "{}", created_by: "uat-demo-seed", created_at: at, + })); + s.push(insert("lead_sla_clocks", { + id: clockId, idempotency_key: `${clockId}-idem`, lead_id: leadId, assignment_id: assignmentId, policy_id: "LSP-UAT-DEMO", policy_version: 1, + clock_type: "first_response", cycle: 1, status: metSla ? "met" : "breached", started_at: at, due_at: at + 600000, + manager_escalation_due_at: at + 1_800_000, reassignment_due_at: at + 3_600_000, met_at: metSla ? at + 300000 : null, + breached_at: metSla ? null : at + 900000, paused_at: null, pause_reason: null, paused_remaining_minutes: null, + last_action_at: at + 300000, next_action_at: at + 2 * DAY, detail_json: "{}", created_by: "uat-demo-seed", created_at: at, updated_at: at + 300000, + })); + // Every touch is a recorded action; its outcome is what makes the lead "qualified" under a policy. + for (const [actionIndex, actionType] of ["call", "whatsapp"].entries()) { + const eventId = `EVT-UAT-${String(leadSeq).padStart(4, "0")}-${actionIndex}`; + s.push(insert("lead_sla_events", { + id: eventId, idempotency_key: `${eventId}-idem`, clock_id: clockId, lead_id: leadId, event_type: "action_recorded", actor_id: rep.email, + detail_json: JSON.stringify({ actionType, outcome: actionIndex === 0 && qualified ? "qualified" : converted ? "booked" : "call_back_later" }), + created_at: at + 300000 + actionIndex * 60000, + })); + } + if (converted) { + // Money for a converted lead comes from the revenue ledger, keyed to the booking. + s.push(insert("revenue_mission_events", { + id: `RME-UAT-${leadSeq}-c`, mission_id: "MIS-UAT-DEMO", source_event_key: `${bookingId}-collected`, event_type: "collected", + customer_id: customerId, booking_id: bookingId, payment_id: `PAY-${bookingId}`, refund_id: null, service_code: "grooming", city_id: "blr", + gross_amount: amount, refund_amount: 0, eligible_amount: amount, currency: "INR", source_at: at + 2 * DAY, + source_version: "uat_demo_seed:v1", attribution_json: "{}", created_at: at + 2 * DAY, + })); + if (leadSeq % 7 === 0) { + s.push(insert("revenue_mission_events", { + id: `RME-UAT-${leadSeq}-r`, mission_id: "MIS-UAT-DEMO", source_event_key: `${bookingId}-refunded`, event_type: "refunded", + customer_id: customerId, booking_id: bookingId, payment_id: `PAY-${bookingId}`, refund_id: `RFD-${bookingId}`, service_code: "grooming", city_id: "blr", + gross_amount: 0, refund_amount: Math.round(amount / 2), eligible_amount: 0, currency: "INR", source_at: at + 3 * DAY, + source_version: "uat_demo_seed:v1", attribution_json: "{}", created_at: at + 3 * DAY, + })); + } + } + leadSeq += 1; + } +} + +// An active policy so the leaderboard has a definition to measure against. The fact run is deliberately +// left out: generating it is one click on /team/performance, which is also how the pipeline is proven. +s.push("-- Active productivity policy. Generate the report from /team/performance to fill the board."); +s.push(insert("sales_productivity_policies", { + id: "SPP-UAT-DEMO", name: "Sales productivity (UAT baseline)", status: "active_uat", version: 1, team_code: "sales", timezone: "Asia/Kolkata", + meaningful_action_types_json: JSON.stringify(["call", "whatsapp"]), qualified_outcomes_json: JSON.stringify(["qualified", "booked"]), + revenue_basis: "net_collected", require_canonical_lead_booking_link: 1, effective_from: BASE - 365 * DAY, effective_until: null, + approval_reference: "UAT-DEMO-SEED", created_by: "uat-demo-seed", created_at: BASE - 120 * DAY, updated_by: "uat-demo-seed", updated_at: BASE - 120 * DAY, +})); + +// -------------------------------------------------------------------------------------- marketing +s.push("-- Governed campaigns: one live with a taken audience snapshot, one awaiting approval."); +const CAMPAIGNS = [ + { id: "CMP-UAT-MONSOON", name: "Monsoon grooming refresh", objective: "reactivation", service: "grooming", city: "blr", budget: 45000, holdout: 10, status: "active", approval: "approved" }, + { id: "CMP-UAT-BOARDING", name: "Festive boarding pre-book", objective: "acquisition", service: "boarding", city: "hyd", budget: 60000, holdout: 15, status: "draft", approval: "approval_required" }, +]; +for (const campaign of CAMPAIGNS) { + s.push(insert("governed_marketing_campaigns", { + id: campaign.id, name: campaign.name, objective: campaign.objective, service_code: campaign.service, city_id: campaign.city, + audience_rule_json: JSON.stringify({ marketingConsent: true, serviceCode: campaign.service, cityId: campaign.city }), + budget_amount: campaign.budget, currency: "INR", holdout_percent: campaign.holdout, status: campaign.status, approval_status: campaign.approval, + approved_by: campaign.approval === "approved" ? "marketing.head@pawspace.in" : null, approved_at: campaign.approval === "approved" ? BASE - 20 * DAY : null, + start_at: campaign.status === "active" ? BASE - 18 * DAY : null, end_at: campaign.status === "active" ? BASE + 12 * DAY : null, + created_by: "uat-demo-seed", created_at: BASE - 25 * DAY, updated_at: BASE - 18 * DAY, + })); +} + +// The snapshot mirrors what snapshotCampaignAudience() records: consent-eligible customers, an explicit +// holdout, and everyone else suppressed with the reason they were held back. +const live = CAMPAIGNS[0]; +const audience = []; +for (let index = 0; index < 220; index += 3) audience.push(`CUS${String(index).padStart(4, "0")}`); +const holdout = audience.filter((_, index) => index % 10 === 0); +const suppressed = audience.filter((_, index) => index % 7 === 0 && index % 10 !== 0); +const eligible = audience.filter((id) => !holdout.includes(id) && !suppressed.includes(id)); +s.push(insert("marketing_audience_snapshots", { + id: "MAS-UAT-MONSOON", campaign_id: live.id, snapshot_at: BASE - 18 * DAY, total_candidates: audience.length, + eligible_count: eligible.length, holdout_count: holdout.length, suppressed_count: suppressed.length, + policy_json: JSON.stringify({ requiresMarketingConsent: true, holdoutPercent: live.holdout, suppressionReasons: ["marketing_consent_missing"] }), + created_by: "uat-demo-seed", +})); +for (const customerId of audience) { + const cohort = holdout.includes(customerId) ? "holdout" : suppressed.includes(customerId) ? "suppressed" : "eligible"; + s.push(insert("marketing_audience_members", { + snapshot_id: "MAS-UAT-MONSOON", campaign_id: live.id, customer_id: customerId, cohort, + suppression_reason: cohort === "suppressed" ? "marketing_consent_missing" : null, + })); +} + +// Attribution facts carry the spend the unit-economics CAC line refuses to invent. +for (let index = 0; index < 6; index += 1) { + const bookingId = `BK${String(index).padStart(5, "0")}`; + s.push(insert("marketing_attribution_facts", { + id: `MAF-UAT-${index}`, campaign_id: live.id, customer_id: `CUS${String(index * 3).padStart(4, "0")}`, lead_id: null, booking_id: bookingId, + collection_id: null, source: "meta", medium: "paid_social", spend_amount: 1500, booked_revenue: AMOUNTS[index % AMOUNTS.length], + collected_revenue: AMOUNTS[index % AMOUNTS.length], contribution_margin: null, attribution_model: "last_touch_uat", + created_at: BASE - 15 * DAY, updated_at: BASE - 15 * DAY, + })); +} + +writeFileSync(new URL("./uat-demo-seed.sql", import.meta.url), `${s.join("\n")}\n`); +console.log(`scripts/uat-demo-seed.sql written: ${s.length} statements, ${REPS.length} reps, ${leadSeq} leads, ${CAMPAIGNS.length} campaigns, ${audience.length} audience members`); diff --git a/scripts/uat-demo-seed.sql b/scripts/uat-demo-seed.sql new file mode 100644 index 000000000..2cb0033f7 --- /dev/null +++ b/scripts/uat-demo-seed.sql @@ -0,0 +1,254 @@ +-- PawSpace UAT demo seed (generated). Load scripts/staging-seed.sql first. Safe to re-run. +CREATE TABLE IF NOT EXISTS app_users (id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT NOT NULL, role_code TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL); +CREATE TABLE IF NOT EXISTS lead_assignment_memberships (id TEXT PRIMARY KEY,employee_email TEXT NOT NULL,team_code TEXT NOT NULL,service_codes_json TEXT NOT NULL,city_ids_json TEXT NOT NULL,language_codes_json TEXT NOT NULL,active INTEGER NOT NULL DEFAULT 1,workload_cap_override INTEGER,created_by TEXT NOT NULL,created_at INTEGER NOT NULL,updated_by TEXT NOT NULL,updated_at INTEGER NOT NULL,UNIQUE(employee_email,team_code)); +CREATE TABLE IF NOT EXISTS lead_assignments (id TEXT PRIMARY KEY,idempotency_key TEXT NOT NULL UNIQUE,lead_id TEXT NOT NULL,employee_email TEXT,team_code TEXT NOT NULL,policy_id TEXT NOT NULL,policy_version INTEGER NOT NULL,assignment_reason TEXT NOT NULL,status TEXT NOT NULL DEFAULT 'current',fallback_queue TEXT,assigned_at INTEGER NOT NULL,accepted_at INTEGER,ended_at INTEGER,ended_reason TEXT,previous_assignment_id TEXT,detail_json TEXT NOT NULL DEFAULT '{}',created_by TEXT NOT NULL,created_at INTEGER NOT NULL); +CREATE TABLE IF NOT EXISTS lead_work_items (id TEXT PRIMARY KEY, customer_id TEXT NOT NULL, source TEXT NOT NULL, service TEXT NOT NULL, owner TEXT NOT NULL, manager TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active', stage TEXT NOT NULL DEFAULT 'day_1', work_day INTEGER NOT NULL DEFAULT 1, assigned_at INTEGER NOT NULL, first_action_due_at INTEGER NOT NULL, manager_alert_at INTEGER NOT NULL, first_action_at INTEGER, call_attempts INTEGER NOT NULL DEFAULT 0, whatsapp_attempts INTEGER NOT NULL DEFAULT 0, last_outcome TEXT, next_action_at INTEGER, recycle_at INTEGER, recycle_cycle INTEGER NOT NULL DEFAULT 0, opt_out INTEGER NOT NULL DEFAULT 0, converted_booking_id TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL); +CREATE TABLE IF NOT EXISTS lead_sla_clocks (id TEXT PRIMARY KEY,idempotency_key TEXT NOT NULL UNIQUE,lead_id TEXT NOT NULL,assignment_id TEXT NOT NULL,policy_id TEXT NOT NULL,policy_version INTEGER NOT NULL,clock_type TEXT NOT NULL,cycle INTEGER NOT NULL DEFAULT 1,status TEXT NOT NULL DEFAULT 'running',started_at INTEGER NOT NULL,due_at INTEGER NOT NULL,manager_escalation_due_at INTEGER NOT NULL,reassignment_due_at INTEGER NOT NULL,met_at INTEGER,breached_at INTEGER,paused_at INTEGER,pause_reason TEXT,paused_remaining_minutes INTEGER,last_action_at INTEGER,next_action_at INTEGER,detail_json TEXT NOT NULL DEFAULT '{}',created_by TEXT NOT NULL,created_at INTEGER NOT NULL,updated_at INTEGER NOT NULL,UNIQUE(lead_id,clock_type,cycle)); +CREATE TABLE IF NOT EXISTS lead_sla_events (id TEXT PRIMARY KEY,idempotency_key TEXT NOT NULL UNIQUE,clock_id TEXT NOT NULL,lead_id TEXT NOT NULL,event_type TEXT NOT NULL,actor_id TEXT NOT NULL,detail_json TEXT NOT NULL DEFAULT '{}',created_at INTEGER NOT NULL); +CREATE TABLE IF NOT EXISTS sales_productivity_policies (id TEXT PRIMARY KEY,name TEXT NOT NULL,status TEXT NOT NULL DEFAULT 'draft',version INTEGER NOT NULL DEFAULT 1,team_code TEXT NOT NULL,timezone TEXT NOT NULL,meaningful_action_types_json TEXT NOT NULL,qualified_outcomes_json TEXT NOT NULL,revenue_basis TEXT NOT NULL,require_canonical_lead_booking_link INTEGER NOT NULL DEFAULT 1,effective_from INTEGER NOT NULL,effective_until INTEGER,approval_reference TEXT,created_by TEXT NOT NULL,created_at INTEGER NOT NULL,updated_by TEXT NOT NULL,updated_at INTEGER NOT NULL); +CREATE TABLE IF NOT EXISTS revenue_mission_events (id TEXT PRIMARY KEY,mission_id TEXT NOT NULL,source_event_key TEXT NOT NULL,event_type TEXT NOT NULL,customer_id TEXT NOT NULL,booking_id TEXT NOT NULL,payment_id TEXT,refund_id TEXT,service_code TEXT NOT NULL,city_id TEXT NOT NULL,gross_amount REAL NOT NULL DEFAULT 0,refund_amount REAL NOT NULL DEFAULT 0,eligible_amount REAL NOT NULL DEFAULT 0,currency TEXT NOT NULL,source_at INTEGER NOT NULL,source_version TEXT NOT NULL,attribution_json TEXT NOT NULL DEFAULT '{}',created_at INTEGER NOT NULL,UNIQUE(mission_id,source_event_key)); +CREATE TABLE IF NOT EXISTS customer_experience_tickets (id TEXT PRIMARY KEY, customer_id TEXT, booking_id TEXT, lead_id TEXT, category TEXT NOT NULL, priority TEXT NOT NULL, subject TEXT NOT NULL, detail TEXT NOT NULL, owner TEXT NOT NULL, manager TEXT NOT NULL, sla_due_at INTEGER NOT NULL, status TEXT NOT NULL DEFAULT 'open', escalation_level INTEGER NOT NULL DEFAULT 0, customer_status TEXT NOT NULL DEFAULT 'We received your request', resolution TEXT, root_cause TEXT, resolution_evidence TEXT, reopened_count INTEGER NOT NULL DEFAULT 0, created_by TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, resolved_at INTEGER); +CREATE TABLE IF NOT EXISTS canonical_revenue_opportunities (id TEXT PRIMARY KEY,idempotency_key TEXT NOT NULL UNIQUE,customer_id TEXT NOT NULL,opportunity_type TEXT NOT NULL,service_code TEXT,reason TEXT NOT NULL,status TEXT NOT NULL,preferred_channel TEXT NOT NULL,estimated_value REAL NOT NULL DEFAULT 0,confidence REAL NOT NULL DEFAULT 0,signal_snapshot_json TEXT NOT NULL,suppression_reasons_json TEXT NOT NULL DEFAULT '[]',policy_id TEXT NOT NULL,policy_version INTEGER NOT NULL,source_key TEXT NOT NULL,converted_booking_id TEXT,created_by TEXT NOT NULL,created_at INTEGER NOT NULL,updated_at INTEGER NOT NULL,UNIQUE(customer_id,source_key,policy_id,policy_version)); +CREATE TABLE IF NOT EXISTS governed_marketing_campaigns (id TEXT PRIMARY KEY,name TEXT NOT NULL,objective TEXT NOT NULL,service_code TEXT,city_id TEXT,audience_rule_json TEXT NOT NULL DEFAULT '{}',budget_amount REAL NOT NULL,currency TEXT NOT NULL DEFAULT 'INR',holdout_percent INTEGER NOT NULL,status TEXT NOT NULL DEFAULT 'draft',approval_status TEXT NOT NULL DEFAULT 'approval_required',approved_by TEXT,approved_at INTEGER,start_at INTEGER,end_at INTEGER,created_by TEXT NOT NULL,created_at INTEGER NOT NULL,updated_at INTEGER NOT NULL); +CREATE TABLE IF NOT EXISTS marketing_audience_snapshots (id TEXT PRIMARY KEY,campaign_id TEXT NOT NULL,snapshot_at INTEGER NOT NULL,total_candidates INTEGER NOT NULL,eligible_count INTEGER NOT NULL,holdout_count INTEGER NOT NULL,suppressed_count INTEGER NOT NULL,policy_json TEXT NOT NULL,created_by TEXT NOT NULL); +CREATE TABLE IF NOT EXISTS marketing_audience_members (snapshot_id TEXT NOT NULL,campaign_id TEXT NOT NULL,customer_id TEXT NOT NULL,cohort TEXT NOT NULL,suppression_reason TEXT,PRIMARY KEY(snapshot_id,customer_id)); +CREATE TABLE IF NOT EXISTS marketing_attribution_facts (id TEXT PRIMARY KEY,campaign_id TEXT NOT NULL,customer_id TEXT,lead_id TEXT,booking_id TEXT,collection_id TEXT,source TEXT NOT NULL,medium TEXT,spend_amount REAL,booked_revenue REAL,collected_revenue REAL,contribution_margin REAL,attribution_model TEXT NOT NULL DEFAULT 'unconfigured',created_at INTEGER NOT NULL,updated_at INTEGER NOT NULL); +-- Sales team: reps, their team membership, and the lead work the leaderboard measures. +INSERT OR IGNORE INTO app_users (id,email,name,role_code,status,created_at,updated_at) VALUES ('USR-SALES-0','asha.rao@pawspace.in','Asha Rao','associate','active',1775174400000,1775174400000); +INSERT OR IGNORE INTO lead_assignment_memberships (id,employee_email,team_code,service_codes_json,city_ids_json,language_codes_json,active,workload_cap_override,created_by,created_at,updated_by,updated_at) VALUES ('LAM-SALES-0','asha.rao@pawspace.in','sales','["grooming","boarding","dog_training"]','["blr","hyd"]','["en","hi"]',1,NULL,'uat-demo-seed',1775174400000,'uat-demo-seed',1775174400000); +INSERT OR IGNORE INTO lead_work_items (id,customer_id,source,service,owner,manager,status,stage,work_day,assigned_at,first_action_due_at,manager_alert_at,first_action_at,call_attempts,whatsapp_attempts,last_outcome,next_action_at,recycle_at,recycle_cycle,opt_out,converted_booking_id,created_at,updated_at) VALUES ('LEAD-UAT-0000','CUS0000','website','grooming','asha.rao@pawspace.in','sales.manager@pawspace.in','converted','day_1',1,1785283200000,1785283800000,1785285000000,1785283500000,1,0,'qualified',1785456000000,NULL,0,0,'BK00000',1785283200000,1785542400000); +INSERT OR IGNORE INTO lead_assignments (id,idempotency_key,lead_id,employee_email,team_code,policy_id,policy_version,assignment_reason,status,fallback_queue,assigned_at,accepted_at,ended_at,ended_reason,previous_assignment_id,detail_json,created_by,created_at) VALUES ('ASG-UAT-0000','ASG-UAT-0000-idem','LEAD-UAT-0000','asha.rao@pawspace.in','sales','LAP-UAT-DEMO',1,'round_robin','current',NULL,1785283200000,1785283320000,NULL,NULL,NULL,'{}','uat-demo-seed',1785283200000); +INSERT OR IGNORE INTO lead_sla_clocks (id,idempotency_key,lead_id,assignment_id,policy_id,policy_version,clock_type,cycle,status,started_at,due_at,manager_escalation_due_at,reassignment_due_at,met_at,breached_at,paused_at,pause_reason,paused_remaining_minutes,last_action_at,next_action_at,detail_json,created_by,created_at,updated_at) VALUES ('CLK-UAT-0000','CLK-UAT-0000-idem','LEAD-UAT-0000','ASG-UAT-0000','LSP-UAT-DEMO',1,'first_response',1,'met',1785283200000,1785283800000,1785285000000,1785286800000,1785283500000,NULL,NULL,NULL,NULL,1785283500000,1785456000000,'{}','uat-demo-seed',1785283200000,1785283500000); +INSERT OR IGNORE INTO lead_sla_events (id,idempotency_key,clock_id,lead_id,event_type,actor_id,detail_json,created_at) VALUES ('EVT-UAT-0000-0','EVT-UAT-0000-0-idem','CLK-UAT-0000','LEAD-UAT-0000','action_recorded','asha.rao@pawspace.in','{"actionType":"call","outcome":"qualified"}',1785283500000); +INSERT OR IGNORE INTO lead_sla_events (id,idempotency_key,clock_id,lead_id,event_type,actor_id,detail_json,created_at) VALUES ('EVT-UAT-0000-1','EVT-UAT-0000-1-idem','CLK-UAT-0000','LEAD-UAT-0000','action_recorded','asha.rao@pawspace.in','{"actionType":"whatsapp","outcome":"booked"}',1785283560000); +INSERT OR IGNORE INTO revenue_mission_events (id,mission_id,source_event_key,event_type,customer_id,booking_id,payment_id,refund_id,service_code,city_id,gross_amount,refund_amount,eligible_amount,currency,source_at,source_version,attribution_json,created_at) VALUES ('RME-UAT-0-c','MIS-UAT-DEMO','BK00000-collected','collected','CUS0000','BK00000','PAY-BK00000',NULL,'grooming','blr',799,0,799,'INR',1785456000000,'uat_demo_seed:v1','{}',1785456000000); +INSERT OR IGNORE INTO revenue_mission_events (id,mission_id,source_event_key,event_type,customer_id,booking_id,payment_id,refund_id,service_code,city_id,gross_amount,refund_amount,eligible_amount,currency,source_at,source_version,attribution_json,created_at) VALUES ('RME-UAT-0-r','MIS-UAT-DEMO','BK00000-refunded','refunded','CUS0000','BK00000','PAY-BK00000','RFD-BK00000','grooming','blr',0,400,0,'INR',1785542400000,'uat_demo_seed:v1','{}',1785542400000); +INSERT OR IGNORE INTO lead_work_items (id,customer_id,source,service,owner,manager,status,stage,work_day,assigned_at,first_action_due_at,manager_alert_at,first_action_at,call_attempts,whatsapp_attempts,last_outcome,next_action_at,recycle_at,recycle_cycle,opt_out,converted_booking_id,created_at,updated_at) VALUES ('LEAD-UAT-0001','CUS0003','whatsapp','boarding','asha.rao@pawspace.in','sales.manager@pawspace.in','converted','day_1',1,1785196800000,1785197400000,1785198600000,1785197100000,2,1,'qualified',1785369600000,NULL,0,0,'BK00001',1785196800000,1785456000000); +INSERT OR IGNORE INTO lead_assignments (id,idempotency_key,lead_id,employee_email,team_code,policy_id,policy_version,assignment_reason,status,fallback_queue,assigned_at,accepted_at,ended_at,ended_reason,previous_assignment_id,detail_json,created_by,created_at) VALUES ('ASG-UAT-0001','ASG-UAT-0001-idem','LEAD-UAT-0001','asha.rao@pawspace.in','sales','LAP-UAT-DEMO',1,'round_robin','current',NULL,1785196800000,1785196920000,NULL,NULL,NULL,'{}','uat-demo-seed',1785196800000); +INSERT OR IGNORE INTO lead_sla_clocks (id,idempotency_key,lead_id,assignment_id,policy_id,policy_version,clock_type,cycle,status,started_at,due_at,manager_escalation_due_at,reassignment_due_at,met_at,breached_at,paused_at,pause_reason,paused_remaining_minutes,last_action_at,next_action_at,detail_json,created_by,created_at,updated_at) VALUES ('CLK-UAT-0001','CLK-UAT-0001-idem','LEAD-UAT-0001','ASG-UAT-0001','LSP-UAT-DEMO',1,'first_response',1,'met',1785196800000,1785197400000,1785198600000,1785200400000,1785197100000,NULL,NULL,NULL,NULL,1785197100000,1785369600000,'{}','uat-demo-seed',1785196800000,1785197100000); +INSERT OR IGNORE INTO lead_sla_events (id,idempotency_key,clock_id,lead_id,event_type,actor_id,detail_json,created_at) VALUES ('EVT-UAT-0001-0','EVT-UAT-0001-0-idem','CLK-UAT-0001','LEAD-UAT-0001','action_recorded','asha.rao@pawspace.in','{"actionType":"call","outcome":"qualified"}',1785197100000); +INSERT OR IGNORE INTO lead_sla_events (id,idempotency_key,clock_id,lead_id,event_type,actor_id,detail_json,created_at) VALUES ('EVT-UAT-0001-1','EVT-UAT-0001-1-idem','CLK-UAT-0001','LEAD-UAT-0001','action_recorded','asha.rao@pawspace.in','{"actionType":"whatsapp","outcome":"booked"}',1785197160000); +INSERT OR IGNORE INTO revenue_mission_events (id,mission_id,source_event_key,event_type,customer_id,booking_id,payment_id,refund_id,service_code,city_id,gross_amount,refund_amount,eligible_amount,currency,source_at,source_version,attribution_json,created_at) VALUES ('RME-UAT-1-c','MIS-UAT-DEMO','BK00001-collected','collected','CUS0003','BK00001','PAY-BK00001',NULL,'grooming','blr',1299,0,1299,'INR',1785369600000,'uat_demo_seed:v1','{}',1785369600000); +INSERT OR IGNORE INTO lead_work_items (id,customer_id,source,service,owner,manager,status,stage,work_day,assigned_at,first_action_due_at,manager_alert_at,first_action_at,call_attempts,whatsapp_attempts,last_outcome,next_action_at,recycle_at,recycle_cycle,opt_out,converted_booking_id,created_at,updated_at) VALUES ('LEAD-UAT-0002','CUS0006','referral','dog_training','asha.rao@pawspace.in','sales.manager@pawspace.in','converted','day_1',1,1785110400000,1785111000000,1785112200000,1785110700000,3,0,'qualified',1785283200000,NULL,0,0,'BK00002',1785110400000,1785369600000); +INSERT OR IGNORE INTO lead_assignments (id,idempotency_key,lead_id,employee_email,team_code,policy_id,policy_version,assignment_reason,status,fallback_queue,assigned_at,accepted_at,ended_at,ended_reason,previous_assignment_id,detail_json,created_by,created_at) VALUES ('ASG-UAT-0002','ASG-UAT-0002-idem','LEAD-UAT-0002','asha.rao@pawspace.in','sales','LAP-UAT-DEMO',1,'round_robin','current',NULL,1785110400000,1785110520000,NULL,NULL,NULL,'{}','uat-demo-seed',1785110400000); +INSERT OR IGNORE INTO lead_sla_clocks (id,idempotency_key,lead_id,assignment_id,policy_id,policy_version,clock_type,cycle,status,started_at,due_at,manager_escalation_due_at,reassignment_due_at,met_at,breached_at,paused_at,pause_reason,paused_remaining_minutes,last_action_at,next_action_at,detail_json,created_by,created_at,updated_at) VALUES ('CLK-UAT-0002','CLK-UAT-0002-idem','LEAD-UAT-0002','ASG-UAT-0002','LSP-UAT-DEMO',1,'first_response',1,'breached',1785110400000,1785111000000,1785112200000,1785114000000,NULL,1785111300000,NULL,NULL,NULL,1785110700000,1785283200000,'{}','uat-demo-seed',1785110400000,1785110700000); +INSERT OR IGNORE INTO lead_sla_events (id,idempotency_key,clock_id,lead_id,event_type,actor_id,detail_json,created_at) VALUES ('EVT-UAT-0002-0','EVT-UAT-0002-0-idem','CLK-UAT-0002','LEAD-UAT-0002','action_recorded','asha.rao@pawspace.in','{"actionType":"call","outcome":"qualified"}',1785110700000); +INSERT OR IGNORE INTO lead_sla_events (id,idempotency_key,clock_id,lead_id,event_type,actor_id,detail_json,created_at) VALUES ('EVT-UAT-0002-1','EVT-UAT-0002-1-idem','CLK-UAT-0002','LEAD-UAT-0002','action_recorded','asha.rao@pawspace.in','{"actionType":"whatsapp","outcome":"booked"}',1785110760000); +INSERT OR IGNORE INTO revenue_mission_events (id,mission_id,source_event_key,event_type,customer_id,booking_id,payment_id,refund_id,service_code,city_id,gross_amount,refund_amount,eligible_amount,currency,source_at,source_version,attribution_json,created_at) VALUES ('RME-UAT-2-c','MIS-UAT-DEMO','BK00002-collected','collected','CUS0006','BK00002','PAY-BK00002',NULL,'grooming','blr',699,0,699,'INR',1785283200000,'uat_demo_seed:v1','{}',1785283200000); +INSERT OR IGNORE INTO lead_work_items (id,customer_id,source,service,owner,manager,status,stage,work_day,assigned_at,first_action_due_at,manager_alert_at,first_action_at,call_attempts,whatsapp_attempts,last_outcome,next_action_at,recycle_at,recycle_cycle,opt_out,converted_booking_id,created_at,updated_at) VALUES ('LEAD-UAT-0003','CUS0009','website','grooming','asha.rao@pawspace.in','sales.manager@pawspace.in','active','day_1',1,1785024000000,1785024600000,1785025800000,1785024300000,1,1,'qualified',1785196800000,NULL,0,0,NULL,1785024000000,1785283200000); +INSERT OR IGNORE INTO lead_assignments (id,idempotency_key,lead_id,employee_email,team_code,policy_id,policy_version,assignment_reason,status,fallback_queue,assigned_at,accepted_at,ended_at,ended_reason,previous_assignment_id,detail_json,created_by,created_at) VALUES ('ASG-UAT-0003','ASG-UAT-0003-idem','LEAD-UAT-0003','asha.rao@pawspace.in','sales','LAP-UAT-DEMO',1,'round_robin','current',NULL,1785024000000,1785024120000,NULL,NULL,NULL,'{}','uat-demo-seed',1785024000000); +INSERT OR IGNORE INTO lead_sla_clocks (id,idempotency_key,lead_id,assignment_id,policy_id,policy_version,clock_type,cycle,status,started_at,due_at,manager_escalation_due_at,reassignment_due_at,met_at,breached_at,paused_at,pause_reason,paused_remaining_minutes,last_action_at,next_action_at,detail_json,created_by,created_at,updated_at) VALUES ('CLK-UAT-0003','CLK-UAT-0003-idem','LEAD-UAT-0003','ASG-UAT-0003','LSP-UAT-DEMO',1,'first_response',1,'met',1785024000000,1785024600000,1785025800000,1785027600000,1785024300000,NULL,NULL,NULL,NULL,1785024300000,1785196800000,'{}','uat-demo-seed',1785024000000,1785024300000); +INSERT OR IGNORE INTO lead_sla_events (id,idempotency_key,clock_id,lead_id,event_type,actor_id,detail_json,created_at) VALUES ('EVT-UAT-0003-0','EVT-UAT-0003-0-idem','CLK-UAT-0003','LEAD-UAT-0003','action_recorded','asha.rao@pawspace.in','{"actionType":"call","outcome":"qualified"}',1785024300000); +INSERT OR IGNORE INTO lead_sla_events (id,idempotency_key,clock_id,lead_id,event_type,actor_id,detail_json,created_at) VALUES ('EVT-UAT-0003-1','EVT-UAT-0003-1-idem','CLK-UAT-0003','LEAD-UAT-0003','action_recorded','asha.rao@pawspace.in','{"actionType":"whatsapp","outcome":"call_back_later"}',1785024360000); +INSERT OR IGNORE INTO lead_work_items (id,customer_id,source,service,owner,manager,status,stage,work_day,assigned_at,first_action_due_at,manager_alert_at,first_action_at,call_attempts,whatsapp_attempts,last_outcome,next_action_at,recycle_at,recycle_cycle,opt_out,converted_booking_id,created_at,updated_at) VALUES ('LEAD-UAT-0004','CUS0012','whatsapp','boarding','asha.rao@pawspace.in','sales.manager@pawspace.in','active','day_1',1,1784937600000,1784938200000,1784939400000,1784937900000,2,0,'qualified',1785110400000,NULL,0,0,NULL,1784937600000,1785196800000); +INSERT OR IGNORE INTO lead_assignments (id,idempotency_key,lead_id,employee_email,team_code,policy_id,policy_version,assignment_reason,status,fallback_queue,assigned_at,accepted_at,ended_at,ended_reason,previous_assignment_id,detail_json,created_by,created_at) VALUES ('ASG-UAT-0004','ASG-UAT-0004-idem','LEAD-UAT-0004','asha.rao@pawspace.in','sales','LAP-UAT-DEMO',1,'round_robin','current',NULL,1784937600000,1784937720000,NULL,NULL,NULL,'{}','uat-demo-seed',1784937600000); +INSERT OR IGNORE INTO lead_sla_clocks (id,idempotency_key,lead_id,assignment_id,policy_id,policy_version,clock_type,cycle,status,started_at,due_at,manager_escalation_due_at,reassignment_due_at,met_at,breached_at,paused_at,pause_reason,paused_remaining_minutes,last_action_at,next_action_at,detail_json,created_by,created_at,updated_at) VALUES ('CLK-UAT-0004','CLK-UAT-0004-idem','LEAD-UAT-0004','ASG-UAT-0004','LSP-UAT-DEMO',1,'first_response',1,'met',1784937600000,1784938200000,1784939400000,1784941200000,1784937900000,NULL,NULL,NULL,NULL,1784937900000,1785110400000,'{}','uat-demo-seed',1784937600000,1784937900000); +INSERT OR IGNORE INTO lead_sla_events (id,idempotency_key,clock_id,lead_id,event_type,actor_id,detail_json,created_at) VALUES ('EVT-UAT-0004-0','EVT-UAT-0004-0-idem','CLK-UAT-0004','LEAD-UAT-0004','action_recorded','asha.rao@pawspace.in','{"actionType":"call","outcome":"qualified"}',1784937900000); +INSERT OR IGNORE INTO lead_sla_events (id,idempotency_key,clock_id,lead_id,event_type,actor_id,detail_json,created_at) VALUES ('EVT-UAT-0004-1','EVT-UAT-0004-1-idem','CLK-UAT-0004','LEAD-UAT-0004','action_recorded','asha.rao@pawspace.in','{"actionType":"whatsapp","outcome":"call_back_later"}',1784937960000); +INSERT OR IGNORE INTO lead_work_items (id,customer_id,source,service,owner,manager,status,stage,work_day,assigned_at,first_action_due_at,manager_alert_at,first_action_at,call_attempts,whatsapp_attempts,last_outcome,next_action_at,recycle_at,recycle_cycle,opt_out,converted_booking_id,created_at,updated_at) VALUES ('LEAD-UAT-0005','CUS0015','referral','dog_training','asha.rao@pawspace.in','sales.manager@pawspace.in','active','day_1',1,1784851200000,1784851800000,1784853000000,1784851500000,3,1,'call_back_later',1785024000000,NULL,0,0,NULL,1784851200000,1785110400000); +INSERT OR IGNORE INTO lead_assignments (id,idempotency_key,lead_id,employee_email,team_code,policy_id,policy_version,assignment_reason,status,fallback_queue,assigned_at,accepted_at,ended_at,ended_reason,previous_assignment_id,detail_json,created_by,created_at) VALUES ('ASG-UAT-0005','ASG-UAT-0005-idem','LEAD-UAT-0005','asha.rao@pawspace.in','sales','LAP-UAT-DEMO',1,'round_robin','current',NULL,1784851200000,1784851320000,NULL,NULL,NULL,'{}','uat-demo-seed',1784851200000); +INSERT OR IGNORE INTO lead_sla_clocks (id,idempotency_key,lead_id,assignment_id,policy_id,policy_version,clock_type,cycle,status,started_at,due_at,manager_escalation_due_at,reassignment_due_at,met_at,breached_at,paused_at,pause_reason,paused_remaining_minutes,last_action_at,next_action_at,detail_json,created_by,created_at,updated_at) VALUES ('CLK-UAT-0005','CLK-UAT-0005-idem','LEAD-UAT-0005','ASG-UAT-0005','LSP-UAT-DEMO',1,'first_response',1,'breached',1784851200000,1784851800000,1784853000000,1784854800000,NULL,1784852100000,NULL,NULL,NULL,1784851500000,1785024000000,'{}','uat-demo-seed',1784851200000,1784851500000); +INSERT OR IGNORE INTO lead_sla_events (id,idempotency_key,clock_id,lead_id,event_type,actor_id,detail_json,created_at) VALUES ('EVT-UAT-0005-0','EVT-UAT-0005-0-idem','CLK-UAT-0005','LEAD-UAT-0005','action_recorded','asha.rao@pawspace.in','{"actionType":"call","outcome":"call_back_later"}',1784851500000); +INSERT OR IGNORE INTO lead_sla_events (id,idempotency_key,clock_id,lead_id,event_type,actor_id,detail_json,created_at) VALUES ('EVT-UAT-0005-1','EVT-UAT-0005-1-idem','CLK-UAT-0005','LEAD-UAT-0005','action_recorded','asha.rao@pawspace.in','{"actionType":"whatsapp","outcome":"call_back_later"}',1784851560000); +INSERT OR IGNORE INTO lead_work_items (id,customer_id,source,service,owner,manager,status,stage,work_day,assigned_at,first_action_due_at,manager_alert_at,first_action_at,call_attempts,whatsapp_attempts,last_outcome,next_action_at,recycle_at,recycle_cycle,opt_out,converted_booking_id,created_at,updated_at) VALUES ('LEAD-UAT-0006','CUS0018','website','grooming','asha.rao@pawspace.in','sales.manager@pawspace.in','active','day_1',1,1784764800000,1784765400000,1784766600000,1784765100000,1,0,'call_back_later',1784937600000,NULL,0,0,NULL,1784764800000,1785024000000); +INSERT OR IGNORE INTO lead_assignments (id,idempotency_key,lead_id,employee_email,team_code,policy_id,policy_version,assignment_reason,status,fallback_queue,assigned_at,accepted_at,ended_at,ended_reason,previous_assignment_id,detail_json,created_by,created_at) VALUES ('ASG-UAT-0006','ASG-UAT-0006-idem','LEAD-UAT-0006','asha.rao@pawspace.in','sales','LAP-UAT-DEMO',1,'round_robin','current',NULL,1784764800000,1784764920000,NULL,NULL,NULL,'{}','uat-demo-seed',1784764800000); +INSERT OR IGNORE INTO lead_sla_clocks (id,idempotency_key,lead_id,assignment_id,policy_id,policy_version,clock_type,cycle,status,started_at,due_at,manager_escalation_due_at,reassignment_due_at,met_at,breached_at,paused_at,pause_reason,paused_remaining_minutes,last_action_at,next_action_at,detail_json,created_by,created_at,updated_at) VALUES ('CLK-UAT-0006','CLK-UAT-0006-idem','LEAD-UAT-0006','ASG-UAT-0006','LSP-UAT-DEMO',1,'first_response',1,'met',1784764800000,1784765400000,1784766600000,1784768400000,1784765100000,NULL,NULL,NULL,NULL,1784765100000,1784937600000,'{}','uat-demo-seed',1784764800000,1784765100000); +INSERT OR IGNORE INTO lead_sla_events (id,idempotency_key,clock_id,lead_id,event_type,actor_id,detail_json,created_at) VALUES ('EVT-UAT-0006-0','EVT-UAT-0006-0-idem','CLK-UAT-0006','LEAD-UAT-0006','action_recorded','asha.rao@pawspace.in','{"actionType":"call","outcome":"call_back_later"}',1784765100000); +INSERT OR IGNORE INTO lead_sla_events (id,idempotency_key,clock_id,lead_id,event_type,actor_id,detail_json,created_at) VALUES ('EVT-UAT-0006-1','EVT-UAT-0006-1-idem','CLK-UAT-0006','LEAD-UAT-0006','action_recorded','asha.rao@pawspace.in','{"actionType":"whatsapp","outcome":"call_back_later"}',1784765160000); +INSERT OR IGNORE INTO lead_work_items (id,customer_id,source,service,owner,manager,status,stage,work_day,assigned_at,first_action_due_at,manager_alert_at,first_action_at,call_attempts,whatsapp_attempts,last_outcome,next_action_at,recycle_at,recycle_cycle,opt_out,converted_booking_id,created_at,updated_at) VALUES ('LEAD-UAT-0007','CUS0021','whatsapp','boarding','asha.rao@pawspace.in','sales.manager@pawspace.in','active','day_1',1,1784678400000,1784679000000,1784680200000,1784678700000,2,1,'call_back_later',1784851200000,NULL,0,0,NULL,1784678400000,1784937600000); +INSERT OR IGNORE INTO lead_assignments (id,idempotency_key,lead_id,employee_email,team_code,policy_id,policy_version,assignment_reason,status,fallback_queue,assigned_at,accepted_at,ended_at,ended_reason,previous_assignment_id,detail_json,created_by,created_at) VALUES ('ASG-UAT-0007','ASG-UAT-0007-idem','LEAD-UAT-0007','asha.rao@pawspace.in','sales','LAP-UAT-DEMO',1,'round_robin','current',NULL,1784678400000,1784678520000,NULL,NULL,NULL,'{}','uat-demo-seed',1784678400000); +INSERT OR IGNORE INTO lead_sla_clocks (id,idempotency_key,lead_id,assignment_id,policy_id,policy_version,clock_type,cycle,status,started_at,due_at,manager_escalation_due_at,reassignment_due_at,met_at,breached_at,paused_at,pause_reason,paused_remaining_minutes,last_action_at,next_action_at,detail_json,created_by,created_at,updated_at) VALUES ('CLK-UAT-0007','CLK-UAT-0007-idem','LEAD-UAT-0007','ASG-UAT-0007','LSP-UAT-DEMO',1,'first_response',1,'met',1784678400000,1784679000000,1784680200000,1784682000000,1784678700000,NULL,NULL,NULL,NULL,1784678700000,1784851200000,'{}','uat-demo-seed',1784678400000,1784678700000); +INSERT OR IGNORE INTO lead_sla_events (id,idempotency_key,clock_id,lead_id,event_type,actor_id,detail_json,created_at) VALUES ('EVT-UAT-0007-0','EVT-UAT-0007-0-idem','CLK-UAT-0007','LEAD-UAT-0007','action_recorded','asha.rao@pawspace.in','{"actionType":"call","outcome":"call_back_later"}',1784678700000); +INSERT OR IGNORE INTO lead_sla_events (id,idempotency_key,clock_id,lead_id,event_type,actor_id,detail_json,created_at) VALUES ('EVT-UAT-0007-1','EVT-UAT-0007-1-idem','CLK-UAT-0007','LEAD-UAT-0007','action_recorded','asha.rao@pawspace.in','{"actionType":"whatsapp","outcome":"call_back_later"}',1784678760000); +INSERT OR IGNORE INTO lead_work_items (id,customer_id,source,service,owner,manager,status,stage,work_day,assigned_at,first_action_due_at,manager_alert_at,first_action_at,call_attempts,whatsapp_attempts,last_outcome,next_action_at,recycle_at,recycle_cycle,opt_out,converted_booking_id,created_at,updated_at) VALUES ('LEAD-UAT-0008','CUS0024','referral','dog_training','asha.rao@pawspace.in','sales.manager@pawspace.in','active','day_1',1,1784592000000,1784592600000,1784593800000,1784592300000,3,0,'call_back_later',1784764800000,NULL,0,0,NULL,1784592000000,1784851200000); +INSERT OR IGNORE INTO lead_assignments (id,idempotency_key,lead_id,employee_email,team_code,policy_id,policy_version,assignment_reason,status,fallback_queue,assigned_at,accepted_at,ended_at,ended_reason,previous_assignment_id,detail_json,created_by,created_at) VALUES ('ASG-UAT-0008','ASG-UAT-0008-idem','LEAD-UAT-0008','asha.rao@pawspace.in','sales','LAP-UAT-DEMO',1,'round_robin','current',NULL,1784592000000,1784592120000,NULL,NULL,NULL,'{}','uat-demo-seed',1784592000000); +INSERT OR IGNORE INTO lead_sla_clocks (id,idempotency_key,lead_id,assignment_id,policy_id,policy_version,clock_type,cycle,status,started_at,due_at,manager_escalation_due_at,reassignment_due_at,met_at,breached_at,paused_at,pause_reason,paused_remaining_minutes,last_action_at,next_action_at,detail_json,created_by,created_at,updated_at) VALUES ('CLK-UAT-0008','CLK-UAT-0008-idem','LEAD-UAT-0008','ASG-UAT-0008','LSP-UAT-DEMO',1,'first_response',1,'breached',1784592000000,1784592600000,1784593800000,1784595600000,NULL,1784592900000,NULL,NULL,NULL,1784592300000,1784764800000,'{}','uat-demo-seed',1784592000000,1784592300000); +INSERT OR IGNORE INTO lead_sla_events (id,idempotency_key,clock_id,lead_id,event_type,actor_id,detail_json,created_at) VALUES ('EVT-UAT-0008-0','EVT-UAT-0008-0-idem','CLK-UAT-0008','LEAD-UAT-0008','action_recorded','asha.rao@pawspace.in','{"actionType":"call","outcome":"call_back_later"}',1784592300000); +INSERT OR IGNORE INTO lead_sla_events (id,idempotency_key,clock_id,lead_id,event_type,actor_id,detail_json,created_at) VALUES ('EVT-UAT-0008-1','EVT-UAT-0008-1-idem','CLK-UAT-0008','LEAD-UAT-0008','action_recorded','asha.rao@pawspace.in','{"actionType":"whatsapp","outcome":"call_back_later"}',1784592360000); +INSERT OR IGNORE INTO app_users (id,email,name,role_code,status,created_at,updated_at) VALUES ('USR-SALES-1','vikram.shetty@pawspace.in','Vikram Shetty','associate','active',1775174400000,1775174400000); +INSERT OR IGNORE INTO lead_assignment_memberships (id,employee_email,team_code,service_codes_json,city_ids_json,language_codes_json,active,workload_cap_override,created_by,created_at,updated_by,updated_at) VALUES ('LAM-SALES-1','vikram.shetty@pawspace.in','sales','["grooming","boarding","dog_training"]','["blr","hyd"]','["en","hi"]',1,NULL,'uat-demo-seed',1775174400000,'uat-demo-seed',1775174400000); +INSERT OR IGNORE INTO lead_work_items (id,customer_id,source,service,owner,manager,status,stage,work_day,assigned_at,first_action_due_at,manager_alert_at,first_action_at,call_attempts,whatsapp_attempts,last_outcome,next_action_at,recycle_at,recycle_cycle,opt_out,converted_booking_id,created_at,updated_at) VALUES ('LEAD-UAT-0009','CUS0027','website','grooming','vikram.shetty@pawspace.in','sales.manager@pawspace.in','converted','day_1',1,1784505600000,1784506200000,1784507400000,1784505900000,1,0,'qualified',1784678400000,NULL,0,0,'BK00003',1784505600000,1784764800000); +INSERT OR IGNORE INTO lead_assignments (id,idempotency_key,lead_id,employee_email,team_code,policy_id,policy_version,assignment_reason,status,fallback_queue,assigned_at,accepted_at,ended_at,ended_reason,previous_assignment_id,detail_json,created_by,created_at) VALUES ('ASG-UAT-0009','ASG-UAT-0009-idem','LEAD-UAT-0009','vikram.shetty@pawspace.in','sales','LAP-UAT-DEMO',1,'round_robin','current',NULL,1784505600000,1784505720000,NULL,NULL,NULL,'{}','uat-demo-seed',1784505600000); +INSERT OR IGNORE INTO lead_sla_clocks (id,idempotency_key,lead_id,assignment_id,policy_id,policy_version,clock_type,cycle,status,started_at,due_at,manager_escalation_due_at,reassignment_due_at,met_at,breached_at,paused_at,pause_reason,paused_remaining_minutes,last_action_at,next_action_at,detail_json,created_by,created_at,updated_at) VALUES ('CLK-UAT-0009','CLK-UAT-0009-idem','LEAD-UAT-0009','ASG-UAT-0009','LSP-UAT-DEMO',1,'first_response',1,'met',1784505600000,1784506200000,1784507400000,1784509200000,1784505900000,NULL,NULL,NULL,NULL,1784505900000,1784678400000,'{}','uat-demo-seed',1784505600000,1784505900000); +INSERT OR IGNORE INTO lead_sla_events (id,idempotency_key,clock_id,lead_id,event_type,actor_id,detail_json,created_at) VALUES ('EVT-UAT-0009-0','EVT-UAT-0009-0-idem','CLK-UAT-0009','LEAD-UAT-0009','action_recorded','vikram.shetty@pawspace.in','{"actionType":"call","outcome":"qualified"}',1784505900000); +INSERT OR IGNORE INTO lead_sla_events (id,idempotency_key,clock_id,lead_id,event_type,actor_id,detail_json,created_at) VALUES ('EVT-UAT-0009-1','EVT-UAT-0009-1-idem','CLK-UAT-0009','LEAD-UAT-0009','action_recorded','vikram.shetty@pawspace.in','{"actionType":"whatsapp","outcome":"booked"}',1784505960000); +INSERT OR IGNORE INTO revenue_mission_events (id,mission_id,source_event_key,event_type,customer_id,booking_id,payment_id,refund_id,service_code,city_id,gross_amount,refund_amount,eligible_amount,currency,source_at,source_version,attribution_json,created_at) VALUES ('RME-UAT-9-c','MIS-UAT-DEMO','BK00003-collected','collected','CUS0027','BK00003','PAY-BK00003',NULL,'grooming','blr',599,0,599,'INR',1784678400000,'uat_demo_seed:v1','{}',1784678400000); +INSERT OR IGNORE INTO lead_work_items (id,customer_id,source,service,owner,manager,status,stage,work_day,assigned_at,first_action_due_at,manager_alert_at,first_action_at,call_attempts,whatsapp_attempts,last_outcome,next_action_at,recycle_at,recycle_cycle,opt_out,converted_booking_id,created_at,updated_at) VALUES ('LEAD-UAT-0010','CUS0030','whatsapp','boarding','vikram.shetty@pawspace.in','sales.manager@pawspace.in','converted','day_1',1,1784419200000,1784419800000,1784421000000,1784419500000,2,1,'qualified',1784592000000,NULL,0,0,'BK00004',1784419200000,1784678400000); +INSERT OR IGNORE INTO lead_assignments (id,idempotency_key,lead_id,employee_email,team_code,policy_id,policy_version,assignment_reason,status,fallback_queue,assigned_at,accepted_at,ended_at,ended_reason,previous_assignment_id,detail_json,created_by,created_at) VALUES ('ASG-UAT-0010','ASG-UAT-0010-idem','LEAD-UAT-0010','vikram.shetty@pawspace.in','sales','LAP-UAT-DEMO',1,'round_robin','current',NULL,1784419200000,1784419320000,NULL,NULL,NULL,'{}','uat-demo-seed',1784419200000); +INSERT OR IGNORE INTO lead_sla_clocks (id,idempotency_key,lead_id,assignment_id,policy_id,policy_version,clock_type,cycle,status,started_at,due_at,manager_escalation_due_at,reassignment_due_at,met_at,breached_at,paused_at,pause_reason,paused_remaining_minutes,last_action_at,next_action_at,detail_json,created_by,created_at,updated_at) VALUES ('CLK-UAT-0010','CLK-UAT-0010-idem','LEAD-UAT-0010','ASG-UAT-0010','LSP-UAT-DEMO',1,'first_response',1,'met',1784419200000,1784419800000,1784421000000,1784422800000,1784419500000,NULL,NULL,NULL,NULL,1784419500000,1784592000000,'{}','uat-demo-seed',1784419200000,1784419500000); +INSERT OR IGNORE INTO lead_sla_events (id,idempotency_key,clock_id,lead_id,event_type,actor_id,detail_json,created_at) VALUES ('EVT-UAT-0010-0','EVT-UAT-0010-0-idem','CLK-UAT-0010','LEAD-UAT-0010','action_recorded','vikram.shetty@pawspace.in','{"actionType":"call","outcome":"qualified"}',1784419500000); +INSERT OR IGNORE INTO lead_sla_events (id,idempotency_key,clock_id,lead_id,event_type,actor_id,detail_json,created_at) VALUES ('EVT-UAT-0010-1','EVT-UAT-0010-1-idem','CLK-UAT-0010','LEAD-UAT-0010','action_recorded','vikram.shetty@pawspace.in','{"actionType":"whatsapp","outcome":"booked"}',1784419560000); +INSERT OR IGNORE INTO revenue_mission_events (id,mission_id,source_event_key,event_type,customer_id,booking_id,payment_id,refund_id,service_code,city_id,gross_amount,refund_amount,eligible_amount,currency,source_at,source_version,attribution_json,created_at) VALUES ('RME-UAT-10-c','MIS-UAT-DEMO','BK00004-collected','collected','CUS0030','BK00004','PAY-BK00004',NULL,'grooming','blr',799,0,799,'INR',1784592000000,'uat_demo_seed:v1','{}',1784592000000); +INSERT OR IGNORE INTO lead_work_items (id,customer_id,source,service,owner,manager,status,stage,work_day,assigned_at,first_action_due_at,manager_alert_at,first_action_at,call_attempts,whatsapp_attempts,last_outcome,next_action_at,recycle_at,recycle_cycle,opt_out,converted_booking_id,created_at,updated_at) VALUES ('LEAD-UAT-0011','CUS0033','referral','dog_training','vikram.shetty@pawspace.in','sales.manager@pawspace.in','active','day_1',1,1784332800000,1784333400000,1784334600000,1784333100000,3,0,'qualified',1784505600000,NULL,0,0,NULL,1784332800000,1784592000000); +INSERT OR IGNORE INTO lead_assignments (id,idempotency_key,lead_id,employee_email,team_code,policy_id,policy_version,assignment_reason,status,fallback_queue,assigned_at,accepted_at,ended_at,ended_reason,previous_assignment_id,detail_json,created_by,created_at) VALUES ('ASG-UAT-0011','ASG-UAT-0011-idem','LEAD-UAT-0011','vikram.shetty@pawspace.in','sales','LAP-UAT-DEMO',1,'round_robin','current',NULL,1784332800000,1784332920000,NULL,NULL,NULL,'{}','uat-demo-seed',1784332800000); +INSERT OR IGNORE INTO lead_sla_clocks (id,idempotency_key,lead_id,assignment_id,policy_id,policy_version,clock_type,cycle,status,started_at,due_at,manager_escalation_due_at,reassignment_due_at,met_at,breached_at,paused_at,pause_reason,paused_remaining_minutes,last_action_at,next_action_at,detail_json,created_by,created_at,updated_at) VALUES ('CLK-UAT-0011','CLK-UAT-0011-idem','LEAD-UAT-0011','ASG-UAT-0011','LSP-UAT-DEMO',1,'first_response',1,'breached',1784332800000,1784333400000,1784334600000,1784336400000,NULL,1784333700000,NULL,NULL,NULL,1784333100000,1784505600000,'{}','uat-demo-seed',1784332800000,1784333100000); +INSERT OR IGNORE INTO lead_sla_events (id,idempotency_key,clock_id,lead_id,event_type,actor_id,detail_json,created_at) VALUES ('EVT-UAT-0011-0','EVT-UAT-0011-0-idem','CLK-UAT-0011','LEAD-UAT-0011','action_recorded','vikram.shetty@pawspace.in','{"actionType":"call","outcome":"qualified"}',1784333100000); +INSERT OR IGNORE INTO lead_sla_events (id,idempotency_key,clock_id,lead_id,event_type,actor_id,detail_json,created_at) VALUES ('EVT-UAT-0011-1','EVT-UAT-0011-1-idem','CLK-UAT-0011','LEAD-UAT-0011','action_recorded','vikram.shetty@pawspace.in','{"actionType":"whatsapp","outcome":"call_back_later"}',1784333160000); +INSERT OR IGNORE INTO lead_work_items (id,customer_id,source,service,owner,manager,status,stage,work_day,assigned_at,first_action_due_at,manager_alert_at,first_action_at,call_attempts,whatsapp_attempts,last_outcome,next_action_at,recycle_at,recycle_cycle,opt_out,converted_booking_id,created_at,updated_at) VALUES ('LEAD-UAT-0012','CUS0036','website','grooming','vikram.shetty@pawspace.in','sales.manager@pawspace.in','active','day_1',1,1784246400000,1784247000000,1784248200000,1784246700000,1,1,'qualified',1784419200000,NULL,0,0,NULL,1784246400000,1784505600000); +INSERT OR IGNORE INTO lead_assignments (id,idempotency_key,lead_id,employee_email,team_code,policy_id,policy_version,assignment_reason,status,fallback_queue,assigned_at,accepted_at,ended_at,ended_reason,previous_assignment_id,detail_json,created_by,created_at) VALUES ('ASG-UAT-0012','ASG-UAT-0012-idem','LEAD-UAT-0012','vikram.shetty@pawspace.in','sales','LAP-UAT-DEMO',1,'round_robin','current',NULL,1784246400000,1784246520000,NULL,NULL,NULL,'{}','uat-demo-seed',1784246400000); +INSERT OR IGNORE INTO lead_sla_clocks (id,idempotency_key,lead_id,assignment_id,policy_id,policy_version,clock_type,cycle,status,started_at,due_at,manager_escalation_due_at,reassignment_due_at,met_at,breached_at,paused_at,pause_reason,paused_remaining_minutes,last_action_at,next_action_at,detail_json,created_by,created_at,updated_at) VALUES ('CLK-UAT-0012','CLK-UAT-0012-idem','LEAD-UAT-0012','ASG-UAT-0012','LSP-UAT-DEMO',1,'first_response',1,'met',1784246400000,1784247000000,1784248200000,1784250000000,1784246700000,NULL,NULL,NULL,NULL,1784246700000,1784419200000,'{}','uat-demo-seed',1784246400000,1784246700000); +INSERT OR IGNORE INTO lead_sla_events (id,idempotency_key,clock_id,lead_id,event_type,actor_id,detail_json,created_at) VALUES ('EVT-UAT-0012-0','EVT-UAT-0012-0-idem','CLK-UAT-0012','LEAD-UAT-0012','action_recorded','vikram.shetty@pawspace.in','{"actionType":"call","outcome":"qualified"}',1784246700000); +INSERT OR IGNORE INTO lead_sla_events (id,idempotency_key,clock_id,lead_id,event_type,actor_id,detail_json,created_at) VALUES ('EVT-UAT-0012-1','EVT-UAT-0012-1-idem','CLK-UAT-0012','LEAD-UAT-0012','action_recorded','vikram.shetty@pawspace.in','{"actionType":"whatsapp","outcome":"call_back_later"}',1784246760000); +INSERT OR IGNORE INTO lead_work_items (id,customer_id,source,service,owner,manager,status,stage,work_day,assigned_at,first_action_due_at,manager_alert_at,first_action_at,call_attempts,whatsapp_attempts,last_outcome,next_action_at,recycle_at,recycle_cycle,opt_out,converted_booking_id,created_at,updated_at) VALUES ('LEAD-UAT-0013','CUS0039','whatsapp','boarding','vikram.shetty@pawspace.in','sales.manager@pawspace.in','active','day_1',1,1784160000000,1784160600000,1784161800000,1784160300000,2,0,'call_back_later',1784332800000,NULL,0,0,NULL,1784160000000,1784419200000); +INSERT OR IGNORE INTO lead_assignments (id,idempotency_key,lead_id,employee_email,team_code,policy_id,policy_version,assignment_reason,status,fallback_queue,assigned_at,accepted_at,ended_at,ended_reason,previous_assignment_id,detail_json,created_by,created_at) VALUES ('ASG-UAT-0013','ASG-UAT-0013-idem','LEAD-UAT-0013','vikram.shetty@pawspace.in','sales','LAP-UAT-DEMO',1,'round_robin','current',NULL,1784160000000,1784160120000,NULL,NULL,NULL,'{}','uat-demo-seed',1784160000000); +INSERT OR IGNORE INTO lead_sla_clocks (id,idempotency_key,lead_id,assignment_id,policy_id,policy_version,clock_type,cycle,status,started_at,due_at,manager_escalation_due_at,reassignment_due_at,met_at,breached_at,paused_at,pause_reason,paused_remaining_minutes,last_action_at,next_action_at,detail_json,created_by,created_at,updated_at) VALUES ('CLK-UAT-0013','CLK-UAT-0013-idem','LEAD-UAT-0013','ASG-UAT-0013','LSP-UAT-DEMO',1,'first_response',1,'met',1784160000000,1784160600000,1784161800000,1784163600000,1784160300000,NULL,NULL,NULL,NULL,1784160300000,1784332800000,'{}','uat-demo-seed',1784160000000,1784160300000); +INSERT OR IGNORE INTO lead_sla_events (id,idempotency_key,clock_id,lead_id,event_type,actor_id,detail_json,created_at) VALUES ('EVT-UAT-0013-0','EVT-UAT-0013-0-idem','CLK-UAT-0013','LEAD-UAT-0013','action_recorded','vikram.shetty@pawspace.in','{"actionType":"call","outcome":"call_back_later"}',1784160300000); +INSERT OR IGNORE INTO lead_sla_events (id,idempotency_key,clock_id,lead_id,event_type,actor_id,detail_json,created_at) VALUES ('EVT-UAT-0013-1','EVT-UAT-0013-1-idem','CLK-UAT-0013','LEAD-UAT-0013','action_recorded','vikram.shetty@pawspace.in','{"actionType":"whatsapp","outcome":"call_back_later"}',1784160360000); +INSERT OR IGNORE INTO lead_work_items (id,customer_id,source,service,owner,manager,status,stage,work_day,assigned_at,first_action_due_at,manager_alert_at,first_action_at,call_attempts,whatsapp_attempts,last_outcome,next_action_at,recycle_at,recycle_cycle,opt_out,converted_booking_id,created_at,updated_at) VALUES ('LEAD-UAT-0014','CUS0042','referral','dog_training','vikram.shetty@pawspace.in','sales.manager@pawspace.in','active','day_1',1,1784073600000,1784074200000,1784075400000,1784073900000,3,1,'call_back_later',1784246400000,NULL,0,0,NULL,1784073600000,1784332800000); +INSERT OR IGNORE INTO lead_assignments (id,idempotency_key,lead_id,employee_email,team_code,policy_id,policy_version,assignment_reason,status,fallback_queue,assigned_at,accepted_at,ended_at,ended_reason,previous_assignment_id,detail_json,created_by,created_at) VALUES ('ASG-UAT-0014','ASG-UAT-0014-idem','LEAD-UAT-0014','vikram.shetty@pawspace.in','sales','LAP-UAT-DEMO',1,'round_robin','current',NULL,1784073600000,1784073720000,NULL,NULL,NULL,'{}','uat-demo-seed',1784073600000); +INSERT OR IGNORE INTO lead_sla_clocks (id,idempotency_key,lead_id,assignment_id,policy_id,policy_version,clock_type,cycle,status,started_at,due_at,manager_escalation_due_at,reassignment_due_at,met_at,breached_at,paused_at,pause_reason,paused_remaining_minutes,last_action_at,next_action_at,detail_json,created_by,created_at,updated_at) VALUES ('CLK-UAT-0014','CLK-UAT-0014-idem','LEAD-UAT-0014','ASG-UAT-0014','LSP-UAT-DEMO',1,'first_response',1,'breached',1784073600000,1784074200000,1784075400000,1784077200000,NULL,1784074500000,NULL,NULL,NULL,1784073900000,1784246400000,'{}','uat-demo-seed',1784073600000,1784073900000); +INSERT OR IGNORE INTO lead_sla_events (id,idempotency_key,clock_id,lead_id,event_type,actor_id,detail_json,created_at) VALUES ('EVT-UAT-0014-0','EVT-UAT-0014-0-idem','CLK-UAT-0014','LEAD-UAT-0014','action_recorded','vikram.shetty@pawspace.in','{"actionType":"call","outcome":"call_back_later"}',1784073900000); +INSERT OR IGNORE INTO lead_sla_events (id,idempotency_key,clock_id,lead_id,event_type,actor_id,detail_json,created_at) VALUES ('EVT-UAT-0014-1','EVT-UAT-0014-1-idem','CLK-UAT-0014','LEAD-UAT-0014','action_recorded','vikram.shetty@pawspace.in','{"actionType":"whatsapp","outcome":"call_back_later"}',1784073960000); +INSERT OR IGNORE INTO lead_work_items (id,customer_id,source,service,owner,manager,status,stage,work_day,assigned_at,first_action_due_at,manager_alert_at,first_action_at,call_attempts,whatsapp_attempts,last_outcome,next_action_at,recycle_at,recycle_cycle,opt_out,converted_booking_id,created_at,updated_at) VALUES ('LEAD-UAT-0015','CUS0045','website','grooming','vikram.shetty@pawspace.in','sales.manager@pawspace.in','active','day_1',1,1783987200000,1783987800000,1783989000000,1783987500000,1,0,'call_back_later',1784160000000,NULL,0,0,NULL,1783987200000,1784246400000); +INSERT OR IGNORE INTO lead_assignments (id,idempotency_key,lead_id,employee_email,team_code,policy_id,policy_version,assignment_reason,status,fallback_queue,assigned_at,accepted_at,ended_at,ended_reason,previous_assignment_id,detail_json,created_by,created_at) VALUES ('ASG-UAT-0015','ASG-UAT-0015-idem','LEAD-UAT-0015','vikram.shetty@pawspace.in','sales','LAP-UAT-DEMO',1,'round_robin','current',NULL,1783987200000,1783987320000,NULL,NULL,NULL,'{}','uat-demo-seed',1783987200000); +INSERT OR IGNORE INTO lead_sla_clocks (id,idempotency_key,lead_id,assignment_id,policy_id,policy_version,clock_type,cycle,status,started_at,due_at,manager_escalation_due_at,reassignment_due_at,met_at,breached_at,paused_at,pause_reason,paused_remaining_minutes,last_action_at,next_action_at,detail_json,created_by,created_at,updated_at) VALUES ('CLK-UAT-0015','CLK-UAT-0015-idem','LEAD-UAT-0015','ASG-UAT-0015','LSP-UAT-DEMO',1,'first_response',1,'met',1783987200000,1783987800000,1783989000000,1783990800000,1783987500000,NULL,NULL,NULL,NULL,1783987500000,1784160000000,'{}','uat-demo-seed',1783987200000,1783987500000); +INSERT OR IGNORE INTO lead_sla_events (id,idempotency_key,clock_id,lead_id,event_type,actor_id,detail_json,created_at) VALUES ('EVT-UAT-0015-0','EVT-UAT-0015-0-idem','CLK-UAT-0015','LEAD-UAT-0015','action_recorded','vikram.shetty@pawspace.in','{"actionType":"call","outcome":"call_back_later"}',1783987500000); +INSERT OR IGNORE INTO lead_sla_events (id,idempotency_key,clock_id,lead_id,event_type,actor_id,detail_json,created_at) VALUES ('EVT-UAT-0015-1','EVT-UAT-0015-1-idem','CLK-UAT-0015','LEAD-UAT-0015','action_recorded','vikram.shetty@pawspace.in','{"actionType":"whatsapp","outcome":"call_back_later"}',1783987560000); +INSERT OR IGNORE INTO app_users (id,email,name,role_code,status,created_at,updated_at) VALUES ('USR-SALES-2','neha.kulkarni@pawspace.in','Neha Kulkarni','associate','active',1775174400000,1775174400000); +INSERT OR IGNORE INTO lead_assignment_memberships (id,employee_email,team_code,service_codes_json,city_ids_json,language_codes_json,active,workload_cap_override,created_by,created_at,updated_by,updated_at) VALUES ('LAM-SALES-2','neha.kulkarni@pawspace.in','sales','["grooming","boarding","dog_training"]','["blr","hyd"]','["en","hi"]',1,NULL,'uat-demo-seed',1775174400000,'uat-demo-seed',1775174400000); +INSERT OR IGNORE INTO lead_work_items (id,customer_id,source,service,owner,manager,status,stage,work_day,assigned_at,first_action_due_at,manager_alert_at,first_action_at,call_attempts,whatsapp_attempts,last_outcome,next_action_at,recycle_at,recycle_cycle,opt_out,converted_booking_id,created_at,updated_at) VALUES ('LEAD-UAT-0016','CUS0048','website','grooming','neha.kulkarni@pawspace.in','sales.manager@pawspace.in','converted','day_1',1,1783900800000,1783901400000,1783902600000,1783901100000,1,0,'qualified',1784073600000,NULL,0,0,'BK00005',1783900800000,1784160000000); +INSERT OR IGNORE INTO lead_assignments (id,idempotency_key,lead_id,employee_email,team_code,policy_id,policy_version,assignment_reason,status,fallback_queue,assigned_at,accepted_at,ended_at,ended_reason,previous_assignment_id,detail_json,created_by,created_at) VALUES ('ASG-UAT-0016','ASG-UAT-0016-idem','LEAD-UAT-0016','neha.kulkarni@pawspace.in','sales','LAP-UAT-DEMO',1,'round_robin','current',NULL,1783900800000,1783900920000,NULL,NULL,NULL,'{}','uat-demo-seed',1783900800000); +INSERT OR IGNORE INTO lead_sla_clocks (id,idempotency_key,lead_id,assignment_id,policy_id,policy_version,clock_type,cycle,status,started_at,due_at,manager_escalation_due_at,reassignment_due_at,met_at,breached_at,paused_at,pause_reason,paused_remaining_minutes,last_action_at,next_action_at,detail_json,created_by,created_at,updated_at) VALUES ('CLK-UAT-0016','CLK-UAT-0016-idem','LEAD-UAT-0016','ASG-UAT-0016','LSP-UAT-DEMO',1,'first_response',1,'met',1783900800000,1783901400000,1783902600000,1783904400000,1783901100000,NULL,NULL,NULL,NULL,1783901100000,1784073600000,'{}','uat-demo-seed',1783900800000,1783901100000); +INSERT OR IGNORE INTO lead_sla_events (id,idempotency_key,clock_id,lead_id,event_type,actor_id,detail_json,created_at) VALUES ('EVT-UAT-0016-0','EVT-UAT-0016-0-idem','CLK-UAT-0016','LEAD-UAT-0016','action_recorded','neha.kulkarni@pawspace.in','{"actionType":"call","outcome":"qualified"}',1783901100000); +INSERT OR IGNORE INTO lead_sla_events (id,idempotency_key,clock_id,lead_id,event_type,actor_id,detail_json,created_at) VALUES ('EVT-UAT-0016-1','EVT-UAT-0016-1-idem','CLK-UAT-0016','LEAD-UAT-0016','action_recorded','neha.kulkarni@pawspace.in','{"actionType":"whatsapp","outcome":"booked"}',1783901160000); +INSERT OR IGNORE INTO revenue_mission_events (id,mission_id,source_event_key,event_type,customer_id,booking_id,payment_id,refund_id,service_code,city_id,gross_amount,refund_amount,eligible_amount,currency,source_at,source_version,attribution_json,created_at) VALUES ('RME-UAT-16-c','MIS-UAT-DEMO','BK00005-collected','collected','CUS0048','BK00005','PAY-BK00005',NULL,'grooming','blr',1299,0,1299,'INR',1784073600000,'uat_demo_seed:v1','{}',1784073600000); +INSERT OR IGNORE INTO lead_work_items (id,customer_id,source,service,owner,manager,status,stage,work_day,assigned_at,first_action_due_at,manager_alert_at,first_action_at,call_attempts,whatsapp_attempts,last_outcome,next_action_at,recycle_at,recycle_cycle,opt_out,converted_booking_id,created_at,updated_at) VALUES ('LEAD-UAT-0017','CUS0051','whatsapp','boarding','neha.kulkarni@pawspace.in','sales.manager@pawspace.in','converted','day_1',1,1783814400000,1783815000000,1783816200000,1783814700000,2,1,'qualified',1783987200000,NULL,0,0,'BK00006',1783814400000,1784073600000); +INSERT OR IGNORE INTO lead_assignments (id,idempotency_key,lead_id,employee_email,team_code,policy_id,policy_version,assignment_reason,status,fallback_queue,assigned_at,accepted_at,ended_at,ended_reason,previous_assignment_id,detail_json,created_by,created_at) VALUES ('ASG-UAT-0017','ASG-UAT-0017-idem','LEAD-UAT-0017','neha.kulkarni@pawspace.in','sales','LAP-UAT-DEMO',1,'round_robin','current',NULL,1783814400000,1783814520000,NULL,NULL,NULL,'{}','uat-demo-seed',1783814400000); +INSERT OR IGNORE INTO lead_sla_clocks (id,idempotency_key,lead_id,assignment_id,policy_id,policy_version,clock_type,cycle,status,started_at,due_at,manager_escalation_due_at,reassignment_due_at,met_at,breached_at,paused_at,pause_reason,paused_remaining_minutes,last_action_at,next_action_at,detail_json,created_by,created_at,updated_at) VALUES ('CLK-UAT-0017','CLK-UAT-0017-idem','LEAD-UAT-0017','ASG-UAT-0017','LSP-UAT-DEMO',1,'first_response',1,'met',1783814400000,1783815000000,1783816200000,1783818000000,1783814700000,NULL,NULL,NULL,NULL,1783814700000,1783987200000,'{}','uat-demo-seed',1783814400000,1783814700000); +INSERT OR IGNORE INTO lead_sla_events (id,idempotency_key,clock_id,lead_id,event_type,actor_id,detail_json,created_at) VALUES ('EVT-UAT-0017-0','EVT-UAT-0017-0-idem','CLK-UAT-0017','LEAD-UAT-0017','action_recorded','neha.kulkarni@pawspace.in','{"actionType":"call","outcome":"qualified"}',1783814700000); +INSERT OR IGNORE INTO lead_sla_events (id,idempotency_key,clock_id,lead_id,event_type,actor_id,detail_json,created_at) VALUES ('EVT-UAT-0017-1','EVT-UAT-0017-1-idem','CLK-UAT-0017','LEAD-UAT-0017','action_recorded','neha.kulkarni@pawspace.in','{"actionType":"whatsapp","outcome":"booked"}',1783814760000); +INSERT OR IGNORE INTO revenue_mission_events (id,mission_id,source_event_key,event_type,customer_id,booking_id,payment_id,refund_id,service_code,city_id,gross_amount,refund_amount,eligible_amount,currency,source_at,source_version,attribution_json,created_at) VALUES ('RME-UAT-17-c','MIS-UAT-DEMO','BK00006-collected','collected','CUS0051','BK00006','PAY-BK00006',NULL,'grooming','blr',699,0,699,'INR',1783987200000,'uat_demo_seed:v1','{}',1783987200000); +INSERT OR IGNORE INTO lead_work_items (id,customer_id,source,service,owner,manager,status,stage,work_day,assigned_at,first_action_due_at,manager_alert_at,first_action_at,call_attempts,whatsapp_attempts,last_outcome,next_action_at,recycle_at,recycle_cycle,opt_out,converted_booking_id,created_at,updated_at) VALUES ('LEAD-UAT-0018','CUS0054','referral','dog_training','neha.kulkarni@pawspace.in','sales.manager@pawspace.in','active','day_1',1,1785283200000,1785283800000,1785285000000,1785283500000,3,0,'call_back_later',1785456000000,NULL,0,0,NULL,1785283200000,1785542400000); +INSERT OR IGNORE INTO lead_assignments (id,idempotency_key,lead_id,employee_email,team_code,policy_id,policy_version,assignment_reason,status,fallback_queue,assigned_at,accepted_at,ended_at,ended_reason,previous_assignment_id,detail_json,created_by,created_at) VALUES ('ASG-UAT-0018','ASG-UAT-0018-idem','LEAD-UAT-0018','neha.kulkarni@pawspace.in','sales','LAP-UAT-DEMO',1,'round_robin','current',NULL,1785283200000,1785283320000,NULL,NULL,NULL,'{}','uat-demo-seed',1785283200000); +INSERT OR IGNORE INTO lead_sla_clocks (id,idempotency_key,lead_id,assignment_id,policy_id,policy_version,clock_type,cycle,status,started_at,due_at,manager_escalation_due_at,reassignment_due_at,met_at,breached_at,paused_at,pause_reason,paused_remaining_minutes,last_action_at,next_action_at,detail_json,created_by,created_at,updated_at) VALUES ('CLK-UAT-0018','CLK-UAT-0018-idem','LEAD-UAT-0018','ASG-UAT-0018','LSP-UAT-DEMO',1,'first_response',1,'breached',1785283200000,1785283800000,1785285000000,1785286800000,NULL,1785284100000,NULL,NULL,NULL,1785283500000,1785456000000,'{}','uat-demo-seed',1785283200000,1785283500000); +INSERT OR IGNORE INTO lead_sla_events (id,idempotency_key,clock_id,lead_id,event_type,actor_id,detail_json,created_at) VALUES ('EVT-UAT-0018-0','EVT-UAT-0018-0-idem','CLK-UAT-0018','LEAD-UAT-0018','action_recorded','neha.kulkarni@pawspace.in','{"actionType":"call","outcome":"call_back_later"}',1785283500000); +INSERT OR IGNORE INTO lead_sla_events (id,idempotency_key,clock_id,lead_id,event_type,actor_id,detail_json,created_at) VALUES ('EVT-UAT-0018-1','EVT-UAT-0018-1-idem','CLK-UAT-0018','LEAD-UAT-0018','action_recorded','neha.kulkarni@pawspace.in','{"actionType":"whatsapp","outcome":"call_back_later"}',1785283560000); +INSERT OR IGNORE INTO lead_work_items (id,customer_id,source,service,owner,manager,status,stage,work_day,assigned_at,first_action_due_at,manager_alert_at,first_action_at,call_attempts,whatsapp_attempts,last_outcome,next_action_at,recycle_at,recycle_cycle,opt_out,converted_booking_id,created_at,updated_at) VALUES ('LEAD-UAT-0019','CUS0057','website','grooming','neha.kulkarni@pawspace.in','sales.manager@pawspace.in','active','day_1',1,1785196800000,1785197400000,1785198600000,1785197100000,1,1,'call_back_later',1785369600000,NULL,0,0,NULL,1785196800000,1785456000000); +INSERT OR IGNORE INTO lead_assignments (id,idempotency_key,lead_id,employee_email,team_code,policy_id,policy_version,assignment_reason,status,fallback_queue,assigned_at,accepted_at,ended_at,ended_reason,previous_assignment_id,detail_json,created_by,created_at) VALUES ('ASG-UAT-0019','ASG-UAT-0019-idem','LEAD-UAT-0019','neha.kulkarni@pawspace.in','sales','LAP-UAT-DEMO',1,'round_robin','current',NULL,1785196800000,1785196920000,NULL,NULL,NULL,'{}','uat-demo-seed',1785196800000); +INSERT OR IGNORE INTO lead_sla_clocks (id,idempotency_key,lead_id,assignment_id,policy_id,policy_version,clock_type,cycle,status,started_at,due_at,manager_escalation_due_at,reassignment_due_at,met_at,breached_at,paused_at,pause_reason,paused_remaining_minutes,last_action_at,next_action_at,detail_json,created_by,created_at,updated_at) VALUES ('CLK-UAT-0019','CLK-UAT-0019-idem','LEAD-UAT-0019','ASG-UAT-0019','LSP-UAT-DEMO',1,'first_response',1,'met',1785196800000,1785197400000,1785198600000,1785200400000,1785197100000,NULL,NULL,NULL,NULL,1785197100000,1785369600000,'{}','uat-demo-seed',1785196800000,1785197100000); +INSERT OR IGNORE INTO lead_sla_events (id,idempotency_key,clock_id,lead_id,event_type,actor_id,detail_json,created_at) VALUES ('EVT-UAT-0019-0','EVT-UAT-0019-0-idem','CLK-UAT-0019','LEAD-UAT-0019','action_recorded','neha.kulkarni@pawspace.in','{"actionType":"call","outcome":"call_back_later"}',1785197100000); +INSERT OR IGNORE INTO lead_sla_events (id,idempotency_key,clock_id,lead_id,event_type,actor_id,detail_json,created_at) VALUES ('EVT-UAT-0019-1','EVT-UAT-0019-1-idem','CLK-UAT-0019','LEAD-UAT-0019','action_recorded','neha.kulkarni@pawspace.in','{"actionType":"whatsapp","outcome":"call_back_later"}',1785197160000); +INSERT OR IGNORE INTO lead_work_items (id,customer_id,source,service,owner,manager,status,stage,work_day,assigned_at,first_action_due_at,manager_alert_at,first_action_at,call_attempts,whatsapp_attempts,last_outcome,next_action_at,recycle_at,recycle_cycle,opt_out,converted_booking_id,created_at,updated_at) VALUES ('LEAD-UAT-0020','CUS0060','whatsapp','boarding','neha.kulkarni@pawspace.in','sales.manager@pawspace.in','active','day_1',1,1785110400000,1785111000000,1785112200000,1785110700000,2,0,'call_back_later',1785283200000,NULL,0,0,NULL,1785110400000,1785369600000); +INSERT OR IGNORE INTO lead_assignments (id,idempotency_key,lead_id,employee_email,team_code,policy_id,policy_version,assignment_reason,status,fallback_queue,assigned_at,accepted_at,ended_at,ended_reason,previous_assignment_id,detail_json,created_by,created_at) VALUES ('ASG-UAT-0020','ASG-UAT-0020-idem','LEAD-UAT-0020','neha.kulkarni@pawspace.in','sales','LAP-UAT-DEMO',1,'round_robin','current',NULL,1785110400000,1785110520000,NULL,NULL,NULL,'{}','uat-demo-seed',1785110400000); +INSERT OR IGNORE INTO lead_sla_clocks (id,idempotency_key,lead_id,assignment_id,policy_id,policy_version,clock_type,cycle,status,started_at,due_at,manager_escalation_due_at,reassignment_due_at,met_at,breached_at,paused_at,pause_reason,paused_remaining_minutes,last_action_at,next_action_at,detail_json,created_by,created_at,updated_at) VALUES ('CLK-UAT-0020','CLK-UAT-0020-idem','LEAD-UAT-0020','ASG-UAT-0020','LSP-UAT-DEMO',1,'first_response',1,'met',1785110400000,1785111000000,1785112200000,1785114000000,1785110700000,NULL,NULL,NULL,NULL,1785110700000,1785283200000,'{}','uat-demo-seed',1785110400000,1785110700000); +INSERT OR IGNORE INTO lead_sla_events (id,idempotency_key,clock_id,lead_id,event_type,actor_id,detail_json,created_at) VALUES ('EVT-UAT-0020-0','EVT-UAT-0020-0-idem','CLK-UAT-0020','LEAD-UAT-0020','action_recorded','neha.kulkarni@pawspace.in','{"actionType":"call","outcome":"call_back_later"}',1785110700000); +INSERT OR IGNORE INTO lead_sla_events (id,idempotency_key,clock_id,lead_id,event_type,actor_id,detail_json,created_at) VALUES ('EVT-UAT-0020-1','EVT-UAT-0020-1-idem','CLK-UAT-0020','LEAD-UAT-0020','action_recorded','neha.kulkarni@pawspace.in','{"actionType":"whatsapp","outcome":"call_back_later"}',1785110760000); +INSERT OR IGNORE INTO lead_work_items (id,customer_id,source,service,owner,manager,status,stage,work_day,assigned_at,first_action_due_at,manager_alert_at,first_action_at,call_attempts,whatsapp_attempts,last_outcome,next_action_at,recycle_at,recycle_cycle,opt_out,converted_booking_id,created_at,updated_at) VALUES ('LEAD-UAT-0021','CUS0063','referral','dog_training','neha.kulkarni@pawspace.in','sales.manager@pawspace.in','active','day_1',1,1785024000000,1785024600000,1785025800000,1785024300000,3,1,'call_back_later',1785196800000,NULL,0,0,NULL,1785024000000,1785283200000); +INSERT OR IGNORE INTO lead_assignments (id,idempotency_key,lead_id,employee_email,team_code,policy_id,policy_version,assignment_reason,status,fallback_queue,assigned_at,accepted_at,ended_at,ended_reason,previous_assignment_id,detail_json,created_by,created_at) VALUES ('ASG-UAT-0021','ASG-UAT-0021-idem','LEAD-UAT-0021','neha.kulkarni@pawspace.in','sales','LAP-UAT-DEMO',1,'round_robin','current',NULL,1785024000000,1785024120000,NULL,NULL,NULL,'{}','uat-demo-seed',1785024000000); +INSERT OR IGNORE INTO lead_sla_clocks (id,idempotency_key,lead_id,assignment_id,policy_id,policy_version,clock_type,cycle,status,started_at,due_at,manager_escalation_due_at,reassignment_due_at,met_at,breached_at,paused_at,pause_reason,paused_remaining_minutes,last_action_at,next_action_at,detail_json,created_by,created_at,updated_at) VALUES ('CLK-UAT-0021','CLK-UAT-0021-idem','LEAD-UAT-0021','ASG-UAT-0021','LSP-UAT-DEMO',1,'first_response',1,'breached',1785024000000,1785024600000,1785025800000,1785027600000,NULL,1785024900000,NULL,NULL,NULL,1785024300000,1785196800000,'{}','uat-demo-seed',1785024000000,1785024300000); +INSERT OR IGNORE INTO lead_sla_events (id,idempotency_key,clock_id,lead_id,event_type,actor_id,detail_json,created_at) VALUES ('EVT-UAT-0021-0','EVT-UAT-0021-0-idem','CLK-UAT-0021','LEAD-UAT-0021','action_recorded','neha.kulkarni@pawspace.in','{"actionType":"call","outcome":"call_back_later"}',1785024300000); +INSERT OR IGNORE INTO lead_sla_events (id,idempotency_key,clock_id,lead_id,event_type,actor_id,detail_json,created_at) VALUES ('EVT-UAT-0021-1','EVT-UAT-0021-1-idem','CLK-UAT-0021','LEAD-UAT-0021','action_recorded','neha.kulkarni@pawspace.in','{"actionType":"whatsapp","outcome":"call_back_later"}',1785024360000); +INSERT OR IGNORE INTO app_users (id,email,name,role_code,status,created_at,updated_at) VALUES ('USR-SALES-3','rohit.menon@pawspace.in','Rohit Menon','associate','active',1775174400000,1775174400000); +INSERT OR IGNORE INTO lead_assignment_memberships (id,employee_email,team_code,service_codes_json,city_ids_json,language_codes_json,active,workload_cap_override,created_by,created_at,updated_by,updated_at) VALUES ('LAM-SALES-3','rohit.menon@pawspace.in','sales','["grooming","boarding","dog_training"]','["blr","hyd"]','["en","hi"]',1,NULL,'uat-demo-seed',1775174400000,'uat-demo-seed',1775174400000); +INSERT OR IGNORE INTO lead_work_items (id,customer_id,source,service,owner,manager,status,stage,work_day,assigned_at,first_action_due_at,manager_alert_at,first_action_at,call_attempts,whatsapp_attempts,last_outcome,next_action_at,recycle_at,recycle_cycle,opt_out,converted_booking_id,created_at,updated_at) VALUES ('LEAD-UAT-0022','CUS0066','website','grooming','rohit.menon@pawspace.in','sales.manager@pawspace.in','converted','day_1',1,1784937600000,1784938200000,1784939400000,1784937900000,1,0,'qualified',1785110400000,NULL,0,0,'BK00007',1784937600000,1785196800000); +INSERT OR IGNORE INTO lead_assignments (id,idempotency_key,lead_id,employee_email,team_code,policy_id,policy_version,assignment_reason,status,fallback_queue,assigned_at,accepted_at,ended_at,ended_reason,previous_assignment_id,detail_json,created_by,created_at) VALUES ('ASG-UAT-0022','ASG-UAT-0022-idem','LEAD-UAT-0022','rohit.menon@pawspace.in','sales','LAP-UAT-DEMO',1,'round_robin','current',NULL,1784937600000,1784937720000,NULL,NULL,NULL,'{}','uat-demo-seed',1784937600000); +INSERT OR IGNORE INTO lead_sla_clocks (id,idempotency_key,lead_id,assignment_id,policy_id,policy_version,clock_type,cycle,status,started_at,due_at,manager_escalation_due_at,reassignment_due_at,met_at,breached_at,paused_at,pause_reason,paused_remaining_minutes,last_action_at,next_action_at,detail_json,created_by,created_at,updated_at) VALUES ('CLK-UAT-0022','CLK-UAT-0022-idem','LEAD-UAT-0022','ASG-UAT-0022','LSP-UAT-DEMO',1,'first_response',1,'met',1784937600000,1784938200000,1784939400000,1784941200000,1784937900000,NULL,NULL,NULL,NULL,1784937900000,1785110400000,'{}','uat-demo-seed',1784937600000,1784937900000); +INSERT OR IGNORE INTO lead_sla_events (id,idempotency_key,clock_id,lead_id,event_type,actor_id,detail_json,created_at) VALUES ('EVT-UAT-0022-0','EVT-UAT-0022-0-idem','CLK-UAT-0022','LEAD-UAT-0022','action_recorded','rohit.menon@pawspace.in','{"actionType":"call","outcome":"qualified"}',1784937900000); +INSERT OR IGNORE INTO lead_sla_events (id,idempotency_key,clock_id,lead_id,event_type,actor_id,detail_json,created_at) VALUES ('EVT-UAT-0022-1','EVT-UAT-0022-1-idem','CLK-UAT-0022','LEAD-UAT-0022','action_recorded','rohit.menon@pawspace.in','{"actionType":"whatsapp","outcome":"booked"}',1784937960000); +INSERT OR IGNORE INTO revenue_mission_events (id,mission_id,source_event_key,event_type,customer_id,booking_id,payment_id,refund_id,service_code,city_id,gross_amount,refund_amount,eligible_amount,currency,source_at,source_version,attribution_json,created_at) VALUES ('RME-UAT-22-c','MIS-UAT-DEMO','BK00007-collected','collected','CUS0066','BK00007','PAY-BK00007',NULL,'grooming','blr',699,0,699,'INR',1785110400000,'uat_demo_seed:v1','{}',1785110400000); +INSERT OR IGNORE INTO lead_work_items (id,customer_id,source,service,owner,manager,status,stage,work_day,assigned_at,first_action_due_at,manager_alert_at,first_action_at,call_attempts,whatsapp_attempts,last_outcome,next_action_at,recycle_at,recycle_cycle,opt_out,converted_booking_id,created_at,updated_at) VALUES ('LEAD-UAT-0023','CUS0069','whatsapp','boarding','rohit.menon@pawspace.in','sales.manager@pawspace.in','active','day_1',1,1784851200000,1784851800000,1784853000000,1784851500000,2,1,'call_back_later',1785024000000,NULL,0,0,NULL,1784851200000,1785110400000); +INSERT OR IGNORE INTO lead_assignments (id,idempotency_key,lead_id,employee_email,team_code,policy_id,policy_version,assignment_reason,status,fallback_queue,assigned_at,accepted_at,ended_at,ended_reason,previous_assignment_id,detail_json,created_by,created_at) VALUES ('ASG-UAT-0023','ASG-UAT-0023-idem','LEAD-UAT-0023','rohit.menon@pawspace.in','sales','LAP-UAT-DEMO',1,'round_robin','current',NULL,1784851200000,1784851320000,NULL,NULL,NULL,'{}','uat-demo-seed',1784851200000); +INSERT OR IGNORE INTO lead_sla_clocks (id,idempotency_key,lead_id,assignment_id,policy_id,policy_version,clock_type,cycle,status,started_at,due_at,manager_escalation_due_at,reassignment_due_at,met_at,breached_at,paused_at,pause_reason,paused_remaining_minutes,last_action_at,next_action_at,detail_json,created_by,created_at,updated_at) VALUES ('CLK-UAT-0023','CLK-UAT-0023-idem','LEAD-UAT-0023','ASG-UAT-0023','LSP-UAT-DEMO',1,'first_response',1,'met',1784851200000,1784851800000,1784853000000,1784854800000,1784851500000,NULL,NULL,NULL,NULL,1784851500000,1785024000000,'{}','uat-demo-seed',1784851200000,1784851500000); +INSERT OR IGNORE INTO lead_sla_events (id,idempotency_key,clock_id,lead_id,event_type,actor_id,detail_json,created_at) VALUES ('EVT-UAT-0023-0','EVT-UAT-0023-0-idem','CLK-UAT-0023','LEAD-UAT-0023','action_recorded','rohit.menon@pawspace.in','{"actionType":"call","outcome":"call_back_later"}',1784851500000); +INSERT OR IGNORE INTO lead_sla_events (id,idempotency_key,clock_id,lead_id,event_type,actor_id,detail_json,created_at) VALUES ('EVT-UAT-0023-1','EVT-UAT-0023-1-idem','CLK-UAT-0023','LEAD-UAT-0023','action_recorded','rohit.menon@pawspace.in','{"actionType":"whatsapp","outcome":"call_back_later"}',1784851560000); +INSERT OR IGNORE INTO lead_work_items (id,customer_id,source,service,owner,manager,status,stage,work_day,assigned_at,first_action_due_at,manager_alert_at,first_action_at,call_attempts,whatsapp_attempts,last_outcome,next_action_at,recycle_at,recycle_cycle,opt_out,converted_booking_id,created_at,updated_at) VALUES ('LEAD-UAT-0024','CUS0072','referral','dog_training','rohit.menon@pawspace.in','sales.manager@pawspace.in','active','day_1',1,1784764800000,1784765400000,1784766600000,1784765100000,3,0,'call_back_later',1784937600000,NULL,0,0,NULL,1784764800000,1785024000000); +INSERT OR IGNORE INTO lead_assignments (id,idempotency_key,lead_id,employee_email,team_code,policy_id,policy_version,assignment_reason,status,fallback_queue,assigned_at,accepted_at,ended_at,ended_reason,previous_assignment_id,detail_json,created_by,created_at) VALUES ('ASG-UAT-0024','ASG-UAT-0024-idem','LEAD-UAT-0024','rohit.menon@pawspace.in','sales','LAP-UAT-DEMO',1,'round_robin','current',NULL,1784764800000,1784764920000,NULL,NULL,NULL,'{}','uat-demo-seed',1784764800000); +INSERT OR IGNORE INTO lead_sla_clocks (id,idempotency_key,lead_id,assignment_id,policy_id,policy_version,clock_type,cycle,status,started_at,due_at,manager_escalation_due_at,reassignment_due_at,met_at,breached_at,paused_at,pause_reason,paused_remaining_minutes,last_action_at,next_action_at,detail_json,created_by,created_at,updated_at) VALUES ('CLK-UAT-0024','CLK-UAT-0024-idem','LEAD-UAT-0024','ASG-UAT-0024','LSP-UAT-DEMO',1,'first_response',1,'breached',1784764800000,1784765400000,1784766600000,1784768400000,NULL,1784765700000,NULL,NULL,NULL,1784765100000,1784937600000,'{}','uat-demo-seed',1784764800000,1784765100000); +INSERT OR IGNORE INTO lead_sla_events (id,idempotency_key,clock_id,lead_id,event_type,actor_id,detail_json,created_at) VALUES ('EVT-UAT-0024-0','EVT-UAT-0024-0-idem','CLK-UAT-0024','LEAD-UAT-0024','action_recorded','rohit.menon@pawspace.in','{"actionType":"call","outcome":"call_back_later"}',1784765100000); +INSERT OR IGNORE INTO lead_sla_events (id,idempotency_key,clock_id,lead_id,event_type,actor_id,detail_json,created_at) VALUES ('EVT-UAT-0024-1','EVT-UAT-0024-1-idem','CLK-UAT-0024','LEAD-UAT-0024','action_recorded','rohit.menon@pawspace.in','{"actionType":"whatsapp","outcome":"call_back_later"}',1784765160000); +INSERT OR IGNORE INTO lead_work_items (id,customer_id,source,service,owner,manager,status,stage,work_day,assigned_at,first_action_due_at,manager_alert_at,first_action_at,call_attempts,whatsapp_attempts,last_outcome,next_action_at,recycle_at,recycle_cycle,opt_out,converted_booking_id,created_at,updated_at) VALUES ('LEAD-UAT-0025','CUS0075','website','grooming','rohit.menon@pawspace.in','sales.manager@pawspace.in','active','day_1',1,1784678400000,1784679000000,1784680200000,1784678700000,1,1,'call_back_later',1784851200000,NULL,0,0,NULL,1784678400000,1784937600000); +INSERT OR IGNORE INTO lead_assignments (id,idempotency_key,lead_id,employee_email,team_code,policy_id,policy_version,assignment_reason,status,fallback_queue,assigned_at,accepted_at,ended_at,ended_reason,previous_assignment_id,detail_json,created_by,created_at) VALUES ('ASG-UAT-0025','ASG-UAT-0025-idem','LEAD-UAT-0025','rohit.menon@pawspace.in','sales','LAP-UAT-DEMO',1,'round_robin','current',NULL,1784678400000,1784678520000,NULL,NULL,NULL,'{}','uat-demo-seed',1784678400000); +INSERT OR IGNORE INTO lead_sla_clocks (id,idempotency_key,lead_id,assignment_id,policy_id,policy_version,clock_type,cycle,status,started_at,due_at,manager_escalation_due_at,reassignment_due_at,met_at,breached_at,paused_at,pause_reason,paused_remaining_minutes,last_action_at,next_action_at,detail_json,created_by,created_at,updated_at) VALUES ('CLK-UAT-0025','CLK-UAT-0025-idem','LEAD-UAT-0025','ASG-UAT-0025','LSP-UAT-DEMO',1,'first_response',1,'met',1784678400000,1784679000000,1784680200000,1784682000000,1784678700000,NULL,NULL,NULL,NULL,1784678700000,1784851200000,'{}','uat-demo-seed',1784678400000,1784678700000); +INSERT OR IGNORE INTO lead_sla_events (id,idempotency_key,clock_id,lead_id,event_type,actor_id,detail_json,created_at) VALUES ('EVT-UAT-0025-0','EVT-UAT-0025-0-idem','CLK-UAT-0025','LEAD-UAT-0025','action_recorded','rohit.menon@pawspace.in','{"actionType":"call","outcome":"call_back_later"}',1784678700000); +INSERT OR IGNORE INTO lead_sla_events (id,idempotency_key,clock_id,lead_id,event_type,actor_id,detail_json,created_at) VALUES ('EVT-UAT-0025-1','EVT-UAT-0025-1-idem','CLK-UAT-0025','LEAD-UAT-0025','action_recorded','rohit.menon@pawspace.in','{"actionType":"whatsapp","outcome":"call_back_later"}',1784678760000); +INSERT OR IGNORE INTO lead_work_items (id,customer_id,source,service,owner,manager,status,stage,work_day,assigned_at,first_action_due_at,manager_alert_at,first_action_at,call_attempts,whatsapp_attempts,last_outcome,next_action_at,recycle_at,recycle_cycle,opt_out,converted_booking_id,created_at,updated_at) VALUES ('LEAD-UAT-0026','CUS0078','whatsapp','boarding','rohit.menon@pawspace.in','sales.manager@pawspace.in','active','day_1',1,1784592000000,1784592600000,1784593800000,1784592300000,2,0,'call_back_later',1784764800000,NULL,0,0,NULL,1784592000000,1784851200000); +INSERT OR IGNORE INTO lead_assignments (id,idempotency_key,lead_id,employee_email,team_code,policy_id,policy_version,assignment_reason,status,fallback_queue,assigned_at,accepted_at,ended_at,ended_reason,previous_assignment_id,detail_json,created_by,created_at) VALUES ('ASG-UAT-0026','ASG-UAT-0026-idem','LEAD-UAT-0026','rohit.menon@pawspace.in','sales','LAP-UAT-DEMO',1,'round_robin','current',NULL,1784592000000,1784592120000,NULL,NULL,NULL,'{}','uat-demo-seed',1784592000000); +INSERT OR IGNORE INTO lead_sla_clocks (id,idempotency_key,lead_id,assignment_id,policy_id,policy_version,clock_type,cycle,status,started_at,due_at,manager_escalation_due_at,reassignment_due_at,met_at,breached_at,paused_at,pause_reason,paused_remaining_minutes,last_action_at,next_action_at,detail_json,created_by,created_at,updated_at) VALUES ('CLK-UAT-0026','CLK-UAT-0026-idem','LEAD-UAT-0026','ASG-UAT-0026','LSP-UAT-DEMO',1,'first_response',1,'met',1784592000000,1784592600000,1784593800000,1784595600000,1784592300000,NULL,NULL,NULL,NULL,1784592300000,1784764800000,'{}','uat-demo-seed',1784592000000,1784592300000); +INSERT OR IGNORE INTO lead_sla_events (id,idempotency_key,clock_id,lead_id,event_type,actor_id,detail_json,created_at) VALUES ('EVT-UAT-0026-0','EVT-UAT-0026-0-idem','CLK-UAT-0026','LEAD-UAT-0026','action_recorded','rohit.menon@pawspace.in','{"actionType":"call","outcome":"call_back_later"}',1784592300000); +INSERT OR IGNORE INTO lead_sla_events (id,idempotency_key,clock_id,lead_id,event_type,actor_id,detail_json,created_at) VALUES ('EVT-UAT-0026-1','EVT-UAT-0026-1-idem','CLK-UAT-0026','LEAD-UAT-0026','action_recorded','rohit.menon@pawspace.in','{"actionType":"whatsapp","outcome":"call_back_later"}',1784592360000); +-- Active productivity policy. Generate the report from /team/performance to fill the board. +INSERT OR IGNORE INTO sales_productivity_policies (id,name,status,version,team_code,timezone,meaningful_action_types_json,qualified_outcomes_json,revenue_basis,require_canonical_lead_booking_link,effective_from,effective_until,approval_reference,created_by,created_at,updated_by,updated_at) VALUES ('SPP-UAT-DEMO','Sales productivity (UAT baseline)','active_uat',1,'sales','Asia/Kolkata','["call","whatsapp"]','["qualified","booked"]','net_collected',1,1754006400000,NULL,'UAT-DEMO-SEED','uat-demo-seed',1775174400000,'uat-demo-seed',1775174400000); +-- Governed campaigns: one live with a taken audience snapshot, one awaiting approval. +INSERT OR IGNORE INTO governed_marketing_campaigns (id,name,objective,service_code,city_id,audience_rule_json,budget_amount,currency,holdout_percent,status,approval_status,approved_by,approved_at,start_at,end_at,created_by,created_at,updated_at) VALUES ('CMP-UAT-MONSOON','Monsoon grooming refresh','reactivation','grooming','blr','{"marketingConsent":true,"serviceCode":"grooming","cityId":"blr"}',45000,'INR',10,'active','approved','marketing.head@pawspace.in',1783814400000,1783987200000,1786579200000,'uat-demo-seed',1783382400000,1783987200000); +INSERT OR IGNORE INTO governed_marketing_campaigns (id,name,objective,service_code,city_id,audience_rule_json,budget_amount,currency,holdout_percent,status,approval_status,approved_by,approved_at,start_at,end_at,created_by,created_at,updated_at) VALUES ('CMP-UAT-BOARDING','Festive boarding pre-book','acquisition','boarding','hyd','{"marketingConsent":true,"serviceCode":"boarding","cityId":"hyd"}',60000,'INR',15,'draft','approval_required',NULL,NULL,NULL,NULL,'uat-demo-seed',1783382400000,1783987200000); +INSERT OR IGNORE INTO marketing_audience_snapshots (id,campaign_id,snapshot_at,total_candidates,eligible_count,holdout_count,suppressed_count,policy_json,created_by) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON',1783987200000,74,57,8,9,'{"requiresMarketingConsent":true,"holdoutPercent":10,"suppressionReasons":["marketing_consent_missing"]}','uat-demo-seed'); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0000','holdout',NULL); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0003','eligible',NULL); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0006','eligible',NULL); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0009','eligible',NULL); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0012','eligible',NULL); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0015','eligible',NULL); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0018','eligible',NULL); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0021','suppressed','marketing_consent_missing'); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0024','eligible',NULL); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0027','eligible',NULL); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0030','holdout',NULL); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0033','eligible',NULL); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0036','eligible',NULL); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0039','eligible',NULL); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0042','suppressed','marketing_consent_missing'); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0045','eligible',NULL); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0048','eligible',NULL); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0051','eligible',NULL); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0054','eligible',NULL); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0057','eligible',NULL); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0060','holdout',NULL); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0063','suppressed','marketing_consent_missing'); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0066','eligible',NULL); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0069','eligible',NULL); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0072','eligible',NULL); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0075','eligible',NULL); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0078','eligible',NULL); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0081','eligible',NULL); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0084','suppressed','marketing_consent_missing'); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0087','eligible',NULL); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0090','holdout',NULL); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0093','eligible',NULL); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0096','eligible',NULL); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0099','eligible',NULL); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0102','eligible',NULL); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0105','suppressed','marketing_consent_missing'); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0108','eligible',NULL); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0111','eligible',NULL); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0114','eligible',NULL); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0117','eligible',NULL); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0120','holdout',NULL); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0123','eligible',NULL); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0126','suppressed','marketing_consent_missing'); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0129','eligible',NULL); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0132','eligible',NULL); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0135','eligible',NULL); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0138','eligible',NULL); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0141','eligible',NULL); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0144','eligible',NULL); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0147','suppressed','marketing_consent_missing'); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0150','holdout',NULL); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0153','eligible',NULL); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0156','eligible',NULL); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0159','eligible',NULL); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0162','eligible',NULL); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0165','eligible',NULL); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0168','suppressed','marketing_consent_missing'); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0171','eligible',NULL); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0174','eligible',NULL); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0177','eligible',NULL); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0180','holdout',NULL); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0183','eligible',NULL); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0186','eligible',NULL); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0189','suppressed','marketing_consent_missing'); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0192','eligible',NULL); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0195','eligible',NULL); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0198','eligible',NULL); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0201','eligible',NULL); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0204','eligible',NULL); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0207','eligible',NULL); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0210','holdout',NULL); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0213','eligible',NULL); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0216','eligible',NULL); +INSERT OR IGNORE INTO marketing_audience_members (snapshot_id,campaign_id,customer_id,cohort,suppression_reason) VALUES ('MAS-UAT-MONSOON','CMP-UAT-MONSOON','CUS0219','eligible',NULL); +INSERT OR IGNORE INTO marketing_attribution_facts (id,campaign_id,customer_id,lead_id,booking_id,collection_id,source,medium,spend_amount,booked_revenue,collected_revenue,contribution_margin,attribution_model,created_at,updated_at) VALUES ('MAF-UAT-0','CMP-UAT-MONSOON','CUS0000',NULL,'BK00000',NULL,'meta','paid_social',1500,799,799,NULL,'last_touch_uat',1784246400000,1784246400000); +INSERT OR IGNORE INTO marketing_attribution_facts (id,campaign_id,customer_id,lead_id,booking_id,collection_id,source,medium,spend_amount,booked_revenue,collected_revenue,contribution_margin,attribution_model,created_at,updated_at) VALUES ('MAF-UAT-1','CMP-UAT-MONSOON','CUS0003',NULL,'BK00001',NULL,'meta','paid_social',1500,1299,1299,NULL,'last_touch_uat',1784246400000,1784246400000); +INSERT OR IGNORE INTO marketing_attribution_facts (id,campaign_id,customer_id,lead_id,booking_id,collection_id,source,medium,spend_amount,booked_revenue,collected_revenue,contribution_margin,attribution_model,created_at,updated_at) VALUES ('MAF-UAT-2','CMP-UAT-MONSOON','CUS0006',NULL,'BK00002',NULL,'meta','paid_social',1500,699,699,NULL,'last_touch_uat',1784246400000,1784246400000); +INSERT OR IGNORE INTO marketing_attribution_facts (id,campaign_id,customer_id,lead_id,booking_id,collection_id,source,medium,spend_amount,booked_revenue,collected_revenue,contribution_margin,attribution_model,created_at,updated_at) VALUES ('MAF-UAT-3','CMP-UAT-MONSOON','CUS0009',NULL,'BK00003',NULL,'meta','paid_social',1500,899,899,NULL,'last_touch_uat',1784246400000,1784246400000); +INSERT OR IGNORE INTO marketing_attribution_facts (id,campaign_id,customer_id,lead_id,booking_id,collection_id,source,medium,spend_amount,booked_revenue,collected_revenue,contribution_margin,attribution_model,created_at,updated_at) VALUES ('MAF-UAT-4','CMP-UAT-MONSOON','CUS0012',NULL,'BK00004',NULL,'meta','paid_social',1500,599,599,NULL,'last_touch_uat',1784246400000,1784246400000); +INSERT OR IGNORE INTO marketing_attribution_facts (id,campaign_id,customer_id,lead_id,booking_id,collection_id,source,medium,spend_amount,booked_revenue,collected_revenue,contribution_margin,attribution_model,created_at,updated_at) VALUES ('MAF-UAT-5','CMP-UAT-MONSOON','CUS0015',NULL,'BK00005',NULL,'meta','paid_social',1500,799,799,NULL,'last_touch_uat',1784246400000,1784246400000); diff --git a/tests/d1-in-clause-fanout.test.mjs b/tests/d1-in-clause-fanout.test.mjs new file mode 100644 index 000000000..b05eb32b8 --- /dev/null +++ b/tests/d1-in-clause-fanout.test.mjs @@ -0,0 +1,197 @@ +/** + * Staging /team/analytics reported 331 bookings and GMV of Rs 3,24,472 next to "Collected Rs 0" on + * every service line, while the seeded database held a captured payment for each of those bookings. + * + * D1 caps a query at 100 bound parameters. Reads shaped `WHERE booking_id IN (?,?,...)` built from a + * result set pass under 100 rows and fail above it, and because these reads sit inside + * swallow-and-continue helpers the failure renders as a confident zero. The shim below enforces the + * same cap as D1 so the tests fail the way production did. + */ +import assert from "node:assert/strict"; +import test from "node:test"; +import { readFile, readdir } from "node:fs/promises"; +import { DatabaseSync } from "node:sqlite"; +import * as nodeModule from "node:module"; + +const CF_STUB = "data:text/javascript,export const env=new Proxy({},{get:(t,k)=>k===\"DB\"?globalThis.__FANOUT_DB__:(globalThis.__FANOUT_ENV__??{})[k]});"; +nodeModule.registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === "cloudflare:workers") return { url: CF_STUB, shortCircuit: true }; + try { + return nextResolve(specifier, context); + } catch (error) { + if (specifier.startsWith(".") && !specifier.endsWith(".ts")) return nextResolve(`${specifier}.ts`, context); + throw error; + } + }, +}); + +const D1_BOUND_PARAMETER_LIMIT = 100; + +function makeD1(sqlite) { + function statement(sql, args) { + const guard = () => { + if (args.length > D1_BOUND_PARAMETER_LIMIT) { + throw new Error(`D1_ERROR: too many SQL variables at offset 0: SQLITE_ERROR (${args.length} bound parameters, limit ${D1_BOUND_PARAMETER_LIMIT})`); + } + }; + return { + bind: (...bound) => statement(sql, bound), + first: async () => { + guard(); + const row = sqlite.prepare(sql).get(...args); + return row === undefined ? null : row; + }, + run: async () => { + guard(); + const info = sqlite.prepare(sql).run(...args); + return { success: true, meta: { changes: Number(info.changes) } }; + }, + all: async () => { + guard(); + return { results: sqlite.prepare(sql).all(...args) }; + }, + }; + } + return { + prepare: (sql) => statement(sql, []), + batch: async (statements) => { + const out = []; + for (const item of statements) out.push(await item.run()); + return out; + }, + exec: async (sql) => { + sqlite.exec(sql); + return { count: 0, duration: 0 }; + }, + }; +} + +// DDL copied verbatim from scripts/staging-seed.sql (booking_payments) and +// drizzle/0011_serious_shaman.sql (canonical_bookings) - the same shapes staging runs. +const BOOKINGS = "CREATE TABLE IF NOT EXISTS canonical_bookings (id text PRIMARY KEY NOT NULL, idempotency_key text NOT NULL, customer_id text NOT NULL, pet_ids_json text NOT NULL, source_pet_ids_json text NOT NULL, city_id text NOT NULL, zone_id text NOT NULL, service_code text NOT NULL, package_code text NOT NULL, package_name text NOT NULL, schedule_group_id text NOT NULL, provider_id text NOT NULL, scheduled_start text NOT NULL, scheduled_end text NOT NULL, status text DEFAULT 'confirmed' NOT NULL, channel text DEFAULT 'customer_app' NOT NULL, total_amount real NOT NULL, currency text DEFAULT 'INR' NOT NULL, pricing_json text DEFAULT '{}' NOT NULL, created_by text NOT NULL, created_at integer NOT NULL, updated_at integer NOT NULL)"; +const PAYMENTS = "CREATE TABLE IF NOT EXISTS booking_payments (id TEXT PRIMARY KEY,booking_id TEXT NOT NULL UNIQUE,customer_id TEXT NOT NULL,amount REAL NOT NULL,amount_due_now REAL NOT NULL,currency TEXT NOT NULL DEFAULT 'INR',method TEXT NOT NULL,mode TEXT NOT NULL,status TEXT NOT NULL,gateway TEXT NOT NULL DEFAULT 'uat_sandbox',idempotency_key TEXT NOT NULL,detail_json TEXT NOT NULL DEFAULT '{}',created_at INTEGER NOT NULL,updated_at INTEGER NOT NULL)"; +const TICKETS = "CREATE TABLE IF NOT EXISTS customer_experience_tickets (id TEXT PRIMARY KEY, customer_id TEXT, booking_id TEXT, lead_id TEXT, category TEXT NOT NULL, priority TEXT NOT NULL, subject TEXT NOT NULL, detail TEXT NOT NULL, owner TEXT NOT NULL, manager TEXT NOT NULL, sla_due_at INTEGER NOT NULL, status TEXT NOT NULL DEFAULT 'open', escalation_level INTEGER NOT NULL DEFAULT 0, customer_status TEXT NOT NULL DEFAULT 'We received your request', resolution TEXT, root_cause TEXT, resolution_evidence TEXT, reopened_count INTEGER NOT NULL DEFAULT 0, created_by TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, resolved_at INTEGER)"; + +// Staging holds 331 bookings; anything over the 100-parameter cap reproduces it. +const BOOKING_COUNT = 150; +const AMOUNT = 1000; + +function seedBookings() { + const sqlite = new DatabaseSync(":memory:"); + sqlite.exec(BOOKINGS); + sqlite.exec(PAYMENTS); + sqlite.exec(TICKETS); + const now = Date.now(); + for (let index = 0; index < BOOKING_COUNT; index += 1) { + const id = `BK${String(index).padStart(5, "0")}`; + const customerId = `CUS${String(index).padStart(4, "0")}`; + sqlite.prepare("INSERT INTO canonical_bookings (id,idempotency_key,customer_id,pet_ids_json,source_pet_ids_json,city_id,zone_id,service_code,package_code,package_name,schedule_group_id,provider_id,scheduled_start,scheduled_end,status,channel,total_amount,currency,pricing_json,created_by,created_at,updated_at) VALUES (?,?,?,'[]','[]','blr','zone-1','grooming','pkg','Full groom',?,'PRV-1','2026-07-01T09:00:00.000Z','2026-07-01T10:00:00.000Z','completed','customer_app',?,'INR','{}','seed',?,?)") + .run(id, `${id}-idem`, customerId, `SG-${id}`, AMOUNT, now, now); + sqlite.prepare("INSERT INTO booking_payments (id,booking_id,customer_id,amount,amount_due_now,currency,method,mode,status,gateway,idempotency_key,detail_json,created_at,updated_at) VALUES (?,?,?,?,?,'INR','card','prepaid','captured','uat_sandbox',?,'{}',?,?)") + .run(`PAY-${id}`, id, customerId, AMOUNT, AMOUNT, `pidem-${id}`, now, now); + } + sqlite.prepare("INSERT INTO customer_experience_tickets (id,customer_id,booking_id,category,priority,subject,detail,owner,manager,sla_due_at,status,created_by,created_at,updated_at) VALUES ('CXT-1','CUS0000','BK00000','service','high','Late groomer','detail','owner@pawspace.in','manager@pawspace.in',?,'open','seed',?,?)") + .run(now + 3_600_000, now, now); + globalThis.__FANOUT_DB__ = makeD1(sqlite); + globalThis.__FANOUT_ENV__ = {}; + return sqlite; +} + +test("the D1 shim enforces the same 100-bound-parameter cap as production", async () => { + seedBookings(); + const ids = Array.from({ length: 150 }, (_, index) => `BK${String(index).padStart(5, "0")}`); + await assert.rejects( + globalThis.__FANOUT_DB__.prepare(`SELECT id FROM canonical_bookings WHERE id IN (${ids.map(() => "?").join(",")})`).bind(...ids).all(), + /too many SQL variables/, + "without this the tests below would pass against a limit production does not have", + ); +}); + +test("real execution: collected revenue survives more bookings than D1 allows bound parameters", async () => { + seedBookings(); + const { buildCompanyAnalytics } = await import("../lib/company-analytics.ts"); + const analytics = await buildCompanyAnalytics(globalThis.__FANOUT_DB__); + + assert.equal(analytics.bookings.total, BOOKING_COUNT); + assert.equal(analytics.money.gmv, BOOKING_COUNT * AMOUNT); + // The staging symptom: GMV counted, collected silently zero. + assert.equal(analytics.money.collected, BOOKING_COUNT * AMOUNT, "every captured payment is counted"); + const grooming = Object.fromEntries(Object.entries(analytics.services))["grooming"]; + assert.equal(grooming.collected, BOOKING_COUNT * AMOUNT, "per-service collected is not zero either"); + // The CX read in the same call was discarded by the same cap. + assert.equal(analytics.cx.tickets, 1); +}); + +test("real execution: the payment and invoice reads behind the accounts view survive the cap", async () => { + const sqlite = seedBookings(); + sqlite.exec("CREATE TABLE IF NOT EXISTS booking_invoices (id TEXT PRIMARY KEY,booking_id TEXT NOT NULL,customer_id TEXT NOT NULL,invoice_number TEXT NOT NULL,status TEXT NOT NULL,gross_amount REAL NOT NULL,tax_amount REAL NOT NULL,net_amount REAL NOT NULL,issued_at INTEGER NOT NULL)"); + const accounts = await import("../lib/accounts-business-view.ts"); + const build = accounts.buildAccountsBusinessView || accounts.accountsBusinessView || Object.values(accounts).find((value) => typeof value === "function"); + const view = await build(globalThis.__FANOUT_DB__, {}); + // The ledger itself is capped at 100 rows for display (lib/accounts-business-view.ts:88); what matters + // is that the payment behind each row was read at all - before chunking, every status was null. + assert.equal(view.ledger.length, 100); + assert.equal(view.ledger.filter((row) => row.status === "captured").length, 100, "payment status comes from the chunked read"); + assert.equal(view.ledger.reduce((sum, row) => sum + Number(row.gross || 0), 0), 100 * AMOUNT); + // Rows past the first chunk are the ones the single query used to lose. + assert.ok(view.ledger.some((row) => Number(String(row.bookingId).replace("BK", "")) > 90), "bookings beyond the first chunk are present"); +}); + +test("real execution: customer names on the partner job feed survive the cap", async () => { + const sqlite = seedBookings(); + sqlite.exec("CREATE TABLE IF NOT EXISTS canonical_customers (id TEXT PRIMARY KEY,city_id TEXT NOT NULL,name TEXT NOT NULL,primary_phone TEXT NOT NULL,secondary_phone TEXT,email TEXT,source TEXT NOT NULL DEFAULT 'seed',consent_json TEXT NOT NULL DEFAULT '{}',created_at INTEGER NOT NULL,updated_at INTEGER NOT NULL)"); + const now = Date.now(); + const ids = []; + for (let index = 0; index < BOOKING_COUNT; index += 1) { + const id = `CUS${String(index).padStart(4, "0")}`; + ids.push(id); + sqlite.prepare("INSERT INTO canonical_customers (id,city_id,name,primary_phone,created_at,updated_at) VALUES (?,'blr',?,?,?,?)") + .run(id, `Customer ${index} Sharma`, `98765${String(index).padStart(5, "0")}`, now, now); + } + const { chunkedIn } = await import("../lib/d1-chunked-in.ts"); + const rows = await chunkedIn(ids, async (chunk, placeholders) => + (await globalThis.__FANOUT_DB__.prepare(`SELECT id,name FROM canonical_customers WHERE id IN (${placeholders})`).bind(...chunk).all()).results); + assert.equal(rows.length, BOOKING_COUNT, "every id in the list is answered, not just the first chunk"); + assert.equal(new Set(rows.map((row) => row.id)).size, BOOKING_COUNT, "chunks do not overlap or drop ids"); +}); + +test("chunkedIn preserves the semantics of the single query it replaces", async () => { + const { chunkedIn, idChunks, D1_IN_CHUNK } = await import("../lib/d1-chunked-in.ts"); + assert.ok(D1_IN_CHUNK <= 100, "the chunk must fit inside D1's bound-parameter cap"); + assert.deepEqual(idChunks([1, 2, 3, 4, 5], 2), [[1, 2], [3, 4], [5]]); + assert.deepEqual(await chunkedIn([], async () => [1]), [], "an empty list runs no query at all"); + const seen = []; + const out = await chunkedIn(["a", "b", "c"], async (chunk, placeholders) => { + seen.push(placeholders); + return chunk; + }, 2); + assert.deepEqual(out, ["a", "b", "c"], "results are concatenated in order"); + assert.deepEqual(seen, ["?,?", "?"], "each chunk gets placeholders matching its own length"); +}); + +// Files whose IN lists cannot grow past the cap: each is a fixed vocabulary or a per-row list, not a +// result set. A new name appearing here must be justified the same way or routed through chunkedIn. +const BOUNDED_IN_LISTS = { + "boarding-ops-governance.ts": "pet ids of a single stay", + "lead-assignment-governance.ts": "the service codes on one policy", + "meet-and-greet.ts": "the literal statuses a transition may come from", + "ops-work-queue.ts": "the literal statuses a task may be claimed from", + "staff-alert-center.ts": "the literal alert types the sweep owns", + "statutory-compliance.ts": "the obligations of one month", + "subscription-wallet.ts": "the grooming plan catalogue", + "tds-governance.ts": "the months of one financial year", +}; + +test("no library builds an IN list straight from a result set any more", async () => { + const files = (await readdir(new URL("../lib", import.meta.url))).filter((name) => name.endsWith(".ts") && name !== "d1-chunked-in.ts"); + const offenders = []; + for (const name of files) { + const source = await readFile(new URL(`../lib/${name}`, import.meta.url), "utf8"); + // `.map(() => "?")` over a variable-length list is the shape that breaks past 100 rows. + if (!/\.map\(\s*\(\s*\)\s*=>\s*"\?"\s*\)/.test(source)) continue; + if (source.includes("d1-chunked-in") || name in BOUNDED_IN_LISTS) continue; + offenders.push(name); + } + assert.deepEqual(offenders, [], "these files must build IN lists through chunkedIn so they cannot exceed D1's cap"); +}); diff --git a/tests/uat-demo-seed.test.mjs b/tests/uat-demo-seed.test.mjs new file mode 100644 index 000000000..64a6407f3 --- /dev/null +++ b/tests/uat-demo-seed.test.mjs @@ -0,0 +1,160 @@ +/** + * "Where is the UI, it can't be blank - we can always feed the test data to check the functionality + * and the outcome." + * + * scripts/uat-demo-seed.sql is that data. These tests load the generated file into a real database and + * then drive the same modules the screens call, so the seed is proven by the outcome it produces + * rather than by the rows it contains: the leaderboard ranks four reps with real SLA, conversion and + * revenue numbers, and the campaign command centre shows a live campaign with its audience snapshot. + */ +import assert from "node:assert/strict"; +import test from "node:test"; +import { readFile } from "node:fs/promises"; +import { DatabaseSync } from "node:sqlite"; +import * as nodeModule from "node:module"; + +const CF_STUB = "data:text/javascript,export const env=new Proxy({},{get:(t,k)=>k===\"DB\"?globalThis.__SEED_DB__:(globalThis.__SEED_ENV__??{})[k]});"; +nodeModule.registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === "cloudflare:workers") return { url: CF_STUB, shortCircuit: true }; + try { + return nextResolve(specifier, context); + } catch (error) { + if (specifier.startsWith(".") && !specifier.endsWith(".ts")) return nextResolve(`${specifier}.ts`, context); + throw error; + } + }, +}); + +function makeD1(sqlite) { + function statement(sql, args) { + return { + bind: (...bound) => statement(sql, bound), + first: async () => { + const row = sqlite.prepare(sql).get(...args); + return row === undefined ? null : row; + }, + run: async () => { + const info = sqlite.prepare(sql).run(...args); + return { success: true, meta: { changes: Number(info.changes) } }; + }, + all: async () => ({ results: sqlite.prepare(sql).all(...args) }), + }; + } + return { + prepare: (sql) => statement(sql, []), + batch: async (statements) => { + const out = []; + for (const item of statements) out.push(await item.run()); + return out; + }, + exec: async (sql) => { + sqlite.exec(sql); + return { count: 0, duration: 0 }; + }, + }; +} + +const SEED_BASE = Date.UTC(2026, 7, 1); +const DAY = 86_400_000; + +async function loadSeed() { + const sqlite = new DatabaseSync(":memory:"); + // The documented load order: the staging seed supplies customers and bookings, the demo seed layers + // the team and marketing work on top of them. Loading both is also what proves they line up. + sqlite.exec(await readFile(new URL("../scripts/staging-seed.sql", import.meta.url), "utf8")); + sqlite.exec(await readFile(new URL("../scripts/uat-demo-seed.sql", import.meta.url), "utf8")); + globalThis.__SEED_DB__ = makeD1(sqlite); + globalThis.__SEED_ENV__ = {}; + return sqlite; +} + +test("the demo seed loads into a fresh database and is safe to re-run", async () => { + const sqlite = await loadSeed(); + const before = sqlite.prepare("SELECT COUNT(*) AS value FROM lead_sla_events").get().value; + sqlite.exec(await readFile(new URL("../scripts/uat-demo-seed.sql", import.meta.url), "utf8")); + const after = sqlite.prepare("SELECT COUNT(*) AS value FROM lead_sla_events").get().value; + assert.equal(after, before, "re-running the seed must not duplicate a single row"); + assert.equal(sqlite.prepare("SELECT COUNT(*) AS value FROM lead_assignment_memberships WHERE team_code='sales' AND active=1").get().value, 4); +}); + +test("real execution: the seeded team produces a ranked leaderboard with real numbers", async () => { + await loadSeed(); + const { generateSalesProductivityFacts } = await import("../lib/sales-productivity-governance.ts"); + const { employeePerformanceCenter } = await import("../lib/employee-performance-center.ts"); + + // Exactly what "Generate report" on /team/performance does, over the window the seed covers. + const periodEnd = SEED_BASE + DAY, periodStart = periodEnd - 30 * DAY; + const run = await generateSalesProductivityFacts(globalThis.__SEED_DB__, { periodStart, periodEnd, idempotencyKey: "uat-demo-30d", actorId: "tester@pawspace.in" }); + const facts = [...run.facts].sort((a, b) => b.netCollectedRevenue - a.netCollectedRevenue); + assert.equal(facts.length, 4, "one row per seeded rep"); + assert.ok(facts.every((fact) => fact.leadsAssigned > 0), "every rep carries assigned leads"); + assert.ok(facts.every((fact) => fact.meaningfulActions > 0), "every rep has actions the policy counts"); + assert.ok(facts.some((fact) => fact.qualifiedLeads > 0), "qualified outcomes are measured"); + assert.ok(facts.some((fact) => fact.bookingConversions > 0), "leads converted to canonical bookings"); + assert.ok(facts[0].netCollectedRevenue > 0, "the top rep has real collected revenue, not zero"); + assert.ok(facts.some((fact) => fact.refunds > 0), "refunds exist so the net figure is exercised"); + assert.ok(facts.every((fact) => fact.netCollectedRevenue === fact.collectedRevenue - fact.refunds), "net is collected less refunds"); + assert.ok(facts.some((fact) => fact.firstResponseBreached > 0), "SLA misses are visible, not a flat 100%"); + assert.ok(facts.every((fact) => fact.firstResponseMet + fact.firstResponseBreached <= fact.firstResponseClocks)); + + // The board itself is anchored on the live clock, so a report for the window on screen is what fills + // it. The roster is what puts a rep on the board at all. + const now = Date.now(); + await generateSalesProductivityFacts(globalThis.__SEED_DB__, { periodStart: now - 30 * DAY, periodEnd: now, idempotencyKey: "uat-demo-current", actorId: "tester@pawspace.in" }); + const board = await employeePerformanceCenter(globalThis.__SEED_DB__, { metric: "net_collected_revenue", days: 30 }); + assert.equal(board.rows.length, 4, "all four seeded reps appear on the leaderboard"); + assert.ok(board.rows.every((row) => row.employeeName && row.employeeName !== row.employeeEmail), "each rep resolves to a real name"); + assert.deepEqual(board.rows.map((row) => row.rank), [1, 2, 3, 4]); + assert.ok(board.period.sourceRun, "the board names the run it is showing"); +}); + +test("real execution: the campaign command centre is not blank on the seeded database", async () => { + const sqlite = await loadSeed(); + const campaigns = sqlite.prepare("SELECT * FROM governed_marketing_campaigns ORDER BY id").all(); + assert.equal(campaigns.length, 2); + + const live = campaigns.find((row) => row.status === "active"); + assert.ok(live, "one campaign is live so the screen shows an activated state"); + assert.equal(live.approval_status, "approved", "activation never bypasses approval"); + assert.ok(live.approved_by && live.approved_at, "the approval is attributed to a human"); + const pending = campaigns.find((row) => row.status === "draft"); + assert.equal(pending.approval_status, "approval_required", "the second campaign shows the approval gate itself"); + + const snapshot = sqlite.prepare("SELECT * FROM marketing_audience_snapshots WHERE campaign_id=?").get(live.id); + assert.ok(snapshot, "the live campaign has an audience snapshot"); + const members = sqlite.prepare("SELECT cohort,COUNT(*) AS value FROM marketing_audience_members WHERE campaign_id=? GROUP BY cohort").all(live.id); + const byCohort = Object.fromEntries(members.map((row) => [row.cohort, row.value])); + assert.equal(byCohort.eligible + byCohort.holdout + byCohort.suppressed, snapshot.total_candidates, "the snapshot counts agree with its members"); + assert.equal(byCohort.eligible, snapshot.eligible_count); + assert.equal(byCohort.holdout, snapshot.holdout_count); + assert.equal(byCohort.suppressed, snapshot.suppressed_count); + assert.ok(byCohort.holdout > 0, "an explicit holdout exists, which is the point of the governance"); + assert.equal( + sqlite.prepare("SELECT COUNT(*) AS value FROM marketing_audience_members WHERE cohort='suppressed' AND suppression_reason IS NULL").get().value, + 0, + "every suppression names its reason", + ); +}); + +test("real execution: seeded ad spend reaches the unit-economics CAC line", async () => { + const sqlite = await loadSeed(); + const spend = sqlite.prepare("SELECT COALESCE(SUM(spend_amount),0) AS value FROM marketing_attribution_facts").get().value; + assert.ok(spend > 0, "the seed carries real spend rows rather than leaving CAC unconfigured"); + assert.equal( + sqlite.prepare("SELECT COUNT(*) AS value FROM marketing_attribution_facts WHERE attribution_model='unconfigured'").get().value, + 0, + "each fact declares the attribution model it was produced under", + ); +}); + +test("the generator and the checked-in seed file agree", async () => { + const [generator, seed] = await Promise.all([ + readFile(new URL("../scripts/uat-demo-seed-gen.mjs", import.meta.url), "utf8"), + readFile(new URL("../scripts/uat-demo-seed.sql", import.meta.url), "utf8"), + ]); + // Deterministic output: no clock, no randomness, and every write is re-runnable. + assert.doesNotMatch(generator, /Date\.now\(\)|Math\.random\(\)/); + assert.match(seed, /^-- PawSpace UAT demo seed/); + assert.equal(seed.split("\n").filter((line) => line.startsWith("INSERT ") && !line.startsWith("INSERT OR IGNORE")).length, 0, "every insert must be INSERT OR IGNORE"); +}); From a0d2801148b0bec38b41758d41c4b4a663d3356b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 07:14:12 +0000 Subject: [PATCH 2/5] Fix the CI failure these suites had on the Node that CI actually pins Green locally, red on GitHub, and the reason was one line: CI pins Node 22.13.0, where module.registerHooks does not exist yet - it arrived in 22.15. Calling it unguarded threw `TypeError: nodeModule.registerHooks is not a function` and took the whole test file down before a single test ran, so the Web tests job failed on work that passes on a newer laptop. Several suites already carried both branches inline - the modern API when it exists, an out-of-thread loader hook when it does not. That pattern is now one helper, tests/helpers/module-hooks.mjs, and the suites here use it. The fallback was the half that had never been exercised, because on any machine new enough to write it, it never runs. PAWSPACE_FORCE_LOADER_HOOK=1 takes that path on any Node, and a test fails if any suite calls registerHooks without a fallback again. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CE4fKzrWRZetcYKh8CaU3b --- tests/crm-stack-hardening.test.mjs | 16 ++-------- tests/d1-in-clause-fanout.test.mjs | 37 ++++++++++++++--------- tests/helpers/module-hooks.mjs | 47 ++++++++++++++++++++++++++++++ tests/uat-demo-seed.test.mjs | 16 ++-------- 4 files changed, 76 insertions(+), 40 deletions(-) create mode 100644 tests/helpers/module-hooks.mjs diff --git a/tests/crm-stack-hardening.test.mjs b/tests/crm-stack-hardening.test.mjs index a3f8d020b..ce7eb50ac 100644 --- a/tests/crm-stack-hardening.test.mjs +++ b/tests/crm-stack-hardening.test.mjs @@ -1,19 +1,9 @@ import test from "node:test"; import assert from "node:assert/strict"; -import * as nodeModule from "node:module"; +import { installWorkersHooks } from "./helpers/module-hooks.mjs"; -// lib modules import each other extensionlessly, the way the app bundler resolves them. This suite -// loads one of them directly, so it needs the same fallback every other real-execution suite installs. -nodeModule.registerHooks({ - resolve(specifier, context, nextResolve) { - try { - return nextResolve(specifier, context); - } catch (error) { - if (specifier.startsWith(".") && !specifier.endsWith(".ts")) return nextResolve(`${specifier}.ts`, context); - throw error; - } - }, -}); +// lib modules import each other extensionlessly; this installs the resolver on every supported Node. +installWorkersHooks("__CRM_DB__"); import fs from "node:fs"; diff --git a/tests/d1-in-clause-fanout.test.mjs b/tests/d1-in-clause-fanout.test.mjs index f0fa400f9..ff72185c2 100644 --- a/tests/d1-in-clause-fanout.test.mjs +++ b/tests/d1-in-clause-fanout.test.mjs @@ -11,20 +11,10 @@ import assert from "node:assert/strict"; import test from "node:test"; import { readFile, readdir } from "node:fs/promises"; import { DatabaseSync } from "node:sqlite"; -import * as nodeModule from "node:module"; - -const CF_STUB = "data:text/javascript,export const env=new Proxy({},{get:(t,k)=>k===\"DB\"?globalThis.__FANOUT_DB__:(globalThis.__FANOUT_ENV__??{})[k]});"; -nodeModule.registerHooks({ - resolve(specifier, context, nextResolve) { - if (specifier === "cloudflare:workers") return { url: CF_STUB, shortCircuit: true }; - try { - return nextResolve(specifier, context); - } catch (error) { - if (specifier.startsWith(".") && !specifier.endsWith(".ts")) return nextResolve(`${specifier}.ts`, context); - throw error; - } - }, -}); +import { installWorkersHooks } from "./helpers/module-hooks.mjs"; + + +installWorkersHooks("__FANOUT_DB__"); const D1_BOUND_PARAMETER_LIMIT = 100; @@ -211,3 +201,22 @@ test("one chunk size for the whole platform, not one per module", async () => { const { D1_IN_CHUNK } = await import("../lib/d1-chunked-in.ts"); assert.equal(D1_IN_CHUNK, 80, "the size #166 measured as correct: a chunk of 50 cost 82 D1 calls for 500 customers where 58 would do"); }); + +test("the suites run on the Node the CI pins, not just the one on this machine", async () => { + // CI pins 22.13.0, where module.registerHooks does not exist. Calling it unguarded threw + // `TypeError: nodeModule.registerHooks is not a function` and took the whole file down before a + // single test ran - which is why these suites were red on GitHub while green locally. + const helper = await readFile(new URL("./helpers/module-hooks.mjs", import.meta.url), "utf8"); + assert.match(helper, /typeof nodeModule\.registerHooks === "function"/, "the modern API must be feature-detected"); + assert.match(helper, /nodeModule\.register\(/, "and an older Node must still get a resolver"); + + const suites = (await readdir(new URL(".", import.meta.url))).filter((name) => name.endsWith(".test.mjs")); + const unguarded = []; + for (const name of suites) { + const source = await readFile(new URL(name, import.meta.url), "utf8"); + if (!source.includes("registerHooks")) continue; + if (source.includes('typeof nodeModule.registerHooks === "function"')) continue; + unguarded.push(name); + } + assert.deepEqual(unguarded, [], "these suites call registerHooks without a fallback and will die on CI's Node"); +}); diff --git a/tests/helpers/module-hooks.mjs b/tests/helpers/module-hooks.mjs new file mode 100644 index 000000000..e4fa9a99c --- /dev/null +++ b/tests/helpers/module-hooks.mjs @@ -0,0 +1,47 @@ +/** + * Installs the resolver every real-execution suite needs: `cloudflare:workers` resolves to a stub that + * reads a per-suite global, and lib modules that import each other extensionlessly resolve to `.ts`. + * + * `module.registerHooks` only exists from Node 22.15. CI pins 22.13.0, where calling it throws + * `TypeError: nodeModule.registerHooks is not a function` and takes the whole file down before a single + * test runs - which is exactly what it did. On that version the same resolver is registered as an + * out-of-thread loader hook instead. Several suites already carried both branches inline; this is that + * pattern in one place so a new suite cannot pick only the half that works on a newer laptop. + */ +import * as nodeModule from "node:module"; + +export function installWorkersHooks(globalName) { + const shim = `export const env = new Proxy({}, { get: (_, key) => key === "DB" ? globalThis[${JSON.stringify(globalName)}] : (globalThis[${JSON.stringify(`${globalName}_ENV`)}] ?? {})[key] });`; + const workersUrl = `data:text/javascript,${encodeURIComponent(shim)}`; + + // The fallback below only runs on the Node CI pins, so on a newer machine it is never exercised - + // which is how it came to be missing at all. PAWSPACE_FORCE_LOADER_HOOK=1 takes that path on any + // version, so the suite can prove both branches work. + const forceLoader = process.env.PAWSPACE_FORCE_LOADER_HOOK === "1"; + if (!forceLoader && typeof nodeModule.registerHooks === "function") { + nodeModule.registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === "cloudflare:workers") return { url: workersUrl, shortCircuit: true }; + try { + return nextResolve(specifier, context); + } catch (error) { + if (specifier.startsWith(".") && !specifier.endsWith(".ts")) return nextResolve(`${specifier}.ts`, context); + throw error; + } + }, + }); + return workersUrl; + } + + const hook = `const workersUrl=${JSON.stringify(workersUrl)}; + export async function resolve(specifier, context, nextResolve) { + if (specifier === "cloudflare:workers") return { url: workersUrl, shortCircuit: true }; + try { return await nextResolve(specifier, context); } + catch (error) { + if (specifier.startsWith(".") && !specifier.endsWith(".ts")) return nextResolve(specifier + ".ts", context); + throw error; + } + }`; + nodeModule.register(new URL(`data:text/javascript,${encodeURIComponent(hook)}`)); + return workersUrl; +} diff --git a/tests/uat-demo-seed.test.mjs b/tests/uat-demo-seed.test.mjs index 64a6407f3..7eee1a368 100644 --- a/tests/uat-demo-seed.test.mjs +++ b/tests/uat-demo-seed.test.mjs @@ -11,20 +11,10 @@ import assert from "node:assert/strict"; import test from "node:test"; import { readFile } from "node:fs/promises"; import { DatabaseSync } from "node:sqlite"; -import * as nodeModule from "node:module"; +import { installWorkersHooks } from "./helpers/module-hooks.mjs"; -const CF_STUB = "data:text/javascript,export const env=new Proxy({},{get:(t,k)=>k===\"DB\"?globalThis.__SEED_DB__:(globalThis.__SEED_ENV__??{})[k]});"; -nodeModule.registerHooks({ - resolve(specifier, context, nextResolve) { - if (specifier === "cloudflare:workers") return { url: CF_STUB, shortCircuit: true }; - try { - return nextResolve(specifier, context); - } catch (error) { - if (specifier.startsWith(".") && !specifier.endsWith(".ts")) return nextResolve(`${specifier}.ts`, context); - throw error; - } - }, -}); + +installWorkersHooks("__SEED_DB__"); function makeD1(sqlite) { function statement(sql, args) { From 83d939cee3775c413bfc0252a5b2bfa438d686cc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 07:20:11 +0000 Subject: [PATCH 3/5] Pass the env global explicitly at every call site A derived name hands the module an empty env rather than the one the suite sets, which would make an env-dependent assertion pass for the wrong reason. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CE4fKzrWRZetcYKh8CaU3b --- tests/d1-in-clause-fanout.test.mjs | 2 +- tests/uat-demo-seed.test.mjs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/d1-in-clause-fanout.test.mjs b/tests/d1-in-clause-fanout.test.mjs index ff72185c2..07947dc56 100644 --- a/tests/d1-in-clause-fanout.test.mjs +++ b/tests/d1-in-clause-fanout.test.mjs @@ -14,7 +14,7 @@ import { DatabaseSync } from "node:sqlite"; import { installWorkersHooks } from "./helpers/module-hooks.mjs"; -installWorkersHooks("__FANOUT_DB__"); +installWorkersHooks("__FANOUT_DB__", "__FANOUT_ENV__"); const D1_BOUND_PARAMETER_LIMIT = 100; diff --git a/tests/uat-demo-seed.test.mjs b/tests/uat-demo-seed.test.mjs index 7eee1a368..a9d5b977b 100644 --- a/tests/uat-demo-seed.test.mjs +++ b/tests/uat-demo-seed.test.mjs @@ -14,7 +14,7 @@ import { DatabaseSync } from "node:sqlite"; import { installWorkersHooks } from "./helpers/module-hooks.mjs"; -installWorkersHooks("__SEED_DB__"); +installWorkersHooks("__SEED_DB__", "__SEED_ENV__"); function makeD1(sqlite) { function statement(sql, args) { From 4863327faea8077678065315e0071243814ddcbf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 07:31:41 +0000 Subject: [PATCH 4/5] Keep the chunking fix from costing more subrequests than it saves Splitting one `IN (...)` statement into several is not a free refactor. Three things the single statement guaranteed change when it becomes N statements, and none of them can fail a bind-cap test, because they all pass under 100 rows. Subrequest cost. In lib/unit-economics.ts the chunked reads sit inside a helper that first asks sqlite_master whether the table exists. Chunked, that guard is charged once per chunk: eight guarded lookups over 5,000 bookings measured 1,012 D1 subrequests, and a Worker invocation is cut off near 1,000. The fix for "too many SQL variables" would have re-broken the same screen one order of magnitude further up, as a different error. The guard is now memoised for the life of one request - per call, not per module, because a module-level cache would remember "table absent" after another module created it and go on reporting zeros for the rest of the isolate's life, which is the confident-zero failure this file exists to remove. 1,012 subrequests -> 564. LIMIT. In lib/people-reports.ts the payroll and incentive approval trails read `ORDER BY created_at DESC LIMIT 200` inside the chunking, so the limit applied per chunk: the trail could hold 200 rows per chunk, sorted only in blocks, and the "newest 200" shown to a reviewer were not the newest 200. Both are now sorted and cut over the whole answer. ORDER BY. Checked every other chunked call site. lib/manager-dashboard.ts looks like the same bug and is not: resolveDashboardScope already returns the scope ORDER BY display_name, so chunks are name-contiguous and each chunk's ORDER BY reproduces the global order. Same for the reads grouped by the very column being chunked - every row of a group lands in one chunk. Nothing to fix there, so nothing was changed there. tests/chunked-in-semantics.test.mjs asserts all three. The first two run the real module against a counting D1 shim above one chunk; each was confirmed to fail when its fix is reverted (564 -> 1,012, and the wrong 200 events). ORDER BY has no reachable failure today, so it is guarded at the class level instead: any chunkedIn call site whose own statement orders or limits must either wrap the concatenated rows in a reapplication or carry a recorded reason. That check judges each call site on its own statement - a file-level "does it sort somewhere" version passed while the payroll trail was still wrong. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011Hon8zmG1pwVY6zwxtT5th --- lib/people-reports.ts | 6 +- lib/unit-economics.ts | 43 +++++--- tests/chunked-in-semantics.test.mjs | 153 ++++++++++++++++++++++++++++ 3 files changed, 188 insertions(+), 14 deletions(-) create mode 100644 tests/chunked-in-semantics.test.mjs diff --git a/lib/people-reports.ts b/lib/people-reports.ts index ec47f1bdb..a7f751c06 100644 --- a/lib/people-reports.ts +++ b/lib/people-reports.ts @@ -19,6 +19,10 @@ function scoped(scope:Scope,column:string){if(scope.mode==="all")return{sql:"",p async function resolveScope(db:Db,input:{actorEmail:string;roleCode:string;permissions:string[]}):Promise{if(has(input.permissions,"people.manage")||has(input.permissions,"payroll.view")||has(input.permissions,"audit.view"))return{mode:"all",managerEmployeeId:null,employeeIds:[],employeeEmails:[]};const own=await db.prepare("SELECT id FROM employees WHERE lower(COALESCE(user_email,work_email))=? AND employment_status='active'").bind(input.actorEmail.toLowerCase()).first();if(!own)return{mode:"manager",managerEmployeeId:null,employeeIds:[],employeeEmails:[]};const managerId=text(own.id),direct=await all(db,"SELECT e.id,e.work_email,e.user_email FROM employees e JOIN employee_employment_versions v ON v.employee_id=e.id AND v.effective_until IS NULL WHERE e.employment_status='active' AND v.manager_employee_id=? ORDER BY e.id",[managerId]);return{mode:"manager",managerEmployeeId:managerId,employeeIds:direct.map(r=>text(r.id)),employeeEmails:direct.map(r=>text(r.user_email||r.work_email).toLowerCase()).filter(Boolean)};} function counts(rows:Row[],key:string){const out:Record={};for(const row of rows){const k=text(row[key])||"unknown";out[k]=(out[k]||0)+1;}return out;} function sum(rows:Row[],key:string){return Number(rows.reduce((a,r)=>a+num(r[key]),0).toFixed(2));} +// "ORDER BY created_at DESC LIMIT n" inside a chunked read applies per chunk, not to the answer: the +// concatenation is sorted only in blocks and can hold n rows per chunk. Reapply both to the whole set +// so the approval trail is the newest n events overall, the way the single statement meant it. +function newestFirst(rows:Row[],limit:number){return rows.sort((left,right)=>num(right.created_at)-num(left.created_at)).slice(0,limit);} export async function peopleReports(db:Db,input:{actorEmail:string;roleCode:string;permissions:string[];periodStart?:number|null;periodEnd?:number|null}){await ensurePeopleTables(db);await ensureAttendanceLeaveTables(db);await ensurePayrollTables(db);await ensureIncentiveTables(db);await ensurePeopleFinanceTables(db);const now=Date.now(),d=new Date(now),defaultStart=Date.UTC(d.getUTCFullYear(),d.getUTCMonth(),1),start=Number.isFinite(Number(input.periodStart))&&Number(input.periodStart)>0?Number(input.periodStart):defaultStart,end=Number.isFinite(Number(input.periodEnd))&&Number(input.periodEnd)>=start?Number(input.periodEnd):now,startDate=new Date(start).toISOString().slice(0,10),endDate=new Date(end).toISOString().slice(0,10),scope=await resolveScope(db,input),employeeScope=scoped(scope,"e.id"); const employees=await all(db,`SELECT e.id,e.employee_code,e.display_name,e.work_email,e.user_email,e.employment_status,e.joined_at,e.ended_at,v.team_code,v.manager_employee_id,v.cost_centre_code,v.location_code FROM employees e LEFT JOIN employee_employment_versions v ON v.employee_id=e.id AND v.effective_until IS NULL WHERE 1=1${employeeScope.sql} ORDER BY e.display_name`,employeeScope.params),employeeIds=new Set(employees.map(r=>text(r.id))),employeeTeam=new Map(employees.map(r=>[text(r.id),text(r.team_code)||"unassigned"])),active=employees.filter(r=>text(r.employment_status)==="active").length,joiners=employees.filter(r=>num(r.joined_at)>=start&&num(r.joined_at)<=end).length,leavers=employees.filter(r=>num(r.ended_at)>=start&&num(r.ended_at)<=end).length; const canAttendance=has(input.permissions,"attendance.view"),canPayroll=has(input.permissions,"payroll.view"),canIncentives=has(input.permissions,"incentives.view"),canFinance=has(input.permissions,"finance.view"),canAudit=has(input.permissions,"audit.view"),attendanceScope=scoped(scope,"employee_id"),attendance=canAttendance?await all(db,`SELECT id,employee_id,work_date,status,worked_minutes,exception_code FROM attendance_days WHERE work_date>=? AND work_date<=?${attendanceScope.sql} ORDER BY work_date DESC LIMIT 500`,[startDate,endDate,...attendanceScope.params]):[],leave=canAttendance?await all(db,`SELECT id,employee_id,leave_code,start_date,end_date,units,status FROM leave_requests WHERE end_date>=? AND start_date<=?${attendanceScope.sql} ORDER BY created_at DESC LIMIT 300`,[startDate,endDate,...attendanceScope.params]):[]; @@ -27,6 +31,6 @@ export async function peopleReports(db:Db,input:{actorEmail:string;roleCode:stri const incentiveScope=scoped(scope,"r.employee_id"),incentives=canIncentives?await all(db,`SELECT r.id result_id,r.employee_id,r.status,r.calculated_amount,r.approved_amount,r.source_fact_run_id,p.id period_id,p.period_start,p.period_end FROM employee_incentive_results r JOIN employee_incentive_periods p ON p.id=r.period_id WHERE p.period_end>=? AND p.period_start<=?${incentiveScope.sql} ORDER BY p.period_end DESC LIMIT 300`,[start,end,...incentiveScope.params]):[]; const expenseScope=scoped(scope,"l.employee_id"),expenses=canFinance?await all(db,`SELECT l.expense_id,l.employee_id,l.linkage_status,f.expense_date,f.amount,f.status,f.cost_centre FROM people_expense_links l JOIN finance_expenses f ON f.id=l.expense_id WHERE f.expense_date>=? AND f.expense_date<=?${expenseScope.sql} ORDER BY f.expense_date DESC LIMIT 300`,[startDate,endDate,...expenseScope.params]):[],statutory=canFinance&&scope.mode==="all"?await all(db,"SELECT id,payroll_run_id,period_code,sandbox_only,external_submission,created_at FROM people_statutory_exports WHERE created_at>=? AND created_at<=? ORDER BY created_at DESC LIMIT 100",[start,end]):[]; const peopleAuditScope=scoped(scope,"employee_id"),peopleAudit=canAudit?await all(db,`SELECT id,employee_id,action,actor_id,created_at FROM people_audit_events WHERE created_at>=? AND created_at<=?${peopleAuditScope.sql} ORDER BY created_at DESC LIMIT 200`,[start,end,...peopleAuditScope.params]):[];let securityAudit:Row[]=[];if(canAudit&&scope.mode==="all")securityAudit=await all(db,"SELECT id,actor_email,actor_role,action,resource_type,resource_id,outcome,created_at FROM security_audit_events WHERE created_at>=? AND created_at<=? AND (action LIKE 'people.%' OR action LIKE 'attendance.%' OR action LIKE 'payroll.%' OR action LIKE 'incentive.%' OR action LIKE 'people_finance.%') ORDER BY created_at DESC LIMIT 300",[start,end]); - const runIds=[...new Set(payroll.map(r=>text(r.run_id)).filter(Boolean))],resultIds=[...new Set(incentives.map(r=>text(r.result_id)).filter(Boolean))];let payrollApprovalEvents:Row[]=[];if(canPayroll&&runIds.length)payrollApprovalEvents=await chunkedIn(runIds,(chunk,placeholders)=>all(db,`SELECT id,run_id,event_type,actor_id,created_at FROM payroll_approval_events WHERE run_id IN (${placeholders}) ORDER BY created_at DESC LIMIT 200`,chunk));let incentiveApprovalEvents:Row[]=[];if(canIncentives&&resultIds.length)incentiveApprovalEvents=await chunkedIn(resultIds,(chunk,placeholders)=>all(db,`SELECT id,result_id,event_type,actor_id,created_at FROM incentive_approval_events WHERE result_id IN (${placeholders}) ORDER BY created_at DESC LIMIT 200`,chunk)); + const runIds=[...new Set(payroll.map(r=>text(r.run_id)).filter(Boolean))],resultIds=[...new Set(incentives.map(r=>text(r.result_id)).filter(Boolean))];let payrollApprovalEvents:Row[]=[];if(canPayroll&&runIds.length)payrollApprovalEvents=newestFirst(await chunkedIn(runIds,(chunk,placeholders)=>all(db,`SELECT id,run_id,event_type,actor_id,created_at FROM payroll_approval_events WHERE run_id IN (${placeholders}) ORDER BY created_at DESC LIMIT 200`,chunk)),200);let incentiveApprovalEvents:Row[]=[];if(canIncentives&&resultIds.length)incentiveApprovalEvents=newestFirst(await chunkedIn(resultIds,(chunk,placeholders)=>all(db,`SELECT id,result_id,event_type,actor_id,created_at FROM incentive_approval_events WHERE result_id IN (${placeholders}) ORDER BY created_at DESC LIMIT 200`,chunk)),200); const teams=new Map();for(const e of employees){const team=text(e.team_code)||"unassigned",r=teams.get(team)||{teamCode:team,headcount:0,attendanceExceptions:0,payrollCost:0};if(text(e.employment_status)==="active")r.headcount++;teams.set(team,r);}for(const a of attendance){if(!text(a.exception_code))continue;const team=employeeTeam.get(text(a.employee_id))||"unassigned",r=teams.get(team)||{teamCode:team,headcount:0,attendanceExceptions:0,payrollCost:0};r.attendanceExceptions++;teams.set(team,r);}for(const p of payroll){const team=text(p.team_code)||employeeTeam.get(text(p.employee_id))||"unassigned",r=teams.get(team)||{teamCode:team,headcount:0,attendanceExceptions:0,payrollCost:0};r.payrollCost=Number((r.payrollCost+num(p.gross_earnings)+num(p.employer_cost)).toFixed(2));teams.set(team,r);} return{period:{start,end,startDate,endDate},scope:{mode:scope.mode,managerEmployeeId:scope.managerEmployeeId,employeeCount:employees.length},metricDefinitions,headcount:{active,inactive:employees.length-active,joiners,leavers,total:employees.length},teamRollups:[...teams.values()].sort((a,b)=>a.teamCode.localeCompare(b.teamCode)),attendance:{available:canAttendance,byStatus:counts(attendance,"status"),exceptions:attendance.filter(r=>text(r.exception_code)).length,rows:attendance,leaveByStatus:counts(leave,"status"),leaveRows:leave},payroll:{available:canPayroll,register:payroll,runTotals:runs,variance,costByCostCentre:Object.entries(payroll.reduce((a:Record,r)=>{const k=text(r.cost_centre_code)||"unassigned";a[k]=(a[k]||0)+num(r.gross_earnings)+num(r.employer_cost);return a;},{} as Record)).map(([costCentre,amount])=>({costCentre,amount:Number(amount.toFixed(2))})),approvalEvents:payrollApprovalEvents},incentives:{available:canIncentives,byStatus:counts(incentives,"status"),calculatedAmount:sum(incentives,"calculated_amount"),approvedAmount:sum(incentives,"approved_amount"),rows:incentives,approvalEvents:incentiveApprovalEvents},finance:{available:canFinance,expenseLinks:expenses,expenseAmount:sum(expenses,"amount"),statutoryExports:statutory,statutoryExternalSubmissionEnabled:false,liveBankTransmissionEnabled:false},audit:{available:canAudit,peopleEvents:peopleAudit,securityEvents:securityAudit},sourceDrilldown:{employeeIds:[...employeeIds],attendanceIds:attendance.map(r=>text(r.id)),payrollResultIds:payroll.map(r=>text(r.result_id)),incentiveResultIds:incentives.map(r=>text(r.result_id)),expenseIds:expenses.map(r=>text(r.expense_id))},truth:{managerScopeEnforced:true,sensitivePayrollPermissionGated:true,sensitiveIncentivePermissionGated:true,staticDemoCountersAccepted:false,sourceDrilldownEnabled:true,payrollAuthority:false,disciplinaryAuthority:false,productionReady:false}};} diff --git a/lib/unit-economics.ts b/lib/unit-economics.ts index 82154454d..42b4065b1 100644 --- a/lib/unit-economics.ts +++ b/lib/unit-economics.ts @@ -20,7 +20,23 @@ const round2=(value:number)=>Math.round(value*100)/100; const pct=(part:number,whole:number)=>whole>0?Math.round((part/whole)*1000)/10:null; async function tableExists(db:Db,name:string){const row=await db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").bind(name).first();return Boolean(row);} -async function safeAll(db:Db,guard:string[],sql:string,binds:unknown[]):Promise{for(const table of guard)if(!await tableExists(db,table))return[];const rows=await db.prepare(sql).bind(...binds).all();return rows.results;} + +/** + * Table-existence guards are memoised for the life of one request. + * + * Each guard is its own sqlite_master read, and each of these reads is now wrapped in chunkedIn, so + * an unmemoised guard costs one extra subrequest PER CHUNK: 7 guarded lookups over 5,000 bookings + * spent 1,012 D1 subrequests where a Worker invocation is allowed about 1,000. The chunking fix would + * then have re-broken the screen it was fixing, one order of magnitude further up. + * + * The memo is per call, not per module. A module-level cache would remember "table absent" after + * another module ran CREATE TABLE IF NOT EXISTS, and go on reporting zeros for the rest of the + * isolate's life - the same confident-zero failure this file exists to remove. + */ +type Guards=Map>; +function guardCache():Guards{return new Map();} +function knownTable(db:Db,guards:Guards,name:string){const hit=guards.get(name);if(hit)return hit;const probe=tableExists(db,name);guards.set(name,probe);return probe;} +async function safeAll(db:Db,guard:string[],sql:string,binds:unknown[],guards:Guards=guardCache()):Promise{for(const table of guard)if(!await knownTable(db,guards,table))return[];const rows=await db.prepare(sql).bind(...binds).all();return rows.results;} export type UnitEconomicsFilters={from?:string;to?:string;cityId?:string}; @@ -29,7 +45,8 @@ export async function buildUnitEconomics(db:Db,input:UnitEconomicsFilters={}){ const filters=["substr(scheduled_start,1,10)>=?","substr(scheduled_start,1,10)<=?"],binds:unknown[]=[from,to]; if(input.cityId){filters.push("city_id=?");binds.push(input.cityId);} const where=filters.join(" AND "); - const bookings=await safeAll(db,["canonical_bookings"],`SELECT id,customer_id,provider_id,service_code,status,total_amount,scheduled_start,scheduled_end FROM canonical_bookings WHERE ${where}`,binds); + const guards=guardCache(); + const bookings=await safeAll(db,["canonical_bookings"],`SELECT id,customer_id,provider_id,service_code,status,total_amount,scheduled_start,scheduled_end FROM canonical_bookings WHERE ${where}`,binds,guards); if(!bookings.length)return{from,to,cityId:input.cityId??null,services:{},company:emptyCompany(),dataCoverage:coverageNote(),truth:truthNote()}; const ids=bookings.map(row=>String(row.id)); @@ -37,13 +54,13 @@ export async function buildUnitEconomics(db:Db,input:UnitEconomicsFilters={}){ const serviceOf=new Map(bookings.map(row=>[String(row.id),String(row.service_code)])); // Real per-booking money components (each guarded on its owning table) - const coupons=await chunkedIn(ids,(chunk,placeholders)=>safeAll(db,["coupon_redemptions"],`SELECT booking_id,discount_amount FROM coupon_redemptions WHERE status='consumed' AND booking_id IN (${placeholders})`,chunk)); - const points=await chunkedIn(ids,(chunk,placeholders)=>safeAll(db,["paw_points_ledger"],`SELECT booking_id,points FROM paw_points_ledger WHERE entry_type='redeemed' AND booking_id IN (${placeholders})`,chunk)); - const wallet=await chunkedIn(ids,(chunk,placeholders)=>safeAll(db,["pawspace_wallet_ledger"],`SELECT source_id booking_id,applied_value FROM pawspace_wallet_ledger WHERE entry_type='redeem' AND source_id IN (${placeholders})`,chunk)); - const payouts=await chunkedIn(ids,(chunk,placeholders)=>safeAll(db,["provider_order_payouts"],`SELECT booking_id,amount FROM provider_order_payouts WHERE booking_id IN (${placeholders})`,chunk)); - const refunds=await chunkedIn(ids,(chunk,placeholders)=>safeAll(db,["booking_refund_cases"],`SELECT booking_id,amount FROM booking_refund_cases WHERE status='processed' AND booking_id IN (${placeholders})`,chunk)); - const reviews=await chunkedIn(ids,(chunk,placeholders)=>safeAll(db,["service_reviews"],`SELECT booking_id,stars FROM service_reviews WHERE booking_id IN (${placeholders})`,chunk)); - const tickets=await chunkedIn(ids,(chunk,placeholders)=>safeAll(db,["customer_experience_tickets"],`SELECT booking_id FROM customer_experience_tickets WHERE booking_id IN (${placeholders})`,chunk)); + const coupons=await chunkedIn(ids,(chunk,placeholders)=>safeAll(db,["coupon_redemptions"],`SELECT booking_id,discount_amount FROM coupon_redemptions WHERE status='consumed' AND booking_id IN (${placeholders})`,chunk,guards)); + const points=await chunkedIn(ids,(chunk,placeholders)=>safeAll(db,["paw_points_ledger"],`SELECT booking_id,points FROM paw_points_ledger WHERE entry_type='redeemed' AND booking_id IN (${placeholders})`,chunk,guards)); + const wallet=await chunkedIn(ids,(chunk,placeholders)=>safeAll(db,["pawspace_wallet_ledger"],`SELECT source_id booking_id,applied_value FROM pawspace_wallet_ledger WHERE entry_type='redeem' AND source_id IN (${placeholders})`,chunk,guards)); + const payouts=await chunkedIn(ids,(chunk,placeholders)=>safeAll(db,["provider_order_payouts"],`SELECT booking_id,amount FROM provider_order_payouts WHERE booking_id IN (${placeholders})`,chunk,guards)); + const refunds=await chunkedIn(ids,(chunk,placeholders)=>safeAll(db,["booking_refund_cases"],`SELECT booking_id,amount FROM booking_refund_cases WHERE status='processed' AND booking_id IN (${placeholders})`,chunk,guards)); + const reviews=await chunkedIn(ids,(chunk,placeholders)=>safeAll(db,["service_reviews"],`SELECT booking_id,stars FROM service_reviews WHERE booking_id IN (${placeholders})`,chunk,guards)); + const tickets=await chunkedIn(ids,(chunk,placeholders)=>safeAll(db,["customer_experience_tickets"],`SELECT booking_id FROM customer_experience_tickets WHERE booking_id IN (${placeholders})`,chunk,guards)); type Ladder={gmv:number;orders:number;cancelled:number;discounts:number;providerPayout:number;refunds:number;tax:null;paymentFee:null;variableCost:null;contributionKnown:number;contributionPctOfGmv:number|null;avgOrderValue:number|null;reviews:number;csatAvgStars:number|null;csatPct:number|null;complaintsPer100:number|null;repeatRatePct:number|null;revenuePerProviderDay:number|null}; const ladders:Record={}; @@ -84,15 +101,15 @@ export async function buildUnitEconomics(db:Db,input:UnitEconomicsFilters={}){ const activeCustomers=[...new Set(active.map(row=>String(row.customer_id)))]; let ltvPerActiveCustomer:number|null=null; if(activeCustomers.length){ - const lifetime=await chunkedIn(activeCustomers,(chunk,placeholders)=>safeAll(db,["canonical_bookings"],`SELECT customer_id,SUM(total_amount) total FROM canonical_bookings WHERE status NOT IN ('cancelled','draft') AND customer_id IN (${placeholders}) GROUP BY customer_id`,chunk)); + const lifetime=await chunkedIn(activeCustomers,(chunk,placeholders)=>safeAll(db,["canonical_bookings"],`SELECT customer_id,SUM(total_amount) total FROM canonical_bookings WHERE status NOT IN ('cancelled','draft') AND customer_id IN (${placeholders}) GROUP BY customer_id`,chunk,guards)); ltvPerActiveCustomer=round2(lifetime.reduce((sum,row)=>sum+Number(row.total||0),0)/activeCustomers.length); } // Roster utilisation: booked reservation hours / rostered window hours across the window let utilisationPct:number|null=null; if(await tableExists(db,"scheduling_reservations")&&await tableExists(db,"scheduling_availability")){ - const reservations=await safeAll(db,["scheduling_reservations"],"SELECT scheduled_start,scheduled_end FROM scheduling_reservations WHERE status!='cancelled' AND substr(scheduled_start,1,10)>=? AND substr(scheduled_start,1,10)<=?",[from,to]); + const reservations=await safeAll(db,["scheduling_reservations"],"SELECT scheduled_start,scheduled_end FROM scheduling_reservations WHERE status!='cancelled' AND substr(scheduled_start,1,10)>=? AND substr(scheduled_start,1,10)<=?",[from,to],guards); const bookedHours=reservations.reduce((sum,row)=>sum+Math.max(0,(new Date(String(row.scheduled_end)).getTime()-new Date(String(row.scheduled_start)).getTime())/3_600_000),0); - const roster=await safeAll(db,["scheduling_availability"],"SELECT windows_json FROM scheduling_availability WHERE date>=? AND date<=?",[from,to]); + const roster=await safeAll(db,["scheduling_availability"],"SELECT windows_json FROM scheduling_availability WHERE date>=? AND date<=?",[from,to],guards); let rosterHours=0; for(const row of roster){let windows:string[]=[];try{windows=JSON.parse(String(row.windows_json||"[]"));}catch{windows=[];}for(const window of windows){const match=/^(\d{2}):(\d{2})-(\d{2}):(\d{2})$/.exec(window);if(match)rosterHours+=Math.max(0,(Number(match[3])*60+Number(match[4])-Number(match[1])*60-Number(match[2]))/60);}} utilisationPct=rosterHours>0?pct(bookedHours,rosterHours):null; @@ -103,7 +120,7 @@ export async function buildUnitEconomics(db:Db,input:UnitEconomicsFilters={}){ const spendRow=await db.prepare("SELECT COALESCE(SUM(spend_amount),0) spend,COUNT(*) rows FROM marketing_attribution_facts WHERE spend_amount IS NOT NULL").first(); const spend=Number(spendRow?.spend||0); if(Number(spendRow?.rows||0)>0&&spend>0){ - const firsts=await safeAll(db,["canonical_bookings"],`SELECT customer_id,MIN(substr(scheduled_start,1,10)) first_day FROM canonical_bookings WHERE status NOT IN ('cancelled','draft') GROUP BY customer_id`,[]); + const firsts=await safeAll(db,["canonical_bookings"],`SELECT customer_id,MIN(substr(scheduled_start,1,10)) first_day FROM canonical_bookings WHERE status NOT IN ('cancelled','draft') GROUP BY customer_id`,[],guards); const newCustomers=firsts.filter(row=>String(row.first_day)>=from&&String(row.first_day)<=to).length; cac={status:"derived_from_recorded_spend",spend:round2(spend),newCustomers,cacPerNewCustomer:newCustomers>0?round2(spend/newCustomers):null}; } diff --git a/tests/chunked-in-semantics.test.mjs b/tests/chunked-in-semantics.test.mjs new file mode 100644 index 000000000..96c07eb91 --- /dev/null +++ b/tests/chunked-in-semantics.test.mjs @@ -0,0 +1,153 @@ +/** + * Chunking a `WHERE x IN (...)` read is not a free refactor. Splitting one statement into several + * changes three things the single statement guaranteed, and the D1 bind-cap tests cannot see any of + * them because they all pass under 100 rows: + * + * subrequest cost a guard or lookup inside the chunked callback now runs once PER CHUNK + * ORDER BY sorts within each chunk, so the concatenation is sorted only in blocks + * LIMIT n applies per chunk, so the answer can hold n rows per chunk instead of n + * + * The subrequest cost and the LIMIT are asserted by running the real module against a D1 shim at a + * size above one chunk - under one chunk every assertion here passes whether the code is right or + * wrong. ORDER BY is guarded at the class level instead: today's ordered call sites are all pre-sorted + * or grouped by the chunked column, so no execution test can fail on them, and a guard that only + * covers reachable-today sites would not be there for the next one. + */ +import assert from "node:assert/strict"; +import test from "node:test"; +import { installWorkersHooks } from "./helpers/module-hooks.mjs"; +import { freshCountingD1, assertWithinBudget } from "./helpers/d1-harness.mjs"; + +installWorkersHooks("__CHUNK_SEMANTICS_DB__"); + +import { D1_IN_CHUNK } from "../lib/d1-chunked-in.ts"; + +/** A Worker invocation is cut off near 1,000 subrequests. Stay visibly clear of it, not just under. */ +const D1_SUBREQUEST_CEILING = 1_000; + +function use(harness) { + globalThis.__CHUNK_SEMANTICS_DB__ = harness.db; + globalThis.__CHUNK_SEMANTICS_DB___ENV = {}; + return harness; +} + +test("a guarded read wrapped in chunkedIn does not pay its table guard once per chunk", async () => { + const harness = use(freshCountingD1()); + const { sqlite } = harness; + sqlite.exec( + "CREATE TABLE canonical_bookings (id text PRIMARY KEY, customer_id text, provider_id text, city_id text, service_code text, status text, scheduled_start text, scheduled_end text, total_amount real)", + ); + for (const table of [ + "coupon_redemptions", "paw_points_ledger", "pawspace_wallet_ledger", + "provider_order_payouts", "booking_refund_cases", "service_reviews", "customer_experience_tickets", + ]) { + sqlite.exec( + `CREATE TABLE ${table} (booking_id text, source_id text, discount_amount real, points real, applied_value real, amount real, stars real, status text, entry_type text)`, + ); + } + + // 5,000 bookings is not hypothetical volume for a launched city: it is roughly one quarter. + const BOOKINGS = 5_000; + const insert = sqlite.prepare( + "INSERT INTO canonical_bookings VALUES (?,?,'PRV-1','blr','grooming','completed','2026-07-01T09:00:00.000Z','2026-07-01T10:00:00.000Z',1000)", + ); + for (let index = 0; index < BOOKINGS; index += 1) insert.run(`BK${index}`, `CUS${index}`); + + const { buildUnitEconomics } = await import("../lib/unit-economics.ts"); + const chunks = Math.ceil(BOOKINGS / D1_IN_CHUNK); + + const result = await assertWithinBudget( + harness, + // Eight guarded lookups. Memoised, their guards cost 8 reads for the whole request; charged per + // chunk they cost 8 x 63 = 504 on their own, which measured 1,012 subrequests in total and put + // the screen back over the ceiling the chunking was introduced to get it under. + { max: chunks * 8 + 60, label: "buildUnitEconomics over 5,000 bookings" }, + () => buildUnitEconomics(harness.db, {}), + ); + + assert.equal(result.services.grooming.orders, BOOKINGS, "the reads actually returned the bookings"); + assert.equal(result.services.grooming.gmv, BOOKINGS * 1000); + assert.ok(harness.calls() < D1_SUBREQUEST_CEILING * 0.7, `used ${harness.calls()} subrequests; a Worker gets about ${D1_SUBREQUEST_CEILING}`); +}); + +test("every chunked read whose SQL orders or limits reapplies it, or is provably pre-ordered", async () => { + // The reachable half of this class is asserted by real execution in the test below. This one guards + // the class itself: a future chunkedIn call site with ORDER BY or LIMIT in its SQL is a decision, and + // an undecided one is a silently wrong answer, so it has to be answered here to land. + const { readdir, readFile } = await import("node:fs/promises"); + const dir = new URL("../lib/", import.meta.url); + const names = (await readdir(dir)).filter((name) => name.endsWith(".ts")); + + // Call sites that need no reapplication, each with the reason. A "does this file re-sort somewhere" + // check is worthless - people-reports.ts sorts payroll runs elsewhere and would satisfy it while the + // approval trail stayed wrong - so each call site is judged on its own statement. + const preOrdered = new Map([ + ["manager-dashboard.ts", "resolveDashboardScope already returns the scope ORDER BY e.display_name, so chunks are name-contiguous and each chunk's ORDER BY reproduces the global order"], + ]); + + const undecided = []; + const exemptionUsed = new Set(); + for (const name of names) { + const source = await readFile(new URL(name, dir), "utf8"); + for (let at = source.indexOf("chunkedIn("); at !== -1; at = source.indexOf("chunkedIn(", at + 1)) { + // The statement this call site belongs to: SQL here never contains a semicolon. + const end = source.indexOf(";", at); + const statement = source.slice(at, end === -1 ? source.length : end); + if (!/ORDER BY|LIMIT \d+/.test(statement)) continue; + if (preOrdered.has(name)) { exemptionUsed.add(name); continue; } + // Wrapped in a named call that takes the concatenated rows: `= reapply(await chunkedIn(...))`. + const wrapped = /=\s*[A-Za-z_$][\w$]*\(\s*await\s*$/.test(source.slice(Math.max(0, at - 40), at)); + if (!wrapped) undecided.push(`${name}: ${statement.slice(0, 70)}`); + } + } + assert.deepEqual(undecided, [], "these call sites chunk a read that orders or limits, without reapplying it to the concatenated rows or recording why that is unnecessary"); + + for (const [name, reason] of preOrdered) { + assert.ok(exemptionUsed.has(name), `${name} no longer has an ordered chunked read - drop its exemption (${reason})`); + } +}); + +test("a chunked LIMIT returns the newest n overall, not n per chunk", async () => { + const harness = use(freshCountingD1()); + const { sqlite } = harness; + const { peopleReports } = await import("../lib/people-reports.ts"); + // Read by the audit panel in the same call; not created by any of the people ensure* helpers. + sqlite.exec("CREATE TABLE IF NOT EXISTS security_audit_events (id text PRIMARY KEY, actor_email text, actor_role text, action text, resource_type text, resource_id text, outcome text, created_at integer)"); + + // Let the module create its own schema, so this test cannot disagree with production about shapes. + await peopleReports(harness.db, { actorEmail: "founder@pawspace.in", roleCode: "founder", permissions: ["*"] }); + + const APPROVAL_LIMIT = 200; + const RUNS = D1_IN_CHUNK * 2 + 5; + const periodStart = Date.UTC(2026, 6, 1), periodEnd = Date.UTC(2026, 6, 31); + sqlite.prepare("INSERT INTO employees (id,employee_code,display_name,work_email,user_email,employment_status,joined_at,created_at,updated_at) VALUES ('EMP-1','E-1','Payee','payee@pawspace.in','payee@pawspace.in','active',0,0,0)").run(); + + let newest = 0; + const stamps = []; + for (let run = 0; run < RUNS; run += 1) { + const runId = `RUN-${String(run).padStart(4, "0")}`; + sqlite.prepare("INSERT INTO payroll_runs (id,idempotency_key,period_start,period_end,status,input_snapshot_json,created_by,created_at) VALUES (?,?,?,?,'approved','{}','seed',0)").run(runId, `${runId}-idem`, periodStart, periodEnd); + sqlite.prepare("INSERT INTO employee_payroll_results (id,run_id,employee_id,structure_id,gross_earnings,total_deductions,reimbursements,employer_cost,net_pay,source_snapshot_json) VALUES (?,?,'EMP-1','STR-1',1000,0,0,100,900,'{}')").run(`RES-${run}`, runId); + // Five events per run: 460 in total, and every chunk holds well under the per-chunk LIMIT of 200, + // so the wrong answer is not "too many rows" but "the wrong rows, in the wrong order". + for (let event = 0; event < 5; event += 1) { + const createdAt = run * 1_000 + event; + sqlite.prepare("INSERT INTO payroll_approval_events (id,run_id,event_type,actor_id,detail_json,created_at) VALUES (?,?,'approved','approver@pawspace.in','{}',?)").run(`EVT-${run}-${event}`, runId, createdAt); + stamps.push(createdAt); + newest = Math.max(newest, createdAt); + } + } + + const report = await peopleReports(harness.db, { + actorEmail: "founder@pawspace.in", roleCode: "founder", permissions: ["*"], + periodStart, periodEnd, + }); + const events = report.payroll.approvalEvents; + + assert.equal(events.length, APPROVAL_LIMIT, `the limit holds over the whole answer (${stamps.length} events exist)`); + assert.equal(Number(events[0].created_at), newest, "the newest event overall is first, not the newest of chunk one"); + const descending = events.every((row, index) => index === 0 || Number(events[index - 1].created_at) >= Number(row.created_at)); + assert.ok(descending, "the trail is ordered newest-first across chunk boundaries"); + const expectedOldestKept = [...stamps].sort((left, right) => right - left)[APPROVAL_LIMIT - 1]; + assert.equal(Number(events[APPROVAL_LIMIT - 1].created_at), expectedOldestKept, "the 200 kept are the 200 newest, not the first 200 found"); +}); From 06095c6f89a4fa373fde5b09120e42c5ab45453a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 07:34:28 +0000 Subject: [PATCH 5/5] Make the env global name a real parameter, not an ignored argument Two suites call installWorkersHooks with a second argument naming their env global, and the helper takes one parameter, so the name was dropped: the shim read globalThis.__FANOUT_DB___ENV while the suite set __FANOUT_ENV__. Neither suite reads an env value, so nothing failed - the first one that did would have seen undefined with nothing to point at. The parameter now exists and defaults to the derived name the older suites rely on, so those keep working and the explicit call sites mean what they say. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011Hon8zmG1pwVY6zwxtT5th --- tests/chunked-in-semantics.test.mjs | 4 ++-- tests/helpers/module-hooks.mjs | 8 ++++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/chunked-in-semantics.test.mjs b/tests/chunked-in-semantics.test.mjs index 96c07eb91..63b92fd34 100644 --- a/tests/chunked-in-semantics.test.mjs +++ b/tests/chunked-in-semantics.test.mjs @@ -18,7 +18,7 @@ import test from "node:test"; import { installWorkersHooks } from "./helpers/module-hooks.mjs"; import { freshCountingD1, assertWithinBudget } from "./helpers/d1-harness.mjs"; -installWorkersHooks("__CHUNK_SEMANTICS_DB__"); +installWorkersHooks("__CHUNK_SEMANTICS_DB__", "__CHUNK_SEMANTICS_ENV__"); import { D1_IN_CHUNK } from "../lib/d1-chunked-in.ts"; @@ -27,7 +27,7 @@ const D1_SUBREQUEST_CEILING = 1_000; function use(harness) { globalThis.__CHUNK_SEMANTICS_DB__ = harness.db; - globalThis.__CHUNK_SEMANTICS_DB___ENV = {}; + globalThis.__CHUNK_SEMANTICS_ENV__ = {}; return harness; } diff --git a/tests/helpers/module-hooks.mjs b/tests/helpers/module-hooks.mjs index e4fa9a99c..53f982d92 100644 --- a/tests/helpers/module-hooks.mjs +++ b/tests/helpers/module-hooks.mjs @@ -10,8 +10,12 @@ */ import * as nodeModule from "node:module"; -export function installWorkersHooks(globalName) { - const shim = `export const env = new Proxy({}, { get: (_, key) => key === "DB" ? globalThis[${JSON.stringify(globalName)}] : (globalThis[${JSON.stringify(`${globalName}_ENV`)}] ?? {})[key] });`; +// envName defaults to `${globalName}_ENV`, which is what the suites written before it existed use. The +// two call sites that pass a name of their own (__FANOUT_ENV__, __SEED_ENV__) were setting a global the +// shim never read: it looked for __FANOUT_DB___ENV. Those suites need no env values, so nothing failed - +// the first suite to read one would have got undefined and no clue why. +export function installWorkersHooks(globalName, envName = `${globalName}_ENV`) { + const shim = `export const env = new Proxy({}, { get: (_, key) => key === "DB" ? globalThis[${JSON.stringify(globalName)}] : (globalThis[${JSON.stringify(envName)}] ?? {})[key] });`; const workersUrl = `data:text/javascript,${encodeURIComponent(shim)}`; // The fallback below only runs on the Node CI pins, so on a newer machine it is never exercised -