mein-solar: full feature set
- Schema: companyType, topics, salesScore, salesReason, offerPackage, approved, approvedAt, SearchHistory table - /api/search-history: GET (by mode) + POST (save query) - /api/ai-search: stadtwerke/industrie/custom prompts with history dedup - /api/enrich-leads: website scraping + GPT-4o-mini enrichment (fire-and-forget after each job) - /api/generate-email: personalized outreach via GPT-4o - Suche page: 3 mode tabs (Stadtwerke/Industrie/Freie Suche), Alle-Bundesländer queue button, AiSearchModal gets searchMode + history - Leadspeicher: Bewertung dots column, Paket badge column, Freigeben toggle button, email generator in SidePanel, approved-only export option - Leads API: approvedOnly + companyType filters, new fields in PATCH Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const SYSTEM_PROMPT = `Du bist ein spezialisierter Assistent für B2B-Lead-Generierung im deutschsprachigen Raum.
|
||||
const SYSTEM_PROMPT_DEFAULT = `Du bist ein spezialisierter Assistent für B2B-Lead-Generierung im deutschsprachigen Raum.
|
||||
|
||||
## Was passiert mit deiner Ausgabe
|
||||
|
||||
@@ -36,45 +36,110 @@ Nutze mehrere Queries wenn:
|
||||
|
||||
Maximal 4 Queries. Keine Duplikate (gleiche query + gleiche region).
|
||||
|
||||
## Beispiele
|
||||
|
||||
Eingabe: "Dachdecker in Bayern"
|
||||
Ausgabe:
|
||||
[
|
||||
{ "query": "Dachdecker", "region": "München" },
|
||||
{ "query": "Dachdecker", "region": "Nürnberg" }
|
||||
]
|
||||
|
||||
Eingabe: "Steuerberater in ganz Deutschland"
|
||||
Ausgabe:
|
||||
[
|
||||
{ "query": "Steuerberater", "region": "Bayern" },
|
||||
{ "query": "Steuerberater", "region": "NRW" },
|
||||
{ "query": "Steuerberater", "region": "Hamburg" },
|
||||
{ "query": "Steuerberater", "region": "Berlin" }
|
||||
]
|
||||
|
||||
Eingabe: "Solaranlagen Installateure und Elektriker in Stuttgart"
|
||||
Ausgabe:
|
||||
[
|
||||
{ "query": "Solaranlage", "region": "Stuttgart" },
|
||||
{ "query": "Elektriker", "region": "Stuttgart" }
|
||||
]
|
||||
|
||||
Eingabe: "Metallbaubetriebe in Süddeutschland"
|
||||
Ausgabe:
|
||||
[
|
||||
{ "query": "Metallbau", "region": "Bayern" },
|
||||
{ "query": "Metallbau", "region": "Baden-Württemberg" }
|
||||
]
|
||||
|
||||
## Ausgabeformat
|
||||
|
||||
Antworte ausschließlich mit einem JSON-Array. Kein Markdown, kein erklärender Text, keine Kommentare — nur das reine JSON-Array.`;
|
||||
|
||||
const SYSTEM_PROMPT_STADTWERKE = `Du bist ein spezialisierter Assistent für B2B-Lead-Generierung im deutschen Energiesektor.
|
||||
|
||||
## Kontext
|
||||
Das Tool sucht deutsche Stadtwerke und kommunale Energieversorger als potenzielle Kunden für ein Solarunternehmen (mein-solar.com). Stadtwerke betreiben lokale Energie-, Wasser- und Verkehrsinfrastruktur und sind starke Abnehmer für Photovoltaik-Großanlagen, Batteriespeicher und Ladeinfrastruktur.
|
||||
|
||||
## Was passiert mit deiner Ausgabe
|
||||
Die Queries werden an die Google Maps Places API und Google SERP übergeben. Gesucht werden Stadtwerke-Websites, aus denen dann Entscheidungsträger-E-Mails extrahiert werden.
|
||||
|
||||
## Deine Aufgabe
|
||||
Generiere 2–4 Suchanfragen die Stadtwerke, Gemeindewerke und kommunale Energieversorger in Deutschland finden. Decke verschiedene Bundesländer oder Regionen ab.
|
||||
|
||||
## Suchbegriffe die funktionieren
|
||||
- "Stadtwerke" — findet kommunale Versorger
|
||||
- "Gemeindewerk" — kleinere Gemeindebetriebe
|
||||
- "Stadtwerk" — Variante
|
||||
- "kommunaler Energieversorger" — für SERP
|
||||
- Niemals: LinkedIn, XING, Verzeichnisse, Social Media
|
||||
|
||||
## Priorisierung
|
||||
"Stadtwerke" schlägt immer "Gemeindewerk". Große Bundesländer (NRW, Bayern, BW) zuerst wenn keine Region angegeben.
|
||||
|
||||
## Bereits verwendete Queries (diese NICHT nochmal verwenden — generiere neue Regionen/Varianten):
|
||||
[HISTORY_PLACEHOLDER]
|
||||
|
||||
## Beispiel
|
||||
Eingabe: "Stadtwerke in Norddeutschland"
|
||||
Ausgabe:
|
||||
[
|
||||
{ "query": "Stadtwerke", "region": "Hamburg" },
|
||||
{ "query": "Stadtwerke", "region": "Schleswig-Holstein" },
|
||||
{ "query": "Gemeindewerk", "region": "Niedersachsen" }
|
||||
]
|
||||
|
||||
## Ausgabeformat
|
||||
Nur reines JSON-Array, kein Markdown, keine Erklärungen.`;
|
||||
|
||||
const SYSTEM_PROMPT_INDUSTRIE = `Du bist ein spezialisierter Assistent für B2B-Lead-Generierung im deutschen Industriesektor.
|
||||
|
||||
## Kontext
|
||||
Das Tool sucht deutsche Industriebetriebe als Kunden für ein Solarunternehmen (mein-solar.com), das PV-Großanlagen (5 kW bis 10+ MW), Batteriespeicher und Ladeinfrastruktur anbietet. Zielkunden sind Firmen mit hohem Energieverbrauch, großen Dachflächen oder Freiflächen, und Interesse an Kostensenkung durch Eigenerzeugung.
|
||||
|
||||
## Was passiert mit deiner Ausgabe
|
||||
Die Queries werden an Google Maps und SERP übergeben um Unternehmenswebsites zu finden, aus denen Entscheidungsträger-E-Mails extrahiert werden.
|
||||
|
||||
## Deine Aufgabe
|
||||
Generiere 2–4 Suchanfragen die energieintensive Industriebetriebe, Produktionsunternehmen, Logistiker oder Gewerbeparks finden — ideale Kandidaten für Solar-Großanlagen.
|
||||
|
||||
## Gut geeignete Branchen
|
||||
Produktion/Fertigung, Logistik/Lager, Lebensmittelindustrie, Metallverarbeitung, Automobilzulieferer, Landwirtschaft (Agri-PV), Gewerbeparks, Einzelhandel (große Flächen)
|
||||
|
||||
## Suchbegriffe die funktionieren
|
||||
Kurze branchenbezogene Begriffe die bei Google Maps echte Betriebe finden. Keine Adjektive.
|
||||
|
||||
## Priorisierung
|
||||
Wähle immer den geläufigsten Begriff (z.B. "Logistikzentrum" vor "Lagerhaus"). Branchen mit hohem Energieverbrauch und Dachfläche zuerst.
|
||||
|
||||
## Bereits verwendete Queries (diese NICHT nochmal verwenden):
|
||||
[HISTORY_PLACEHOLDER]
|
||||
|
||||
## Beispiel
|
||||
Eingabe: "Industriebetriebe in Bayern mit großen Dachflächen"
|
||||
Ausgabe:
|
||||
[
|
||||
{ "query": "Produktionsbetrieb", "region": "München" },
|
||||
{ "query": "Logistikzentrum", "region": "Augsburg" },
|
||||
{ "query": "Lebensmittelproduktion", "region": "Bayern" }
|
||||
]
|
||||
|
||||
## Ausgabeformat
|
||||
Nur reines JSON-Array, kein Markdown, keine Erklärungen.`;
|
||||
|
||||
function buildSystemPrompt(
|
||||
searchMode: string,
|
||||
history: Array<{ query: string; region: string }>
|
||||
): string {
|
||||
const historyText =
|
||||
history.length > 0
|
||||
? history
|
||||
.slice(0, 20)
|
||||
.map(h => `- "${h.query}" in "${h.region}"`)
|
||||
.join("\n")
|
||||
: "Keine bisherigen Suchen.";
|
||||
|
||||
if (searchMode === "stadtwerke") {
|
||||
return SYSTEM_PROMPT_STADTWERKE.replace("[HISTORY_PLACEHOLDER]", historyText);
|
||||
}
|
||||
if (searchMode === "industrie") {
|
||||
return SYSTEM_PROMPT_INDUSTRIE.replace("[HISTORY_PLACEHOLDER]", historyText);
|
||||
}
|
||||
return SYSTEM_PROMPT_DEFAULT;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { description } = await req.json() as { description: string };
|
||||
const body = await req.json() as {
|
||||
description: string;
|
||||
searchMode?: string;
|
||||
history?: Array<{ query: string; region: string }>;
|
||||
};
|
||||
const { description, searchMode = "custom", history = [] } = body;
|
||||
|
||||
if (!description?.trim()) {
|
||||
return NextResponse.json({ error: "Beschreibung fehlt" }, { status: 400 });
|
||||
@@ -85,20 +150,22 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: "OpenRouter API Key nicht konfiguriert" }, { status: 500 });
|
||||
}
|
||||
|
||||
const systemPrompt = buildSystemPrompt(searchMode, history);
|
||||
|
||||
const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
"HTTP-Referer": "https://onvyaleads.app",
|
||||
"X-Title": "OnyvaLeads",
|
||||
"HTTP-Referer": "https://mein-solar.com",
|
||||
"X-Title": "MeinSolar Leads",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "openai/gpt-4o-mini",
|
||||
temperature: 0.4,
|
||||
max_tokens: 512,
|
||||
messages: [
|
||||
{ role: "system", content: SYSTEM_PROMPT },
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: description.trim() },
|
||||
],
|
||||
}),
|
||||
@@ -134,6 +201,18 @@ export async function POST(req: NextRequest) {
|
||||
count: 50,
|
||||
}));
|
||||
|
||||
// Save to SearchHistory (fire and forget)
|
||||
if (searchMode && searchMode !== "custom" && queries.length > 0) {
|
||||
const baseUrl = req.url.replace(/\/api\/ai-search.*/, "");
|
||||
for (const q of queries) {
|
||||
fetch(`${baseUrl}/api/search-history`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ query: q.query, region: q.region, searchMode }),
|
||||
}).catch(err => console.error("[ai-search] search-history save error:", err));
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ queries });
|
||||
} catch (err) {
|
||||
console.error("[ai-search] error:", err);
|
||||
|
||||
173
app/api/enrich-leads/route.ts
Normal file
173
app/api/enrich-leads/route.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
const ENRICHMENT_PROMPT = `Du analysierst eine Unternehmenswebsite für ein Solarunternehmen und extrahierst strukturierte Informationen.
|
||||
|
||||
Antworte ausschließlich mit JSON:
|
||||
{
|
||||
"companyType": "stadtwerke" | "industrie" | "sonstiges",
|
||||
"topics": ["photovoltaik"|"speicher"|"emobilitaet"|"waerme"|"netz"|"wasserstoff"],
|
||||
"salesScore": 1-5,
|
||||
"salesReason": "Ein Satz warum dieser Lead gut/schlecht passt",
|
||||
"offerPackage": "solar-basis" | "solar-pro" | "solar-speicher" | "komplett"
|
||||
}
|
||||
|
||||
Bewertungskriterien salesScore:
|
||||
5 = Stadtwerk oder Industriebetrieb mit explizitem Energiethema, perfekter Fit
|
||||
4 = Energierelevante Branche, wahrscheinlich guter Fit
|
||||
3 = Mittelständischer Betrieb, möglicher Fit
|
||||
2 = Kleiner Betrieb oder unklare Relevanz
|
||||
1 = Offensichtlich nicht relevant (Privatpersonen, Behörden ohne Energie-Relevanz)
|
||||
|
||||
Angebotspaket-Logik:
|
||||
- solar-basis: Kleine Betriebe <50 Mitarbeiter, residentiell
|
||||
- solar-pro: Mittelstand, Gewerbe
|
||||
- solar-speicher: Energieintensive Betriebe, Stadtwerke
|
||||
- komplett: Stadtwerke, Großindustrie, wenn topics viele Themen enthält
|
||||
|
||||
Website-Text:
|
||||
[TEXT]`;
|
||||
|
||||
async function fetchWebsiteText(domain: string): Promise<string> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 3000);
|
||||
try {
|
||||
const url = domain.startsWith("http") ? domain : `https://${domain}`;
|
||||
const res = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: { "User-Agent": "Mozilla/5.0 (compatible; LeadBot/1.0)" },
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
const html = await res.text();
|
||||
// Strip tags, extract text
|
||||
const text = html
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, "")
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, "")
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, 2000);
|
||||
return text;
|
||||
} catch {
|
||||
clearTimeout(timeout);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
interface EnrichmentResult {
|
||||
companyType: string;
|
||||
topics: string[];
|
||||
salesScore: number;
|
||||
salesReason: string;
|
||||
offerPackage: string;
|
||||
}
|
||||
|
||||
async function enrichWithAI(text: string, apiKey: string): Promise<EnrichmentResult | null> {
|
||||
if (!text.trim()) return null;
|
||||
|
||||
const prompt = ENRICHMENT_PROMPT.replace("[TEXT]", text);
|
||||
|
||||
const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
"HTTP-Referer": "https://mein-solar.com",
|
||||
"X-Title": "MeinSolar Leads",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "openai/gpt-4o-mini",
|
||||
temperature: 0.2,
|
||||
max_tokens: 256,
|
||||
messages: [
|
||||
{ role: "user", content: prompt },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) return null;
|
||||
|
||||
const data = await res.json() as { choices: Array<{ message: { content: string } }> };
|
||||
const raw = data.choices[0]?.message?.content?.trim() ?? "";
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as EnrichmentResult;
|
||||
return parsed;
|
||||
} catch {
|
||||
const match = raw.match(/\{[\s\S]*\}/);
|
||||
if (!match) return null;
|
||||
try {
|
||||
return JSON.parse(match[0]) as EnrichmentResult;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { jobId } = await req.json() as { jobId: string };
|
||||
|
||||
if (!jobId) {
|
||||
return NextResponse.json({ error: "jobId fehlt" }, { status: 400 });
|
||||
}
|
||||
|
||||
const apiKey = process.env.OPENROUTER_API_KEY;
|
||||
if (!apiKey) {
|
||||
return NextResponse.json({ error: "OpenRouter API Key nicht konfiguriert" }, { status: 500 });
|
||||
}
|
||||
|
||||
// Load all LeadResults for this job
|
||||
const results = await prisma.leadResult.findMany({
|
||||
where: { jobId },
|
||||
});
|
||||
|
||||
// Find corresponding leads with domains
|
||||
const domainsToEnrich = results
|
||||
.filter(r => r.domain)
|
||||
.map(r => r.domain as string);
|
||||
|
||||
if (domainsToEnrich.length === 0) {
|
||||
return NextResponse.json({ enriched: 0, skipped: 0 });
|
||||
}
|
||||
|
||||
// Find leads matching these domains
|
||||
const leads = await prisma.lead.findMany({
|
||||
where: {
|
||||
domain: { in: domainsToEnrich },
|
||||
salesScore: null, // only enrich if not already enriched
|
||||
},
|
||||
});
|
||||
|
||||
let enriched = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const lead of leads) {
|
||||
if (!lead.domain) { skipped++; continue; }
|
||||
|
||||
const text = await fetchWebsiteText(lead.domain);
|
||||
if (!text) { skipped++; continue; }
|
||||
|
||||
const result = await enrichWithAI(text, apiKey);
|
||||
if (!result) { skipped++; continue; }
|
||||
|
||||
await prisma.lead.update({
|
||||
where: { id: lead.id },
|
||||
data: {
|
||||
companyType: result.companyType || null,
|
||||
topics: result.topics ? JSON.stringify(result.topics) : null,
|
||||
salesScore: typeof result.salesScore === "number" ? result.salesScore : null,
|
||||
salesReason: result.salesReason || null,
|
||||
offerPackage: result.offerPackage || null,
|
||||
},
|
||||
});
|
||||
|
||||
enriched++;
|
||||
}
|
||||
|
||||
return NextResponse.json({ enriched, skipped, total: leads.length });
|
||||
} catch (err) {
|
||||
console.error("POST /api/enrich-leads error:", err);
|
||||
return NextResponse.json({ error: "Enrichment failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
108
app/api/generate-email/route.ts
Normal file
108
app/api/generate-email/route.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
const EMAIL_PROMPT_TEMPLATE = `Du schreibst eine professionelle Erstansprache-E-Mail für mein-solar.com an einen potenziellen Kunden.
|
||||
|
||||
mein-solar.com ist ein Full-Service EPC-Anbieter für Photovoltaik (5 kW bis 10+ MW), Batteriespeicher und Ladeinfrastruktur.
|
||||
|
||||
Firmendaten des Empfängers:
|
||||
- Unternehmen: [companyName]
|
||||
- Ansprechpartner: [contactName], [contactTitle]
|
||||
- Unternehmenstyp: [companyType]
|
||||
- Themen des Unternehmens: [topics]
|
||||
- Empfohlenes Paket: [offerPackage]
|
||||
|
||||
Schreibe eine E-Mail auf Deutsch:
|
||||
- Betreff: prägnant, bezieht sich auf konkretes Thema des Unternehmens
|
||||
- Anrede: personalisiert wenn Name bekannt
|
||||
- 2-3 kurze Absätze: Bezug auf das Unternehmen → Mehrwert von mein-solar → konkreter nächster Schritt (Erstgespräch)
|
||||
- Ton: professionell, direkt, kein Marketing-Sprech
|
||||
- Keine Buzzwords wie "innovativ" oder "nachhaltig"
|
||||
- Signatur: [Vorname] von mein-solar.com
|
||||
|
||||
Antworte mit JSON: { "subject": "...", "body": "..." }`;
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { leadId } = await req.json() as { leadId: string };
|
||||
|
||||
if (!leadId) {
|
||||
return NextResponse.json({ error: "leadId fehlt" }, { status: 400 });
|
||||
}
|
||||
|
||||
const apiKey = process.env.OPENROUTER_API_KEY;
|
||||
if (!apiKey) {
|
||||
return NextResponse.json({ error: "OpenRouter API Key nicht konfiguriert" }, { status: 500 });
|
||||
}
|
||||
|
||||
const lead = await prisma.lead.findUnique({ where: { id: leadId } });
|
||||
if (!lead) {
|
||||
return NextResponse.json({ error: "Lead nicht gefunden" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (!lead.email) {
|
||||
return NextResponse.json({ error: "Lead hat keine E-Mail-Adresse" }, { status: 400 });
|
||||
}
|
||||
|
||||
let topicsText = "";
|
||||
if (lead.topics) {
|
||||
try {
|
||||
const topicsArr = JSON.parse(lead.topics) as string[];
|
||||
topicsText = topicsArr.join(", ");
|
||||
} catch {
|
||||
topicsText = lead.topics;
|
||||
}
|
||||
}
|
||||
|
||||
const prompt = EMAIL_PROMPT_TEMPLATE
|
||||
.replace("[companyName]", lead.companyName || "Unbekannt")
|
||||
.replace("[contactName]", lead.contactName || "–")
|
||||
.replace("[contactTitle]", lead.contactTitle || "–")
|
||||
.replace("[companyType]", lead.companyType || "–")
|
||||
.replace("[topics]", topicsText || "–")
|
||||
.replace("[offerPackage]", lead.offerPackage || "–");
|
||||
|
||||
const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
"HTTP-Referer": "https://mein-solar.com",
|
||||
"X-Title": "MeinSolar Leads",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "openai/gpt-4o",
|
||||
temperature: 0.5,
|
||||
max_tokens: 600,
|
||||
messages: [
|
||||
{ role: "user", content: prompt },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
console.error("[generate-email] OpenRouter error:", err);
|
||||
return NextResponse.json({ error: "KI-Anfrage fehlgeschlagen" }, { status: 500 });
|
||||
}
|
||||
|
||||
const data = await res.json() as { choices: Array<{ message: { content: string } }> };
|
||||
const raw = data.choices[0]?.message?.content?.trim() ?? "";
|
||||
|
||||
let email: { subject: string; body: string };
|
||||
try {
|
||||
email = JSON.parse(raw) as { subject: string; body: string };
|
||||
} catch {
|
||||
const match = raw.match(/\{[\s\S]*\}/);
|
||||
if (!match) {
|
||||
return NextResponse.json({ error: "KI-Antwort konnte nicht geparst werden" }, { status: 500 });
|
||||
}
|
||||
email = JSON.parse(match[0]) as { subject: string; body: string };
|
||||
}
|
||||
|
||||
return NextResponse.json({ subject: email.subject, body: email.body });
|
||||
} catch (err) {
|
||||
console.error("POST /api/generate-email error:", err);
|
||||
return NextResponse.json({ error: "E-Mail-Generierung fehlgeschlagen" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id
|
||||
"status", "priority", "notes", "tags", "country", "headcount",
|
||||
"industry", "contactedAt", "companyName", "contactName", "contactTitle",
|
||||
"email", "phone", "linkedinUrl", "domain",
|
||||
"companyType", "topics", "salesScore", "salesReason", "offerPackage",
|
||||
"approved", "approvedAt",
|
||||
];
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
|
||||
@@ -23,11 +23,13 @@ export async function GET(req: NextRequest) {
|
||||
{ email: { contains: search } },
|
||||
];
|
||||
}
|
||||
const approvedOnly = searchParams.get("approvedOnly") === "true";
|
||||
if (statuses.length > 0) where.status = { in: statuses };
|
||||
if (sourceTabs.length > 0) where.sourceTab = { in: sourceTabs };
|
||||
if (priorities.length > 0) where.priority = { in: priorities };
|
||||
if (hasEmail === "yes" || emailOnly) where.email = { not: null };
|
||||
else if (hasEmail === "no") where.email = null;
|
||||
if (approvedOnly) where.approved = true;
|
||||
|
||||
const leads = await prisma.lead.findMany({
|
||||
where,
|
||||
@@ -46,8 +48,14 @@ export async function GET(req: NextRequest) {
|
||||
"LinkedIn": l.linkedinUrl || "",
|
||||
"Branche": l.industry || "",
|
||||
"Suchbegriff": l.sourceTerm || "",
|
||||
"Tags": l.tags ? (JSON.parse(l.tags) as string[]).join(", ") : "",
|
||||
"Erfasst am": new Date(l.capturedAt).toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "numeric" }),
|
||||
"Tags": l.tags ? (JSON.parse(l.tags) as string[]).join(", ") : "",
|
||||
"Unternehmenstyp": l.companyType || "",
|
||||
"Themen": l.topics ? (JSON.parse(l.topics) as string[]).join(", ") : "",
|
||||
"Vertriebsrelevanz": l.salesScore?.toString() || "",
|
||||
"Begründung": l.salesReason || "",
|
||||
"Angebotspaket": l.offerPackage || "",
|
||||
"Freigegeben": l.approved ? "Ja" : "Nein",
|
||||
"Erfasst am": new Date(l.capturedAt).toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "numeric" }),
|
||||
}));
|
||||
|
||||
const filename = `onyva-leads-vault-${new Date().toISOString().split("T")[0]}`;
|
||||
|
||||
@@ -22,6 +22,8 @@ export async function GET(req: NextRequest) {
|
||||
const searchTerms = searchParams.getAll("searchTerm");
|
||||
const contacted = searchParams.get("contacted");
|
||||
const favorite = searchParams.get("favorite");
|
||||
const approvedOnly = searchParams.get("approvedOnly");
|
||||
const companyType = searchParams.get("companyType");
|
||||
|
||||
const where: Prisma.LeadWhereInput = {};
|
||||
|
||||
@@ -61,6 +63,9 @@ export async function GET(req: NextRequest) {
|
||||
where.tags = { contains: "favorit" };
|
||||
}
|
||||
|
||||
if (approvedOnly === "true") where.approved = true;
|
||||
if (companyType) where.companyType = companyType;
|
||||
|
||||
if (tags.length > 0) {
|
||||
// SQLite JSON contains — search for each tag in the JSON string
|
||||
where.AND = tags.map(tag => ({
|
||||
|
||||
47
app/api/search-history/route.ts
Normal file
47
app/api/search-history/route.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const mode = searchParams.get("mode") || "";
|
||||
|
||||
const where = mode ? { searchMode: mode } : {};
|
||||
|
||||
const history = await prisma.searchHistory.findMany({
|
||||
where,
|
||||
orderBy: { executedAt: "desc" },
|
||||
take: 50,
|
||||
select: { query: true, region: true, searchMode: true, executedAt: true },
|
||||
});
|
||||
|
||||
return NextResponse.json(history);
|
||||
} catch (err) {
|
||||
console.error("GET /api/search-history error:", err);
|
||||
return NextResponse.json({ error: "Failed to fetch search history" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json() as { query: string; region: string; searchMode: string };
|
||||
const { query, region, searchMode } = body;
|
||||
|
||||
if (!query?.trim() || !searchMode?.trim()) {
|
||||
return NextResponse.json({ error: "query und searchMode sind erforderlich" }, { status: 400 });
|
||||
}
|
||||
|
||||
const entry = await prisma.searchHistory.create({
|
||||
data: {
|
||||
query: query.trim(),
|
||||
region: (region || "").trim(),
|
||||
searchMode: searchMode.trim(),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(entry);
|
||||
} catch (err) {
|
||||
console.error("POST /api/search-history error:", err);
|
||||
return NextResponse.json({ error: "Failed to save search history" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user