@kelpie/server 0.6.0 → 0.7.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.
- package/dist/app.d.ts +13 -0
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +9 -0
- package/dist/app.js.map +1 -1
- package/dist/boot.d.ts +42 -0
- package/dist/boot.d.ts.map +1 -0
- package/dist/boot.js +57 -0
- package/dist/boot.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/config.d.ts +7 -0
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +2 -0
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/kelpieConfigFile.d.ts +6 -0
- package/dist/lib/kelpieConfigFile.d.ts.map +1 -1
- package/dist/lib/kelpieConfigFile.js +1 -0
- package/dist/lib/kelpieConfigFile.js.map +1 -1
- package/dist/modules/eventCatalogs.d.ts +35 -0
- package/dist/modules/eventCatalogs.d.ts.map +1 -0
- package/dist/modules/eventCatalogs.js +35 -0
- package/dist/modules/eventCatalogs.js.map +1 -0
- package/dist/modules/import-export/drafts.d.ts +9 -0
- package/dist/modules/import-export/drafts.d.ts.map +1 -1
- package/dist/modules/import-export/drafts.js +25 -0
- package/dist/modules/import-export/drafts.js.map +1 -1
- package/dist/modules/import-export/exportRows.d.ts +9 -0
- package/dist/modules/import-export/exportRows.d.ts.map +1 -1
- package/dist/modules/import-export/exportRows.js +10 -1
- package/dist/modules/import-export/exportRows.js.map +1 -1
- package/dist/modules/import-export/plan.d.ts +23 -1
- package/dist/modules/import-export/plan.d.ts.map +1 -1
- package/dist/modules/import-export/plan.js +62 -3
- package/dist/modules/import-export/plan.js.map +1 -1
- package/dist/modules/import-export/presets.d.ts.map +1 -1
- package/dist/modules/import-export/presets.js +25 -0
- package/dist/modules/import-export/presets.js.map +1 -1
- package/dist/modules/import-export/repository.d.ts +17 -0
- package/dist/modules/import-export/repository.d.ts.map +1 -1
- package/dist/modules/import-export/repository.js +21 -1
- package/dist/modules/import-export/repository.js.map +1 -1
- package/dist/modules/import-export/routes.d.ts.map +1 -1
- package/dist/modules/import-export/routes.js +5 -1
- package/dist/modules/import-export/routes.js.map +1 -1
- package/dist/modules/import-export/schema.d.ts +55 -0
- package/dist/modules/import-export/schema.d.ts.map +1 -1
- package/dist/modules/import-export/schema.js +13 -1
- package/dist/modules/import-export/schema.js.map +1 -1
- package/dist/modules/import-export/service.d.ts +4 -1
- package/dist/modules/import-export/service.d.ts.map +1 -1
- package/dist/modules/import-export/service.js +51 -3
- package/dist/modules/import-export/service.js.map +1 -1
- package/dist/modules/import-export/tools.d.ts.map +1 -1
- package/dist/modules/import-export/tools.js +7 -1
- package/dist/modules/import-export/tools.js.map +1 -1
- package/dist/modules/import-export/writes.d.ts +16 -0
- package/dist/modules/import-export/writes.d.ts.map +1 -1
- package/dist/modules/import-export/writes.js +77 -2
- package/dist/modules/import-export/writes.js.map +1 -1
- package/dist/testing/app.d.ts +5 -1
- package/dist/testing/app.d.ts.map +1 -1
- package/dist/testing/app.js +2 -0
- package/dist/testing/app.js.map +1 -1
- package/migrations/0019_sharp_red_skull.sql +4 -0
- package/migrations/0020_early_nemesis.sql +2 -0
- package/migrations/meta/0019_snapshot.json +5138 -0
- package/migrations/meta/0020_snapshot.json +5138 -0
- package/migrations/meta/_journal.json +14 -0
- package/package.json +2 -2
- package/src/app.ts +26 -0
- package/src/boot.ts +89 -0
- package/src/index.ts +8 -0
- package/src/lib/config.ts +9 -0
- package/src/lib/kelpieConfigFile.ts +8 -0
- package/src/modules/eventCatalogs.ts +35 -0
- package/src/modules/import-export/drafts.ts +27 -0
- package/src/modules/import-export/exportRows.ts +10 -1
- package/src/modules/import-export/plan.ts +107 -5
- package/src/modules/import-export/presets.ts +25 -0
- package/src/modules/import-export/repository.ts +35 -1
- package/src/modules/import-export/routes.ts +5 -0
- package/src/modules/import-export/schema.ts +13 -0
- package/src/modules/import-export/service.ts +70 -4
- package/src/modules/import-export/tools.ts +9 -0
- package/src/modules/import-export/writes.ts +116 -3
- package/src/testing/app.ts +7 -1
|
@@ -134,6 +134,20 @@
|
|
|
134
134
|
"when": 1786656430307,
|
|
135
135
|
"tag": "0018_hard_mystique",
|
|
136
136
|
"breakpoints": true
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
"idx": 19,
|
|
140
|
+
"version": "7",
|
|
141
|
+
"when": 1787446086593,
|
|
142
|
+
"tag": "0019_sharp_red_skull",
|
|
143
|
+
"breakpoints": true
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
"idx": 20,
|
|
147
|
+
"version": "7",
|
|
148
|
+
"when": 1787447911296,
|
|
149
|
+
"tag": "0020_early_nemesis",
|
|
150
|
+
"breakpoints": true
|
|
137
151
|
}
|
|
138
152
|
]
|
|
139
153
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kelpie/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "The Kelpie service as a library: module runtime, core CRM modules, REST API, MCP surface, and the shared migration pipeline.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"kelpie",
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
},
|
|
53
53
|
"dependencies": {
|
|
54
54
|
"@hono/node-server": "^2.0.12",
|
|
55
|
-
"@kelpie/schemas": "^0.
|
|
55
|
+
"@kelpie/schemas": "^0.7.0",
|
|
56
56
|
"@node-rs/argon2": "^2.0.2",
|
|
57
57
|
"drizzle-orm": "^0.45.2",
|
|
58
58
|
"emittery": "^2.0.0",
|
package/src/app.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { Hono } from 'hono'
|
|
|
2
2
|
import type { Context } from 'hono'
|
|
3
3
|
import { cors } from 'hono/cors'
|
|
4
4
|
|
|
5
|
+
import type { RuntimeMode } from './lib/config.ts'
|
|
5
6
|
import type { DatabaseProbe } from './lib/database.ts'
|
|
6
7
|
import { AppError, internalErrorBody, toErrorBody } from './lib/errors.ts'
|
|
7
8
|
import { PUBLIC_ROUTE_PREFIX } from './lib/http.ts'
|
|
@@ -51,6 +52,18 @@ export interface AppDependencies {
|
|
|
51
52
|
* control (`testing/app.ts`).
|
|
52
53
|
*/
|
|
53
54
|
readonly resolveClientIp: (context: Context) => string
|
|
55
|
+
/**
|
|
56
|
+
* Reported through `GET /v1/public/config` so the browser knows which
|
|
57
|
+
* runtime it is talking to. Defaults to `'production'` so a caller that has
|
|
58
|
+
* not thought about it fails safe: no banner shows.
|
|
59
|
+
*/
|
|
60
|
+
readonly runtimeMode?: RuntimeMode
|
|
61
|
+
/**
|
|
62
|
+
* Reported through `GET /v1/public/config` so a non-production UI can name
|
|
63
|
+
* the site it is on. Undefined is fine and means "this deployment did not
|
|
64
|
+
* name itself".
|
|
65
|
+
*/
|
|
66
|
+
readonly siteName?: string | undefined
|
|
54
67
|
}
|
|
55
68
|
|
|
56
69
|
/** Per-request values the middleware chain sets and handlers read. */
|
|
@@ -161,6 +174,19 @@ export function createApp(dependencies: AppDependencies): Hono<AppBindings> {
|
|
|
161
174
|
app.route(PUBLIC_ROUTE_PREFIX, router)
|
|
162
175
|
}
|
|
163
176
|
|
|
177
|
+
// Public deployment metadata the browser reads once at boot. Sits under the
|
|
178
|
+
// public prefix so the CORS and rate-limit layers above cover it, and takes
|
|
179
|
+
// no credentials for the same reason /healthz does not: the pages that read
|
|
180
|
+
// it include the sign-in page, which runs before any session exists. The
|
|
181
|
+
// runtime mode is already visible in error messages, and the site name
|
|
182
|
+
// exists to be visible; nothing here is sensitive.
|
|
183
|
+
const runtimeMode: RuntimeMode = dependencies.runtimeMode ?? 'production'
|
|
184
|
+
const siteName = dependencies.siteName ?? null
|
|
185
|
+
|
|
186
|
+
app.get(`${PUBLIC_ROUTE_PREFIX}/config`, (context) =>
|
|
187
|
+
context.json({ runtime_mode: runtimeMode, site_name: siteName }, 200),
|
|
188
|
+
)
|
|
189
|
+
|
|
164
190
|
for (const { router } of dependencies.contributions.routers) {
|
|
165
191
|
app.route('/v1', router)
|
|
166
192
|
}
|
package/src/boot.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { Environment, KelpieConfig } from './lib/config.ts'
|
|
2
|
+
import { connectDatabase } from './lib/database.ts'
|
|
3
|
+
import type { DatabaseConnection } from './lib/database.ts'
|
|
4
|
+
import { createIdFactory } from './lib/ids.ts'
|
|
5
|
+
import type { IdFactory } from './lib/ids.ts'
|
|
6
|
+
import { resolveKelpieConfig } from './lib/kelpieConfigFile.ts'
|
|
7
|
+
import type { KelpieConfigInput } from './lib/kelpieConfigFile.ts'
|
|
8
|
+
import { createLogger, createTransportForDestination } from './lib/logger.ts'
|
|
9
|
+
import type { Logger } from './lib/logger.ts'
|
|
10
|
+
import { readModuleConfigFile } from './lib/moduleConfig.ts'
|
|
11
|
+
import { resolveActorFrom } from './modules/auth/credentials.ts'
|
|
12
|
+
import type { CredentialDependencies } from './modules/auth/credentials.ts'
|
|
13
|
+
import { createEventBus } from './runtime/events.ts'
|
|
14
|
+
import { registerModules } from './runtime/registry.ts'
|
|
15
|
+
import type { ModuleContributions } from './runtime/registry.ts'
|
|
16
|
+
import { createTransactionScope } from './runtime/transaction.ts'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A resolved assembly: config parsed, database connected, modules registered.
|
|
20
|
+
*
|
|
21
|
+
* This is the common prelude every entry point needs before it does its own
|
|
22
|
+
* work. `server.ts` goes on to build the app and serve; `migrate.ts` goes on to
|
|
23
|
+
* apply migrations and exit. Both start here, so the wiring lives in one place
|
|
24
|
+
* rather than drifting between two copies.
|
|
25
|
+
*
|
|
26
|
+
* The database is connected but not opened: postgres.js connects lazily, and
|
|
27
|
+
* registration issues no query, so a caller that only needs the contributions
|
|
28
|
+
* pays for no connection. The caller owns `database.close()`.
|
|
29
|
+
*/
|
|
30
|
+
export interface AssemblyBoot {
|
|
31
|
+
readonly config: KelpieConfig
|
|
32
|
+
readonly logger: Logger
|
|
33
|
+
readonly database: DatabaseConnection
|
|
34
|
+
readonly createId: IdFactory
|
|
35
|
+
readonly credentials: CredentialDependencies
|
|
36
|
+
readonly contributions: ModuleContributions
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Resolves an assembly's config, connects the database, and runs the module
|
|
41
|
+
* registration pass.
|
|
42
|
+
*
|
|
43
|
+
* Registration touches no database. It only collects what the modules
|
|
44
|
+
* contribute, migrations directories included, which is why an entry point can
|
|
45
|
+
* call this and then decide whether to serve, migrate, or both.
|
|
46
|
+
*
|
|
47
|
+
* @throws ConfigurationError when a required variable is missing or malformed.
|
|
48
|
+
* @throws ModuleConfigFileError when the module override file cannot be read.
|
|
49
|
+
* @throws ModuleBootError when the module list is invalid or a module fails to
|
|
50
|
+
* register. The caller maps these to a fix hint and an exit code.
|
|
51
|
+
*/
|
|
52
|
+
export async function bootAssembly(
|
|
53
|
+
kelpieConfig: KelpieConfigInput,
|
|
54
|
+
environment: Environment,
|
|
55
|
+
): Promise<AssemblyBoot> {
|
|
56
|
+
const config = resolveKelpieConfig(kelpieConfig, environment)
|
|
57
|
+
const logger = createLogger({
|
|
58
|
+
level: config.logging.level,
|
|
59
|
+
transports: config.logging.destinations.map(createTransportForDestination),
|
|
60
|
+
})
|
|
61
|
+
const database = connectDatabase(config.databaseUrl, logger)
|
|
62
|
+
const events = createEventBus(logger)
|
|
63
|
+
const createId = createIdFactory()
|
|
64
|
+
const credentials: CredentialDependencies = { db: database.db, now: () => new Date() }
|
|
65
|
+
const moduleConfig = readModuleConfigFile(config.moduleConfigPath)
|
|
66
|
+
const contributions = await registerModules({
|
|
67
|
+
modules: kelpieConfig.modules,
|
|
68
|
+
environment: config.env,
|
|
69
|
+
logger,
|
|
70
|
+
events,
|
|
71
|
+
moduleConfig,
|
|
72
|
+
resolveActor: (context) => resolveActorFrom(credentials, context),
|
|
73
|
+
services: {
|
|
74
|
+
db: database.db,
|
|
75
|
+
transaction: createTransactionScope({ db: database.db, bus: events, logger, createId }),
|
|
76
|
+
createId,
|
|
77
|
+
now: () => new Date(),
|
|
78
|
+
appBaseUrl: config.appBaseUrl,
|
|
79
|
+
secretEncryption: config.secretEncryption,
|
|
80
|
+
},
|
|
81
|
+
// `provider` picks a named sender from the runtime's registry. `'log'` is
|
|
82
|
+
// built in; `'smtp'` is registered by the built-in `smtp-email` core
|
|
83
|
+
// module; other names come from third-party provider modules listed in
|
|
84
|
+
// `kelpie.config.ts`. `from` is the address on every outgoing message.
|
|
85
|
+
email: { provider: config.email.EMAIL_PROVIDER, from: config.email.EMAIL_FROM },
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
return { config, logger, database, createId, credentials, contributions }
|
|
89
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
|
+
// Carries each module's `KelpieEventMap` augmentation into the emitted
|
|
2
|
+
// `index.d.ts` so consumers installed from npm get a typed `event.data`. See
|
|
3
|
+
// `modules/eventCatalogs.ts` for why the entry point has to reference them.
|
|
4
|
+
import './modules/eventCatalogs.ts'
|
|
5
|
+
|
|
1
6
|
export { createApp } from './app.ts'
|
|
2
7
|
export type { AppDependencies, AppBindings } from './app.ts'
|
|
3
8
|
|
|
9
|
+
export { bootAssembly } from './boot.ts'
|
|
10
|
+
export type { AssemblyBoot } from './boot.ts'
|
|
11
|
+
|
|
4
12
|
export { WebBundleError, serveWebBundle } from './webBundle.ts'
|
|
5
13
|
export type { WebBundleOptions } from './webBundle.ts'
|
|
6
14
|
|
package/src/lib/config.ts
CHANGED
|
@@ -39,6 +39,13 @@ export interface KelpieConfig {
|
|
|
39
39
|
readonly runtimeMode: RuntimeMode
|
|
40
40
|
readonly port: number
|
|
41
41
|
readonly databaseUrl: string
|
|
42
|
+
/**
|
|
43
|
+
* Human-readable name of this deployment ("dev", "demo", "cloud", or
|
|
44
|
+
* whatever the self-hoster picks). Undefined is normal. Exposed to the
|
|
45
|
+
* browser through `GET /v1/public/config` so a non-production UI can name
|
|
46
|
+
* the site it is on. Ignored when `runtimeMode` is `production`.
|
|
47
|
+
*/
|
|
48
|
+
readonly siteName: string | undefined
|
|
42
49
|
readonly logging: LoggingConfig
|
|
43
50
|
/**
|
|
44
51
|
* The provider name the module runtime looks up in its registry, and the
|
|
@@ -119,6 +126,7 @@ const environmentSchema = z.object({
|
|
|
119
126
|
.refine(isPostgresUrl, { message: 'must be a postgres:// or postgresql:// connection string' }),
|
|
120
127
|
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']),
|
|
121
128
|
KELPIE_MODULE_CONFIG_PATH: z.string().min(1).optional(),
|
|
129
|
+
KELPIE_SITE_NAME: z.string().min(1).optional(),
|
|
122
130
|
WEB_BUNDLE_DIR: z.string().min(1).optional(),
|
|
123
131
|
TRUSTED_PROXY_HOP_COUNT: z.coerce.number().int().nonnegative().default(0),
|
|
124
132
|
...rateLimitConfigSchema.shape,
|
|
@@ -156,6 +164,7 @@ export function loadConfig(environment: Environment): KelpieConfig {
|
|
|
156
164
|
},
|
|
157
165
|
email: emailResult.data,
|
|
158
166
|
moduleConfigPath: environmentResult.data.KELPIE_MODULE_CONFIG_PATH,
|
|
167
|
+
siteName: environmentResult.data.KELPIE_SITE_NAME,
|
|
159
168
|
webBundleDirectory: environmentResult.data.WEB_BUNDLE_DIR,
|
|
160
169
|
rateLimit: rateLimitConfigFrom(environmentResult.data),
|
|
161
170
|
trustedProxyHopCount: environmentResult.data.TRUSTED_PROXY_HOP_COUNT,
|
|
@@ -27,6 +27,12 @@ export interface KelpieConfigInput {
|
|
|
27
27
|
readonly webBundleDirectory?: ConfigValue<string | undefined>
|
|
28
28
|
/** Optional; unset in development, set to the deploy-time module override file. */
|
|
29
29
|
readonly moduleConfigPath?: ConfigValue<string | undefined>
|
|
30
|
+
/**
|
|
31
|
+
* Human-readable name of this deployment ("dev", "demo", "cloud"). Optional.
|
|
32
|
+
* Read by the browser through `GET /v1/public/config` so a non-production UI
|
|
33
|
+
* can name the site it is on. Ignored when `runtimeMode` is `production`.
|
|
34
|
+
*/
|
|
35
|
+
readonly siteName?: ConfigValue<string | undefined>
|
|
30
36
|
/** Optional; defaults to 0 (no proxy in front). */
|
|
31
37
|
readonly trustedProxyHopCount?: ConfigValue<number>
|
|
32
38
|
/**
|
|
@@ -173,6 +179,7 @@ export function resolveKelpieConfig(input: KelpieConfigInput, environment: Envir
|
|
|
173
179
|
},
|
|
174
180
|
email,
|
|
175
181
|
moduleConfigPath: resolved.moduleConfigPath,
|
|
182
|
+
siteName: resolved.siteName,
|
|
176
183
|
webBundleDirectory: resolved.webBundleDirectory,
|
|
177
184
|
rateLimit,
|
|
178
185
|
trustedProxyHopCount: resolved.trustedProxyHopCount ?? 0,
|
|
@@ -208,6 +215,7 @@ interface ResolvedInput {
|
|
|
208
215
|
}
|
|
209
216
|
readonly webBundleDirectory?: string | undefined
|
|
210
217
|
readonly moduleConfigPath?: string | undefined
|
|
218
|
+
readonly siteName?: string | undefined
|
|
211
219
|
readonly trustedProxyHopCount?: number
|
|
212
220
|
readonly appBaseUrl?: string
|
|
213
221
|
readonly secretEncryption?: ResolvedSecretEncryption
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loads every core module's `KelpieEventMap` augmentation at the package entry.
|
|
3
|
+
*
|
|
4
|
+
* Each module declares its event payload types in `<module>/events.ts` through
|
|
5
|
+
* `declare module '../../runtime/events.ts'`. Inside the monorepo those merge
|
|
6
|
+
* because the source graph reaches every file. A consumer that installs
|
|
7
|
+
* `@kelpie/server` from npm typechecks against `dist`, and declaration emit
|
|
8
|
+
* keeps only the imports a file actually references. Nothing in `dist/index.d.ts`
|
|
9
|
+
* referenced the per-module `events.d.ts` files, so `KelpieEventMap` stayed empty
|
|
10
|
+
* for consumers and every `subscribe` handler saw `event.data: unknown`.
|
|
11
|
+
*
|
|
12
|
+
* These side-effect imports give `index.ts` one reference into each catalog. The
|
|
13
|
+
* emitted `index.d.ts` carries them, so a consumer loads the augmentations and
|
|
14
|
+
* gets a typed `event.data` without a cast at the read site.
|
|
15
|
+
*
|
|
16
|
+
* List only modules whose `events.ts` augments `KelpieEventMap`. A module that
|
|
17
|
+
* publishes no events has no `events.ts` and does not belong here. Order mirrors
|
|
18
|
+
* `coreModules` in `core.ts` for readability; it has no runtime effect.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import './workspace/events.ts'
|
|
22
|
+
import './people/events.ts'
|
|
23
|
+
import './companies/events.ts'
|
|
24
|
+
import './positions/events.ts'
|
|
25
|
+
import './notes/events.ts'
|
|
26
|
+
import './deals/events.ts'
|
|
27
|
+
import './opportunities/events.ts'
|
|
28
|
+
import './partnerships/events.ts'
|
|
29
|
+
import './raises/events.ts'
|
|
30
|
+
import './hiring/events.ts'
|
|
31
|
+
import './plans/events.ts'
|
|
32
|
+
import './decisions/events.ts'
|
|
33
|
+
import './handbook/events.ts'
|
|
34
|
+
import './forms/events.ts'
|
|
35
|
+
import './import-export/events.ts'
|
|
@@ -174,6 +174,33 @@ export function personDraft(mapped: Readonly<Record<string, string>>): PersonDra
|
|
|
174
174
|
})
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
+
/**
|
|
178
|
+
* The company a People row's affiliation would create.
|
|
179
|
+
*
|
|
180
|
+
* Built from the row's `company_domain` and `company_name` cells, not the
|
|
181
|
+
* `name`/`domain` a Companies file uses. The name falls back to the raw domain
|
|
182
|
+
* so a create from a domain-only row is not a nameless company. The write lays
|
|
183
|
+
* `NEW_COMPANY_DEFAULTS` over this, the same as a Companies import.
|
|
184
|
+
*/
|
|
185
|
+
export function affiliationCompanyDraft(mapped: Readonly<Record<string, string>>): CompanyDraft {
|
|
186
|
+
const domainRaw = text(mapped, 'company_domain')
|
|
187
|
+
|
|
188
|
+
return present<CompanyDraft>({
|
|
189
|
+
name: text(mapped, 'company_name') ?? domainRaw,
|
|
190
|
+
domain: domainRaw === undefined ? undefined : (normaliseDomain(domainRaw) ?? undefined),
|
|
191
|
+
industry: undefined,
|
|
192
|
+
description: undefined,
|
|
193
|
+
stage: undefined,
|
|
194
|
+
sizeBand: undefined,
|
|
195
|
+
hq: undefined,
|
|
196
|
+
website: undefined,
|
|
197
|
+
accountType: undefined,
|
|
198
|
+
icpFit: undefined,
|
|
199
|
+
summary: undefined,
|
|
200
|
+
tags: undefined,
|
|
201
|
+
})
|
|
202
|
+
}
|
|
203
|
+
|
|
177
204
|
/**
|
|
178
205
|
* `value` is required, so `moneyToCents` has already been checked by
|
|
179
206
|
* `validateRow` and cannot be undefined here.
|
|
@@ -26,8 +26,17 @@ function joinList(values: readonly string[]): string {
|
|
|
26
26
|
return values.join('|')
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* The header row of an export and of a template.
|
|
31
|
+
*
|
|
32
|
+
* `importOnly` columns are left out: a People import may map `company_domain`,
|
|
33
|
+
* `company_name` and `title` to drive a Position, but a person holds many
|
|
34
|
+
* positions and none of them is a field on the person. Writing one on the export
|
|
35
|
+
* would be lossy, so the export and the template carry the round-trippable
|
|
36
|
+
* columns only, and each cell function matches this order one for one.
|
|
37
|
+
*/
|
|
29
38
|
export function headersFor(object: ImportObject): readonly string[] {
|
|
30
|
-
return OBJECT_COLUMNS[object].map((column) => column.key)
|
|
39
|
+
return OBJECT_COLUMNS[object].filter((column) => column.importOnly !== true).map((column) => column.key)
|
|
31
40
|
}
|
|
32
41
|
|
|
33
42
|
export function companyCells(row: ExportCompanyRow): readonly string[] {
|
|
@@ -3,10 +3,11 @@ import type {
|
|
|
3
3
|
ImportCounts,
|
|
4
4
|
ImportObject,
|
|
5
5
|
MatchKeyOption,
|
|
6
|
+
OnMissingCompany,
|
|
6
7
|
} from '@kelpie/schemas'
|
|
7
8
|
|
|
8
9
|
import { normaliseDomain, normaliseEmail } from '../../lib/normalisation.ts'
|
|
9
|
-
import { companyDraft, dealFieldsDraft, personDraft } from './drafts.ts'
|
|
10
|
+
import { affiliationCompanyDraft, companyDraft, dealFieldsDraft, personDraft } from './drafts.ts'
|
|
10
11
|
import type { CompanyDraft, DealFieldsDraft, PersonDraft } from './drafts.ts'
|
|
11
12
|
import { buildMatchKey, splitList } from './mapping.ts'
|
|
12
13
|
import { aliasedStageSlug } from './presets.ts'
|
|
@@ -29,6 +30,8 @@ export interface ImportLookups {
|
|
|
29
30
|
readonly existing: ReadonlyMap<string, string>
|
|
30
31
|
readonly personIdByEmail: ReadonlyMap<string, string>
|
|
31
32
|
readonly companyIdByDomain: ReadonlyMap<string, string>
|
|
33
|
+
/** Company id by its folded name, for a People affiliation matched by name. */
|
|
34
|
+
readonly companyIdByName: ReadonlyMap<string, string>
|
|
32
35
|
/** Workspace member id by the address of the user behind it. */
|
|
33
36
|
readonly memberIdByEmail: ReadonlyMap<string, string>
|
|
34
37
|
/** Deal stage id, keyed by both its slug and its folded label. */
|
|
@@ -39,13 +42,28 @@ export interface PlanContext {
|
|
|
39
42
|
readonly object: ImportObject
|
|
40
43
|
readonly matchKey: MatchKeyOption
|
|
41
44
|
readonly conflictMode: ImportConflictMode
|
|
45
|
+
/** What a People row does with an absent company. Ignored by the other objects. */
|
|
46
|
+
readonly onMissingCompany: OnMissingCompany
|
|
42
47
|
readonly lookups: ImportLookups
|
|
43
48
|
}
|
|
44
49
|
|
|
50
|
+
/**
|
|
51
|
+
* A company affiliation a People row asks for: the position to write, and
|
|
52
|
+
* whether the company must be created before it can be linked.
|
|
53
|
+
*/
|
|
54
|
+
export type PlannedAffiliation =
|
|
55
|
+
| { readonly kind: 'link'; readonly companyId: string; readonly title: string }
|
|
56
|
+
| { readonly kind: 'create'; readonly company: CompanyDraft; readonly title: string }
|
|
57
|
+
|
|
45
58
|
/** The values a write applies, once every reference in the row has been resolved. */
|
|
46
59
|
export type ImportWrite =
|
|
47
60
|
| { readonly object: 'companies'; readonly draft: CompanyDraft }
|
|
48
|
-
| {
|
|
61
|
+
| {
|
|
62
|
+
readonly object: 'people'
|
|
63
|
+
readonly draft: PersonDraft
|
|
64
|
+
/** A position to upsert alongside the person, when the row named a company and a title. */
|
|
65
|
+
readonly affiliation?: PlannedAffiliation
|
|
66
|
+
}
|
|
49
67
|
| {
|
|
50
68
|
readonly object: 'positions'
|
|
51
69
|
readonly personId: string
|
|
@@ -78,12 +96,19 @@ export type RowPlan =
|
|
|
78
96
|
* reaches the row the earlier one has been written, so it resolves to an id.
|
|
79
97
|
*/
|
|
80
98
|
| { readonly action: 'skip'; readonly key: string; readonly targetId: string | null }
|
|
81
|
-
| {
|
|
99
|
+
| {
|
|
100
|
+
readonly action: 'create'
|
|
101
|
+
readonly key: string
|
|
102
|
+
readonly write: ImportWrite
|
|
103
|
+
/** Non-fatal notes about the applied row, e.g. a People affiliation left unlinked. */
|
|
104
|
+
readonly warnings?: readonly StoredRowError[]
|
|
105
|
+
}
|
|
82
106
|
| {
|
|
83
107
|
readonly action: 'update'
|
|
84
108
|
readonly key: string
|
|
85
109
|
readonly targetId: string | null
|
|
86
110
|
readonly write: ImportWrite
|
|
111
|
+
readonly warnings?: readonly StoredRowError[]
|
|
87
112
|
}
|
|
88
113
|
|
|
89
114
|
export interface PlannedRow {
|
|
@@ -233,6 +258,80 @@ function resolveWrite(context: PlanContext, mapped: Readonly<Record<string, stri
|
|
|
233
258
|
}
|
|
234
259
|
}
|
|
235
260
|
|
|
261
|
+
/**
|
|
262
|
+
* The company affiliation a People row asks for, if any, and any note about it.
|
|
263
|
+
*
|
|
264
|
+
* A row states an affiliation only when it carries both a title and a company
|
|
265
|
+
* identity, so a blank optional cell still says nothing, the additive rule every
|
|
266
|
+
* column follows. The company is matched by domain when the row has one and by
|
|
267
|
+
* name otherwise. A named company that is not here follows `on_missing_company`:
|
|
268
|
+
* `create` invents it from the row, `skip` imports the person alone and returns
|
|
269
|
+
* a warning.
|
|
270
|
+
*/
|
|
271
|
+
function planAffiliation(
|
|
272
|
+
context: PlanContext,
|
|
273
|
+
mapped: Readonly<Record<string, string>>,
|
|
274
|
+
): { readonly affiliation?: PlannedAffiliation; readonly warnings: readonly StoredRowError[] } {
|
|
275
|
+
const title = (mapped.title ?? '').trim()
|
|
276
|
+
const domainRaw = (mapped.company_domain ?? '').trim()
|
|
277
|
+
const nameRaw = (mapped.company_name ?? '').trim()
|
|
278
|
+
|
|
279
|
+
if (title.length === 0 || (domainRaw.length === 0 && nameRaw.length === 0)) {
|
|
280
|
+
return { warnings: [] }
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const domain = normaliseDomain(domainRaw)
|
|
284
|
+
const companyId =
|
|
285
|
+
domainRaw.length > 0
|
|
286
|
+
? domain === null
|
|
287
|
+
? undefined
|
|
288
|
+
: context.lookups.companyIdByDomain.get(domain)
|
|
289
|
+
: context.lookups.companyIdByName.get(nameRaw.toLowerCase())
|
|
290
|
+
|
|
291
|
+
if (companyId !== undefined) {
|
|
292
|
+
return { affiliation: { kind: 'link', companyId, title }, warnings: [] }
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (context.onMissingCompany === 'create') {
|
|
296
|
+
return {
|
|
297
|
+
affiliation: { kind: 'create', company: affiliationCompanyDraft(mapped), title },
|
|
298
|
+
warnings: [],
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const named = domainRaw.length > 0 ? domainRaw : nameRaw
|
|
303
|
+
|
|
304
|
+
return {
|
|
305
|
+
warnings: [
|
|
306
|
+
{
|
|
307
|
+
field: domainRaw.length > 0 ? 'company_domain' : 'company_name',
|
|
308
|
+
message: `No company here matches "${named}", so the person imported without a position`,
|
|
309
|
+
},
|
|
310
|
+
],
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Folds a People row's affiliation into its create or update plan.
|
|
316
|
+
*
|
|
317
|
+
* A no-op for every other object, and for a person the row said nothing about a
|
|
318
|
+
* company for. The affiliation rides on the write; any note rides on the plan.
|
|
319
|
+
*/
|
|
320
|
+
function withAffiliation(
|
|
321
|
+
context: PlanContext,
|
|
322
|
+
mapped: Readonly<Record<string, string>>,
|
|
323
|
+
plan: Extract<RowPlan, { action: 'create' } | { action: 'update' }>,
|
|
324
|
+
): RowPlan {
|
|
325
|
+
if (context.object !== 'people' || plan.write.object !== 'people') {
|
|
326
|
+
return plan
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const { affiliation, warnings } = planAffiliation(context, mapped)
|
|
330
|
+
const write = affiliation === undefined ? plan.write : { ...plan.write, affiliation }
|
|
331
|
+
|
|
332
|
+
return { ...plan, write, ...(warnings.length > 0 ? { warnings } : {}) }
|
|
333
|
+
}
|
|
334
|
+
|
|
236
335
|
/**
|
|
237
336
|
* @param mapped The row's cells by Kelpie column, from `mapRow`.
|
|
238
337
|
* @returns What this row would do. `create` when its key matches nothing;
|
|
@@ -265,15 +364,18 @@ export function planRow(context: PlanContext, mapped: Readonly<Record<string, st
|
|
|
265
364
|
const targetId = context.lookups.existing.get(key)
|
|
266
365
|
|
|
267
366
|
if (targetId === undefined) {
|
|
268
|
-
return { action: 'create', key, write }
|
|
367
|
+
return withAffiliation(context, mapped, { action: 'create', key, write })
|
|
269
368
|
}
|
|
270
369
|
|
|
271
370
|
// An empty string is the placeholder an in-file match carries: the record it
|
|
272
371
|
// matched has not been written yet, so there is no id to report.
|
|
273
372
|
const resolved = targetId.length === 0 ? null : targetId
|
|
274
373
|
|
|
374
|
+
// A skipped row is left entirely alone, affiliation included. A caller who
|
|
375
|
+
// wants an existing person's position updated runs the job in `update` mode,
|
|
376
|
+
// which is where the rename-in-place lives.
|
|
275
377
|
return context.conflictMode === 'update'
|
|
276
|
-
? { action: 'update', key, targetId: resolved, write }
|
|
378
|
+
? withAffiliation(context, mapped, { action: 'update', key, targetId: resolved, write })
|
|
277
379
|
: { action: 'skip', key, targetId: resolved }
|
|
278
380
|
}
|
|
279
381
|
|
|
@@ -31,6 +31,8 @@ export const SOURCE_PRESETS: Readonly<Record<ImportSource, SourcePreset>> = {
|
|
|
31
31
|
email: 'Email',
|
|
32
32
|
phones: 'Phone Number',
|
|
33
33
|
location: 'City',
|
|
34
|
+
company_domain: 'Company Domain Name',
|
|
35
|
+
title: 'Job Title',
|
|
34
36
|
},
|
|
35
37
|
positions: {
|
|
36
38
|
person_email: 'Email',
|
|
@@ -60,6 +62,8 @@ export const SOURCE_PRESETS: Readonly<Record<ImportSource, SourcePreset>> = {
|
|
|
60
62
|
email: 'Email',
|
|
61
63
|
phones: 'Phone',
|
|
62
64
|
location: 'Mailing City',
|
|
65
|
+
company_name: 'Account Name',
|
|
66
|
+
title: 'Title',
|
|
63
67
|
},
|
|
64
68
|
positions: {
|
|
65
69
|
person_email: 'Email',
|
|
@@ -76,6 +80,27 @@ export const SOURCE_PRESETS: Readonly<Record<ImportSource, SourcePreset>> = {
|
|
|
76
80
|
external_id: 'Opportunity ID',
|
|
77
81
|
},
|
|
78
82
|
},
|
|
83
|
+
attio: {
|
|
84
|
+
// Attio's export writes one column per attribute, and a linked or nested
|
|
85
|
+
// attribute as `Parent > Child`. Only Companies and People are mapped: the
|
|
86
|
+
// sample export carries no Deals or Positions file, and an Attio People row
|
|
87
|
+
// names its company but not the company's domain, so a Position could not be
|
|
88
|
+
// keyed from it. Those objects fall back to `custom`.
|
|
89
|
+
companies: {
|
|
90
|
+
name: 'Record',
|
|
91
|
+
domain: 'Domains',
|
|
92
|
+
industry: 'Categories',
|
|
93
|
+
description: 'Description',
|
|
94
|
+
hq: 'Primary location > Country',
|
|
95
|
+
},
|
|
96
|
+
people: {
|
|
97
|
+
name: 'Record',
|
|
98
|
+
email: 'Email addresses',
|
|
99
|
+
location: 'Primary location > Country',
|
|
100
|
+
company_name: 'Company > Name',
|
|
101
|
+
title: 'Job title',
|
|
102
|
+
},
|
|
103
|
+
},
|
|
79
104
|
}
|
|
80
105
|
|
|
81
106
|
/**
|
|
@@ -175,6 +175,8 @@ export interface RowOutcome {
|
|
|
175
175
|
readonly rowNumber: number
|
|
176
176
|
readonly action: SettledRowAction
|
|
177
177
|
readonly errors: readonly { readonly field: string; readonly message: string }[]
|
|
178
|
+
/** Non-fatal notes about a row that was applied anyway, e.g. an unlinked position. */
|
|
179
|
+
readonly warnings: readonly { readonly field: string; readonly message: string }[]
|
|
178
180
|
}
|
|
179
181
|
|
|
180
182
|
/**
|
|
@@ -202,12 +204,18 @@ export async function recordRowOutcome(
|
|
|
202
204
|
values,
|
|
203
205
|
action: outcome.action,
|
|
204
206
|
errors: outcome.errors,
|
|
207
|
+
warnings: outcome.warnings,
|
|
205
208
|
createdAt: now,
|
|
206
209
|
updatedAt: now,
|
|
207
210
|
})
|
|
208
211
|
.onConflictDoUpdate({
|
|
209
212
|
target: [importJobRows.jobId, importJobRows.rowNumber],
|
|
210
|
-
set: {
|
|
213
|
+
set: {
|
|
214
|
+
action: outcome.action,
|
|
215
|
+
errors: outcome.errors,
|
|
216
|
+
warnings: outcome.warnings,
|
|
217
|
+
updatedAt: now,
|
|
218
|
+
},
|
|
211
219
|
})
|
|
212
220
|
}
|
|
213
221
|
|
|
@@ -381,6 +389,32 @@ export function findCompanyIdsByDomain(
|
|
|
381
389
|
)
|
|
382
390
|
}
|
|
383
391
|
|
|
392
|
+
/**
|
|
393
|
+
* Company ids by folded name, for a People affiliation matched on a company
|
|
394
|
+
* name rather than a domain.
|
|
395
|
+
*
|
|
396
|
+
* `lower(name) in (…)` rather than `ilike`, so a `%` inside a caller-supplied
|
|
397
|
+
* name is a literal, not a wildcard. The same rule `findCompanyKeys` follows.
|
|
398
|
+
* The caller passes names already lowercased.
|
|
399
|
+
*/
|
|
400
|
+
export function findCompanyIdsByName(
|
|
401
|
+
db: Queryable,
|
|
402
|
+
workspaceId: string,
|
|
403
|
+
names: readonly string[],
|
|
404
|
+
): Promise<{ name: string; id: string }[]> {
|
|
405
|
+
return overChunks(names, (chunk) =>
|
|
406
|
+
db
|
|
407
|
+
.select({ id: companies.id, name: companies.name })
|
|
408
|
+
.from(companies)
|
|
409
|
+
.where(
|
|
410
|
+
and(
|
|
411
|
+
eq(companies.workspaceId, workspaceId),
|
|
412
|
+
inArray(sql<string>`lower(${companies.name})`, [...chunk]),
|
|
413
|
+
),
|
|
414
|
+
),
|
|
415
|
+
)
|
|
416
|
+
}
|
|
417
|
+
|
|
384
418
|
/** Every member of the workspace, by the address of the user behind them. */
|
|
385
419
|
export function listMemberEmails(
|
|
386
420
|
db: Queryable,
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
IMPORT_OBJECTS,
|
|
4
4
|
IMPORT_SOURCES,
|
|
5
5
|
MAX_IMPORT_FILE_BYTES,
|
|
6
|
+
ON_MISSING_COMPANY,
|
|
6
7
|
defaultMatchKeyId,
|
|
7
8
|
} from '@kelpie/schemas'
|
|
8
9
|
import type { ImportColumnMap, ImportObject } from '@kelpie/schemas'
|
|
@@ -42,6 +43,7 @@ const createJobForm = z.object({
|
|
|
42
43
|
source: z.enum(IMPORT_SOURCES),
|
|
43
44
|
object: z.enum(IMPORT_OBJECTS),
|
|
44
45
|
conflict_mode: z.enum(IMPORT_CONFLICT_MODES).default('skip'),
|
|
46
|
+
on_missing_company: z.enum(ON_MISSING_COMPANY).default('skip'),
|
|
45
47
|
match_key: z.string().min(1).optional(),
|
|
46
48
|
column_map: z.string().optional(),
|
|
47
49
|
dry_run: z.enum(['true', 'false']).default('true'),
|
|
@@ -58,12 +60,14 @@ export function importJobResponse(job: ImportJobView): Record<string, unknown> {
|
|
|
58
60
|
object: job.object,
|
|
59
61
|
status: job.status,
|
|
60
62
|
conflict_mode: job.conflictMode,
|
|
63
|
+
on_missing_company: job.onMissingCompany,
|
|
61
64
|
match_key: job.matchKey,
|
|
62
65
|
column_map: job.columnMap,
|
|
63
66
|
source_headers: job.sourceHeaders,
|
|
64
67
|
file_name: job.fileName,
|
|
65
68
|
counts: job.counts,
|
|
66
69
|
errors: job.errors,
|
|
70
|
+
warnings: job.warnings,
|
|
67
71
|
preview: job.preview,
|
|
68
72
|
created_at: job.createdAt.toISOString(),
|
|
69
73
|
updated_at: job.updatedAt.toISOString(),
|
|
@@ -230,6 +234,7 @@ export function mountImportExportRoutes(
|
|
|
230
234
|
source: parsed.data.source,
|
|
231
235
|
object: parsed.data.object,
|
|
232
236
|
conflictMode: parsed.data.conflict_mode,
|
|
237
|
+
onMissingCompany: parsed.data.on_missing_company,
|
|
233
238
|
matchKeyId: parsed.data.match_key ?? defaultMatchKeyId(parsed.data.object),
|
|
234
239
|
columnMap: readColumnMap(parsed.data.column_map),
|
|
235
240
|
fileName: file.name.length === 0 ? null : file.name,
|