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:
1 parent
e45b68bb8b
commit
2aa3496a90
4 files changed
+470
No files matched your search
Binary file not shown.
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
// Content-addressed file storage (PRD §5.5: "Storage: SHA-256 addressing,
|
||||
// hash verified on read, content type from bytes")
|
||||
//
|
||||
// Files are stored on a volume, addressed by their SHA-256 hash.
|
||||
// This ensures: deduplication, tamper detection, integrity verification.
|
||||
// Atomic writes prevent partial files.
|
||||
|
||||
import { createHash } from 'crypto'
|
||||
import { promises as fs } from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
const STORAGE_DIR = process.env.STORAGE_DIR ?? path.join(process.cwd(), 'data', 'files')
|
||||
|
||||
// Ensure storage directory exists
|
||||
async function ensureDir() {
|
||||
await fs.mkdir(STORAGE_DIR, { recursive: true })
|
||||
}
|
||||
|
||||
// Compute SHA-256 hash of file content
|
||||
export function computeHash(content: Buffer | string): string {
|
||||
return createHash('sha256').update(content).digest('hex')
|
||||
}
|
||||
|
||||
// Store file content (atomic write via temp file + rename)
|
||||
// Returns the hash (which is also the storage path)
|
||||
export async function storeFile(
|
||||
content: Buffer,
|
||||
contentType: string = 'application/octet-stream'
|
||||
): Promise<{ hash: string; path: string; size: number; contentType: string }> {
|
||||
await ensureDir()
|
||||
const hash = computeHash(content)
|
||||
|
||||
// Sharded directory structure: first 2 chars as subdirectory
|
||||
const subdir = hash.slice(0, 2)
|
||||
const dir = path.join(STORAGE_DIR, subdir)
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
|
||||
const filePath = path.join(dir, hash)
|
||||
|
||||
// Check if file already exists (deduplication)
|
||||
try {
|
||||
await fs.access(filePath)
|
||||
// File exists — skip write (dedup)
|
||||
const stat = await fs.stat(filePath)
|
||||
return { hash, path: filePath, size: stat.size, contentType }
|
||||
} catch {
|
||||
// File doesn't exist — write atomically
|
||||
}
|
||||
|
||||
// Atomic write: temp file → rename
|
||||
const tempPath = filePath + '.tmp'
|
||||
await fs.writeFile(tempPath, content)
|
||||
await fs.rename(tempPath, filePath)
|
||||
|
||||
return { hash, path: filePath, size: content.length, contentType }
|
||||
}
|
||||
|
||||
// Read file content, verifying hash on read (tamper detection)
|
||||
export async function readFile(hash: string): Promise<{ content: Buffer; verified: boolean; size: number }> {
|
||||
const subdir = hash.slice(0, 2)
|
||||
const filePath = path.join(STORAGE_DIR, subdir, hash)
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(filePath)
|
||||
|
||||
// Verify hash on read (PRD: "hash verified on read")
|
||||
const actualHash = computeHash(content)
|
||||
const verified = actualHash === hash
|
||||
|
||||
if (!verified) {
|
||||
console.warn(`File hash mismatch! Expected: ${hash}, got: ${actualHash}. File may be tampered.`)
|
||||
}
|
||||
|
||||
return { content, verified, size: content.length }
|
||||
} catch {
|
||||
throw new Error(`File not found: ${hash}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if a file exists by hash
|
||||
export async function fileExists(hash: string): Promise<boolean> {
|
||||
const subdir = hash.slice(0, 2)
|
||||
const filePath = path.join(STORAGE_DIR, subdir, hash)
|
||||
try {
|
||||
await fs.access(filePath)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Delete file by hash (only if not referenced elsewhere)
|
||||
export async function deleteFile(hash: string): Promise<boolean> {
|
||||
const subdir = hash.slice(0, 2)
|
||||
const filePath = path.join(STORAGE_DIR, subdir, hash)
|
||||
try {
|
||||
await fs.unlink(filePath)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Detect content type from file bytes (magic numbers)
|
||||
export function detectContentType(bytes: Buffer): string {
|
||||
if (bytes.length < 4) return 'application/octet-stream'
|
||||
|
||||
// PDF: %PDF
|
||||
if (bytes[0] === 0x25 && bytes[1] === 0x50 && bytes[2] === 0x44 && bytes[3] === 0x46) {
|
||||
return 'application/pdf'
|
||||
}
|
||||
// PNG: 0x89504E47
|
||||
if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4E && bytes[3] === 0x47) {
|
||||
return 'image/png'
|
||||
}
|
||||
// JPEG: 0xFFD8
|
||||
if (bytes[0] === 0xFF && bytes[1] === 0xD8) {
|
||||
return 'image/jpeg'
|
||||
}
|
||||
// GIF: GIF8
|
||||
if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46) {
|
||||
return 'image/gif'
|
||||
}
|
||||
// WebP: RIFF....WEBP
|
||||
if (bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46) {
|
||||
return 'image/webp'
|
||||
}
|
||||
// ZIP/docx/xlsx: PK
|
||||
if (bytes[0] === 0x50 && bytes[1] === 0x4B) {
|
||||
return 'application/zip'
|
||||
}
|
||||
|
||||
return 'application/octet-stream'
|
||||
}
|
||||
Reference in new issue
Block a user