From b7ced847f718491d72c56944761853799dfb35e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=ED=83=9C=EA=B7=A0?= Date: Wed, 16 Sep 2026 13:33:18 +0900 Subject: [PATCH 1/4] feat: append marketing inquiries to CRM with phone matching --- .env.example | 2 + .github/workflows/deploy-ec2-ssm.yml | 1 + .../workflows/verify-direct-integration.yml | 2 + app/api/external/marketing-inquiries/route.ts | 6 + .../WARP_MARKETING_INQUIRIES.json | 85 ++++++ docs/integrations/WARP_MARKETING_INQUIRIES.md | 32 ++ lib/marketing-inquiries.test.ts | 280 ++++++++++++++++++ lib/marketing-inquiries.ts | 217 ++++++++++++++ package.json | 1 + .../verify-direct-integration-contract.mjs | 17 +- 10 files changed, 639 insertions(+), 4 deletions(-) create mode 100644 app/api/external/marketing-inquiries/route.ts create mode 100644 docs/integrations/WARP_MARKETING_INQUIRIES.json create mode 100644 docs/integrations/WARP_MARKETING_INQUIRIES.md create mode 100644 lib/marketing-inquiries.test.ts create mode 100644 lib/marketing-inquiries.ts diff --git a/.env.example b/.env.example index 5fa718a..cd3df72 100644 --- a/.env.example +++ b/.env.example @@ -11,3 +11,5 @@ GOOGLE_API_KEY="" ANTHROPIC_API_KEY="" BUILDUP_API_BASE_URL="" WARP_LOOKUP_API_KEY="" +# Dedicated marketing inquiry append key; leave empty until the integration is reviewed. +WARP_MARKETING_API_KEY="" diff --git a/.github/workflows/deploy-ec2-ssm.yml b/.github/workflows/deploy-ec2-ssm.yml index 1a83392..24fc7c2 100644 --- a/.github/workflows/deploy-ec2-ssm.yml +++ b/.github/workflows/deploy-ec2-ssm.yml @@ -95,6 +95,7 @@ jobs: npm run test:account-control npm run test:external-lookup npm run test:buildup-import + npm run test:marketing-inquiries npm run test:integration-contract npm run typecheck npm run security:audit diff --git a/.github/workflows/verify-direct-integration.yml b/.github/workflows/verify-direct-integration.yml index 67d477c..9735dd5 100644 --- a/.github/workflows/verify-direct-integration.yml +++ b/.github/workflows/verify-direct-integration.yml @@ -7,6 +7,7 @@ on: paths: - app/api/external/** - lib/buildup-import/** + - lib/marketing-inquiries*.ts - docs/integrations/** - scripts/verify-direct-integration-contract.mjs - package.json @@ -32,4 +33,5 @@ jobs: npm ci npm run test:external-lookup npm run test:buildup-import + npm run test:marketing-inquiries npm run test:integration-contract diff --git a/app/api/external/marketing-inquiries/route.ts b/app/api/external/marketing-inquiries/route.ts new file mode 100644 index 0000000..4a3c047 --- /dev/null +++ b/app/api/external/marketing-inquiries/route.ts @@ -0,0 +1,6 @@ +import { prisma } from '@/lib/db' +import { handleMarketingInquiryRequest } from '@/lib/marketing-inquiries' + +export async function POST(request: Request) { + return handleMarketingInquiryRequest(request, prisma) +} diff --git a/docs/integrations/WARP_MARKETING_INQUIRIES.json b/docs/integrations/WARP_MARKETING_INQUIRIES.json new file mode 100644 index 0000000..9ec7621 --- /dev/null +++ b/docs/integrations/WARP_MARKETING_INQUIRIES.json @@ -0,0 +1,85 @@ +{ + "schema_version": 1, + "contract_id": "warp-marketing-inquiries", + "status": "draft-pending-owner-review", + "owner": "OziinG", + "review": { + "issue": "https://github.com/EVNSolution/EVN-WARP/issues/48", + "pull_request": null, + "required_before_production": true + }, + "authentication": { + "header": "x-api-key", + "environment": "WARP_MARKETING_API_KEY", + "scope": "marketing.inquiry.append", + "existing_buildup_key_accepted": false + }, + "repositories": { + "EVN-WARP": { + "route_style": "next", + "route_root": "app/api/external", + "source_roots": [ + "app", + "lib" + ] + }, + "evn-marketing": { + "route_style": "caller-only", + "source_roots": [ + "lib", + "workers" + ] + } + }, + "endpoints": [ + { + "id": "warp.marketing-inquiry.append", + "method": "POST", + "path": "/api/external/marketing-inquiries", + "provider": "EVN-WARP", + "caller": "evn-marketing", + "capability": "marketing.inquiry.append", + "sources": { + "EVN-WARP": { + "file": "app/api/external/marketing-inquiries/route.ts", + "token": "export async function POST" + }, + "evn-marketing": { + "file": "lib/warp-delivery.ts", + "token": "/api/external/marketing-inquiries" + } + } + } + ], + "source_scope": { + "source": "mleverage-admin", + "companyScopeId": "55f9a8bb-73f9-4316-bcd7-7dcdce0bdcc3", + "homepageScopeId": "5c7a6115-a0a9-4e8d-bf65-efce52195fa4" + }, + "request_fields": [ + "source", + "companyScopeId", + "homepageScopeId", + "sourceId", + "inquiryDate", + "inquiryTime", + "sourceStatus", + "name", + "phone" + ], + "response_fields": [ + "ok", + "customerId", + "activityId", + "created", + "duplicate" + ], + "rules": [ + "One source inquiry creates exactly one customer activity, including on retry.", + "An exact unique normalized primary phone match receives a new activity; do not overwrite its customer profile.", + "Multiple primary phone matches return ambiguous_phone and create no record.", + "Unknown names remain blank; source status remains in activity content.", + "Read and append only within the fixed source scope. No arbitrary customer updates or deletion.", + "Interpret original date and minute as Asia/Seoul for CRM date fields and preserve the original strings in activity content." + ] +} diff --git a/docs/integrations/WARP_MARKETING_INQUIRIES.md b/docs/integrations/WARP_MARKETING_INQUIRIES.md new file mode 100644 index 0000000..a0ea1ea --- /dev/null +++ b/docs/integrations/WARP_MARKETING_INQUIRIES.md @@ -0,0 +1,32 @@ +# 마케팅 문의 수신 연동 검토 + +2026-09-16 · 운영 담당자: OziinG · 상태: 로컬 검증 완료, Owner 검토·배포 전 + +기계 판독 계약은 [WARP_MARKETING_INQUIRIES.json](WARP_MARKETING_INQUIRIES.json)이다. 동일 계약이 마케팅 저장소에도 있다. 이 변경은 기존 WARP–BUILDUP 계약과 공유키 권한을 수정하지 않는다. [검토 Issue #48](https://github.com/EVNSolution/EVN-WARP/issues/48)에 연결하며 Owner 승인은 아직 없다. + +## 변경 결과 + +`POST /api/external/marketing-inquiries`는 전용 `WARP_MARKETING_API_KEY`로 고정된 mleverage-admin 회사·홈페이지 문의만 받는다. 키는 32자 이상·공백 없음이어야 하며, 비어 있으면 503으로 비활성화한다. 요청은 16KB까지만 읽는다. + +기본 연락처를 숫자로 정규화한 완전일치 고객이 한 명이면 문의 이력만 추가한다. 기존 이름·상태·담당자·메모는 바꾸지 않는다. 일치 고객이 없으면 성함·연락처를 입력한 B2C 잠재고객을 만들며 빈 이름을 허용한다. 이름이 같고 연락처가 다르면 별도 고객이다. 같은 연락처의 고객이 여러 명이면 409로 보류한다. + +활동 ID는 `mleverage_` 접두사와 출처·회사·홈페이지·원본 UUID의 SHA-256이다. 고객 생성과 활동 추가를 같은 트랜잭션에서 처리하고, 재전송은 기존 receipt를 돌려준다. 새로운 DB 테이블·열은 없고 기존 Customer/CustomerActivity를 사용한다. 동일 문의의 병렬 전송과 같은 연락처의 별도 문의 병렬 전송을 합성 SQLite로 검증했다. + +활동 내용에는 원본 문의일자·문의시간·상담상태·성함·연락처와 문의 ID를 읽을 수 있는 문장으로 저장한다. 원본 날짜·시간은 분 단위 문자열로 보존하고 CRM 날짜는 한국 시간으로 해석한다. 원본 화면에 시간대 표기가 없으므로 한국 시간 해석은 확인이 필요한 가정이다. + +## 로컬 검증 + +- `npm run test:marketing-inquiries`: 합성 시나리오 9개 통과. +- `npm run test:integration-contract`: 기존 6개와 신규 1개, 총 7개 계약 통과. +- 타입 검사, 수정 파일 ESLint, 로컬 프로덕션 빌드 통과. 빌드의 DB 주소는 합성 로컬 경로를 사용했다. +- 마케팅 sender와 실제 receiver 함수를 연결한 합성 DB 검사 통과: 이름 공란, 같은 연락처로 문의 이력 추가, 응답 재처리 중복 방지. +- PR 검증과 운영 배포의 소스 검사에 문의 수신 테스트를 추가했다. 배포 워크플로 계약 검사 8개와 기존 정책에 따른 보안 검사를 통과했다. +- 운영 DB·실제 문의 등록·키 설정·배포는 수행하지 않았다. + +## 운영 인계 + +AGENTS.md의 Owner-reviewed Issue/PR 요구에 따라 검토 후 정규 release 절차로 main을 반영한다. 전용 키는 운영 담당자의 승인된 비밀 관리 경로에서 설정하고 앱은 SSM `/evn-warp/app-env`를 읽기만 한다. 기존 BUILDUP 키를 재사용하거나 배포 중 SSM 값 쓰기·로컬 .env 대체·운영 DB 직접 수입으로 우회하지 않는다. + +마케팅은 이미 보관한 문의도 미전달 대상으로 보낸다. 키 설정 전에는 전송하지 않는다. 운영 반영 후 같은 키를 마케팅 프로세스에 안전하게 주입하고, 초기 전달 결과를 확인한 뒤 전체 미전달 문의를 처리한다. 완료·대기·오류 건수와 실제 CRM 이력을 확인해야 운영 연동 완료로 볼 수 있다. 로그·문서에는 고객 자료나 키를 남기지 않는다. + +기본 연락처 스캔은 초기 최소 구현이다. CRM 규모에서 지연이 확인되면 정규화 열과 인덱스를 검토한다. 사용자가 WARP 고객이나 활동을 삭제하면 중복 방지 기록도 함께 없어질 수 있으므로 임의 재전송·DB 복구는 별도 결정이 필요하다. diff --git a/lib/marketing-inquiries.test.ts b/lib/marketing-inquiries.test.ts new file mode 100644 index 0000000..1c72569 --- /dev/null +++ b/lib/marketing-inquiries.test.ts @@ -0,0 +1,280 @@ +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import test from 'node:test' + +import { createPrisma } from '@/lib/db' + +import { handleMarketingInquiryRequest, type MarketingInquiry } from './marketing-inquiries' + +const temporaryDirectory = mkdtempSync(path.join(tmpdir(), 'warp-marketing-inquiries-')) +const prisma = createPrisma(`file:${path.join(temporaryDirectory, 'test.db')}`) +const KEY = 'marketing-test-key-that-is-at-least-32-chars' +const ENV = { WARP_MARKETING_API_KEY: KEY } + +function payload(sourceId: string, overrides: Partial = {}): MarketingInquiry { + return { + source: 'mleverage-admin', + companyScopeId: '55f9a8bb-73f9-4316-bcd7-7dcdce0bdcc3', + homepageScopeId: '5c7a6115-a0a9-4e8d-bf65-efce52195fa4', + sourceId, + inquiryDate: '2026-09-15', + inquiryTime: '09:07', + sourceStatus: '상담대기', + name: '홍길동', + phone: '010-1234-5678', + ...overrides, + } +} + +function request(body: unknown, key = KEY, raw = false) { + return new Request('http://localhost/api/external/marketing-inquiries', { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-api-key': key }, + body: raw ? String(body) : JSON.stringify(body), + }) +} + +async function send(body: unknown, key = KEY, raw = false) { + const response = await handleMarketingInquiryRequest(request(body, key, raw), prisma, ENV) + return { response, body: await response.json() } +} + +test.before(async () => { + await prisma.$executeRawUnsafe(` + CREATE TABLE Customer ( + id TEXT PRIMARY KEY NOT NULL, + name TEXT NOT NULL, + phone TEXT, + companyPhone TEXT, + contactsJson TEXT, + customerSegment TEXT, + status TEXT NOT NULL DEFAULT '잠재고객', + source TEXT, + collectedAt DATETIME, + assignee TEXT, + isAgent INTEGER NOT NULL DEFAULT 0, + memo TEXT, + createdAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updatedAt DATETIME NOT NULL + ) + `) + await prisma.$executeRawUnsafe(` + CREATE TABLE CustomerActivity ( + id TEXT PRIMARY KEY NOT NULL, + customerId TEXT NOT NULL, + type TEXT NOT NULL, + date DATETIME NOT NULL, + content TEXT, + createdAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (customerId) REFERENCES Customer(id) ON DELETE CASCADE + ) + `) +}) + +test.beforeEach(async () => { + await prisma.customerActivity.deleteMany() + await prisma.customer.deleteMany() +}) + +test.after(async () => { + await prisma.$disconnect() + rmSync(temporaryDirectory, { recursive: true, force: true }) +}) + +test('creates named and nameless customers with exact source data', async () => { + const named = payload('00000000-0000-4000-8000-000000000001') + const first = await send(named) + const second = await send(payload('00000000-0000-4000-8000-000000000002', { + name: '', + phone: '010-9999-8888', + inquiryDate: '2026-02-28', + inquiryTime: '00:05', + })) + + assert.equal(first.response.status, 200) + assert.deepEqual(first.body, { + ok: true, + customerId: first.body.customerId, + activityId: `mleverage_${createHash('sha256').update([ + named.source, named.companyScopeId, named.homepageScopeId, named.sourceId, + ].join(':')).digest('hex')}`, + created: true, + duplicate: false, + }) + assert.equal(second.response.status, 200) + const customers = await prisma.customer.findMany({ + orderBy: { phone: 'asc' }, + select: { name: true, phone: true, customerSegment: true, status: true, source: true, collectedAt: true }, + }) + assert.deepEqual(customers, [ + { name: '홍길동', phone: '010-1234-5678', customerSegment: 'B2C', status: '잠재고객', source: 'mleverage-admin', collectedAt: new Date('2026-09-15T00:07:00.000Z') }, + { name: '', phone: '010-9999-8888', customerSegment: 'B2C', status: '잠재고객', source: 'mleverage-admin', collectedAt: new Date('2026-02-27T15:05:00.000Z') }, + ]) + const activity = await prisma.customerActivity.findUnique({ + where: { id: first.body.activityId }, + select: { type: true, date: true, content: true }, + }) + assert.deepEqual(activity, { + type: '이벤트', + date: new Date('2026-09-15T00:07:00.000Z'), + content: [ + 'mleverage-admin 문의', + '문의일자: 2026-09-15', + '문의시간: 09:07', + '상담상태: 상담대기', + '성함: 홍길동', + '연락처: 010-1234-5678', + '원본 문의 ID: 00000000-0000-4000-8000-000000000001', + ].join('\n'), + }) +}) + +test('attaches an exact normalized primary-phone match without changing its profile', async () => { + await prisma.customer.create({ + data: { + id: 'non-primary-match', name: '다른 고객', phone: '010-0000-0000', + companyPhone: '010-1234-5678', contactsJson: '[{"phone":"010-1234-5678"}]', + }, + select: { id: true }, + }) + await prisma.customer.create({ + data: { + id: 'existing-customer', name: '기존 이름', phone: '010 1234 5678', customerSegment: 'B2B', + status: '활성', source: '소개', assignee: '담당자', memo: '기존 메모', + }, + select: { id: true }, + }) + const result = await send(payload('00000000-0000-4000-8000-000000000003', { name: '새 이름' })) + + assert.equal(result.body.customerId, 'existing-customer') + assert.equal(await prisma.customer.count(), 2) + assert.deepEqual(await prisma.customer.findUnique({ + where: { id: 'existing-customer' }, + select: { name: true, phone: true, customerSegment: true, status: true, source: true, assignee: true, memo: true }, + }), { + name: '기존 이름', phone: '010 1234 5678', customerSegment: 'B2B', status: '활성', + source: '소개', assignee: '담당자', memo: '기존 메모', + }) +}) + +test('creates distinct customers for the same name with different primary phones', async () => { + const first = await send(payload('00000000-0000-4000-8000-000000000010')) + const second = await send(payload('00000000-0000-4000-8000-000000000011', { phone: '010-9999-8888' })) + + assert.notEqual(first.body.customerId, second.body.customerId) + assert.equal(await prisma.customer.count(), 2) + assert.equal(await prisma.customerActivity.count(), 2) +}) + +test('rejects multiple primary-phone matches with no writes', async () => { + await prisma.customer.createMany({ data: [ + { id: 'duplicate-1', name: 'A', phone: '010-1234-5678' }, + { id: 'duplicate-2', name: 'B', phone: '01012345678' }, + ] }) + const result = await send(payload('00000000-0000-4000-8000-000000000004')) + + assert.equal(result.response.status, 409) + assert.deepEqual(result.body, { error: 'ambiguous_phone' }) + assert.equal(await prisma.customer.count(), 2) + assert.equal(await prisma.customerActivity.count(), 0) +}) + +test('replays one source inquiry without adding another customer or activity', async () => { + const inquiry = payload('00000000-0000-4000-8000-000000000005') + const first = await send(inquiry) + const replay = await send(inquiry) + + assert.deepEqual(replay.body, { ...first.body, created: false, duplicate: true }) + assert.equal(await prisma.customer.count(), 1) + assert.equal(await prisma.customerActivity.count(), 1) +}) + +test('serializes concurrent retries of one source inquiry', async () => { + const inquiry = payload('00000000-0000-4000-8000-000000000006') + const results = await Promise.all(Array.from({ length: 6 }, () => send(inquiry))) + + assert.equal(results.filter(result => result.body.created).length, 1) + assert.equal(results.filter(result => result.body.duplicate).length, 5) + assert.equal(await prisma.customer.count(), 1) + assert.equal(await prisma.customerActivity.count(), 1) +}) + +test('keeps distinct same-phone inquiries as two activities on one customer', async () => { + const [first, second] = await Promise.all([ + send(payload('00000000-0000-4000-8000-000000000007')), + send(payload('00000000-0000-4000-8000-000000000008', { sourceStatus: '상담완료' })), + ]) + + assert.equal(first.body.customerId, second.body.customerId) + assert.notEqual(first.body.activityId, second.body.activityId) + assert.equal(await prisma.customer.count(), 1) + assert.equal(await prisma.customerActivity.count(), 2) +}) + +test('cancels a streamed body without content-length as soon as it exceeds 16KB', async () => { + let pulls = 0 + let cancelled = false + const chunks = [new Uint8Array(10 * 1024), new Uint8Array(7 * 1024), new Uint8Array(1)] + const stream = new ReadableStream({ + pull(controller) { + const chunk = chunks[pulls] + pulls += 1 + if (chunk) controller.enqueue(chunk) + else controller.close() + }, + cancel() { + cancelled = true + }, + }, { highWaterMark: 0 }) + const streamedRequest = new Request('http://localhost/api/external/marketing-inquiries', { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-api-key': KEY }, + body: stream, + duplex: 'half', + } as RequestInit & { duplex: 'half' }) + + assert.equal(streamedRequest.headers.has('content-length'), false) + const response = await handleMarketingInquiryRequest(streamedRequest, prisma, ENV) + assert.equal(response.status, 400) + assert.deepEqual(await response.json(), { error: 'bad_payload' }) + assert.equal(cancelled, true) + assert.equal(pulls, 2) + assert.equal(await prisma.customer.count(), 0) + assert.equal(await prisma.customerActivity.count(), 0) +}) + +test('rejects missing or weak configuration, bad authentication, and invalid input before writes', async () => { + const valid = payload('00000000-0000-4000-8000-000000000009') + const missing = await handleMarketingInquiryRequest(request(valid), prisma, {}) + const weak = await handleMarketingInquiryRequest(request(valid), prisma, { WARP_MARKETING_API_KEY: 'short' }) + const spaced = await handleMarketingInquiryRequest(request(valid), prisma, { WARP_MARKETING_API_KEY: ` ${KEY}` }) + const unauthorized = await send(valid, 'wrong-key') + assert.deepEqual([missing.status, weak.status, spaced.status, unauthorized.response.status], [503, 503, 503, 401]) + assert.deepEqual(await missing.json(), { error: 'not_configured' }) + assert.deepEqual(await weak.json(), { error: 'not_configured' }) + assert.deepEqual(await spaced.json(), { error: 'not_configured' }) + assert.deepEqual(unauthorized.body, { error: 'unauthorized' }) + + const invalid: Array<[unknown, boolean?]> = [ + ['{', true], + [{ ...valid, extra: 'field' }], + [{ ...valid, source: 'other' }], + [{ ...valid, sourceId: 'not-a-uuid' }], + [{ ...valid, inquiryDate: '2026-02-30' }], + [{ ...valid, inquiryTime: '24:00' }], + [{ ...valid, name: 'x'.repeat(101) }], + [{ ...valid, phone: '123' }], + [{ ...valid, sourceStatus: 1 }], + ['x'.repeat(16 * 1024 + 1), true], + ] + for (const [body, raw] of invalid) { + const result = await send(body, KEY, raw) + assert.equal(result.response.status, 400) + assert.deepEqual(result.body, { error: 'bad_payload' }) + } + assert.equal(await prisma.customer.count(), 0) + assert.equal(await prisma.customerActivity.count(), 0) +}) diff --git a/lib/marketing-inquiries.ts b/lib/marketing-inquiries.ts new file mode 100644 index 0000000..24c839b --- /dev/null +++ b/lib/marketing-inquiries.ts @@ -0,0 +1,217 @@ +import { createHash } from 'node:crypto' + +import type { PrismaClient } from '@/app/generated/prisma/client' +import { safeKeyEqual } from '@/lib/external-lookup/config' +import { digitsOnly } from '@/lib/external-lookup/match' + +const MAX_BODY_BYTES = 16 * 1024 +const MIN_KEY_LENGTH = 32 +const SOURCE = 'mleverage-admin' +const COMPANY_SCOPE_ID = '55f9a8bb-73f9-4316-bcd7-7dcdce0bdcc3' +const HOMEPAGE_SCOPE_ID = '5c7a6115-a0a9-4e8d-bf65-efce52195fa4' +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i +const DATE = /^(\d{4})-(\d{2})-(\d{2})$/ +const TIME = /^(\d{2}):(\d{2})$/ +const NO_STORE = { 'Cache-Control': 'no-store' } + +export type MarketingInquiry = { + source: string + companyScopeId: string + homepageScopeId: string + sourceId: string + inquiryDate: string + inquiryTime: string + sourceStatus: string + name: string + phone: string +} + +type Receipt = { + ok: true + customerId: string + activityId: string + created: boolean + duplicate: boolean +} + +class AmbiguousPhoneError extends Error {} + +function error(code: 'unauthorized' | 'not_configured' | 'bad_payload' | 'ambiguous_phone' | 'temporarily_unavailable', status: number) { + return Response.json({ error: code }, { status, headers: NO_STORE }) +} + +function readApiKey(env: Readonly>) { + const key = env.WARP_MARKETING_API_KEY ?? '' + return key.length >= MIN_KEY_LENGTH && !/\s/u.test(key) ? key : null +} + +function parseDateTime(date: string, time: string): Date | null { + const dateMatch = DATE.exec(date) + const timeMatch = TIME.exec(time) + if (!dateMatch || !timeMatch) return null + const [, year, month, day] = dateMatch.map(Number) + const [, hour, minute] = timeMatch.map(Number) + if (hour > 23 || minute > 59) return null + const utc = new Date(Date.UTC(year, month - 1, day, hour - 9, minute, 0)) + const seoul = new Date(utc.getTime() + 9 * 60 * 60 * 1000) + return seoul.getUTCFullYear() === year && seoul.getUTCMonth() === month - 1 && seoul.getUTCDate() === day + ? utc + : null +} + +function parsePayload(value: unknown): { payload: MarketingInquiry; occurredAt: Date } | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null + const record = value as Record + const keys = Object.keys(record) + const expected = ['source', 'companyScopeId', 'homepageScopeId', 'sourceId', 'inquiryDate', 'inquiryTime', 'sourceStatus', 'name', 'phone'] + if (keys.length !== expected.length || expected.some(key => typeof record[key] !== 'string')) return null + + const payload = record as MarketingInquiry + const occurredAt = parseDateTime(payload.inquiryDate, payload.inquiryTime) + const phoneDigits = digitsOnly(payload.phone) + if ( + payload.source !== SOURCE || + payload.companyScopeId !== COMPANY_SCOPE_ID || + payload.homepageScopeId !== HOMEPAGE_SCOPE_ID || + !UUID.test(payload.sourceId) || + !occurredAt || + payload.sourceStatus.length > 100 || + payload.name.length > 100 || + payload.phone.length > 40 || + phoneDigits.length < 9 || + phoneDigits.length > 15 + ) return null + return { payload, occurredAt } +} + +export function marketingInquiryActivityId(payload: MarketingInquiry) { + return `mleverage_${createHash('sha256') + .update([payload.source, payload.companyScopeId, payload.homepageScopeId, payload.sourceId].join(':')) + .digest('hex')}` +} + +function activityContent(payload: MarketingInquiry) { + return [ + 'mleverage-admin 문의', + `문의일자: ${payload.inquiryDate}`, + `문의시간: ${payload.inquiryTime}`, + `상담상태: ${payload.sourceStatus}`, + `성함: ${payload.name}`, + `연락처: ${payload.phone}`, + `원본 문의 ID: ${payload.sourceId}`, + ].join('\n') +} + +function retryable(error: unknown) { + const code = typeof error === 'object' && error !== null && 'code' in error ? String(error.code) : '' + const message = error instanceof Error ? error.message : '' + return code === 'P2002' || code === 'P2034' || code === 'P1008' || /SQLITE_BUSY|database is locked/i.test(message) +} + +async function readBoundedBody(request: Request): Promise { + const reader = request.body?.getReader() + if (!reader) return new Uint8Array() + const body = new Uint8Array(MAX_BODY_BYTES) + let size = 0 + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + if (size + value.byteLength > MAX_BODY_BYTES) { + await reader.cancel() + return null + } + body.set(value, size) + size += value.byteLength + } + } catch { + await reader.cancel().catch(() => {}) + return null + } + return body.subarray(0, size) +} + +async function appendInquiry(prisma: PrismaClient, payload: MarketingInquiry, occurredAt: Date): Promise { + const activityId = marketingInquiryActivityId(payload) + for (let attempt = 0; attempt < 4; attempt += 1) { + try { + return await prisma.$transaction(async transaction => { + const duplicate = await transaction.customerActivity.findUnique({ + where: { id: activityId }, + select: { id: true, customerId: true }, + }) + if (duplicate) { + return { ok: true, customerId: duplicate.customerId, activityId: duplicate.id, created: false, duplicate: true } + } + + const phoneDigits = digitsOnly(payload.phone) + // ponytail: scans primary phones because SQLite cannot apply the shared JS normalizer; add a normalized column if volume makes this slow. + const candidates = await transaction.customer.findMany({ + where: { phone: { not: null } }, + select: { id: true, phone: true }, + }) + const matches = candidates.filter(customer => digitsOnly(customer.phone) === phoneDigits) + if (matches.length > 1) throw new AmbiguousPhoneError() + + const customerId = matches[0]?.id ?? (await transaction.customer.create({ + data: { + name: payload.name.trim(), + phone: payload.phone, + customerSegment: 'B2C', + status: '잠재고객', + source: SOURCE, + collectedAt: occurredAt, + }, + select: { id: true }, + })).id + + await transaction.customerActivity.create({ + data: { + id: activityId, + customerId, + type: '이벤트', + date: occurredAt, + content: activityContent(payload), + }, + select: { id: true }, + }) + return { ok: true, customerId, activityId, created: true, duplicate: false } + }) + } catch (error) { + if (error instanceof AmbiguousPhoneError) throw error + if (!retryable(error) || attempt === 3) throw error + await new Promise(resolve => setTimeout(resolve, 10 * (attempt + 1))) + } + } + throw new Error('unreachable') +} + +export async function handleMarketingInquiryRequest( + request: Request, + prisma: PrismaClient, + env: Readonly> = process.env, +) { + const apiKey = readApiKey(env) + if (!apiKey) return error('not_configured', 503) + const provided = request.headers.get('x-api-key') ?? '' + if (!provided || !safeKeyEqual(apiKey, provided)) return error('unauthorized', 401) + + const bytes = await readBoundedBody(request) + if (!bytes) return error('bad_payload', 400) + + let parsed: unknown + try { + parsed = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)) + } catch { + return error('bad_payload', 400) + } + const validated = parsePayload(parsed) + if (!validated) return error('bad_payload', 400) + + try { + return Response.json(await appendInquiry(prisma, validated.payload, validated.occurredAt), { headers: NO_STORE }) + } catch (caught) { + if (caught instanceof AmbiguousPhoneError) return error('ambiguous_phone', 409) + return error('temporarily_unavailable', 503) + } +} diff --git a/package.json b/package.json index 85e1e55..fef7df2 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "typecheck": "next typegen && tsc --noEmit", "test:account-control": "tsx --test clever_account_interceptor/typescript/core.test.ts lib/account-control/*.test.ts", "test:external-lookup": "tsx --test lib/external-lookup/*.test.ts", + "test:marketing-inquiries": "tsx --test lib/marketing-inquiries.test.ts", "test:integration-contract": "node scripts/verify-direct-integration-contract.mjs EVN-WARP", "test:deployment": "python3 -m unittest discover -s deploy/tests -p 'test_*.py' && prisma generate && tsx --test lib/db.test.ts app/api/healthz/route.test.ts", "test:buildup-import": "tsx --test lib/buildup-import/*.test.ts", diff --git a/scripts/verify-direct-integration-contract.mjs b/scripts/verify-direct-integration-contract.mjs index da0de16..341b9e0 100644 --- a/scripts/verify-direct-integration-contract.mjs +++ b/scripts/verify-direct-integration-contract.mjs @@ -5,7 +5,9 @@ import process from 'node:process' const METHODS = 'GET|POST|PUT|PATCH|DELETE' const root = process.cwd() const repository = process.argv[2] -const manifestPath = path.join(root, 'docs/integrations/WARP_BUILDUP_DIRECT_API.json') +const manifestNames = repository === 'evn-marketing' + ? ['WARP_MARKETING_INQUIRIES.json'] + : ['WARP_BUILDUP_DIRECT_API.json', 'WARP_MARKETING_INQUIRIES.json'] function fail(message) { throw new Error(`Direct integration contract: ${message}`) @@ -91,7 +93,14 @@ async function verifyOutboundLiterals(manifest, config) { } async function main() { - const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) + const manifests = await Promise.all(manifestNames.map(async name => + JSON.parse(await readFile(path.join(root, 'docs/integrations', name), 'utf8')))) + if (manifests.some(item => item.owner !== 'OziinG')) fail('owner must remain OziinG') + const manifest = { + owner: 'OziinG', + repositories: Object.assign({}, ...manifests.map(item => item.repositories)), + endpoints: manifests.flatMap(item => item.endpoints), + } const config = manifest.repositories?.[repository] if (!repository || !config) fail(`unknown repository ${repository ?? '(missing)'}`) if (manifest.owner !== 'OziinG') fail('owner must remain OziinG') @@ -100,7 +109,7 @@ async function main() { if (new Set(signatures).size !== signatures.length) fail('duplicate method and path') await verifySourceTokens(manifest) - const actual = config.route_style === 'next' + const actual = config.route_style === 'caller-only' ? [] : config.route_style === 'next' ? await nextRoutes(config) : await expressRoutes(config) const declared = manifest.endpoints @@ -111,7 +120,7 @@ async function main() { fail(`owned route drift\ndeclared=${JSON.stringify(declared)}\nactual=${JSON.stringify(actual)}`) } await verifyOutboundLiterals(manifest, config) - console.log(`Verified ${manifest.endpoints.length} WARP–BUILDUP-EV endpoints for ${repository}`) + console.log(`Verified ${manifest.endpoints.length} declared integration endpoints for ${repository}`) } await main() From 858845c35998b467d3423238c6360b3203ffaa7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=ED=83=9C=EA=B7=A0?= Date: Wed, 16 Sep 2026 14:17:57 +0900 Subject: [PATCH 2/4] docs: link marketing integration review PR --- docs/integrations/WARP_MARKETING_INQUIRIES.json | 2 +- docs/integrations/WARP_MARKETING_INQUIRIES.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/integrations/WARP_MARKETING_INQUIRIES.json b/docs/integrations/WARP_MARKETING_INQUIRIES.json index 9ec7621..03556f1 100644 --- a/docs/integrations/WARP_MARKETING_INQUIRIES.json +++ b/docs/integrations/WARP_MARKETING_INQUIRIES.json @@ -5,7 +5,7 @@ "owner": "OziinG", "review": { "issue": "https://github.com/EVNSolution/EVN-WARP/issues/48", - "pull_request": null, + "pull_request": "https://github.com/EVNSolution/EVN-WARP/pull/49", "required_before_production": true }, "authentication": { diff --git a/docs/integrations/WARP_MARKETING_INQUIRIES.md b/docs/integrations/WARP_MARKETING_INQUIRIES.md index a0ea1ea..37f01ad 100644 --- a/docs/integrations/WARP_MARKETING_INQUIRIES.md +++ b/docs/integrations/WARP_MARKETING_INQUIRIES.md @@ -2,7 +2,7 @@ 2026-09-16 · 운영 담당자: OziinG · 상태: 로컬 검증 완료, Owner 검토·배포 전 -기계 판독 계약은 [WARP_MARKETING_INQUIRIES.json](WARP_MARKETING_INQUIRIES.json)이다. 동일 계약이 마케팅 저장소에도 있다. 이 변경은 기존 WARP–BUILDUP 계약과 공유키 권한을 수정하지 않는다. [검토 Issue #48](https://github.com/EVNSolution/EVN-WARP/issues/48)에 연결하며 Owner 승인은 아직 없다. +기계 판독 계약은 [WARP_MARKETING_INQUIRIES.json](WARP_MARKETING_INQUIRIES.json)이다. 동일 계약이 마케팅 저장소에도 있다. 이 변경은 기존 WARP–BUILDUP 계약과 공유키 권한을 수정하지 않는다. [검토 Issue #48](https://github.com/EVNSolution/EVN-WARP/issues/48)과 [검토 PR #49](https://github.com/EVNSolution/EVN-WARP/pull/49)에 연결하며 Owner 승인은 아직 없다. ## 변경 결과 From 49c1974a34686cdc97196cdeea7f2af47a40123b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=ED=83=9C=EA=B7=A0?= Date: Wed, 16 Sep 2026 14:21:35 +0900 Subject: [PATCH 3/4] fix: generate Prisma client before integration CI tests --- .github/workflows/verify-direct-integration.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/verify-direct-integration.yml b/.github/workflows/verify-direct-integration.yml index 9735dd5..e0d19d0 100644 --- a/.github/workflows/verify-direct-integration.yml +++ b/.github/workflows/verify-direct-integration.yml @@ -31,6 +31,7 @@ jobs: - name: Verify direct integration behavior and contract run: | npm ci + DATABASE_URL=file:/tmp/warp-ci.db npx prisma generate npm run test:external-lookup npm run test:buildup-import npm run test:marketing-inquiries From 07224a2209f3089e06a67f44905607f9fe0f1cf1 Mon Sep 17 00:00:00 2001 From: OziinG <145884442+OziinG@users.noreply.github.com> Date: Thu, 17 Sep 2026 09:39:38 +0900 Subject: [PATCH 4/4] Prevent duplicate inquiry history and shared SQLite lock outages Reserve the writer before phone lookup so concurrent receiver processes cannot deadlock during a deferred transaction upgrade. Canonicalize source UUID spelling and preserve safe failure diagnostics. Constraint: Blue/Green containers share one SQLite database and callers may use dynamic public IPs. Rejected: New receipt schema or global adapter replacement | Receiver-local locking and retained-record semantics satisfy the current contract. Confidence: high Scope-risk: narrow Directive: Keep the writer reservation before reads and preserve whole-transaction asynchronous retries. Tested: 29 deployment Python tests; 51 Node tests; 7 integration endpoints; typecheck; ESLint; security audit; production build; three-process HTTP smoke. Not-tested: Local marketing sender end-to-end delivery and real CRM readback. --- .../WARP_MARKETING_INQUIRIES.json | 13 +++- docs/integrations/WARP_MARKETING_INQUIRIES.md | 41 +++++++++++-- lib/marketing-inquiries.test.ts | 61 +++++++++++++++++++ lib/marketing-inquiries.ts | 14 ++++- 4 files changed, 118 insertions(+), 11 deletions(-) diff --git a/docs/integrations/WARP_MARKETING_INQUIRIES.json b/docs/integrations/WARP_MARKETING_INQUIRIES.json index 03556f1..9b534a7 100644 --- a/docs/integrations/WARP_MARKETING_INQUIRIES.json +++ b/docs/integrations/WARP_MARKETING_INQUIRIES.json @@ -1,12 +1,18 @@ { "schema_version": 1, "contract_id": "warp-marketing-inquiries", - "status": "draft-pending-owner-review", + "status": "source-reviewed-activation-pending", "owner": "OziinG", "review": { "issue": "https://github.com/EVNSolution/EVN-WARP/issues/48", "pull_request": "https://github.com/EVNSolution/EVN-WARP/pull/49", - "required_before_production": true + "required_before_production": true, + "source_review_date": "2026-09-17", + "activation_required": [ + "Verify the caller repository uses this reviewed contract and passes its delivery tests.", + "Provision the dedicated key through the approved secret-management path on both sides.", + "Verify an authenticated delivery, replay, and CRM readback before processing the backlog." + ] }, "authentication": { "header": "x-api-key", @@ -75,7 +81,8 @@ "duplicate" ], "rules": [ - "One source inquiry creates exactly one customer activity, including on retry.", + "One source inquiry creates exactly one customer activity while that activity is retained, including on retry; source UUID spelling is case-insensitive.", + "Explicit deletion of the receipt activity or its customer ends replay protection and a later delivery can recreate records.", "An exact unique normalized primary phone match receives a new activity; do not overwrite its customer profile.", "Multiple primary phone matches return ambiguous_phone and create no record.", "Unknown names remain blank; source status remains in activity content.", diff --git a/docs/integrations/WARP_MARKETING_INQUIRIES.md b/docs/integrations/WARP_MARKETING_INQUIRIES.md index 37f01ad..1516f21 100644 --- a/docs/integrations/WARP_MARKETING_INQUIRIES.md +++ b/docs/integrations/WARP_MARKETING_INQUIRIES.md @@ -1,8 +1,8 @@ # 마케팅 문의 수신 연동 검토 -2026-09-16 · 운영 담당자: OziinG · 상태: 로컬 검증 완료, Owner 검토·배포 전 +2026-09-17 · 운영 담당자: OziinG · 상태: Owner 소스 검토, 발신측 활성화 대기 -기계 판독 계약은 [WARP_MARKETING_INQUIRIES.json](WARP_MARKETING_INQUIRIES.json)이다. 동일 계약이 마케팅 저장소에도 있다. 이 변경은 기존 WARP–BUILDUP 계약과 공유키 권한을 수정하지 않는다. [검토 Issue #48](https://github.com/EVNSolution/EVN-WARP/issues/48)과 [검토 PR #49](https://github.com/EVNSolution/EVN-WARP/pull/49)에 연결하며 Owner 승인은 아직 없다. +기계 판독 계약은 [WARP_MARKETING_INQUIRIES.json](WARP_MARKETING_INQUIRIES.json)이다. 이 변경은 기존 WARP–BUILDUP 계약과 공유키 권한을 수정하지 않는다. [검토 Issue #48](https://github.com/EVNSolution/EVN-WARP/issues/48)과 [검토 PR #49](https://github.com/EVNSolution/EVN-WARP/pull/49)에 연결한다. 2026-09-17 Owner 검토에서 UUID 대소문자 중복 및 독립 DB 연결의 잠금 경합을 재현해 수정하고 회귀 검사를 추가했다. 최종 승인·배포 Revision과 결과는 PR에 기록한다. 발신측 저장소는 이번 검토 환경에 없으므로 활성화 전에 수정된 계약의 일치와 발신측 검증을 확인해야 한다. ## 변경 결과 @@ -10,13 +10,13 @@ 기본 연락처를 숫자로 정규화한 완전일치 고객이 한 명이면 문의 이력만 추가한다. 기존 이름·상태·담당자·메모는 바꾸지 않는다. 일치 고객이 없으면 성함·연락처를 입력한 B2C 잠재고객을 만들며 빈 이름을 허용한다. 이름이 같고 연락처가 다르면 별도 고객이다. 같은 연락처의 고객이 여러 명이면 409로 보류한다. -활동 ID는 `mleverage_` 접두사와 출처·회사·홈페이지·원본 UUID의 SHA-256이다. 고객 생성과 활동 추가를 같은 트랜잭션에서 처리하고, 재전송은 기존 receipt를 돌려준다. 새로운 DB 테이블·열은 없고 기존 Customer/CustomerActivity를 사용한다. 동일 문의의 병렬 전송과 같은 연락처의 별도 문의 병렬 전송을 합성 SQLite로 검증했다. +활동 ID는 `mleverage_` 접두사와 출처·회사·홈페이지·소문자로 정규화한 원본 UUID의 SHA-256이다. 고객 생성과 활동 추가를 같은 트랜잭션에서 처리하고, 활동이 보존된 동안의 재전송은 기존 receipt를 돌려준다. 새로운 DB 테이블·열은 없고 기존 Customer/CustomerActivity를 사용한다. 조회 전에 행을 변경하지 않는 UPDATE로 SQLite 쓰기 잠금을 확보해 독립 연결의 read-to-write 잠금 교착을 막는다. 해당 트랜잭션 연결의 busy timeout은 0으로 두고 기존 비동기 재시도를 사용한다. 동일 문의·같은 연락처의 별도 문의 병렬 전송과 경합 이후 쓰기를 합성 SQLite로 검증한다. 활동 내용에는 원본 문의일자·문의시간·상담상태·성함·연락처와 문의 ID를 읽을 수 있는 문장으로 저장한다. 원본 날짜·시간은 분 단위 문자열로 보존하고 CRM 날짜는 한국 시간으로 해석한다. 원본 화면에 시간대 표기가 없으므로 한국 시간 해석은 확인이 필요한 가정이다. ## 로컬 검증 -- `npm run test:marketing-inquiries`: 합성 시나리오 9개 통과. +- `npm run test:marketing-inquiries`: UUID 대소문자 재전송, 독립 연결 경합/후속 쓰기, append 실패의 rollback과 비밀정보 없는 오류 로그를 포함한 합성 시나리오 12개. - `npm run test:integration-contract`: 기존 6개와 신규 1개, 총 7개 계약 통과. - 타입 검사, 수정 파일 ESLint, 로컬 프로덕션 빌드 통과. 빌드의 DB 주소는 합성 로컬 경로를 사용했다. - 마케팅 sender와 실제 receiver 함수를 연결한 합성 DB 검사 통과: 이름 공란, 같은 연락처로 문의 이력 추가, 응답 재처리 중복 방지. @@ -25,8 +25,37 @@ ## 운영 인계 -AGENTS.md의 Owner-reviewed Issue/PR 요구에 따라 검토 후 정규 release 절차로 main을 반영한다. 전용 키는 운영 담당자의 승인된 비밀 관리 경로에서 설정하고 앱은 SSM `/evn-warp/app-env`를 읽기만 한다. 기존 BUILDUP 키를 재사용하거나 배포 중 SSM 값 쓰기·로컬 .env 대체·운영 DB 직접 수입으로 우회하지 않는다. +WARP 수신기는 IP 허용목록 없이 공용 HTTPS에서 접근할 수 있고 전용 키를 요구한다. 로컬 발신기는 Wi-Fi의 동적 공인 IP로 테스트할 수 있으며 고정 IP나 EIP가 필요하지 않다. 키가 없거나 형식이 잘못되면 503 `not_configured`로 닫히고 잘못된 요청 키는 401을 받는다. AGENTS.md의 Owner-reviewed Issue/PR 요구에 따라 검토 후 정규 release 절차로 main을 반영한다. 전용 키는 운영 담당자의 승인된 비밀 관리 경로에서 설정하고 앱은 SSM `/evn-warp/app-env`를 읽기만 한다. 기존 BUILDUP 키를 재사용하거나 배포 중 SSM 값 쓰기·로컬 .env 대체·운영 DB 직접 수입으로 우회하지 않는다. 담당자에게 키를 전달할 때 GitHub 댓글·문서·채팅에 값을 넣지 않는다. 마케팅은 이미 보관한 문의도 미전달 대상으로 보낸다. 키 설정 전에는 전송하지 않는다. 운영 반영 후 같은 키를 마케팅 프로세스에 안전하게 주입하고, 초기 전달 결과를 확인한 뒤 전체 미전달 문의를 처리한다. 완료·대기·오류 건수와 실제 CRM 이력을 확인해야 운영 연동 완료로 볼 수 있다. 로그·문서에는 고객 자료나 키를 남기지 않는다. -기본 연락처 스캔은 초기 최소 구현이다. CRM 규모에서 지연이 확인되면 정규화 열과 인덱스를 검토한다. 사용자가 WARP 고객이나 활동을 삭제하면 중복 방지 기록도 함께 없어질 수 있으므로 임의 재전송·DB 복구는 별도 결정이 필요하다. +기본 연락처 스캔은 초기 최소 구현이다. CRM 규모에서 지연이 확인되면 정규화 열과 인덱스를 검토한다. 사용자가 WARP 고객이나 수신 활동을 명시적으로 삭제하면 중복 방지 기록도 삭제되므로 이후 재전송은 고객 또는 활동을 다시 만들 수 있다. 삭제 이후까지의 영구 중복 방지는 제공하지 않으며 임의 재전송·DB 복구는 별도 결정이 필요하다. + +## 로컬 발신 담당자 연결 안내 + +- 주소: `POST https://warp.cleversystem.ai/api/external/marketing-inquiries` +- 인증: `x-api-key` 헤더에 운영 담당자가 별도 보안 경로로 전달한 전용 키를 넣는다. 기존 BUILDUP 키는 사용하지 않는다. +- 네트워크: 인터넷 연결과 HTTPS(443)만 필요하다. Wi-Fi 공인 IP의 사전 등록, 고정 IP, EIP, VPN은 필요하지 않다. DB·SSH 포트를 개방하는 방식이 아니다. +- 키는 로컬 서버/worker의 비밀 환경변수로만 주입하고 브라우저 번들, 저장소, 로그, GitHub 댓글에 넣지 않는다. + +실제 데이터를 등록하기 전에 발신기 환경의 `WARP_MARKETING_API_KEY`를 사용해 아래 명령으로 인증·접근만 확인할 수 있다. 빈 payload를 의도적으로 보내므로 CRM에 쓰지 않으며 **HTTP 400 / bad_payload가 성공 기준**이다. 응답이 401이면 키를, 503 `not_configured`이면 WARP 키 설정을 확인한다. + +```sh +node --input-type=module <<'JS' +const key = process.env.WARP_MARKETING_API_KEY +if (!key) throw new Error('WARP_MARKETING_API_KEY is required') +const response = await fetch('https://warp.cleversystem.ai/api/external/marketing-inquiries', { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-api-key': key }, + body: '{}', + signal: AbortSignal.timeout(10000), +}) +const body = await response.json() +if (response.status !== 400 || body.error !== 'bad_payload') { + throw new Error(`Connection check failed: HTTP ${response.status}`) +} +console.log('Authentication and public HTTPS access passed; no CRM records written') +JS +``` + +다음 단계에서는 계약의 9개 필드에 맞는 실제 원본 문의 한 건을 발신기에서 전송한다. 첫 응답은 200과 `created: true`, 같은 `sourceId`의 재전송은 200과 `duplicate: true`여야 한다. CRM의 고객 및 문의 이력을 확인한 후 미전달 문의 처리를 시작한다. 409 `ambiguous_phone`은 자동 재시도하지 말고 담당자가 중복 연락처를 확인한다. 503 `temporarily_unavailable`은 같은 `sourceId`를 유지한 채 지연 재시도한다. 서버 로그에는 `marketing_inquiry_failed`와 허용된 오류 코드·재시도 여부만 남는다. diff --git a/lib/marketing-inquiries.test.ts b/lib/marketing-inquiries.test.ts index 1c72569..868cfc2 100644 --- a/lib/marketing-inquiries.test.ts +++ b/lib/marketing-inquiries.test.ts @@ -192,6 +192,18 @@ test('replays one source inquiry without adding another customer or activity', a assert.equal(await prisma.customerActivity.count(), 1) }) +test('treats upper- and lowercase spellings of one source UUID as the same inquiry', async () => { + const inquiry = payload('abcdefab-cdef-4abc-8def-abcdefabcdef') + const first = await send({ ...inquiry, sourceId: inquiry.sourceId.toUpperCase() }) + const replay = await send(inquiry) + + assert.equal(first.response.status, 200) + assert.equal(replay.response.status, 200) + assert.deepEqual(replay.body, { ...first.body, created: false, duplicate: true }) + assert.equal(await prisma.customer.count(), 1) + assert.equal(await prisma.customerActivity.count(), 1) +}) + test('serializes concurrent retries of one source inquiry', async () => { const inquiry = payload('00000000-0000-4000-8000-000000000006') const results = await Promise.all(Array.from({ length: 6 }, () => send(inquiry))) @@ -214,6 +226,55 @@ test('keeps distinct same-phone inquiries as two activities on one customer', as assert.equal(await prisma.customerActivity.count(), 2) }) +test('serializes independent database clients and leaves the database writable after contention', async () => { + const other = createPrisma(`file:${path.join(temporaryDirectory, 'test.db')}`) + const inquiry = payload('abcdefab-cdef-4abc-8def-abcdefabcdef') + try { + const responses = await Promise.all([ + handleMarketingInquiryRequest(request(inquiry), prisma, ENV), + handleMarketingInquiryRequest(request({ ...inquiry, sourceId: inquiry.sourceId.toUpperCase() }), other, ENV), + ]) + assert.deepEqual(responses.map(response => response.status), [200, 200]) + const receipts = await Promise.all(responses.map(response => response.json())) + assert.equal(receipts.filter(receipt => receipt.created).length, 1) + assert.equal(receipts.filter(receipt => receipt.duplicate).length, 1) + assert.equal(await other.customer.count(), 1) + assert.equal(await prisma.customerActivity.count(), 1) + const next = await handleMarketingInquiryRequest(request(payload('cccccccc-cccc-4ccc-8ccc-cccccccccccc')), other, ENV) + assert.equal(next.status, 200) + assert.equal(await prisma.customerActivity.count(), 2) + } finally { + await other.$disconnect() + } +}) + +test('rolls back a failed append, releases the writer, and logs no customer or database error text', async () => { + const logs: unknown[][] = [] + const logger = test.mock.method(console, 'error', (...args: unknown[]) => logs.push(args)) + const inquiry = payload('dddddddd-dddd-4ddd-8ddd-dddddddddddd') + await prisma.$executeRawUnsafe(`CREATE TRIGGER reject_marketing_test BEFORE INSERT ON CustomerActivity + BEGIN SELECT RAISE(ABORT, 'synthetic-private-database-message'); END`) + try { + const failed = await send(inquiry) + assert.equal(failed.response.status, 503) + assert.deepEqual(failed.body, { error: 'temporarily_unavailable' }) + assert.equal(await prisma.customer.count(), 0) + assert.equal(await prisma.customerActivity.count(), 0) + assert.equal(logs.length, 1) + assert.equal(logs[0][0], 'marketing_inquiry_failed') + const logged = JSON.stringify(logs) + for (const privateValue of [inquiry.name, inquiry.phone, KEY, 'synthetic-private-database-message']) { + assert.equal(logged.includes(privateValue), false) + } + } finally { + logger.mock.restore() + await prisma.$executeRawUnsafe('DROP TRIGGER reject_marketing_test') + } + assert.equal((await send(inquiry)).response.status, 200) + assert.equal(await prisma.customer.count(), 1) + assert.equal(await prisma.customerActivity.count(), 1) +}) + test('cancels a streamed body without content-length as soon as it exceeds 16KB', async () => { let pulls = 0 let cancelled = false diff --git a/lib/marketing-inquiries.ts b/lib/marketing-inquiries.ts index 24c839b..38d3a39 100644 --- a/lib/marketing-inquiries.ts +++ b/lib/marketing-inquiries.ts @@ -86,7 +86,7 @@ function parsePayload(value: unknown): { payload: MarketingInquiry; occurredAt: export function marketingInquiryActivityId(payload: MarketingInquiry) { return `mleverage_${createHash('sha256') - .update([payload.source, payload.companyScopeId, payload.homepageScopeId, payload.sourceId].join(':')) + .update([payload.source, payload.companyScopeId, payload.homepageScopeId, payload.sourceId.toLowerCase()].join(':')) .digest('hex')}` } @@ -102,8 +102,13 @@ function activityContent(payload: MarketingInquiry) { ].join('\n') } -function retryable(error: unknown) { +function databaseErrorCode(error: unknown) { const code = typeof error === 'object' && error !== null && 'code' in error ? String(error.code) : '' + return /^P\d{4}$/.test(code) ? code : 'unknown' +} + +function retryable(error: unknown) { + const code = databaseErrorCode(error) const message = error instanceof Error ? error.message : '' return code === 'P2002' || code === 'P2034' || code === 'P1008' || /SQLITE_BUSY|database is locked/i.test(message) } @@ -136,6 +141,10 @@ async function appendInquiry(prisma: PrismaClient, payload: MarketingInquiry, oc for (let attempt = 0; attempt < 4; attempt += 1) { try { return await prisma.$transaction(async transaction => { + // Reserve the SQLite writer before reading; deferred read-to-write upgrades can deadlock across clients. + // Retry asynchronously instead of blocking the Node event loop while another client holds the writer. + await transaction.$executeRaw`PRAGMA busy_timeout = 0` + await transaction.$executeRaw`UPDATE "CustomerActivity" SET "id" = "id" WHERE 0` const duplicate = await transaction.customerActivity.findUnique({ where: { id: activityId }, select: { id: true, customerId: true }, @@ -212,6 +221,7 @@ export async function handleMarketingInquiryRequest( return Response.json(await appendInquiry(prisma, validated.payload, validated.occurredAt), { headers: NO_STORE }) } catch (caught) { if (caught instanceof AmbiguousPhoneError) return error('ambiguous_phone', 409) + console.error('marketing_inquiry_failed', { code: databaseErrorCode(caught), retryable: retryable(caught) }) return error('temporarily_unavailable', 503) } }