@kelpie/server 0.6.0 → 0.8.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/consumerEmailDomains.d.ts +2 -0
- package/dist/lib/consumerEmailDomains.d.ts.map +1 -0
- package/dist/lib/consumerEmailDomains.js +62 -0
- package/dist/lib/consumerEmailDomains.js.map +1 -0
- package/dist/lib/email.d.ts +7 -0
- package/dist/lib/email.d.ts.map +1 -1
- package/dist/lib/email.js.map +1 -1
- package/dist/lib/emailContent.d.ts +33 -0
- package/dist/lib/emailContent.d.ts.map +1 -0
- package/dist/lib/emailContent.js +54 -0
- package/dist/lib/emailContent.js.map +1 -0
- package/dist/lib/emailDomainAutoLink.d.ts +27 -0
- package/dist/lib/emailDomainAutoLink.d.ts.map +1 -0
- package/dist/lib/emailDomainAutoLink.js +119 -0
- package/dist/lib/emailDomainAutoLink.js.map +1 -0
- package/dist/lib/ids.d.ts +2 -0
- package/dist/lib/ids.d.ts.map +1 -1
- package/dist/lib/ids.js +2 -0
- package/dist/lib/ids.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/attachedRecords.d.ts.map +1 -1
- package/dist/modules/attachedRecords.js +4 -2
- package/dist/modules/attachedRecords.js.map +1 -1
- package/dist/modules/auth/preferences.d.ts +11 -1
- package/dist/modules/auth/preferences.d.ts.map +1 -1
- package/dist/modules/auth/preferences.js +11 -0
- package/dist/modules/auth/preferences.js.map +1 -1
- package/dist/modules/auth/routes.d.ts.map +1 -1
- package/dist/modules/auth/routes.js +18 -0
- package/dist/modules/auth/routes.js.map +1 -1
- package/dist/modules/auth/schema.d.ts +20 -0
- package/dist/modules/auth/schema.d.ts.map +1 -1
- package/dist/modules/auth/schema.js +11 -1
- package/dist/modules/auth/schema.js.map +1 -1
- package/dist/modules/auth/service.d.ts.map +1 -1
- package/dist/modules/auth/service.js +27 -3
- package/dist/modules/auth/service.js.map +1 -1
- package/dist/modules/companies/repository.d.ts +2 -0
- package/dist/modules/companies/repository.d.ts.map +1 -1
- package/dist/modules/companies/repository.js +1 -0
- package/dist/modules/companies/repository.js.map +1 -1
- package/dist/modules/companies/routes.d.ts +2 -0
- package/dist/modules/companies/routes.d.ts.map +1 -1
- package/dist/modules/companies/routes.js +11 -0
- package/dist/modules/companies/routes.js.map +1 -1
- package/dist/modules/companies/schema.d.ts +17 -0
- package/dist/modules/companies/schema.d.ts.map +1 -1
- package/dist/modules/companies/schema.js +5 -1
- package/dist/modules/companies/schema.js.map +1 -1
- package/dist/modules/companies/service.d.ts +1 -0
- package/dist/modules/companies/service.d.ts.map +1 -1
- package/dist/modules/companies/service.js +22 -0
- package/dist/modules/companies/service.js.map +1 -1
- package/dist/modules/core.d.ts.map +1 -1
- package/dist/modules/core.js +11 -0
- package/dist/modules/core.js.map +1 -1
- package/dist/modules/email-domain-linker/index.d.ts +12 -0
- package/dist/modules/email-domain-linker/index.d.ts.map +1 -0
- package/dist/modules/email-domain-linker/index.js +34 -0
- package/dist/modules/email-domain-linker/index.js.map +1 -0
- package/dist/modules/email-domain-linker/routes.d.ts +14 -0
- package/dist/modules/email-domain-linker/routes.d.ts.map +1 -0
- package/dist/modules/email-domain-linker/routes.js +15 -0
- package/dist/modules/email-domain-linker/routes.js.map +1 -0
- package/dist/modules/email-domain-linker/service.d.ts +37 -0
- package/dist/modules/email-domain-linker/service.d.ts.map +1 -0
- package/dist/modules/email-domain-linker/service.js +63 -0
- package/dist/modules/email-domain-linker/service.js.map +1 -0
- package/dist/modules/eventCatalogs.d.ts +36 -0
- package/dist/modules/eventCatalogs.d.ts.map +1 -0
- package/dist/modules/eventCatalogs.js +36 -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/modules/lists/events.d.ts +42 -0
- package/dist/modules/lists/events.d.ts.map +1 -0
- package/dist/modules/lists/events.js +16 -0
- package/dist/modules/lists/events.js.map +1 -0
- package/dist/modules/lists/index.d.ts +10 -0
- package/dist/modules/lists/index.d.ts.map +1 -0
- package/dist/modules/lists/index.js +35 -0
- package/dist/modules/lists/index.js.map +1 -0
- package/dist/modules/lists/repository.d.ts +69 -0
- package/dist/modules/lists/repository.d.ts.map +1 -0
- package/dist/modules/lists/repository.js +165 -0
- package/dist/modules/lists/repository.js.map +1 -0
- package/dist/modules/lists/routes.d.ts +51 -0
- package/dist/modules/lists/routes.d.ts.map +1 -0
- package/dist/modules/lists/routes.js +160 -0
- package/dist/modules/lists/routes.js.map +1 -0
- package/dist/modules/lists/schema.d.ts +264 -0
- package/dist/modules/lists/schema.d.ts.map +1 -0
- package/dist/modules/lists/schema.js +73 -0
- package/dist/modules/lists/schema.js.map +1 -0
- package/dist/modules/lists/service.d.ts +70 -0
- package/dist/modules/lists/service.d.ts.map +1 -0
- package/dist/modules/lists/service.js +214 -0
- package/dist/modules/lists/service.js.map +1 -0
- package/dist/modules/lists/tools.d.ts +4 -0
- package/dist/modules/lists/tools.d.ts.map +1 -0
- package/dist/modules/lists/tools.js +86 -0
- package/dist/modules/lists/tools.js.map +1 -0
- package/dist/modules/people/repository.d.ts +11 -0
- package/dist/modules/people/repository.d.ts.map +1 -1
- package/dist/modules/people/repository.js +16 -0
- package/dist/modules/people/repository.js.map +1 -1
- package/dist/modules/people/service.d.ts.map +1 -1
- package/dist/modules/people/service.js +18 -0
- package/dist/modules/people/service.js.map +1 -1
- package/dist/modules/positions/routes.d.ts.map +1 -1
- package/dist/modules/positions/routes.js +4 -2
- package/dist/modules/positions/routes.js.map +1 -1
- package/dist/modules/sample-data/fixture.d.ts +139 -0
- package/dist/modules/sample-data/fixture.d.ts.map +1 -0
- package/dist/modules/sample-data/fixture.js +461 -0
- package/dist/modules/sample-data/fixture.js.map +1 -0
- package/dist/modules/sample-data/index.d.ts +11 -0
- package/dist/modules/sample-data/index.d.ts.map +1 -0
- package/dist/modules/sample-data/index.js +43 -0
- package/dist/modules/sample-data/index.js.map +1 -0
- package/dist/modules/sample-data/routes.d.ts +15 -0
- package/dist/modules/sample-data/routes.d.ts.map +1 -0
- package/dist/modules/sample-data/routes.js +24 -0
- package/dist/modules/sample-data/routes.js.map +1 -0
- package/dist/modules/sample-data/service.d.ts +38 -0
- package/dist/modules/sample-data/service.d.ts.map +1 -0
- package/dist/modules/sample-data/service.js +412 -0
- package/dist/modules/sample-data/service.js.map +1 -0
- package/dist/modules/sample-data/tools.d.ts +4 -0
- package/dist/modules/sample-data/tools.d.ts.map +1 -0
- package/dist/modules/sample-data/tools.js +22 -0
- package/dist/modules/sample-data/tools.js.map +1 -0
- package/dist/modules/smtp-email/index.d.ts +1 -0
- package/dist/modules/smtp-email/index.d.ts.map +1 -1
- package/dist/modules/smtp-email/index.js +1 -0
- package/dist/modules/smtp-email/index.js.map +1 -1
- package/dist/modules/workspace/service.d.ts.map +1 -1
- package/dist/modules/workspace/service.js +10 -1
- package/dist/modules/workspace/service.js.map +1 -1
- package/dist/schema/index.d.ts +1 -0
- package/dist/schema/index.d.ts.map +1 -1
- package/dist/schema/index.js +1 -0
- package/dist/schema/index.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/0021_certain_maggott.sql +30 -0
- package/migrations/0022_stormy_manta.sql +1 -0
- package/migrations/0023_funny_maddog.sql +1 -0
- package/migrations/meta/0019_snapshot.json +5138 -0
- package/migrations/meta/0020_snapshot.json +5138 -0
- package/migrations/meta/0021_snapshot.json +5389 -0
- package/migrations/meta/0022_snapshot.json +5396 -0
- package/migrations/meta/0023_snapshot.json +5403 -0
- package/migrations/meta/_journal.json +35 -0
- package/package.json +3 -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/consumerEmailDomains.ts +62 -0
- package/src/lib/email.ts +7 -0
- package/src/lib/emailContent.ts +91 -0
- package/src/lib/emailDomainAutoLink.ts +165 -0
- package/src/lib/ids.ts +2 -0
- package/src/lib/kelpieConfigFile.ts +8 -0
- package/src/modules/attachedRecords.ts +9 -2
- package/src/modules/auth/preferences.ts +13 -1
- package/src/modules/auth/routes.ts +22 -0
- package/src/modules/auth/schema.ts +12 -2
- package/src/modules/auth/service.ts +37 -3
- package/src/modules/companies/repository.ts +3 -0
- package/src/modules/companies/routes.ts +13 -0
- package/src/modules/companies/schema.ts +5 -1
- package/src/modules/companies/service.ts +25 -0
- package/src/modules/core.ts +11 -0
- package/src/modules/email-domain-linker/index.ts +40 -0
- package/src/modules/email-domain-linker/routes.ts +40 -0
- package/src/modules/email-domain-linker/service.ts +122 -0
- package/src/modules/eventCatalogs.ts +36 -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/modules/lists/events.ts +41 -0
- package/src/modules/lists/index.ts +41 -0
- package/src/modules/lists/repository.ts +313 -0
- package/src/modules/lists/routes.ts +247 -0
- package/src/modules/lists/schema.ts +85 -0
- package/src/modules/lists/service.ts +404 -0
- package/src/modules/lists/tools.ts +128 -0
- package/src/modules/people/repository.ts +26 -0
- package/src/modules/people/service.ts +20 -0
- package/src/modules/positions/routes.ts +4 -2
- package/src/modules/sample-data/fixture.ts +627 -0
- package/src/modules/sample-data/index.ts +50 -0
- package/src/modules/sample-data/routes.ts +49 -0
- package/src/modules/sample-data/service.ts +590 -0
- package/src/modules/sample-data/tools.ts +32 -0
- package/src/modules/smtp-email/index.ts +8 -1
- package/src/modules/workspace/service.ts +13 -1
- package/src/schema/index.ts +1 -0
- package/src/testing/app.ts +7 -1
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
import { changedKeys } from '../../lib/changes.ts'
|
|
2
|
+
import { UNIQUE_VIOLATION, postgresErrorCode } from '../../lib/database.ts'
|
|
3
|
+
import type { Database } from '../../lib/database.ts'
|
|
4
|
+
import { AppError } from '../../lib/errors.ts'
|
|
5
|
+
import type { IdFactory } from '../../lib/ids.ts'
|
|
6
|
+
import { mapPage, readListWindow, toPage } from '../../lib/pagination.ts'
|
|
7
|
+
import type { ListQueryParameters, Page } from '../../lib/pagination.ts'
|
|
8
|
+
import type { TransactionScope } from '../../runtime/transaction.ts'
|
|
9
|
+
import { toEventActor } from '../../lib/actor.ts'
|
|
10
|
+
import type { Actor } from '../auth/actor.ts'
|
|
11
|
+
import { requireWorkspaceId } from '../auth/actor.ts'
|
|
12
|
+
import { missingTargets, resolveTargetNames, targetKey } from '../recordTargets.ts'
|
|
13
|
+
import type { RecordTargetType } from '../recordTargets.ts'
|
|
14
|
+
import './events.ts'
|
|
15
|
+
import * as repository from './repository.ts'
|
|
16
|
+
import {
|
|
17
|
+
DEFAULT_LIST_MEMBER_SORT,
|
|
18
|
+
DEFAULT_LIST_SORT,
|
|
19
|
+
LIST_MEMBER_SORTS,
|
|
20
|
+
LIST_SORTS,
|
|
21
|
+
} from './repository.ts'
|
|
22
|
+
import type {
|
|
23
|
+
ListFilters,
|
|
24
|
+
ListMemberFilters,
|
|
25
|
+
ListMemberRecord,
|
|
26
|
+
ListWithCount,
|
|
27
|
+
MembershipWithList,
|
|
28
|
+
} from './repository.ts'
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Lists: named collections of records of one type.
|
|
32
|
+
*
|
|
33
|
+
* The type is fixed at creation. A member's type must match the parent list's,
|
|
34
|
+
* a rule the database enforces through a composite foreign key. The service
|
|
35
|
+
* checks the same before the insert so a mismatch produces a friendly 422
|
|
36
|
+
* rather than a driver error the caller has to decode.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
export interface ListsDependencies {
|
|
40
|
+
readonly db: Database
|
|
41
|
+
readonly transaction: TransactionScope
|
|
42
|
+
readonly createId: IdFactory
|
|
43
|
+
readonly now: () => Date
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A list as the API returns one: the stored row minus the tenancy column. */
|
|
47
|
+
export type ListView = Omit<ListWithCount, 'workspaceId'>
|
|
48
|
+
|
|
49
|
+
/** A membership plus the list it points at, as `/v1/list-memberships` returns one. */
|
|
50
|
+
export interface ListMembershipView {
|
|
51
|
+
readonly id: string
|
|
52
|
+
readonly listId: string
|
|
53
|
+
readonly listName: string
|
|
54
|
+
readonly listTargetType: RecordTargetType
|
|
55
|
+
readonly targetType: RecordTargetType
|
|
56
|
+
readonly targetId: string
|
|
57
|
+
readonly addedAt: Date
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A member enriched with the resolved name of the record it points at. */
|
|
61
|
+
export interface ListMemberView {
|
|
62
|
+
readonly id: string
|
|
63
|
+
readonly listId: string
|
|
64
|
+
readonly targetType: RecordTargetType
|
|
65
|
+
readonly targetId: string
|
|
66
|
+
readonly targetName: string | null
|
|
67
|
+
readonly addedAt: Date
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface CreateListInput {
|
|
71
|
+
readonly name: string
|
|
72
|
+
readonly targetType: RecordTargetType
|
|
73
|
+
readonly description: string | null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** PATCH semantics: an absent field is left alone. The type never moves. */
|
|
77
|
+
export interface UpdateListInput {
|
|
78
|
+
readonly name?: string | undefined
|
|
79
|
+
readonly description?: string | null | undefined
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface AddListMemberInput {
|
|
83
|
+
readonly targetType: RecordTargetType
|
|
84
|
+
readonly targetId: string
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface ListsService {
|
|
88
|
+
list(actor: Actor, filters: ListFilters, query: ListQueryParameters): Promise<Page<ListView>>
|
|
89
|
+
get(actor: Actor, id: string): Promise<ListView>
|
|
90
|
+
create(actor: Actor, input: CreateListInput): Promise<ListView>
|
|
91
|
+
update(actor: Actor, id: string, changes: UpdateListInput): Promise<ListView>
|
|
92
|
+
remove(actor: Actor, id: string): Promise<void>
|
|
93
|
+
listMembers(
|
|
94
|
+
actor: Actor,
|
|
95
|
+
filters: ListMemberFilters,
|
|
96
|
+
query: ListQueryParameters,
|
|
97
|
+
): Promise<Page<ListMemberView>>
|
|
98
|
+
addMember(actor: Actor, listId: string, input: AddListMemberInput): Promise<ListMemberView>
|
|
99
|
+
removeMember(actor: Actor, listId: string, id: string): Promise<void>
|
|
100
|
+
membershipsFor(
|
|
101
|
+
actor: Actor,
|
|
102
|
+
targetType: RecordTargetType,
|
|
103
|
+
targetId: string,
|
|
104
|
+
): Promise<readonly ListMembershipView[]>
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function toView(record: ListWithCount): ListView {
|
|
108
|
+
const { workspaceId: _workspaceId, ...view } = record
|
|
109
|
+
|
|
110
|
+
return view
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function toMembershipView(row: MembershipWithList): ListMembershipView {
|
|
114
|
+
return {
|
|
115
|
+
id: row.id,
|
|
116
|
+
listId: row.listId,
|
|
117
|
+
listName: row.listName,
|
|
118
|
+
listTargetType: row.listTargetType as RecordTargetType,
|
|
119
|
+
targetType: row.targetType as RecordTargetType,
|
|
120
|
+
targetId: row.targetId,
|
|
121
|
+
addedAt: row.addedAt,
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function toMemberView(record: ListMemberRecord, name: string | undefined): ListMemberView {
|
|
126
|
+
return {
|
|
127
|
+
id: record.id,
|
|
128
|
+
listId: record.listId,
|
|
129
|
+
targetType: record.targetType as RecordTargetType,
|
|
130
|
+
targetId: record.targetId,
|
|
131
|
+
targetName: name ?? null,
|
|
132
|
+
addedAt: record.addedAt,
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function duplicateName(): AppError {
|
|
137
|
+
return AppError.conflict('Another list in this workspace already has that name', [
|
|
138
|
+
{ field: 'name', message: 'Already in use' },
|
|
139
|
+
])
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function typeMismatch(expected: RecordTargetType, got: RecordTargetType): AppError {
|
|
143
|
+
return AppError.validationFailed(
|
|
144
|
+
`That record cannot join a ${expected} list`,
|
|
145
|
+
[
|
|
146
|
+
{
|
|
147
|
+
field: 'target_type',
|
|
148
|
+
message: `Expected ${expected} to match the list's type, got ${got}`,
|
|
149
|
+
},
|
|
150
|
+
],
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function alreadyMember(): AppError {
|
|
155
|
+
return AppError.conflict('That record is already on the list', [
|
|
156
|
+
{ field: 'target_id', message: 'Already a member' },
|
|
157
|
+
])
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function createListsService(dependencies: ListsDependencies): ListsService {
|
|
161
|
+
async function require(workspaceId: string, id: string): Promise<ListWithCount> {
|
|
162
|
+
const list = await repository.findList(dependencies.db, workspaceId, id)
|
|
163
|
+
|
|
164
|
+
if (list === undefined) {
|
|
165
|
+
throw AppError.notFound('List not found')
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return list
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function requireTarget(
|
|
172
|
+
workspaceId: string,
|
|
173
|
+
targetType: RecordTargetType,
|
|
174
|
+
targetId: string,
|
|
175
|
+
): Promise<void> {
|
|
176
|
+
const missing = await missingTargets(dependencies.db, workspaceId, targetType, [targetId])
|
|
177
|
+
|
|
178
|
+
if (missing.length > 0) {
|
|
179
|
+
throw AppError.notFound('Record not found')
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
async list(actor, filters, query) {
|
|
185
|
+
const workspaceId = requireWorkspaceId(actor)
|
|
186
|
+
const window = readListWindow(query, LIST_SORTS, DEFAULT_LIST_SORT)
|
|
187
|
+
const rows = await repository.listLists(dependencies.db, workspaceId, filters, window)
|
|
188
|
+
|
|
189
|
+
return mapPage(toPage(rows, window, (row) => row.id), toView)
|
|
190
|
+
},
|
|
191
|
+
|
|
192
|
+
async get(actor, id) {
|
|
193
|
+
return toView(await require(requireWorkspaceId(actor), id))
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
async create(actor, input) {
|
|
197
|
+
const workspaceId = requireWorkspaceId(actor)
|
|
198
|
+
const id = dependencies.createId('list')
|
|
199
|
+
|
|
200
|
+
return dependencies.transaction(
|
|
201
|
+
async ({ tx, events }) => {
|
|
202
|
+
let created
|
|
203
|
+
try {
|
|
204
|
+
created = await repository.insertList(tx, {
|
|
205
|
+
id,
|
|
206
|
+
workspaceId,
|
|
207
|
+
name: input.name,
|
|
208
|
+
description: input.description,
|
|
209
|
+
targetType: input.targetType,
|
|
210
|
+
})
|
|
211
|
+
} catch (error: unknown) {
|
|
212
|
+
if (postgresErrorCode(error) === UNIQUE_VIOLATION) {
|
|
213
|
+
throw duplicateName()
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
throw error
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
events.emit('lists.list.created', { type: 'list', id: created.id }, {})
|
|
220
|
+
|
|
221
|
+
return toView({ ...created, memberCount: 0 })
|
|
222
|
+
},
|
|
223
|
+
{ workspaceId, actor: toEventActor(actor) },
|
|
224
|
+
)
|
|
225
|
+
},
|
|
226
|
+
|
|
227
|
+
async update(actor, id, changes) {
|
|
228
|
+
const workspaceId = requireWorkspaceId(actor)
|
|
229
|
+
const existing = await require(workspaceId, id)
|
|
230
|
+
const columns: Partial<repository.ListColumns> = {
|
|
231
|
+
...(changes.name === undefined ? {} : { name: changes.name }),
|
|
232
|
+
...(changes.description === undefined ? {} : { description: changes.description }),
|
|
233
|
+
}
|
|
234
|
+
const changed = changedKeys(existing, columns)
|
|
235
|
+
|
|
236
|
+
if (changed.length === 0) {
|
|
237
|
+
return toView(existing)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return dependencies.transaction(
|
|
241
|
+
async ({ tx, events }) => {
|
|
242
|
+
let updated
|
|
243
|
+
try {
|
|
244
|
+
updated = await repository.updateList(tx, workspaceId, id, {
|
|
245
|
+
...columns,
|
|
246
|
+
updatedAt: dependencies.now(),
|
|
247
|
+
})
|
|
248
|
+
} catch (error: unknown) {
|
|
249
|
+
if (postgresErrorCode(error) === UNIQUE_VIOLATION) {
|
|
250
|
+
throw duplicateName()
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
throw error
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (updated === undefined) {
|
|
257
|
+
throw AppError.notFound('List not found')
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
events.emit('lists.list.updated', { type: 'list', id }, { changed })
|
|
261
|
+
|
|
262
|
+
return toView({ ...updated, memberCount: existing.memberCount })
|
|
263
|
+
},
|
|
264
|
+
{ workspaceId, actor: toEventActor(actor) },
|
|
265
|
+
)
|
|
266
|
+
},
|
|
267
|
+
|
|
268
|
+
async remove(actor, id) {
|
|
269
|
+
const workspaceId = requireWorkspaceId(actor)
|
|
270
|
+
|
|
271
|
+
await dependencies.transaction(
|
|
272
|
+
async ({ tx, events }) => {
|
|
273
|
+
await require(workspaceId, id)
|
|
274
|
+
// Members cascade with the list; nothing else references it.
|
|
275
|
+
await repository.deleteList(tx, workspaceId, id)
|
|
276
|
+
|
|
277
|
+
events.emit('lists.list.deleted', { type: 'list', id }, {})
|
|
278
|
+
},
|
|
279
|
+
{ workspaceId, actor: toEventActor(actor) },
|
|
280
|
+
)
|
|
281
|
+
},
|
|
282
|
+
|
|
283
|
+
async listMembers(actor, filters, query) {
|
|
284
|
+
const workspaceId = requireWorkspaceId(actor)
|
|
285
|
+
// The list must exist. A caller reading members of a missing list gets a
|
|
286
|
+
// 404, not a silent empty page.
|
|
287
|
+
await require(workspaceId, filters.listId)
|
|
288
|
+
|
|
289
|
+
const window = readListWindow(query, LIST_MEMBER_SORTS, DEFAULT_LIST_MEMBER_SORT)
|
|
290
|
+
const rows = await repository.listListMembers(dependencies.db, workspaceId, filters, window)
|
|
291
|
+
const names = await resolveTargetNames(
|
|
292
|
+
dependencies.db,
|
|
293
|
+
workspaceId,
|
|
294
|
+
rows.map((row) => ({
|
|
295
|
+
targetType: row.targetType as RecordTargetType,
|
|
296
|
+
targetId: row.targetId,
|
|
297
|
+
})),
|
|
298
|
+
)
|
|
299
|
+
const page = toPage(rows, window, (row) => row.id)
|
|
300
|
+
|
|
301
|
+
return mapPage(page, (row) =>
|
|
302
|
+
toMemberView(
|
|
303
|
+
row,
|
|
304
|
+
names.get(
|
|
305
|
+
targetKey({
|
|
306
|
+
targetType: row.targetType as RecordTargetType,
|
|
307
|
+
targetId: row.targetId,
|
|
308
|
+
}),
|
|
309
|
+
),
|
|
310
|
+
),
|
|
311
|
+
)
|
|
312
|
+
},
|
|
313
|
+
|
|
314
|
+
async addMember(actor, listId, input) {
|
|
315
|
+
const workspaceId = requireWorkspaceId(actor)
|
|
316
|
+
const list = await require(workspaceId, listId)
|
|
317
|
+
|
|
318
|
+
if (list.targetType !== input.targetType) {
|
|
319
|
+
throw typeMismatch(list.targetType as RecordTargetType, input.targetType)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
await requireTarget(workspaceId, input.targetType, input.targetId)
|
|
323
|
+
|
|
324
|
+
const id = dependencies.createId('listMember')
|
|
325
|
+
|
|
326
|
+
return dependencies.transaction(
|
|
327
|
+
async ({ tx, events }) => {
|
|
328
|
+
let created
|
|
329
|
+
try {
|
|
330
|
+
created = await repository.insertListMember(tx, {
|
|
331
|
+
id,
|
|
332
|
+
workspaceId,
|
|
333
|
+
listId,
|
|
334
|
+
targetType: input.targetType,
|
|
335
|
+
targetId: input.targetId,
|
|
336
|
+
})
|
|
337
|
+
} catch (error: unknown) {
|
|
338
|
+
if (postgresErrorCode(error) === UNIQUE_VIOLATION) {
|
|
339
|
+
throw alreadyMember()
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
throw error
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
events.emit(
|
|
346
|
+
'lists.member.added',
|
|
347
|
+
{ type: input.targetType, id: input.targetId },
|
|
348
|
+
{ listId },
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
const names = await resolveTargetNames(tx, workspaceId, [
|
|
352
|
+
{ targetType: input.targetType, targetId: input.targetId },
|
|
353
|
+
])
|
|
354
|
+
|
|
355
|
+
return toMemberView(
|
|
356
|
+
created,
|
|
357
|
+
names.get(targetKey({ targetType: input.targetType, targetId: input.targetId })),
|
|
358
|
+
)
|
|
359
|
+
},
|
|
360
|
+
{ workspaceId, actor: toEventActor(actor) },
|
|
361
|
+
)
|
|
362
|
+
},
|
|
363
|
+
|
|
364
|
+
async membershipsFor(actor, targetType, targetId) {
|
|
365
|
+
const workspaceId = requireWorkspaceId(actor)
|
|
366
|
+
// The target must exist. A caller listing memberships of a missing record
|
|
367
|
+
// gets 404, not a silent empty list — same rule as notes/decisions.
|
|
368
|
+
await requireTarget(workspaceId, targetType, targetId)
|
|
369
|
+
|
|
370
|
+
const rows = await repository.listMembershipsForTarget(
|
|
371
|
+
dependencies.db,
|
|
372
|
+
workspaceId,
|
|
373
|
+
targetType,
|
|
374
|
+
targetId,
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
return rows.map(toMembershipView)
|
|
378
|
+
},
|
|
379
|
+
|
|
380
|
+
async removeMember(actor, listId, id) {
|
|
381
|
+
const workspaceId = requireWorkspaceId(actor)
|
|
382
|
+
await require(workspaceId, listId)
|
|
383
|
+
|
|
384
|
+
const existing = await repository.findListMember(dependencies.db, workspaceId, listId, id)
|
|
385
|
+
|
|
386
|
+
if (existing === undefined) {
|
|
387
|
+
throw AppError.notFound('List member not found')
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
await dependencies.transaction(
|
|
391
|
+
async ({ tx, events }) => {
|
|
392
|
+
await repository.deleteListMember(tx, workspaceId, listId, id)
|
|
393
|
+
|
|
394
|
+
events.emit(
|
|
395
|
+
'lists.member.removed',
|
|
396
|
+
{ type: existing.targetType as RecordTargetType, id: existing.targetId },
|
|
397
|
+
{ listId },
|
|
398
|
+
)
|
|
399
|
+
},
|
|
400
|
+
{ workspaceId, actor: toEventActor(actor) },
|
|
401
|
+
)
|
|
402
|
+
},
|
|
403
|
+
}
|
|
404
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { RECORD_TARGET_TYPES } from '@kelpie/schemas'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
|
|
4
|
+
import type { McpToolRegistry } from '../../runtime/module.ts'
|
|
5
|
+
import {
|
|
6
|
+
deleteResult,
|
|
7
|
+
idArg,
|
|
8
|
+
listWindowShape,
|
|
9
|
+
pageResult,
|
|
10
|
+
registerCrudTools,
|
|
11
|
+
toListQuery,
|
|
12
|
+
} from '../crudTools.ts'
|
|
13
|
+
import {
|
|
14
|
+
addMemberBody,
|
|
15
|
+
createBody,
|
|
16
|
+
listMemberResponse,
|
|
17
|
+
listMembershipResponse,
|
|
18
|
+
listResponse,
|
|
19
|
+
toAddMemberInput,
|
|
20
|
+
toCreateInput,
|
|
21
|
+
toUpdateInput,
|
|
22
|
+
updateBody,
|
|
23
|
+
} from './routes.ts'
|
|
24
|
+
import type { ListsService } from './service.ts'
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* `lists_*`. Same schemas and mappers as `/v1/lists`, so the two cannot drift.
|
|
28
|
+
*
|
|
29
|
+
* The list-level five come from `registerCrudTools`; the three membership tools
|
|
30
|
+
* are declared by hand because they hang off `/v1/lists/:id/members`, which does
|
|
31
|
+
* not fit the by-id CRUD shape.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
const listArgs = z.strictObject({
|
|
35
|
+
...listWindowShape,
|
|
36
|
+
q: z
|
|
37
|
+
.string()
|
|
38
|
+
.min(1)
|
|
39
|
+
.optional()
|
|
40
|
+
.describe('Free text. Matches list names.'),
|
|
41
|
+
target_type: z
|
|
42
|
+
.enum(RECORD_TARGET_TYPES)
|
|
43
|
+
.optional()
|
|
44
|
+
.describe('Only lists that hold this kind of record.'),
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
const membersListArgs = z.strictObject({
|
|
48
|
+
...listWindowShape,
|
|
49
|
+
list_id: idArg.describe('The list whose members you want.'),
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
const addMemberArgs = addMemberBody.extend({
|
|
53
|
+
list_id: idArg.describe('The list to add to. Its type must match target_type.'),
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
const removeMemberArgs = z.strictObject({
|
|
57
|
+
list_id: idArg.describe('The list to remove from.'),
|
|
58
|
+
id: idArg.describe('The membership id, from list_members_list.'),
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
const membershipsForArgs = z.strictObject({
|
|
62
|
+
target_type: z.enum(RECORD_TARGET_TYPES).describe('The kind of record.'),
|
|
63
|
+
target_id: idArg.describe('The record whose list memberships you want.'),
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
export function registerListsTools(mcp: McpToolRegistry, service: ListsService): void {
|
|
67
|
+
registerCrudTools(mcp, {
|
|
68
|
+
resource: 'lists',
|
|
69
|
+
subject: 'list',
|
|
70
|
+
about:
|
|
71
|
+
'A named collection of records of one type, chosen when the list is created and fixed for its lifetime.',
|
|
72
|
+
service,
|
|
73
|
+
render: listResponse,
|
|
74
|
+
listArgs,
|
|
75
|
+
toFilters: (args) => ({ term: args.q, targetType: args.target_type }),
|
|
76
|
+
createArgs: createBody,
|
|
77
|
+
toCreateInput,
|
|
78
|
+
updateArgs: updateBody.extend({ id: idArg }),
|
|
79
|
+
toUpdateInput,
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
mcp.tool({
|
|
83
|
+
name: 'list_members_list',
|
|
84
|
+
description:
|
|
85
|
+
'List the records on one list. Cursor paged. Mirrors GET /v1/lists/{id}/members.',
|
|
86
|
+
inputSchema: membersListArgs,
|
|
87
|
+
invoke: async (args, actor) =>
|
|
88
|
+
pageResult(
|
|
89
|
+
await service.listMembers(actor, { listId: args.list_id }, toListQuery(args)),
|
|
90
|
+
listMemberResponse,
|
|
91
|
+
),
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
mcp.tool({
|
|
95
|
+
name: 'list_members_add',
|
|
96
|
+
description:
|
|
97
|
+
'Add a record to a list. The record\'s type must match the list\'s. ' +
|
|
98
|
+
'Mirrors POST /v1/lists/{id}/members.',
|
|
99
|
+
inputSchema: addMemberArgs,
|
|
100
|
+
invoke: async (args, actor) =>
|
|
101
|
+
listMemberResponse(await service.addMember(actor, args.list_id, toAddMemberInput(args))),
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
mcp.tool({
|
|
105
|
+
name: 'list_members_remove',
|
|
106
|
+
description:
|
|
107
|
+
'Remove a record from a list. Mirrors DELETE /v1/lists/{id}/members/{memberId}.',
|
|
108
|
+
inputSchema: removeMemberArgs,
|
|
109
|
+
invoke: async (args, actor) => {
|
|
110
|
+
await service.removeMember(actor, args.list_id, args.id)
|
|
111
|
+
|
|
112
|
+
return deleteResult(args.id)
|
|
113
|
+
},
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
mcp.tool({
|
|
117
|
+
name: 'list_memberships_for',
|
|
118
|
+
description:
|
|
119
|
+
'Which lists is this record on? Returns one row per membership, with the ' +
|
|
120
|
+
'list joined in. Mirrors GET /v1/list-memberships.',
|
|
121
|
+
inputSchema: membershipsForArgs,
|
|
122
|
+
invoke: async (args, actor) => {
|
|
123
|
+
const memberships = await service.membershipsFor(actor, args.target_type, args.target_id)
|
|
124
|
+
|
|
125
|
+
return { data: memberships.map(listMembershipResponse), next_cursor: null }
|
|
126
|
+
},
|
|
127
|
+
})
|
|
128
|
+
}
|
|
@@ -132,6 +132,32 @@ export async function findPersonByEmail(
|
|
|
132
132
|
return found
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Every person in this workspace whose email is exactly at `domain`, case-
|
|
137
|
+
* insensitive. Used by the email-domain auto-linker when a Company is created
|
|
138
|
+
* or its domain is set.
|
|
139
|
+
*
|
|
140
|
+
* Not `ilike '%@domain'`: that also matches `alex@sub.domain`, which the
|
|
141
|
+
* person-side matcher (exact `email.slice(at + 1) = companies.domain`) would
|
|
142
|
+
* never link. `split_part` peels off the address at the last `@`, keeping the
|
|
143
|
+
* two directions consistent.
|
|
144
|
+
*/
|
|
145
|
+
export async function findPeopleByEmailDomain(
|
|
146
|
+
db: Queryable,
|
|
147
|
+
workspaceId: string,
|
|
148
|
+
domain: string,
|
|
149
|
+
): Promise<PersonRecord[]> {
|
|
150
|
+
return db
|
|
151
|
+
.select()
|
|
152
|
+
.from(people)
|
|
153
|
+
.where(
|
|
154
|
+
and(
|
|
155
|
+
eq(people.workspaceId, workspaceId),
|
|
156
|
+
sql`split_part(${people.email}::text, '@', 2) = lower(${domain})`,
|
|
157
|
+
),
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
|
|
135
161
|
export async function insertPerson(db: Queryable, values: PersonColumns): Promise<PersonRecord> {
|
|
136
162
|
const [created] = await db.insert(people).values(values).returning()
|
|
137
163
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { changedKeys } from '../../lib/changes.ts'
|
|
2
2
|
import { UNIQUE_VIOLATION, isReferenceViolation, postgresErrorCode } from '../../lib/database.ts'
|
|
3
3
|
import type { Database } from '../../lib/database.ts'
|
|
4
|
+
import { autoLinkPersonByEmailDomain } from '../../lib/emailDomainAutoLink.ts'
|
|
4
5
|
import { AppError } from '../../lib/errors.ts'
|
|
5
6
|
import type { IdFactory } from '../../lib/ids.ts'
|
|
6
7
|
import { normaliseEmail } from '../../lib/normalisation.ts'
|
|
@@ -200,6 +201,15 @@ export function createPeopleService(dependencies: PeopleDependencies): PeopleSer
|
|
|
200
201
|
|
|
201
202
|
events.emit('people.person.created', { type: 'person', id: created.id }, {})
|
|
202
203
|
|
|
204
|
+
// Sync auto-link. Inside the same transaction so the response returns
|
|
205
|
+
// with the Position already visible to a follow-up read; without this
|
|
206
|
+
// the UI would not see the new Position until a manual refresh.
|
|
207
|
+
await autoLinkPersonByEmailDomain(tx, events, workspaceId, created, {
|
|
208
|
+
createId: dependencies.createId,
|
|
209
|
+
now: dependencies.now,
|
|
210
|
+
recordActivity: dependencies.recordActivity,
|
|
211
|
+
})
|
|
212
|
+
|
|
203
213
|
return toView(created)
|
|
204
214
|
},
|
|
205
215
|
{ workspaceId, actor: toEventActor(actor) },
|
|
@@ -253,6 +263,16 @@ export function createPeopleService(dependencies: PeopleDependencies): PeopleSer
|
|
|
253
263
|
{ changed },
|
|
254
264
|
)
|
|
255
265
|
|
|
266
|
+
// Sync auto-link when the email moved. Same-transaction rationale as
|
|
267
|
+
// the create path above.
|
|
268
|
+
if (changed.includes('email')) {
|
|
269
|
+
await autoLinkPersonByEmailDomain(tx, events, workspaceId, updated, {
|
|
270
|
+
createId: dependencies.createId,
|
|
271
|
+
now: dependencies.now,
|
|
272
|
+
recordActivity: dependencies.recordActivity,
|
|
273
|
+
})
|
|
274
|
+
}
|
|
275
|
+
|
|
256
276
|
return toView(updated)
|
|
257
277
|
},
|
|
258
278
|
{ workspaceId, actor: toEventActor(actor) },
|
|
@@ -17,11 +17,13 @@ import type {
|
|
|
17
17
|
export const createBody = z.strictObject({
|
|
18
18
|
person_id: z.string().min(1),
|
|
19
19
|
company_id: z.string().min(1),
|
|
20
|
-
title
|
|
20
|
+
// An empty title records the link without a job title — a person known to be
|
|
21
|
+
// at the company, with the role not yet captured.
|
|
22
|
+
title: z.string(),
|
|
21
23
|
})
|
|
22
24
|
|
|
23
25
|
/** Only the title. Moving a link to a different person or company is a delete and a create. */
|
|
24
|
-
export const updateBody = z.strictObject({ title: z.string()
|
|
26
|
+
export const updateBody = z.strictObject({ title: z.string() }).partial()
|
|
25
27
|
|
|
26
28
|
export interface PositionsRoutesDependencies extends CredentialDependencies {
|
|
27
29
|
readonly service: PositionsService
|