Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ ANTHROPIC_MODEL=claude-sonnet-4-6
OPENAI_API_KEY=

# WhatsApp delivery (Meta WhatsApp Cloud API)
WHATSAPP_INBOUND_ENABLED=false
WHATSAPP_PHONE_NUMBER_ID=
WHATSAPP_ACCESS_TOKEN=
WHATSAPP_TO=
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@ jobs:
- name: Lead quality regression
run: npx tsx tests/regression/lead-engine-quality-regression.mjs

- name: Revenue Rescue demo contract
run: npm run test:v2-demo

- name: WhatsApp webhook security regression
run: npm run test:whatsapp-security

- name: Find a Tender OCDS regression
run: npm run test:fts

Expand Down
23 changes: 23 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# JobFilter design direction

## Direction contract

| Decision | Direction |
|---|---|
| Product mode | Operate for the signed-in product; persuade through concrete evidence on public demos |
| Audience and cadence | UK trade-business owners and office managers, often on a phone between jobs; repeated use must be fast and legible |
| Visual world | **Site-office control board** — direct, high-contrast and evidence-led rather than polished corporate SaaS |
| Palette family | Existing Brutalist-Yellow tokens: paper, black ink and restrained yellow; green only for verified success and orange for action/failure |
| Type treatment | Existing Barlow body, Barlow Condensed operational headings and JetBrains Mono for IDs, times and money evidence |
| Composition | Focused workbench: current event, one next action, then progressively disclosed evidence |
| Shape language | Crisp two-pixel borders, near-square corners and hard shadows already defined by `jf-box` and `jf-button` |
| Anti-references | Generic gradient SaaS dashboards, glass cards, decorative AI motifs, status-chip clutter, fictional live-data claims and dense desktop-only tables |

## V2 product rules

- Preserve existing design tokens and shared components; do not create a second visual system.
- Label simulated, test and live states explicitly. A demo must never imply that a message, payment or phone event occurred.
- Put the current state and one primary action in the first viewport.
- Keep provider diagnostics, raw event payloads and advanced settings out of the default operator view.
- Show evidence for money and delivery claims: timestamps, provider state, quote version and attribution source.
- At 320–430px, workflows become one column without horizontal scrolling or truncated action copy.
35 changes: 24 additions & 11 deletions app/api/whatsapp/webhook/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { NextResponse, type NextRequest } from 'next/server';
import crypto from 'crypto';
import { getSupabaseServiceClient } from '../../../../src/lib/supabase/server';
import { isValidMetaSignature } from '../../../../src/lib/whatsappSignature';

export async function GET(request: NextRequest) {
const url = new URL(request.url);
Expand All @@ -15,15 +16,19 @@ export async function GET(request: NextRequest) {
}

export async function POST(request: NextRequest) {
if (process.env.WHATSAPP_INBOUND_ENABLED !== 'true') {
return new NextResponse('WhatsApp inbound is disabled', { status: 503 });
}

const raw = await request.text();
const appSecret = process.env.WHATSAPP_APP_SECRET;

if (appSecret) {
const signature = request.headers.get('x-hub-signature-256');
const expected = `sha256=${crypto.createHmac('sha256', appSecret).update(raw).digest('hex')}`;
if (!signature || signature !== expected) {
return new NextResponse('Invalid signature', { status: 403 });
}
if (!appSecret) {
return new NextResponse('WhatsApp signature verification is not configured', { status: 503 });
}

if (!isValidMetaSignature(raw, request.headers.get('x-hub-signature-256'), appSecret)) {
return new NextResponse('Invalid signature', { status: 403 });
}

const body = (() => { try { return JSON.parse(raw); } catch { return null; } })();
Expand All @@ -36,8 +41,6 @@ export async function POST(request: NextRequest) {
// Ignore status updates
if (!text) return NextResponse.json({ ok: true });

console.log(`[whatsapp/webhook] incoming from ${fromPhone}: ${text}`);

// Simple Rule-Based Receptionist
const postcodeRegex = /[A-Z]{1,2}[0-9][0-9A-Z]?\s?[0-9][A-Z]{2}/i;
const hasPostcode = postcodeRegex.test(text);
Expand Down Expand Up @@ -68,7 +71,7 @@ export async function POST(request: NextRequest) {
replyText = "Hi! I'm the automated receptionist. To help us get you a quote quickly, please reply with:\n1. The type of work you need\n2. Your postcode\n3. When you need it done";
}

await fetch(`https://graph.facebook.com/v21.0/${phoneId}/messages`, {
const response = await fetch(`https://graph.facebook.com/v21.0/${phoneId}/messages`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
Expand All @@ -80,8 +83,18 @@ export async function POST(request: NextRequest) {
to: fromPhone,
type: 'text',
text: { preview_url: false, body: replyText }
})
}).catch(console.error);
}),
signal: AbortSignal.timeout(8_000),
}).catch(() => null);

if (!response?.ok) {
console.error('[whatsapp/webhook] outbound reply failed', { status: response?.status ?? 0 });
// The intake row may already exist. A retriable response here would let Meta
// repeat the event and duplicate side effects until a durable inbox/outbox is live.
return NextResponse.json({ received: true, replyDelivered: false });
}
} else {
return NextResponse.json({ ok: false, error: 'WhatsApp delivery is not configured' }, { status: 503 });
}
}

Expand Down
15 changes: 15 additions & 0 deletions app/demo/revenue-rescue/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { Metadata } from 'next';
import { RevenueRescueDemoPage } from '../../../src/pages/RevenueRescueDemoPage';

export const metadata: Metadata = {
title: 'Revenue Rescue Demo | JobFilter',
description: 'A synthetic walkthrough of JobFilter enquiry qualification, quoting, follow-up and revenue attribution.',
robots: {
index: false,
follow: false,
},
};

export default function Page() {
return <RevenueRescueDemoPage />;
}
61 changes: 61 additions & 0 deletions docs/v2/phase-0-audit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# JobFilter V2 Phase 0 audit

Date: 24 August 2026

Branch: `agents/jobfilter-v2-foundation`

Base: `origin/main` at `5489192`

## Access statement

| System | Status | Evidence |
|---|---|---|
| GitHub repository, branches, PRs and Actions | VERIFIED | Authenticated `gh` access; repository and CI metadata inspected |
| Local repository | VERIFIED | Fresh isolated worktree; the dirty `agents/jobfilter-find-a-tender` tree was not modified |
| Vercel project and production deployment | VERIFIED, read-only | Authenticated CLI; `job-filter-v1` is linked to `jobfilter.uk` and production was Ready |
| Vercel production environment names | VERIFIED | Supabase, Stripe and Resend variables exist; Twilio, WhatsApp and Anthropic variables were absent |
| Vercel billing plan and remaining allowance | UNVERIFIABLE | Not exposed by the inspected project metadata |
| Supabase project existence and region | VERIFIED | `Jobfilter.uk`, Central EU (Frankfurt), project ref `nfjwuwsuaapufmkppoeo` |
| Applied remote schema, RLS behaviour and backups | UNVERIFIABLE | Project is not locally linked and no database credential was available to the audit process |
| Stripe products, live subscriptions, MRR and churn | UNVERIFIABLE | Environment variable names exist, but Stripe CLI/API credentials were not available to the audit process |
| Resend account state and delivery | UNVERIFIABLE | Production key name exists; account and live delivery were not accessed |
| WhatsApp Business account/templates | UNVERIFIABLE / NOT CONFIGURED IN VERCEL | Required production variables are absent |
| UK telephony provider, number and forwarding | NOT CONFIGURED | No Twilio variables or provider configuration found |
| External nightly PR generator | UNVERIFIABLE | Only the CI workflow exists in this repository; the automation producing `nightly/*` PRs is external |

No production configuration, database, subscription, message or deployment was changed during this audit.

## Verified baseline

- `npm ci`: 181 packages installed, zero audit vulnerabilities.
- TypeScript check passed.
- Production source-safety, postcode, source-readiness, scanner-redaction, lead-quality, FTS and production-runtime regressions passed.
- Next.js production build passed and generated 120 static pages plus dynamic routes.
- The graph index found 39 route definitions across the Next and legacy Express surfaces.
- Production currently uses Next.js 16, React 19, Supabase, Stripe, Resend and Vercel Analytics.

## Security and architecture findings

| Severity | Verification | Finding | Required action |
|---|---|---|---|
| CRITICAL | VERIFIED in source; contained on this branch | WhatsApp webhook authentication was optional when `WHATSAPP_APP_SECRET` was missing. It also logged sender/message PII and treated a failed outbound fetch as success. | This branch makes inbound WhatsApp disabled by default, requires the secret, uses constant-time signature comparison, removes message-body logs and records delivery failure without requesting a retry after side effects. Durable inbound deduplication/outbox work is still required before enablement. |
| CRITICAL | UNVERIFIABLE remotely | The repository has user-scoped RLS migrations, but the applied remote schema and two-user isolation were not tested. | Link a non-production Supabase branch and run an adversarial two-user suite before any V2 tenant data is applied. |
| HIGH | VERIFIED in schema | The current model is user-owned, not organisation-owned. There are no organisation or membership tables for multi-user firms. | Design organisations/memberships now; apply physical tables only after the commercial gate. |
| HIGH | VERIFIED in source | Public intake uses service-role writes and stores phone, postcode and IP. Rate limiting depends on the database insert/count/delete path and fails open when Supabase is unavailable. | Replace with an atomic database function or durable rate-limit boundary before live acquisition traffic. |
| HIGH | VERIFIED in source | WhatsApp inbound rows are keyed only by phone and have no tenant identity, consent state, provider event ID or delivery state. | Do not reuse this table as the V2 conversation model. |
| MEDIUM | VERIFIED in source | Stripe webhook signing and event idempotency are implemented, but live subscriber/product state could not be checked. | Pull aggregate Stripe state through approved access before changing pricing or customer treatment. |
| MEDIUM | VERIFIED in CI | CI runs a hand-maintained list of regression scripts and has no general unit-test discovery or E2E suite. | Add each V2 contract test explicitly now; introduce a coherent test runner before multi-tenant mutations grow. |
| MEDIUM | VERIFIED in build | Both App Router handlers and a Pages catch-all Express API remain active. | Assign one owner to every API during route migration; do not create duplicate V2 endpoints. |
| LOW | VERIFIED in build | Next.js reports the `middleware` convention as deprecated in favour of `proxy`. | Migrate separately; it is not part of the first V2 sales-demo slice. |

## Gate 0 verdict

Gate 0 is **not fully passed**. The local build/security baseline is verified, but these items remain required before production schema or pricing changes:

1. aggregate Stripe subscriber/product state;
2. applied Supabase schema and a real two-user RLS test;
3. explicit treatment of any existing paying tender customers;
4. identification and pausing of the external nightly PR generator;
5. Vercel and Supabase plan/backup confirmation.

Safe work may continue on synthetic, no-send, no-payment sales demonstrations and documentation. Live messaging, production migrations, pricing replacement and customer-data writes remain gated.
21 changes: 21 additions & 0 deletions docs/v2/route-migration-matrix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# V2 route migration matrix

No route is deleted during the foundation slice. `KEEP` means the URL remains stable until analytics and customer state are verified. `REDIRECT` is a planned change that must ship with a tested permanent redirect. `RETIRE` routes are already intended to be unavailable in production.

| Decision | Routes | Notes |
|---|---|---|
| KEEP | `/`, `/pricing`, `/trust`, `/login`, `/signup`, `/forgot-password`, `/reset-password`, `/auth/callback`, `/account`, `/activation-pending`, `/dashboard`, `/leads`, `/leads/[id]` | Core public/authenticated journeys; copy and ownership change only in later slices |
| KEEP | `/find-jobs`, `/signals`, `/signals/weekly`, `/territories`, `/territory`, `/intelligence/[city]` | Retained Opportunities module |
| KEEP | `/microsite`, `/pro/[slug]`, `/[slug]`, `/my-link` | Public URL commitments; never break without an explicit slug-level 301 map |
| KEEP | `/methodology`, `/whats-new`, `/privacy`, `/terms`, `/faq`, `/news`, `/tips`, `/for-your-trade`, `/free-tools` | Public trust/content routes |
| KEEP | `/smart-quote`, `/post-job`, `/tradie-zone`, `/tradiestack`, `/vantage`, `/vicinity`, `/trade-map`, `/uk-grid`, `/epc` | Existing tools remain until usage is known |
| KEEP | `/2builduk-alternative`, `/vs/bark`, `/vs/buildalert`, `/vs/checkatrade`, `/vs/mybuilder`, `/vs/rated-people`, `/vs/trustatrader` | Existing comparison SEO routes |
| KEEP | `/construction-leads/birmingham`, `/construction-leads/bristol`, `/construction-leads/glasgow`, `/construction-leads/leeds`, `/construction-leads/london`, `/construction-leads/manchester` | Existing location SEO routes |
| KEEP | `/trade/asbestos-surveyors`, `/trade/builders`, `/trade/cctv-security`, `/trade/damp-proofers`, `/trade/data-cabling`, `/trade/decorators`, `/trade/electricians`, `/trade/ev-charger-installers`, `/trade/fibre-installers`, `/trade/fire-safety`, `/trade/gas-engineers`, `/trade/groundworkers`, `/trade/heat-pump-installers`, `/trade/hvac-engineers`, `/trade/plumbers`, `/trade/quantity-surveyors`, `/trade/roofers`, `/trade/scaffolders`, `/trade/smart-home-installers`, `/trade/solar-pv-installers`, `/trade/structural-engineers` | Existing trade SEO routes; replacement claims remain restricted for regulated trades |
| KEEP | `/acm-report-pack`, `/calc-pack`, `/cctv-compliance-pack`, `/dno-brief`, `/fra-template`, `/gas-safe-kit`, `/material-price-engine`, `/nasc-pack`, `/om-builder`, `/ozev-grant-pack`, `/swmp-template`, `/wayleave-pack` | Existing tool/content commitments; reassess with analytics before any redirect |
| KEEP | `/claim`, `/features/admin-guard`, `/dashboard/admin-guard` | Existing acquisition/product surfaces |
| REDIRECT | `/blueprint` → `/methodology` | Both are labelled “How It Works”; redirect only after content-parity review |
| RETIRE | `/test`, `/test/intake`, `/dev-portal`, `/codex` | Must remain unavailable in production; remove source only in a separate verified change |
| ADD | `/demo/revenue-rescue` | Synthetic, no-send sales walkthrough; `noindex` until the product gate passes |

All existing API paths remain `KEEP` during the foundation slice. The App Router and legacy `/api/[[...path]]` Express catch-all require endpoint-by-endpoint ownership before consolidation.
9 changes: 9 additions & 0 deletions docs/v2/source-manifest.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# V2 source adaptation manifest

Every adapted source file must be recorded here before its implementation PR is approved.

| JobFilter destination | Upstream repository | Exact commit | Upstream path | Licence | Adaptation | Notice required |
|---|---|---|---|---|---|---|
| None yet | — | — | — | — | The first V2 demo is independently implemented from the approved requirements; no third-party source was copied. | No |

Copyleft, source-available, enterprise-only and ambiguously licensed repositories may inform independently written requirements, but their application source must not enter implementation context.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
"preview": "next start",
"clean": "rm -rf .next dist",
"lint": "tsc --noEmit",
"test:v2-demo": "tsx tests/regression/revenue-rescue-demo-regression.mjs",
"test:whatsapp-security": "tsx tests/regression/whatsapp-webhook-security-regression.mjs",
"test:fts": "tsx tests/regression/fts-ocds-regression.mjs",
"benchmark:fts": "tsx scripts/fts-benchmark.ts",
"gate:fts": "tsx scripts/fts-coverage-gate.ts",
Expand Down
76 changes: 76 additions & 0 deletions src/lib/revenueRescueDemo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
export type RevenueRescueStage = {
id: 'missed' | 'qualified' | 'quoted' | 'followed-up' | 'recovered';
label: string;
time: string;
title: string;
summary: string;
evidence: string;
};

export const REVENUE_RESCUE_DEMO = {
mode: 'simulation' as const,
customer: 'Sarah Mitchell',
firm: 'Oak & Ridge Roofing',
job: 'Storm-damaged pitched roof',
postcode: 'DE12',
quoteMinorUnits: 348000,
depositMinorUnits: 69600,
currency: 'GBP' as const,
stages: [
{
id: 'missed',
label: 'Missed call',
time: '09:12',
title: 'The call was not answered',
summary: 'A customer called while the owner was on a roof. The demo creates an enquiry and prepares a one-segment acknowledgement.',
evidence: 'SIMULATED EVENT · No call or message has been sent',
},
{
id: 'qualified',
label: 'Qualified',
time: '09:18',
title: 'Enough detail to make a decision',
summary: 'Roof damage, DE12, work needed this week, photos available and a £2,500–£4,000 budget range.',
evidence: '5 required fields captured · Safety and opt-out rules passed',
},
{
id: 'quoted',
label: 'Quote sent',
time: '10:04',
title: 'A versioned quote is ready',
summary: 'Quote v1 totals £3,480 including a £696 deposit. The customer would receive a secure acceptance link in the live product.',
evidence: 'QUOTE v1 · £3,480.00 · Deposit £696.00',
},
{
id: 'followed-up',
label: 'Followed up',
time: 'Next day',
title: 'The quote did not disappear into a chat',
summary: 'A bounded follow-up becomes due. In production it pauses immediately on reply, acceptance, decline or opt-out.',
evidence: '1 reminder scheduled · 0 duplicate attempts · Budget protected',
},
{
id: 'recovered',
label: 'Recovered',
time: '14:26',
title: 'The customer accepted',
summary: 'The accepted quote is attributed back to the original missed enquiry so the owner can see what progressed and why.',
evidence: 'VALUE RECEIPT · £3,480 progressed · Source: missed enquiry',
},
] satisfies RevenueRescueStage[],
};

export function clampDemoStage(index: number) {
return Math.max(0, Math.min(Math.trunc(index), REVENUE_RESCUE_DEMO.stages.length - 1));
}

export function nextDemoStage(index: number) {
return clampDemoStage(index + 1);
}

export function formatDemoMoney(minorUnits: number) {
return new Intl.NumberFormat('en-GB', {
style: 'currency',
currency: REVENUE_RESCUE_DEMO.currency,
}).format(minorUnits / 100);
}
14 changes: 14 additions & 0 deletions src/lib/whatsappSignature.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import crypto from 'node:crypto';

const META_SIGNATURE_PREFIX = 'sha256=';

export function isValidMetaSignature(rawBody: string, signature: string | null, appSecret: string) {
if (!appSecret || !signature?.startsWith(META_SIGNATURE_PREFIX)) return false;

const providedHex = signature.slice(META_SIGNATURE_PREFIX.length);
if (!/^[a-f0-9]{64}$/i.test(providedHex)) return false;

const expected = crypto.createHmac('sha256', appSecret).update(rawBody).digest();
const provided = Buffer.from(providedHex, 'hex');
return provided.length === expected.length && crypto.timingSafeEqual(provided, expected);
}
Loading