feat: Leads Finder discovery + WordPress license validate + file storage

3 new PRD features:

1. Leads Finder discovery (PRD §6.2 Stage 1: Discover)
   - Real SerpAPI integration (Google Maps local search)
   - Real Tavily integration (web search API)
   - Real Firecrawl integration (search + scrape)
   - Returns company name, website, address, phone, description
   - API key required (503 with hint if not configured)
   - Rate limited, Zod validated, audit-logged
   - POST /api/aos/leads/discover

2. WordPress-compatible license validation (PRD §6.4)
   - POST /api/aos/licenses/validate
   - WordPress plugin updater response format
   - Domain auto-activation with max check
   - Offline grace period (14 day default, configurable)
   - Status checks: active/suspended/expired/revoked
   - Rate limited (30/min), Zod validated, audit-logged
   - Tested: active→valid✓, invalid→404, expired→403, suspended→403

3. Content-addressed file storage (PRD §5.5)
   - src/lib/storage.ts
   - SHA-256 hash addressing with sharded dirs (first 2 chars)
   - Atomic writes (temp file → rename)
   - Hash verified on read (tamper detection)
   - Deduplication (same content = same hash = one file)
   - Content type detection from magic numbers (PDF, PNG, JPEG, GIF, WebP, ZIP)
   - storeFile(), readFile(), fileExists(), deleteFile()

0 TS errors, ESLint clean.
This commit is contained in:
Z User committed 2026-10-27 23:49:25 +00:00
1 parent e45b68bb8b
commit 2aa3496a90
4 files changed
+470

No files matched your search

+197
View File
@@ -0,0 +1,197 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { z } from 'zod'
import { checkRateLimit } from '@/lib/rate-limit'
export const dynamic = 'force-dynamic'
export const maxDuration = 60
const discoverSchema = z.object({
query: z.string().min(2).max(200),
location: z.string().optional().default('Hamburg'),
radius: z.number().int().min(1).max(1000).optional().default(100),
branch: z.string().optional(),
provider: z.enum(['serpapi', 'tavily', 'firecrawl']).optional().default('serpapi'),
limit: z.number().int().min(1).max(20).optional().default(10),
})
// POST /api/aos/leads/discover
// Real provider integration (PRD §6.2 Stage 1: Discover)
// Calls SerpAPI, Tavily, or Firecrawl to find businesses matching the query.
// Results are NOT leads yet — they go through the full pipeline (Audit → Score → Review).
export async function POST(req: Request) {
try {
const rateLimited = checkRateLimit(req, 'public')
if (rateLimited) return rateLimited
const parsed = discoverSchema.safeParse(await req.json())
if (!parsed.success) {
return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 })
}
const { query, location, radius, branch, provider, limit } = parsed.data
const results: {
provider: string
companyName: string
website: string | null
address: string | null
phone: string | null
description: string | null
source: string
}[] = []
if (provider === 'serpapi') {
const apiKey = process.env.SERPAPI_KEY
if (!apiKey) {
return NextResponse.json({
error: 'SERPAPI_KEY not configured',
hint: 'Set SERPAPI_KEY in .env.local to enable Google Search discovery',
provider: 'serpapi',
}, { status: 503 })
}
// Real SerpAPI call (Google Maps / Local search)
const params = new URLSearchParams({
engine: 'google_maps',
q: `${query} ${location}`,
google_domain: 'google.de',
hl: 'de',
gl: 'de',
api_key: apiKey,
})
if (branch) params.set('q', `${branch} ${location}`)
const serpRes = await fetch(`https://serpapi.com/search.json?${params}`, {
signal: AbortSignal.timeout(15000),
})
if (!serpRes.ok) {
return NextResponse.json({ error: `SerpAPI returned ${serpRes.status}` }, { status: 502 })
}
const serpData = await serpRes.json()
const places = serpData.places ?? serpData.local_results ?? []
for (const place of places.slice(0, limit)) {
results.push({
provider: 'serpapi',
companyName: place.title ?? 'Unbekannt',
website: place.website ?? null,
address: place.address ?? null,
phone: place.phone ?? null,
description: place.description ?? place.type ?? null,
source: 'google_maps',
})
}
} else if (provider === 'tavily') {
const apiKey = process.env.TAVILY_KEY
if (!apiKey) {
return NextResponse.json({
error: 'TAVILY_KEY not configured',
hint: 'Set TAVILY_KEY in .env.local to enable Tavily search discovery',
provider: 'tavily',
}, { status: 503 })
}
// Real Tavily API call
const tavRes = await fetch('https://api.tavily.com/search', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
api_key: apiKey,
query: `${query} Unternehmen ${location} ${branch ?? ''}`,
max_results: limit,
include_domains: [],
search_depth: 'basic',
}),
signal: AbortSignal.timeout(15000),
})
if (!tavRes.ok) {
return NextResponse.json({ error: `Tavily returned ${tavRes.status}` }, { status: 502 })
}
const tavData = await tavRes.json()
const tavResults = tavData.results ?? []
for (const r of tavResults) {
// Extract company name from title (usually "Company Name - Stadt" or "Company Name | ...")
const title = r.title ?? ''
const companyName = title.split(/[|\-–—]/)[0]?.trim() ?? title
results.push({
provider: 'tavily',
companyName,
website: r.url ? new URL(r.url).hostname.replace('www.', '') : null,
address: null,
phone: null,
description: r.content?.slice(0, 200) ?? null,
source: 'web_search',
})
}
} else if (provider === 'firecrawl') {
const apiKey = process.env.FIRECRAWL_KEY
if (!apiKey) {
return NextResponse.json({
error: 'FIRECRAWL_KEY not configured',
hint: 'Set FIRECRAWL_KEY in .env.local to enable Firecrawl discovery',
provider: 'firecrawl',
}, { status: 503 })
}
// Real Firecrawl API call (search + scrape)
const fcRes = await fetch('https://api.firecrawl.dev/v0/search', {
method: 'POST',
headers: {
'content-type': 'application/json',
'Authorization': `Bearer ${apiKey}`,
},
body: JSON.stringify({
query: `${query} ${location} ${branch ?? ''} Impressum`,
limit,
}),
signal: AbortSignal.timeout(20000),
})
if (!fcRes.ok) {
return NextResponse.json({ error: `Firecrawl returned ${fcRes.status}` }, { status: 502 })
}
const fcData = await fcRes.json()
const fcResults = fcData.data ?? fcData.results ?? []
for (const r of fcResults) {
const title = r.metadata?.title ?? r.title ?? 'Unbekannt'
const companyName = title.split(/[|\-–—]/)[0]?.trim() ?? title
const url = r.metadata?.sourceURL ?? r.url
results.push({
provider: 'firecrawl',
companyName,
website: url ? new URL(url).hostname.replace('www.', '') : null,
address: null,
phone: null,
description: r.content?.slice(0, 200) ?? r.markdown?.slice(0, 200) ?? null,
source: 'firecrawl_scrape',
})
}
}
// Audit
await db.auditLog.create({
data: {
actor: 'sven@nordlicht.digital',
action: 'leads.discover:' + provider,
entity: 'Lead',
entityId: 'batch',
after: JSON.stringify({ query, location, radius, provider, results: results.length }),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return NextResponse.json({
provider,
query,
location,
radius,
found: results.length,
results,
note: 'These are discovery results, not leads yet. They must go through Audit → Score → Review before becoming CRM leads.',
})
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
+139
View File
@@ -0,0 +1,139 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { z } from 'zod'
import { checkRateLimit } from '@/lib/rate-limit'
export const dynamic = 'force-dynamic'
const validateSchema = z.object({
key: z.string().min(1),
domain: z.string().optional(),
version: z.string().optional(),
})
// POST /api/aos/licenses/validate
// WordPress plugin updater compatible endpoint (PRD §6.4: "update endpoint compatible
// with the WordPress plugin updater plus a small PHP client library")
// Also works as a general license validation API for JS clients (Vue/Astro).
export async function POST(req: Request) {
try {
const rateLimited = checkRateLimit(req, 'license')
if (rateLimited) return rateLimited
const parsed = validateSchema.safeParse(await req.json())
if (!parsed.success) {
return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 })
}
const { key, domain, version } = parsed.data
const license = await db.license.findUnique({ where: { key } })
if (!license) {
return NextResponse.json({
valid: false,
error: 'license_not_found',
message: 'License key not found',
}, { status: 404 })
}
if (license.status === 'revoked') {
return NextResponse.json({
valid: false,
error: 'license_revoked',
message: 'This license has been revoked',
}, { status: 403 })
}
if (license.status === 'expired' || (license.validUntil && license.validUntil < new Date())) {
return NextResponse.json({
valid: false,
error: 'license_expired',
message: 'This license has expired',
expiredAt: license.validUntil,
}, { status: 403 })
}
if (license.status === 'suspended') {
return NextResponse.json({
valid: false,
error: 'license_suspended',
message: 'This license is suspended (check outstanding invoices)',
}, { status: 403 })
}
// Check activation count
const activations = await db.licenseActivation.findMany({ where: { licenseId: license.id } })
if (domain) {
// Domain-specific check: is this domain already activated?
const existing = activations.find((a) => a.domain === domain)
if (!existing && activations.length >= license.maxActivations) {
return NextResponse.json({
valid: false,
error: 'max_activations_reached',
message: `Maximum ${license.maxActivations} activations reached`,
maxActivations: license.maxActivations,
currentActivations: activations.length,
}, { status: 403 })
}
// Auto-activate if domain is new and under limit
if (!existing) {
await db.licenseActivation.create({
data: {
licenseId: license.id,
domain,
environment: 'production',
version: version ?? null,
lastCheck: new Date(),
},
})
} else {
// Update last check + version
await db.licenseActivation.update({
where: { id: existing.id },
data: { lastCheck: new Date(), version: version ?? existing.version },
})
}
}
// Update license lastUsed
await db.license.update({
where: { id: license.id },
data: { /* could track lastValidatedAt */ },
})
// Audit
await db.auditLog.create({
data: {
actor: 'system:license-check',
action: 'license.validate:ok',
entity: 'License',
entityId: license.id,
after: JSON.stringify({ key: key.slice(0, 16) + '…', domain, valid: true, activations: activations.length + (domain && !activations.find((a) => a.domain === domain) ? 1 : 0) }),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
// WordPress-compatible response format
return NextResponse.json({
valid: true,
license: {
key: license.key.slice(0, 16) + '…',
product: license.product,
plan: license.plan,
status: license.status,
maxActivations: license.maxActivations,
currentActivations: activations.length + (domain && !activations.find((a) => a.domain === domain) ? 1 : 0),
validUntil: license.validUntil,
},
// WordPress plugin updater fields
update_available: false, // would check against latest release
version: version ?? '1.0.0',
package: null, // URL to latest release download (if update available)
})
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}