Files
exodevices/hotel/server/src/index.ts
T
Yuriy PanovandClaude Opus 5 e13854e918 leads: new phone number and an amoCRM lead on every call-button click
The published number becomes +7 927 789-60-71 across all four landings —
`contacts` in each `src/data/content.ts`, plus the fallback messages in the
lead endpoints and `src/lib/lead.ts` that spell it out when amoCRM is down.
The `legacy/` reference pages keep the old number: they are the visual
originals, not something that ships.

Tapping the phone button now also creates a lead. It cannot reuse the form
path: a click carries no name and no number, so `callClickSchema` in
`shared/lead.ts` validates the tracking data alone, `LeadService.submitCallClick`
creates a contactless lead tagged `клик по телефону`, and `AmoClient.createLead`
takes an optional `contactId` for it. The mapper's field/note split moved into
`collect()` so both lead kinds share it.

The browser fires this with `sendBeacon` (falling back to `fetch keepalive`),
because the same click hands the page to `tel:` and a plain fetch would be
cut off mid-flight. Duplicates are held down from both ends: one lead per
browser session on the client, four per IP per 30 minutes on the server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 00:58:15 +06:00

162 lines
6.5 KiB
TypeScript

import path from 'node:path'
import { fileURLToPath } from 'node:url'
import express from 'express'
import { callClickSchema, leadSchema, type CallClickResponse, type LeadResponse } from '../../shared/lead.ts'
import { AmoClient, AmoError } from './amocrm.ts'
import { readAmoConfig, serverConfig } from './config.ts'
import { LeadService } from './lead-service.ts'
import { createRateLimiter } from './rate-limit.ts'
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
const clientDir = path.join(rootDir, 'dist/client')
const amoConfig = readAmoConfig()
const leadService = amoConfig ? new LeadService(new AmoClient(amoConfig)) : null
if (!leadService) {
console.warn(
'[amo] AMO_SUBDOMAIN / AMO_LONG_LIVED_TOKEN are not set — the site runs, but /api/leads/* will return 503.',
)
}
const app = express()
// 'loopback', not true: only the local nginx hop is trusted, so req.ip is the
// address nginx actually observed and the rate limiter below cannot be
// side-stepped with a forged X-Forwarded-For header.
app.set('trust proxy', 'loopback')
app.use(express.json({ limit: '64kb' }))
const limiter = createRateLimiter({ limit: 8, windowMs: 10 * 60 * 1000 })
// Its own window: a call click costs the visitor one tap, so the same budget as
// the form would let a single page hold a tab open and fill the pipeline.
const callLimiter = createRateLimiter({ limit: 4, windowMs: 30 * 60 * 1000 })
app.get('/api/health', (_req, res) => {
res.json({ ok: true, amo: Boolean(leadService), pipelineId: amoConfig?.pipelineId ?? null })
})
app.post('/api/leads/hotels', async (req, res) => {
const send = (status: number, body: LeadResponse) => res.status(status).json(body)
const parsed = leadSchema.safeParse(req.body)
if (!parsed.success) {
const fields: Record<string, string> = {}
for (const issue of parsed.error.issues) {
const key = issue.path.join('.')
if (key && !fields[key]) fields[key] = issue.message
}
return send(400, { ok: false, error: 'Проверьте заполненные поля.', fields })
}
const payload = parsed.data
// Honeypot: answer exactly like a success so bots learn nothing.
if (payload.website) {
console.info('[lead] honeypot triggered from %s', req.ip)
return send(200, { ok: true, leadId: 0, contactId: 0 })
}
const { allowed, retryAfterSeconds } = limiter(req.ip ?? 'unknown')
if (!allowed) {
res.setHeader('Retry-After', String(retryAfterSeconds))
return send(429, { ok: false, error: 'Слишком много заявок с этого адреса. Попробуйте позже.' })
}
if (!leadService) {
// Never drop a real lead silently — it must be findable in the logs.
console.error('[lead] amoCRM is not configured. Lead payload:', JSON.stringify(payload))
return send(503, { ok: false, error: 'Форма временно недоступна. Позвоните нам: +7 927 789-60-71.' })
}
try {
const result = await leadService.submit(payload)
console.info(
'[lead] amoCRM lead %d (contact %d, %s)',
result.leadId,
result.contactId,
result.contactCreated ? 'new contact' : 'existing contact',
)
return send(200, { ok: true, leadId: result.leadId, contactId: result.contactId })
} catch (error) {
console.error('[lead] amoCRM submission failed:', error)
console.error('[lead] payload was:', JSON.stringify(payload))
const message =
error instanceof AmoError && error.status === 401
? 'CRM отклонила запрос. Мы уже разбираемся — позвоните нам: +7 927 789-60-71.'
: 'Не удалось отправить заявку. Позвоните нам: +7 927 789-60-71.'
return send(502, { ok: false, error: message })
}
})
/**
* Call-button clicks. Deliberately separate from the form endpoint: there is
* nothing to validate beyond the tracking data, and the browser fires this with
* `sendBeacon` while it is already navigating to `tel:` — nobody reads the
* response, so the handler must never make the visitor wait.
*/
app.post('/api/leads/hotels/call', async (req, res) => {
const send = (status: number, body: CallClickResponse) => res.status(status).json(body)
const parsed = callClickSchema.safeParse(req.body ?? {})
if (!parsed.success) return send(400, { ok: false, error: 'Некорректные данные.' })
const payload = parsed.data
// Honeypot: answer exactly like a success so bots learn nothing.
if (payload.website) {
console.info('[call] honeypot triggered from %s', req.ip)
return send(200, { ok: true, leadId: 0 })
}
const { allowed, retryAfterSeconds } = callLimiter(req.ip ?? 'unknown')
if (!allowed) {
res.setHeader('Retry-After', String(retryAfterSeconds))
return send(429, { ok: false, error: 'Слишком много обращений с этого адреса.' })
}
if (!leadService) {
// The visitor is dialling regardless — at least leave a trace in the logs.
console.error('[call] amoCRM is not configured. Call click payload:', JSON.stringify(payload))
return send(503, { ok: false, error: 'CRM недоступна.' })
}
try {
const { leadId } = await leadService.submitCallClick(payload)
console.info('[call] amoCRM lead %d (клик по телефону)', leadId)
return send(200, { ok: true, leadId })
} catch (error) {
console.error('[call] amoCRM submission failed:', error)
console.error('[call] payload was:', JSON.stringify(payload))
return send(502, { ok: false, error: 'Не удалось зарегистрировать обращение.' })
}
})
if (serverConfig.isProduction) {
app.use(
express.static(clientDir, {
index: false,
setHeaders(res, filePath) {
// Vite fingerprints everything under /assets, so it can be cached hard.
if (filePath.includes(`${path.sep}assets${path.sep}`)) {
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
}
},
}),
)
// SPA fallback. Written as middleware because Express 5 no longer accepts a
// bare '*' route pattern.
app.use((req, res, next) => {
if (req.method !== 'GET' || req.path.startsWith('/api/')) return next()
res.sendFile(path.join(clientDir, 'index.html'))
})
}
app.listen(serverConfig.port, serverConfig.host, () => {
console.info(
'[server] listening on http://localhost:%d%s',
serverConfig.port,
serverConfig.isProduction ? ` (serving ${path.relative(rootDir, clientDir)})` : ' (API only — run vite for the UI)',
)
})