Add email intake endpoint for make.com integration
- New POST /intake/leads protected by INTAKE_API_KEY (header X-API-Key or Authorization: Bearer). Returns 401 if missing or wrong. - Accepts minimal payload (email OR contact_phone required); auto-fills contact_name from subject/email and company_name from email domain. - Scoring inputs are optional with safe defaults; score and status computed via existing scoring engine. - Round-robin auto-assignment to managers (listManagers from auth); optional assigned_to override if value matches a known manager. - auth: exposed listManagers() helper. - config: optional INTAKE_API_KEY env (empty disables endpoint). - docker-compose: pass INTAKE_API_KEY to backend service. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
633c255758
commit
68cf004b6b
@ -33,6 +33,14 @@ function parseAuthUsers(raw: string): Map<string, AuthUserEntry> {
|
||||
|
||||
const users = parseAuthUsers(config.authUsers);
|
||||
|
||||
export function listManagers(): string[] {
|
||||
const result: string[] = [];
|
||||
for (const [username, entry] of users) {
|
||||
if (entry.role === 'manager') result.push(username);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function verifyCredentials(
|
||||
username: string,
|
||||
password: string,
|
||||
|
||||
@ -12,6 +12,7 @@ export const config = {
|
||||
databaseUrl: required('DATABASE_URL'),
|
||||
jwtSecret: required('JWT_SECRET'),
|
||||
authUsers: required('AUTH_USERS'),
|
||||
intakeApiKey: process.env.INTAKE_API_KEY ?? '',
|
||||
nodeEnv: process.env.NODE_ENV ?? 'development',
|
||||
jwtTtl: '12h' as const,
|
||||
};
|
||||
|
||||
186
leadscore/backend/src/routes/intake.ts
Normal file
186
leadscore/backend/src/routes/intake.ts
Normal file
@ -0,0 +1,186 @@
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { query } from '../db.js';
|
||||
import { config } from '../config.js';
|
||||
import { AppError } from '../errors.js';
|
||||
import { listManagers } from '../auth.js';
|
||||
import { calculateScore } from '../scoring.js';
|
||||
import type {
|
||||
Budget,
|
||||
CompanySize,
|
||||
DmContact,
|
||||
LeadType,
|
||||
PrevContact,
|
||||
ScoringInput,
|
||||
Source,
|
||||
Urgency,
|
||||
} from '../types.js';
|
||||
|
||||
interface IntakeBody {
|
||||
contact_name?: unknown;
|
||||
contact_phone?: unknown;
|
||||
email?: unknown;
|
||||
position?: unknown;
|
||||
company_name?: unknown;
|
||||
website?: unknown;
|
||||
industry?: unknown;
|
||||
city?: unknown;
|
||||
lead_type?: unknown;
|
||||
company_size?: unknown;
|
||||
budget?: unknown;
|
||||
urgency?: unknown;
|
||||
dm_contact?: unknown;
|
||||
source?: unknown;
|
||||
prev_contact?: unknown;
|
||||
comment?: unknown;
|
||||
subject?: unknown;
|
||||
message?: unknown;
|
||||
assigned_to?: unknown;
|
||||
}
|
||||
|
||||
function str(v: unknown, fallback = ''): string {
|
||||
if (typeof v === 'string') return v.trim();
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function pickEnum<T extends string>(v: unknown, allowed: readonly T[], fallback: T): T {
|
||||
if (typeof v === 'string' && (allowed as readonly string[]).includes(v)) return v as T;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function checkApiKey(req: FastifyRequest): void {
|
||||
if (!config.intakeApiKey) {
|
||||
throw AppError.forbidden('Intake disabled (INTAKE_API_KEY not set)');
|
||||
}
|
||||
const provided =
|
||||
(req.headers['x-api-key'] as string | undefined) ??
|
||||
(typeof req.headers.authorization === 'string' &&
|
||||
req.headers.authorization.startsWith('Bearer ')
|
||||
? req.headers.authorization.slice(7)
|
||||
: undefined);
|
||||
if (!provided || provided !== config.intakeApiKey) {
|
||||
throw AppError.unauthorized('Invalid API key');
|
||||
}
|
||||
}
|
||||
|
||||
async function pickAssignee(explicit: string | null): Promise<string> {
|
||||
const managers = listManagers();
|
||||
if (managers.length === 0) return 'intake';
|
||||
if (explicit && managers.includes(explicit)) return explicit;
|
||||
const { rows } = await query<{ count: string }>(
|
||||
`SELECT COUNT(*)::text AS count FROM leads WHERE created_by = ANY($1)`,
|
||||
[managers],
|
||||
);
|
||||
const idx = Number(rows[0]?.count ?? 0) % managers.length;
|
||||
return managers[idx];
|
||||
}
|
||||
|
||||
function extractCompanyFromEmail(email: string): string {
|
||||
const at = email.indexOf('@');
|
||||
if (at < 0) return '';
|
||||
const domain = email.slice(at + 1).split('.')[0];
|
||||
if (!domain) return '';
|
||||
return domain.charAt(0).toUpperCase() + domain.slice(1);
|
||||
}
|
||||
|
||||
export async function intakeRoutes(app: FastifyInstance) {
|
||||
app.post<{ Body: IntakeBody }>('/intake/leads', async (req) => {
|
||||
checkApiKey(req);
|
||||
const body = req.body ?? {};
|
||||
|
||||
const emailRaw = str(body.email);
|
||||
const companyFromEmail = emailRaw ? extractCompanyFromEmail(emailRaw) : '';
|
||||
|
||||
const contact_name =
|
||||
str(body.contact_name) ||
|
||||
str(body.subject) ||
|
||||
(emailRaw ? emailRaw.split('@')[0] : '') ||
|
||||
'Без имени';
|
||||
const contact_phone = str(body.contact_phone) || '—';
|
||||
const company_name = str(body.company_name) || companyFromEmail || 'Не указана';
|
||||
|
||||
if (!emailRaw && !str(body.contact_phone)) {
|
||||
throw AppError.validation('email or contact_phone is required');
|
||||
}
|
||||
|
||||
const lead_type = pickEnum<LeadType>(body.lead_type, ['inbound', 'outbound'], 'inbound');
|
||||
const company_size = pickEnum<CompanySize>(
|
||||
body.company_size,
|
||||
['1-10', '11-50', '50+'],
|
||||
'1-10',
|
||||
);
|
||||
const budget = pickEnum<Budget>(body.budget, ['no', 'maybe', 'confirmed'], 'maybe');
|
||||
const urgency = pickEnum<Urgency>(body.urgency, ['none', '3-6m', 'under-3m'], 'none');
|
||||
const dm_contact = pickEnum<DmContact>(
|
||||
body.dm_contact,
|
||||
['no', 'influencer', 'decision-maker'],
|
||||
'no',
|
||||
);
|
||||
const source = pickEnum<Source>(body.source, ['cold', 'referral', 'website'], 'website');
|
||||
const prev_contact = pickEnum<PrevContact>(
|
||||
body.prev_contact,
|
||||
['none', 'once', 'well-known'],
|
||||
'none',
|
||||
);
|
||||
|
||||
const scoring: ScoringInput = {
|
||||
lead_type,
|
||||
company_size,
|
||||
budget,
|
||||
urgency,
|
||||
dm_contact,
|
||||
source: lead_type === 'inbound' ? source : undefined,
|
||||
prev_contact: lead_type === 'outbound' ? prev_contact : undefined,
|
||||
};
|
||||
const { total, status } = calculateScore(scoring);
|
||||
|
||||
const assignee = await pickAssignee(str(body.assigned_to) || null);
|
||||
|
||||
const messageText = str(body.message);
|
||||
const subject = str(body.subject);
|
||||
const commentParts = [
|
||||
'Создан через email-intake',
|
||||
subject ? `Тема: ${subject}` : '',
|
||||
messageText ? `\n${messageText}` : '',
|
||||
str(body.comment) ? `\n${str(body.comment)}` : '',
|
||||
].filter(Boolean);
|
||||
const comment = commentParts.join('\n');
|
||||
|
||||
const { rows } = await query<{ id: string; score: number; status: string }>(
|
||||
`INSERT INTO leads
|
||||
(contact_name, contact_phone, email, position, company_name, website, industry, city,
|
||||
lead_type, company_size, budget, urgency, dm_contact, source, prev_contact,
|
||||
score, status, deal_stage, comment, created_by)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,'new',$18,$19)
|
||||
RETURNING id, score, status`,
|
||||
[
|
||||
contact_name,
|
||||
contact_phone,
|
||||
emailRaw,
|
||||
str(body.position),
|
||||
company_name,
|
||||
str(body.website),
|
||||
str(body.industry),
|
||||
str(body.city),
|
||||
lead_type,
|
||||
company_size,
|
||||
budget,
|
||||
urgency,
|
||||
dm_contact,
|
||||
lead_type === 'inbound' ? source : null,
|
||||
lead_type === 'outbound' ? prev_contact : null,
|
||||
total,
|
||||
status,
|
||||
comment,
|
||||
assignee,
|
||||
],
|
||||
);
|
||||
|
||||
const created = rows[0]!;
|
||||
return {
|
||||
id: created.id,
|
||||
score: created.score,
|
||||
status: created.status,
|
||||
assigned_to: assignee,
|
||||
};
|
||||
});
|
||||
}
|
||||
@ -7,6 +7,7 @@ import { leadRoutes } from './routes/leads.js';
|
||||
import { healthRoutes } from './routes/health.js';
|
||||
import { statsRoutes } from './routes/stats.js';
|
||||
import { reminderRoutes } from './routes/reminders.js';
|
||||
import { intakeRoutes } from './routes/intake.js';
|
||||
|
||||
async function buildApp() {
|
||||
const app = Fastify({
|
||||
@ -40,6 +41,7 @@ async function buildApp() {
|
||||
await app.register(leadRoutes);
|
||||
await app.register(statsRoutes);
|
||||
await app.register(reminderRoutes);
|
||||
await app.register(intakeRoutes);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@ -19,6 +19,7 @@ services:
|
||||
DATABASE_URL: postgres://leadscore:${DB_PASSWORD}@postgres:5432/leadscore
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
AUTH_USERS: ${AUTH_USERS}
|
||||
INTAKE_API_KEY: ${INTAKE_API_KEY}
|
||||
NODE_ENV: production
|
||||
PORT: "8080"
|
||||
depends_on:
|
||||
|
||||
Reference in New Issue
Block a user