@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.
Files changed (88) hide show
  1. package/dist/app.d.ts +13 -0
  2. package/dist/app.d.ts.map +1 -1
  3. package/dist/app.js +9 -0
  4. package/dist/app.js.map +1 -1
  5. package/dist/boot.d.ts +42 -0
  6. package/dist/boot.d.ts.map +1 -0
  7. package/dist/boot.js +57 -0
  8. package/dist/boot.js.map +1 -0
  9. package/dist/index.d.ts +3 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +5 -0
  12. package/dist/index.js.map +1 -1
  13. package/dist/lib/config.d.ts +7 -0
  14. package/dist/lib/config.d.ts.map +1 -1
  15. package/dist/lib/config.js +2 -0
  16. package/dist/lib/config.js.map +1 -1
  17. package/dist/lib/kelpieConfigFile.d.ts +6 -0
  18. package/dist/lib/kelpieConfigFile.d.ts.map +1 -1
  19. package/dist/lib/kelpieConfigFile.js +1 -0
  20. package/dist/lib/kelpieConfigFile.js.map +1 -1
  21. package/dist/modules/eventCatalogs.d.ts +35 -0
  22. package/dist/modules/eventCatalogs.d.ts.map +1 -0
  23. package/dist/modules/eventCatalogs.js +35 -0
  24. package/dist/modules/eventCatalogs.js.map +1 -0
  25. package/dist/modules/import-export/drafts.d.ts +9 -0
  26. package/dist/modules/import-export/drafts.d.ts.map +1 -1
  27. package/dist/modules/import-export/drafts.js +25 -0
  28. package/dist/modules/import-export/drafts.js.map +1 -1
  29. package/dist/modules/import-export/exportRows.d.ts +9 -0
  30. package/dist/modules/import-export/exportRows.d.ts.map +1 -1
  31. package/dist/modules/import-export/exportRows.js +10 -1
  32. package/dist/modules/import-export/exportRows.js.map +1 -1
  33. package/dist/modules/import-export/plan.d.ts +23 -1
  34. package/dist/modules/import-export/plan.d.ts.map +1 -1
  35. package/dist/modules/import-export/plan.js +62 -3
  36. package/dist/modules/import-export/plan.js.map +1 -1
  37. package/dist/modules/import-export/presets.d.ts.map +1 -1
  38. package/dist/modules/import-export/presets.js +25 -0
  39. package/dist/modules/import-export/presets.js.map +1 -1
  40. package/dist/modules/import-export/repository.d.ts +17 -0
  41. package/dist/modules/import-export/repository.d.ts.map +1 -1
  42. package/dist/modules/import-export/repository.js +21 -1
  43. package/dist/modules/import-export/repository.js.map +1 -1
  44. package/dist/modules/import-export/routes.d.ts.map +1 -1
  45. package/dist/modules/import-export/routes.js +5 -1
  46. package/dist/modules/import-export/routes.js.map +1 -1
  47. package/dist/modules/import-export/schema.d.ts +55 -0
  48. package/dist/modules/import-export/schema.d.ts.map +1 -1
  49. package/dist/modules/import-export/schema.js +13 -1
  50. package/dist/modules/import-export/schema.js.map +1 -1
  51. package/dist/modules/import-export/service.d.ts +4 -1
  52. package/dist/modules/import-export/service.d.ts.map +1 -1
  53. package/dist/modules/import-export/service.js +51 -3
  54. package/dist/modules/import-export/service.js.map +1 -1
  55. package/dist/modules/import-export/tools.d.ts.map +1 -1
  56. package/dist/modules/import-export/tools.js +7 -1
  57. package/dist/modules/import-export/tools.js.map +1 -1
  58. package/dist/modules/import-export/writes.d.ts +16 -0
  59. package/dist/modules/import-export/writes.d.ts.map +1 -1
  60. package/dist/modules/import-export/writes.js +77 -2
  61. package/dist/modules/import-export/writes.js.map +1 -1
  62. package/dist/testing/app.d.ts +5 -1
  63. package/dist/testing/app.d.ts.map +1 -1
  64. package/dist/testing/app.js +2 -0
  65. package/dist/testing/app.js.map +1 -1
  66. package/migrations/0019_sharp_red_skull.sql +4 -0
  67. package/migrations/0020_early_nemesis.sql +2 -0
  68. package/migrations/meta/0019_snapshot.json +5138 -0
  69. package/migrations/meta/0020_snapshot.json +5138 -0
  70. package/migrations/meta/_journal.json +14 -0
  71. package/package.json +2 -2
  72. package/src/app.ts +26 -0
  73. package/src/boot.ts +89 -0
  74. package/src/index.ts +8 -0
  75. package/src/lib/config.ts +9 -0
  76. package/src/lib/kelpieConfigFile.ts +8 -0
  77. package/src/modules/eventCatalogs.ts +35 -0
  78. package/src/modules/import-export/drafts.ts +27 -0
  79. package/src/modules/import-export/exportRows.ts +10 -1
  80. package/src/modules/import-export/plan.ts +107 -5
  81. package/src/modules/import-export/presets.ts +25 -0
  82. package/src/modules/import-export/repository.ts +35 -1
  83. package/src/modules/import-export/routes.ts +5 -0
  84. package/src/modules/import-export/schema.ts +13 -0
  85. package/src/modules/import-export/service.ts +70 -4
  86. package/src/modules/import-export/tools.ts +9 -0
  87. package/src/modules/import-export/writes.ts +116 -3
  88. 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.6.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.6.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
- | { readonly object: 'people'; readonly draft: PersonDraft }
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
- | { readonly action: 'create'; readonly key: string; readonly write: ImportWrite }
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: { action: outcome.action, errors: outcome.errors, updatedAt: now },
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,