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:
ask 2026-05-19 16:09:32 +05:00
parent 633c255758
commit 68cf004b6b
5 changed files with 198 additions and 0 deletions

View File

@ -33,6 +33,14 @@ function parseAuthUsers(raw: string): Map<string, AuthUserEntry> {
const users = parseAuthUsers(config.authUsers); 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( export async function verifyCredentials(
username: string, username: string,
password: string, password: string,

View File

@ -12,6 +12,7 @@ export const config = {
databaseUrl: required('DATABASE_URL'), databaseUrl: required('DATABASE_URL'),
jwtSecret: required('JWT_SECRET'), jwtSecret: required('JWT_SECRET'),
authUsers: required('AUTH_USERS'), authUsers: required('AUTH_USERS'),
intakeApiKey: process.env.INTAKE_API_KEY ?? '',
nodeEnv: process.env.NODE_ENV ?? 'development', nodeEnv: process.env.NODE_ENV ?? 'development',
jwtTtl: '12h' as const, jwtTtl: '12h' as const,
}; };

View 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,
};
});
}

View File

@ -7,6 +7,7 @@ import { leadRoutes } from './routes/leads.js';
import { healthRoutes } from './routes/health.js'; import { healthRoutes } from './routes/health.js';
import { statsRoutes } from './routes/stats.js'; import { statsRoutes } from './routes/stats.js';
import { reminderRoutes } from './routes/reminders.js'; import { reminderRoutes } from './routes/reminders.js';
import { intakeRoutes } from './routes/intake.js';
async function buildApp() { async function buildApp() {
const app = Fastify({ const app = Fastify({
@ -40,6 +41,7 @@ async function buildApp() {
await app.register(leadRoutes); await app.register(leadRoutes);
await app.register(statsRoutes); await app.register(statsRoutes);
await app.register(reminderRoutes); await app.register(reminderRoutes);
await app.register(intakeRoutes);
return app; return app;
} }

View File

@ -19,6 +19,7 @@ services:
DATABASE_URL: postgres://leadscore:${DB_PASSWORD}@postgres:5432/leadscore DATABASE_URL: postgres://leadscore:${DB_PASSWORD}@postgres:5432/leadscore
JWT_SECRET: ${JWT_SECRET} JWT_SECRET: ${JWT_SECRET}
AUTH_USERS: ${AUTH_USERS} AUTH_USERS: ${AUTH_USERS}
INTAKE_API_KEY: ${INTAKE_API_KEY}
NODE_ENV: production NODE_ENV: production
PORT: "8080" PORT: "8080"
depends_on: depends_on: