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);
|
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,
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
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 { 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user