@crmforall/connector 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,90 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.validateSchemaName = validateSchemaName;
4
+ exports.migrateCrmSchema = migrateCrmSchema;
5
+ /**
6
+ * CRM 전용 스키마 DDL — 멱등 (CREATE TABLE IF NOT EXISTS).
7
+ * 근거: 01_데이터_모델 §고객사 측 CRM 스키마 (기존 9테이블 중 MVP 사용분).
8
+ * 구조는 고객사 DB든 관리형 테넌트 스키마든 동일해야 한다 (내보내기 보장의 기반).
9
+ * 호출 시점: 설치 단계(connect init)에만 — DDL 권한은 설치 시에만 허용된다.
10
+ */
11
+ function validateSchemaName(crmSchema) {
12
+ if (!/^[a-z_][a-z0-9_]{0,62}$/.test(crmSchema)) {
13
+ throw new Error(`잘못된 스키마 이름: ${crmSchema}`);
14
+ }
15
+ }
16
+ async function migrateCrmSchema(db, crmSchema) {
17
+ validateSchemaName(crmSchema);
18
+ const s = crmSchema;
19
+ const ddl = [
20
+ `CREATE SCHEMA IF NOT EXISTS ${s}`,
21
+ `CREATE TABLE IF NOT EXISTS ${s}.crm_field_mappings (
22
+ version TEXT PRIMARY KEY,
23
+ mapping JSONB NOT NULL,
24
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
25
+ )`,
26
+ `CREATE TABLE IF NOT EXISTS ${s}.crm_target_snapshots (
27
+ snapshot_id TEXT PRIMARY KEY,
28
+ project_id TEXT NOT NULL,
29
+ project_version TEXT NOT NULL,
30
+ mapping_version TEXT NOT NULL,
31
+ snapshot_hash TEXT NOT NULL,
32
+ approval_id TEXT NOT NULL UNIQUE,
33
+ target_count INT NOT NULL,
34
+ exclusions JSONB NOT NULL,
35
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
36
+ )`,
37
+ `CREATE TABLE IF NOT EXISTS ${s}.crm_snapshot_members (
38
+ snapshot_id TEXT NOT NULL REFERENCES ${s}.crm_target_snapshots(snapshot_id),
39
+ customer_key TEXT NOT NULL,
40
+ phone_hash TEXT NOT NULL,
41
+ PRIMARY KEY (snapshot_id, customer_key)
42
+ )`,
43
+ `CREATE TABLE IF NOT EXISTS ${s}.crm_events (
44
+ event_id TEXT PRIMARY KEY,
45
+ event_type TEXT NOT NULL,
46
+ payload JSONB NOT NULL,
47
+ approval_id TEXT NOT NULL,
48
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
49
+ )`,
50
+ `CREATE TABLE IF NOT EXISTS ${s}.crm_send_jobs (
51
+ send_job_id TEXT PRIMARY KEY,
52
+ snapshot_id TEXT NOT NULL REFERENCES ${s}.crm_target_snapshots(snapshot_id),
53
+ channel TEXT NOT NULL,
54
+ message_version TEXT NOT NULL,
55
+ approval_id TEXT NOT NULL UNIQUE,
56
+ status TEXT NOT NULL DEFAULT 'queued',
57
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
58
+ )`,
59
+ `CREATE TABLE IF NOT EXISTS ${s}.crm_send_results (
60
+ send_job_id TEXT NOT NULL REFERENCES ${s}.crm_send_jobs(send_job_id),
61
+ receiver_ref TEXT NOT NULL,
62
+ status TEXT NOT NULL,
63
+ provider_code TEXT,
64
+ internal_code TEXT,
65
+ synced_at TIMESTAMPTZ NOT NULL DEFAULT now(),
66
+ PRIMARY KEY (send_job_id, receiver_ref)
67
+ )`,
68
+ `CREATE TABLE IF NOT EXISTS ${s}.crm_engagements (
69
+ event_id TEXT PRIMARY KEY,
70
+ identity_key TEXT NOT NULL,
71
+ engagement_type TEXT NOT NULL,
72
+ campaign_ref TEXT,
73
+ short_link_ref TEXT,
74
+ reservation_ref TEXT,
75
+ pickup_amount NUMERIC,
76
+ occurred_at TIMESTAMPTZ NOT NULL,
77
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
78
+ )`,
79
+ `CREATE TABLE IF NOT EXISTS ${s}.crm_audit_logs (
80
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
81
+ actor TEXT NOT NULL,
82
+ action TEXT NOT NULL,
83
+ detail JSONB,
84
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
85
+ )`,
86
+ ];
87
+ for (const sql of ddl) {
88
+ await db.execute(sql);
89
+ }
90
+ }
@@ -0,0 +1,3 @@
1
+ export * from "./adapter";
2
+ export * from "./postgres";
3
+ export * from "./crm-schema";
@@ -0,0 +1,19 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ __exportStar(require("./adapter"), exports);
18
+ __exportStar(require("./postgres"), exports);
19
+ __exportStar(require("./crm-schema"), exports);
@@ -0,0 +1,31 @@
1
+ import type { ColumnInfo, DbAdapter, ReadinessReport } from "./adapter";
2
+ export interface PostgresCredentials {
3
+ host: string;
4
+ port: number;
5
+ database: string;
6
+ user: string;
7
+ password: string;
8
+ ssl?: boolean;
9
+ }
10
+ export declare class PostgresAdapter implements DbAdapter {
11
+ private readonly creds;
12
+ readonly sourceType: "postgresql";
13
+ private client;
14
+ constructor(creds: PostgresCredentials);
15
+ connect(): Promise<void>;
16
+ disconnect(): Promise<void>;
17
+ private q;
18
+ selectRows(sql: string, params?: unknown[]): Promise<Record<string, unknown>[]>;
19
+ execute(sql: string, params?: unknown[]): Promise<{
20
+ rowCount: number;
21
+ }>;
22
+ checkReadiness({ crmSchema }: {
23
+ crmSchema: string;
24
+ }): Promise<ReadinessReport>;
25
+ listColumns({ schemaAllowlist }: {
26
+ schemaAllowlist?: string[];
27
+ }): Promise<ColumnInfo[]>;
28
+ ensureCrmSchema({ crmSchema }: {
29
+ crmSchema: string;
30
+ }): Promise<void>;
31
+ }
@@ -0,0 +1,138 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PostgresAdapter = void 0;
4
+ const pg_1 = require("pg");
5
+ const crm_schema_1 = require("./crm-schema");
6
+ /** 시스템 스키마 — 탐색·검사 대상에서 제외 */
7
+ const SYSTEM_SCHEMAS = ["pg_catalog", "information_schema", "pg_toast"];
8
+ class PostgresAdapter {
9
+ creds;
10
+ sourceType = "postgresql";
11
+ client = null;
12
+ constructor(creds) {
13
+ this.creds = creds;
14
+ }
15
+ async connect() {
16
+ const client = new pg_1.Client({
17
+ host: this.creds.host,
18
+ port: this.creds.port,
19
+ database: this.creds.database,
20
+ user: this.creds.user,
21
+ password: this.creds.password,
22
+ ssl: this.creds.ssl ? { rejectUnauthorized: false } : undefined,
23
+ connectionTimeoutMillis: 10_000,
24
+ });
25
+ await client.connect();
26
+ this.client = client;
27
+ }
28
+ async disconnect() {
29
+ await this.client?.end();
30
+ this.client = null;
31
+ }
32
+ q() {
33
+ if (!this.client)
34
+ throw new Error("connect()를 먼저 호출하세요");
35
+ return this.client;
36
+ }
37
+ async selectRows(sql, params = []) {
38
+ const res = await this.q().query(sql, params);
39
+ return res.rows;
40
+ }
41
+ async execute(sql, params = []) {
42
+ const res = await this.q().query(sql, params);
43
+ return { rowCount: res.rowCount ?? 0 };
44
+ }
45
+ async checkReadiness({ crmSchema }) {
46
+ const checks = [];
47
+ const client = this.q();
48
+ checks.push({ name: "connect", passed: true, detail: "DB 연결 성공" });
49
+ // 1. 운영 스키마 SELECT 가능 여부
50
+ const selectable = await client.query(`SELECT count(*)::int AS n FROM information_schema.table_privileges
51
+ WHERE grantee = current_user AND privilege_type = 'SELECT'
52
+ AND table_schema NOT IN (${SYSTEM_SCHEMAS.map((_, i) => `$${i + 1}`).join(",")})
53
+ AND table_schema <> $${SYSTEM_SCHEMAS.length + 1}`, [...SYSTEM_SCHEMAS, crmSchema]);
54
+ const selectCount = selectable.rows[0].n;
55
+ checks.push({
56
+ name: "operational_select",
57
+ passed: selectCount > 0,
58
+ detail: selectCount > 0
59
+ ? `운영 테이블 ${selectCount}개에 SELECT 권한 확인`
60
+ : "운영 테이블 SELECT 권한이 없습니다. 읽기 전용 권한을 요청하세요.",
61
+ });
62
+ // 2. 운영 원천 테이블 쓰기 권한 — 있으면 과권한 (활성화 차단)
63
+ const writable = await client.query(`SELECT DISTINCT table_schema, table_name FROM information_schema.table_privileges
64
+ WHERE grantee = current_user
65
+ AND privilege_type IN ('INSERT','UPDATE','DELETE')
66
+ AND table_schema NOT IN (${SYSTEM_SCHEMAS.map((_, i) => `$${i + 1}`).join(",")})
67
+ AND table_schema <> $${SYSTEM_SCHEMAS.length + 1}
68
+ LIMIT 20`, [...SYSTEM_SCHEMAS, crmSchema]);
69
+ const overprivileged = writable.rows.length > 0;
70
+ checks.push({
71
+ name: "source_table_write_blocked",
72
+ passed: !overprivileged,
73
+ detail: overprivileged
74
+ ? `운영 원천 테이블 ${writable.rows.length}개에 쓰기 권한이 감지되었습니다 (과권한). 활성화가 차단됩니다.`
75
+ : "운영 원천 테이블 쓰기 권한 없음 (정상)",
76
+ remediationSql: overprivileged
77
+ ? writable.rows
78
+ .map((r) => `REVOKE INSERT, UPDATE, DELETE ON ${r.table_schema}.${r.table_name} FROM ${this.creds.user};`)
79
+ .join("\n")
80
+ : undefined,
81
+ });
82
+ // 3. CRM 전용 스키마 존재 여부
83
+ const schemaExists = await client.query(`SELECT 1 FROM information_schema.schemata WHERE schema_name = $1`, [crmSchema]);
84
+ const crmExists = (schemaExists.rowCount ?? 0) > 0;
85
+ checks.push({
86
+ name: "crm_schema_exists",
87
+ passed: crmExists,
88
+ detail: crmExists
89
+ ? `CRM 전용 스키마 ${crmSchema} 존재`
90
+ : `CRM 전용 스키마 ${crmSchema}가 없습니다. connect init이 생성합니다.`,
91
+ });
92
+ // 4. CRM 스키마 쓰기 가능 여부
93
+ let crmWritable = false;
94
+ if (crmExists) {
95
+ const crmWrite = await client.query(`SELECT count(*)::int AS n FROM information_schema.table_privileges
96
+ WHERE grantee = current_user AND table_schema = $1
97
+ AND privilege_type IN ('INSERT','UPDATE')`, [crmSchema]);
98
+ const usage = await client.query(`SELECT has_schema_privilege(current_user, $1, 'CREATE') AS c`, [crmSchema]);
99
+ crmWritable = crmWrite.rows[0].n > 0 || usage.rows[0].c === true;
100
+ }
101
+ checks.push({
102
+ name: "crm_schema_write",
103
+ passed: crmWritable,
104
+ detail: crmWritable
105
+ ? "CRM 스키마 쓰기 권한 확인"
106
+ : "CRM 스키마 쓰기 권한이 없습니다.",
107
+ });
108
+ const ready = !overprivileged &&
109
+ checks
110
+ .filter((c) => c.name !== "ddl_limited")
111
+ .every((c) => c.passed);
112
+ return { ready, overprivileged, checks };
113
+ }
114
+ async listColumns({ schemaAllowlist }) {
115
+ const client = this.q();
116
+ const params = [...SYSTEM_SCHEMAS];
117
+ let where = `table_schema NOT IN (${SYSTEM_SCHEMAS.map((_, i) => `$${i + 1}`).join(",")})`;
118
+ if (schemaAllowlist?.length) {
119
+ where += ` AND table_schema = ANY($${params.length + 1})`;
120
+ params.push(schemaAllowlist);
121
+ }
122
+ const res = await client.query(`SELECT table_schema, table_name, column_name, data_type
123
+ FROM information_schema.columns WHERE ${where}
124
+ ORDER BY table_schema, table_name, ordinal_position`, schemaAllowlist?.length ? [...SYSTEM_SCHEMAS, schemaAllowlist] : [...SYSTEM_SCHEMAS]);
125
+ return res.rows.map((r) => ({
126
+ schema: r.table_schema,
127
+ table: r.table_name,
128
+ column: r.column_name,
129
+ dataType: r.data_type,
130
+ }));
131
+ }
132
+ async ensureCrmSchema({ crmSchema }) {
133
+ this.q();
134
+ // 멱등: IF NOT EXISTS. 식별자는 파라미터 바인딩이 불가하므로 엄격 검증 후 사용.
135
+ await (0, crm_schema_1.migrateCrmSchema)(this, crmSchema);
136
+ }
137
+ }
138
+ exports.PostgresAdapter = PostgresAdapter;
@@ -0,0 +1,13 @@
1
+ import type { ConnectorConfig } from "./config";
2
+ import type { DbAdapter } from "./db/adapter";
3
+ export * from "./config";
4
+ export * from "./contracts";
5
+ export * from "./mapping";
6
+ export * from "./platform";
7
+ export * from "./targets";
8
+ export * from "./tools";
9
+ /**
10
+ * crmforall MCP 커넥터 라이브러리 진입점.
11
+ * 서버 기동은 server.ts(bin)에서 — CLI가 이 모듈을 import해도 서버가 뜨지 않는다.
12
+ */
13
+ export declare function buildAdapter(config: ConnectorConfig): DbAdapter;
package/dist/index.js ADDED
@@ -0,0 +1,41 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.buildAdapter = buildAdapter;
18
+ const postgres_1 = require("./db/postgres");
19
+ __exportStar(require("./config"), exports);
20
+ __exportStar(require("./contracts"), exports);
21
+ __exportStar(require("./mapping"), exports);
22
+ __exportStar(require("./platform"), exports);
23
+ __exportStar(require("./targets"), exports);
24
+ __exportStar(require("./tools"), exports);
25
+ /**
26
+ * crmforall MCP 커넥터 라이브러리 진입점.
27
+ * 서버 기동은 server.ts(bin)에서 — CLI가 이 모듈을 import해도 서버가 뜨지 않는다.
28
+ */
29
+ function buildAdapter(config) {
30
+ if (config.db.sourceType !== "postgresql") {
31
+ throw new Error(`${config.db.sourceType} 어댑터는 아직 구현되지 않았습니다 (MVP: postgresql)`);
32
+ }
33
+ return new postgres_1.PostgresAdapter({
34
+ host: config.db.host,
35
+ port: config.db.port,
36
+ database: config.db.database,
37
+ user: config.db.user,
38
+ password: config.db.password,
39
+ ssl: config.db.ssl,
40
+ });
41
+ }
@@ -0,0 +1,37 @@
1
+ import type { ColumnInfo } from "./db/adapter";
2
+ import type { MappingConfig } from "./config";
3
+ /**
4
+ * 표준 필드 매핑 — 제안(휴리스틱)과 검증.
5
+ * 근거: 기존 SPEC §표준 데이터 모델, 수용 기준 "필수 표준 필드 전부 매핑돼야 활성화".
6
+ */
7
+ type Entity = "customer" | "reservation";
8
+ export interface MappingCandidate {
9
+ entity: Entity;
10
+ field: string;
11
+ required: boolean;
12
+ schema: string | null;
13
+ table: string | null;
14
+ column: string | null;
15
+ confidence: "high" | "medium" | "none";
16
+ }
17
+ export interface MappingProposal {
18
+ candidates: MappingCandidate[];
19
+ /** 엔터티별 가장 유력한 테이블 */
20
+ tables: Record<Entity, {
21
+ schema: string;
22
+ table: string;
23
+ } | null>;
24
+ }
25
+ /**
26
+ * 컬럼명 휴리스틱으로 표준 필드 매핑을 제안한다.
27
+ * 제안은 후보일 뿐 — 운영자가 검토·확정한 매핑만 validate_mapping을 거쳐 저장된다.
28
+ */
29
+ export declare function proposeMapping(columns: ColumnInfo[]): MappingProposal;
30
+ export interface MappingValidation {
31
+ valid: boolean;
32
+ missingRequired: string[];
33
+ /** 매핑이 가리키는 컬럼이 실제 스키마에 없는 항목 */
34
+ unknownColumns: string[];
35
+ }
36
+ export declare function validateMapping(mapping: MappingConfig, columns: ColumnInfo[]): MappingValidation;
37
+ export {};
@@ -0,0 +1,134 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.proposeMapping = proposeMapping;
4
+ exports.validateMapping = validateMapping;
5
+ const contracts_1 = require("./contracts");
6
+ const HEURISTICS = [
7
+ { field: "customerKey", entity: "customer", required: true,
8
+ synonyms: ["customer_key", "customer_id", "member_id", "member_no", "user_id", "id"] },
9
+ { field: "name", entity: "customer", required: false,
10
+ synonyms: ["name", "customer_name", "member_name", "user_name"] },
11
+ { field: "phoneNumberHash", entity: "customer", required: true,
12
+ synonyms: ["phone_number_hash", "phone_hash", "phone_sha256", "hp_hash"] },
13
+ { field: "marketingOptIn", entity: "customer", required: true,
14
+ synonyms: ["marketing_opt_in", "marketing_agree", "ad_agree", "sms_agree", "marketing_consent"] },
15
+ { field: "kakaoChannelStatus", entity: "customer", required: false,
16
+ synonyms: ["kakao_channel_status", "kakao_status", "channel_status", "friend_status"] },
17
+ { field: "updatedAt", entity: "customer", required: false,
18
+ synonyms: ["updated_at", "modified_at", "mod_date", "upd_dt"] },
19
+ { field: "reservationId", entity: "reservation", required: true,
20
+ synonyms: ["reservation_id", "reserve_id", "booking_id", "order_id", "id"] },
21
+ { field: "customerKey", entity: "reservation", required: true,
22
+ synonyms: ["customer_key", "customer_id", "member_id", "member_no", "user_id"] },
23
+ { field: "productId", entity: "reservation", required: true,
24
+ synonyms: ["product_id", "item_id", "goods_id", "sku"] },
25
+ { field: "reservedAt", entity: "reservation", required: false,
26
+ synonyms: ["reserved_at", "reservation_date", "booked_at", "created_at"] },
27
+ { field: "reservationStatus", entity: "reservation", required: false,
28
+ synonyms: ["reservation_status", "status", "state"] },
29
+ { field: "pickupCompletedAt", entity: "reservation", required: true,
30
+ synonyms: ["pickup_completed_at", "pickup_at", "picked_up_at", "pickup_done_at", "completed_at"] },
31
+ { field: "pickupAmount", entity: "reservation", required: false,
32
+ synonyms: ["pickup_amount", "amount", "total_amount", "price", "pay_amount"] },
33
+ { field: "pharmacyId", entity: "reservation", required: false,
34
+ synonyms: ["pharmacy_id", "store_id", "branch_id", "shop_id"] },
35
+ ];
36
+ const CUSTOMER_TABLE_HINTS = ["member", "customer", "user"];
37
+ const RESERVATION_TABLE_HINTS = ["reservation", "reserve", "booking", "order", "pickup"];
38
+ function pickTable(columns, hints) {
39
+ const byTable = new Map();
40
+ for (const c of columns) {
41
+ const key = `${c.schema}.${c.table}`;
42
+ byTable.set(key, [...(byTable.get(key) ?? []), c]);
43
+ }
44
+ let best = null;
45
+ for (const [, cols] of byTable) {
46
+ const { schema, table } = cols[0];
47
+ const nameScore = hints.some((h) => table.toLowerCase().includes(h)) ? 10 : 0;
48
+ if (nameScore === 0)
49
+ continue;
50
+ const score = nameScore + cols.length / 100;
51
+ if (!best || score > best.score)
52
+ best = { schema, table, score };
53
+ }
54
+ return best ? { schema: best.schema, table: best.table } : null;
55
+ }
56
+ /**
57
+ * 컬럼명 휴리스틱으로 표준 필드 매핑을 제안한다.
58
+ * 제안은 후보일 뿐 — 운영자가 검토·확정한 매핑만 validate_mapping을 거쳐 저장된다.
59
+ */
60
+ function proposeMapping(columns) {
61
+ const tables = {
62
+ customer: pickTable(columns, CUSTOMER_TABLE_HINTS),
63
+ reservation: pickTable(columns, RESERVATION_TABLE_HINTS),
64
+ };
65
+ const candidates = HEURISTICS.map((h) => {
66
+ const target = tables[h.entity];
67
+ const pool = target
68
+ ? columns.filter((c) => c.schema === target.schema && c.table === target.table)
69
+ : columns;
70
+ for (const [rank, syn] of h.synonyms.entries()) {
71
+ const hit = pool.find((c) => c.column.toLowerCase() === syn);
72
+ if (hit) {
73
+ return {
74
+ entity: h.entity,
75
+ field: h.field,
76
+ required: h.required,
77
+ schema: hit.schema,
78
+ table: hit.table,
79
+ column: hit.column,
80
+ // 첫 후보 정확 일치는 high, 뒤로 갈수록(일반어 id 등) medium
81
+ confidence: rank === 0 ? "high" : "medium",
82
+ };
83
+ }
84
+ }
85
+ return {
86
+ entity: h.entity,
87
+ field: h.field,
88
+ required: h.required,
89
+ schema: null,
90
+ table: null,
91
+ column: null,
92
+ confidence: "none",
93
+ };
94
+ });
95
+ return { candidates, tables };
96
+ }
97
+ /** 필수 표준 필드 → 소속 엔터티 */
98
+ const REQUIRED_FIELD_ENTITY = {
99
+ customerKey: "customer",
100
+ phoneNumberHash: "customer",
101
+ marketingOptIn: "customer",
102
+ reservationId: "reservation",
103
+ productId: "reservation",
104
+ pickupCompletedAt: "reservation",
105
+ };
106
+ function validateMapping(mapping, columns) {
107
+ const missingRequired = [];
108
+ for (const field of contracts_1.REQUIRED_MAPPING_FIELDS) {
109
+ const entity = REQUIRED_FIELD_ENTITY[field];
110
+ if (!mapping[entity].fields[field])
111
+ missingRequired.push(field);
112
+ }
113
+ // 예약→고객 조인 키는 별도 필수
114
+ if (!mapping.reservation.fields.customerKey) {
115
+ missingRequired.push("reservation.customerKey");
116
+ }
117
+ const unknownColumns = [];
118
+ for (const entity of ["customer", "reservation"]) {
119
+ const { table, fields } = mapping[entity];
120
+ for (const [field, column] of Object.entries(fields)) {
121
+ const exists = columns.some((c) => c.schema === table.schema &&
122
+ c.table === table.table &&
123
+ c.column === column);
124
+ if (!exists) {
125
+ unknownColumns.push(`${entity}.${field} → ${table.schema}.${table.table}.${column}`);
126
+ }
127
+ }
128
+ }
129
+ return {
130
+ valid: missingRequired.length === 0 && unknownColumns.length === 0,
131
+ missingRequired,
132
+ unknownColumns,
133
+ };
134
+ }
@@ -0,0 +1,114 @@
1
+ import { type ApprovalScope, type ErrorCode } from "./contracts";
2
+ /**
3
+ * CRM API(플랫폼) 클라이언트.
4
+ * 승인 토큰의 시크릿·단회용 nonce 저장소는 플랫폼에만 있다 — 커넥터는
5
+ * 검증·소비를 플랫폼에 위임하고 결과 claims만 받는다 (03_CRM_API §인증 모델).
6
+ * 커넥터 인증: X-Connector-Id + Bearer connectorSecret (아웃바운드 전용).
7
+ */
8
+ export interface ApprovalClaims {
9
+ scope: ApprovalScope;
10
+ tenantId: string;
11
+ approvalId: string;
12
+ approverId: string;
13
+ snapshotHash: string | null;
14
+ projectVersion: string | null;
15
+ messageVersion: string | null;
16
+ }
17
+ export type ConsumeResult = {
18
+ ok: true;
19
+ claims: ApprovalClaims;
20
+ } | {
21
+ ok: false;
22
+ code: ErrorCode;
23
+ message: string;
24
+ };
25
+ export interface ProjectDefinition {
26
+ projectId: string;
27
+ projectVersion: string;
28
+ channel: "alimtalk" | "brand";
29
+ conditions: {
30
+ type: "repurchase";
31
+ /** 픽업완료 후 경과일 범위 (재구매 유도 윈도우) */
32
+ daysSincePickupMin: number;
33
+ daysSincePickupMax: number;
34
+ /** 상품 제한 (null = 전체) */
35
+ productIds: string[] | null;
36
+ };
37
+ }
38
+ export interface SendJobStatus {
39
+ sendJobId: string;
40
+ status: "queued" | "sending" | "done" | "failed";
41
+ results: Array<{
42
+ receiverRef: string;
43
+ status: "success" | "failed" | "retryable";
44
+ providerCode: string | null;
45
+ internalCode: string | null;
46
+ }>;
47
+ }
48
+ export interface EngagementReport {
49
+ identityKey: string;
50
+ engagementType: "click" | "reservation" | "pickup_completed";
51
+ campaignRef: string | null;
52
+ shortLinkRef: string | null;
53
+ reservationRef: string | null;
54
+ pickupAmount: number | null;
55
+ occurredAt: string;
56
+ }
57
+ export interface PlatformClient {
58
+ consumeApproval(input: {
59
+ token: string;
60
+ expectedScope: ApprovalScope;
61
+ snapshotHash?: string | null;
62
+ }): Promise<ConsumeResult>;
63
+ getProjectDefinition(projectId: string): Promise<ProjectDefinition>;
64
+ /** 반응 기록 중계 — 깔때기 리포트 집계용 (로컬 crm_engagements가 원본) */
65
+ reportEngagement(input: EngagementReport): Promise<void>;
66
+ createSendJob(input: {
67
+ approvalId: string;
68
+ snapshotId: string;
69
+ snapshotHash: string;
70
+ messageVersion: string;
71
+ channel: "alimtalk" | "brand";
72
+ targetCount: number;
73
+ /** 깔때기 리포트 귀속 기준 (스냅샷의 project_id) */
74
+ campaignRef: string | null;
75
+ }): Promise<{
76
+ sendJobId: string;
77
+ }>;
78
+ getSendJob(sendJobId: string): Promise<SendJobStatus>;
79
+ }
80
+ export interface HttpPlatformClientOptions {
81
+ platformUrl: string;
82
+ connectorId: string;
83
+ connectorSecret: string;
84
+ fetchImpl?: typeof fetch;
85
+ }
86
+ export declare class PlatformError extends Error {
87
+ readonly code: ErrorCode;
88
+ constructor(code: ErrorCode, message: string);
89
+ }
90
+ export declare class HttpPlatformClient implements PlatformClient {
91
+ private readonly opts;
92
+ private readonly fetchImpl;
93
+ constructor(opts: HttpPlatformClientOptions);
94
+ private request;
95
+ consumeApproval(input: {
96
+ token: string;
97
+ expectedScope: ApprovalScope;
98
+ snapshotHash?: string | null;
99
+ }): Promise<ConsumeResult>;
100
+ getProjectDefinition(projectId: string): Promise<ProjectDefinition>;
101
+ reportEngagement(input: EngagementReport): Promise<void>;
102
+ createSendJob(input: {
103
+ approvalId: string;
104
+ snapshotId: string;
105
+ snapshotHash: string;
106
+ messageVersion: string;
107
+ channel: "alimtalk" | "brand";
108
+ targetCount: number;
109
+ campaignRef: string | null;
110
+ }): Promise<{
111
+ sendJobId: string;
112
+ }>;
113
+ getSendJob(sendJobId: string): Promise<SendJobStatus>;
114
+ }