@crombolo/transfer 0.11.0 → 0.12.1
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/package.json +3 -3
- package/src/check.ts +221 -28
- package/src/export.ts +46 -0
- package/src/import.ts +331 -193
- package/src/index.ts +3 -0
- package/src/load.ts +166 -2
- package/src/model.ts +59 -1
- package/src/package.ts +7 -0
- package/src/people-apply.ts +572 -0
- package/src/people.ts +167 -0
- package/src/resolutions.ts +69 -7
- package/src/revert.ts +44 -5
- package/src/seal.ts +129 -0
- package/src/write.ts +173 -22
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A bundle's people, applied (story 071): planned by key, then written in the
|
|
3
|
+
* content run's transaction and recorded on it, so a revert removes the people
|
|
4
|
+
* it created. docs/content-transfer.md, "People".
|
|
5
|
+
*
|
|
6
|
+
* The same key is the same person: updated, keeping a password set or
|
|
7
|
+
* re-hashed here. A different key whose e-mail or username is taken here is a
|
|
8
|
+
* decision — take the bundle's details onto the person here, or leave it out.
|
|
9
|
+
* Findings name people by key only.
|
|
10
|
+
*/
|
|
11
|
+
import { normaliseUuid, ObjectTypes, SystemNodes } from '@crombolo/core'
|
|
12
|
+
import {
|
|
13
|
+
ContentTypeRepository,
|
|
14
|
+
currentSchemaState,
|
|
15
|
+
type Db,
|
|
16
|
+
type Finding,
|
|
17
|
+
MemberRepository,
|
|
18
|
+
NodeRepository,
|
|
19
|
+
type NodeSchemaState,
|
|
20
|
+
type TransferRunRepository,
|
|
21
|
+
UserGroupRepository,
|
|
22
|
+
UserRepository,
|
|
23
|
+
} from '@crombolo/data'
|
|
24
|
+
import type { NodeResolutions, Resolution } from './check.ts'
|
|
25
|
+
import type { BundleMember, BundlePeople, BundleUser } from './people.ts'
|
|
26
|
+
|
|
27
|
+
export type PersonKind = 'member-group' | 'member' | 'user-group' | 'user'
|
|
28
|
+
|
|
29
|
+
export const PERSON_KINDS: readonly PersonKind[] = ['member-group', 'member', 'user-group', 'user']
|
|
30
|
+
|
|
31
|
+
export const isPersonKind = (kind: string): kind is PersonKind =>
|
|
32
|
+
(PERSON_KINDS as readonly string[]).includes(kind)
|
|
33
|
+
|
|
34
|
+
/** What the import would do to one person or group. */
|
|
35
|
+
export interface PersonPlan {
|
|
36
|
+
kind: PersonKind
|
|
37
|
+
/** The bundle's key. */
|
|
38
|
+
key: string
|
|
39
|
+
action: 'create' | 'update' | 'unchanged' | 'skip'
|
|
40
|
+
/** Where it lands here, when that is not its own key: matched by name, alias or answer. */
|
|
41
|
+
localKey?: string
|
|
42
|
+
/** The bundle's password replaces one set here: a new site's seeded administrator. */
|
|
43
|
+
takePassword?: boolean
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface PeoplePlan {
|
|
47
|
+
findings: Finding[]
|
|
48
|
+
plan: PersonPlan[]
|
|
49
|
+
counts: Record<PersonKind, { create: number; update: number; skip: number }>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A hash set or re-hashed here, which an incoming one never replaces. */
|
|
53
|
+
const isLocalHash = (hash: string | null | undefined): boolean =>
|
|
54
|
+
typeof hash === 'string' && hash.startsWith('$argon2')
|
|
55
|
+
|
|
56
|
+
const NOUN: Record<PersonKind, string> = {
|
|
57
|
+
'member-group': 'member group',
|
|
58
|
+
member: 'member',
|
|
59
|
+
'user-group': 'user group',
|
|
60
|
+
user: 'user',
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const finding = (
|
|
64
|
+
kind: Finding['kind'],
|
|
65
|
+
code: string,
|
|
66
|
+
subject: PersonKind,
|
|
67
|
+
key: string,
|
|
68
|
+
message: string,
|
|
69
|
+
): Finding => ({
|
|
70
|
+
kind,
|
|
71
|
+
code,
|
|
72
|
+
subjectType: subject,
|
|
73
|
+
subjectKey: key,
|
|
74
|
+
subjectName: key,
|
|
75
|
+
propertyAlias: null,
|
|
76
|
+
culture: null,
|
|
77
|
+
message,
|
|
78
|
+
link: null,
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
const answerOf = (
|
|
82
|
+
resolutions: Readonly<Record<string, NodeResolutions>>,
|
|
83
|
+
key: string,
|
|
84
|
+
all: Resolution | undefined,
|
|
85
|
+
): Resolution | undefined => resolutions[key]?.['*'] ?? all
|
|
86
|
+
|
|
87
|
+
/** Read-only: what applying these people here would do, and what needs a decision. */
|
|
88
|
+
export async function planPeople(
|
|
89
|
+
db: Db,
|
|
90
|
+
people: BundlePeople,
|
|
91
|
+
options: {
|
|
92
|
+
resolutions?: Readonly<Record<string, NodeResolutions>>
|
|
93
|
+
resolveAll?: Resolution | undefined
|
|
94
|
+
/** A user here the bundle's may take over without asking: a new site's seeded administrator. */
|
|
95
|
+
adoptUsers?: boolean
|
|
96
|
+
} = {},
|
|
97
|
+
): Promise<PeoplePlan> {
|
|
98
|
+
const resolutions = options.resolutions ?? {}
|
|
99
|
+
const findings: Finding[] = []
|
|
100
|
+
const plan: PersonPlan[] = []
|
|
101
|
+
const nodes = new NodeRepository(db)
|
|
102
|
+
const members = new MemberRepository(db)
|
|
103
|
+
const memberTypes = new ContentTypeRepository(db, { kind: 'member' })
|
|
104
|
+
const users = new UserRepository(db)
|
|
105
|
+
const userGroups = new UserGroupRepository(db)
|
|
106
|
+
|
|
107
|
+
for (const group of people.memberGroups) {
|
|
108
|
+
const here = await nodes.byKey(group.key)
|
|
109
|
+
if (here && here.objectType === ObjectTypes.MemberGroup) {
|
|
110
|
+
plan.push({
|
|
111
|
+
kind: 'member-group',
|
|
112
|
+
key: group.key,
|
|
113
|
+
action: here.text === group.name ? 'unchanged' : 'update',
|
|
114
|
+
})
|
|
115
|
+
continue
|
|
116
|
+
}
|
|
117
|
+
const named = await memberGroupNamed(db, group.name)
|
|
118
|
+
plan.push(
|
|
119
|
+
named
|
|
120
|
+
? { kind: 'member-group', key: group.key, action: 'unchanged', localKey: named }
|
|
121
|
+
: { kind: 'member-group', key: group.key, action: 'create' },
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
for (const group of people.userGroups) {
|
|
126
|
+
const here = (await userGroups.byKey(group.key)) ?? (await userGroups.byAlias(group.alias))
|
|
127
|
+
// A group here is this site's to manage: its permissions are not rewritten
|
|
128
|
+
// by a bundle, only its members added.
|
|
129
|
+
plan.push(
|
|
130
|
+
here
|
|
131
|
+
? { kind: 'user-group', key: group.key, action: 'unchanged', localKey: here.key }
|
|
132
|
+
: { kind: 'user-group', key: group.key, action: 'create' },
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const decide = (kind: PersonKind, key: string, clashKey: string): PersonPlan | undefined => {
|
|
137
|
+
const answer = answerOf(resolutions, key, options.resolveAll)
|
|
138
|
+
if (answer === 'skip') return { kind, key, action: 'skip' }
|
|
139
|
+
if (answer === 'take-bundle') return { kind, key, action: 'update', localKey: clashKey }
|
|
140
|
+
findings.push(
|
|
141
|
+
finding(
|
|
142
|
+
'person',
|
|
143
|
+
'person-conflict',
|
|
144
|
+
kind,
|
|
145
|
+
key,
|
|
146
|
+
`the ${NOUN[kind]} ${key} in the bundle has an e-mail address or username that ${NOUN[kind]} ${clashKey} already has here`,
|
|
147
|
+
),
|
|
148
|
+
)
|
|
149
|
+
return undefined
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const missingTypes = new Set<string>()
|
|
153
|
+
for (const member of people.members) {
|
|
154
|
+
if (!(await memberTypes.byAlias(member.memberTypeAlias)))
|
|
155
|
+
missingTypes.add(member.memberTypeAlias)
|
|
156
|
+
const here = await members.byKey(member.key)
|
|
157
|
+
if (here) {
|
|
158
|
+
plan.push({ kind: 'member', key: member.key, action: 'update' })
|
|
159
|
+
continue
|
|
160
|
+
}
|
|
161
|
+
const clash =
|
|
162
|
+
(await memberKeyWhere(db, 'email', member.email)) ??
|
|
163
|
+
(await memberKeyWhere(db, 'login_name', member.username))
|
|
164
|
+
if (!clash) {
|
|
165
|
+
plan.push({ kind: 'member', key: member.key, action: 'create' })
|
|
166
|
+
continue
|
|
167
|
+
}
|
|
168
|
+
const decided = decide('member', member.key, clash)
|
|
169
|
+
if (decided) plan.push(decided)
|
|
170
|
+
}
|
|
171
|
+
for (const alias of missingTypes)
|
|
172
|
+
findings.push(
|
|
173
|
+
finding(
|
|
174
|
+
'blocking',
|
|
175
|
+
'missing-member-type',
|
|
176
|
+
'member',
|
|
177
|
+
alias,
|
|
178
|
+
`no member type "${alias}" here — deploy schema/ before importing these members`,
|
|
179
|
+
),
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
for (const user of people.users) {
|
|
183
|
+
if (await users.byKey(user.key)) {
|
|
184
|
+
plan.push({
|
|
185
|
+
kind: 'user',
|
|
186
|
+
key: user.key,
|
|
187
|
+
action: 'update',
|
|
188
|
+
...(options.adoptUsers ? { takePassword: true } : {}),
|
|
189
|
+
})
|
|
190
|
+
continue
|
|
191
|
+
}
|
|
192
|
+
const clash = (await users.byUserName(user.userName)) ?? (await users.byEmail(user.email))
|
|
193
|
+
if (!clash) {
|
|
194
|
+
plan.push({ kind: 'user', key: user.key, action: 'create' })
|
|
195
|
+
continue
|
|
196
|
+
}
|
|
197
|
+
if (options.adoptUsers) {
|
|
198
|
+
plan.push({
|
|
199
|
+
kind: 'user',
|
|
200
|
+
key: user.key,
|
|
201
|
+
action: 'update',
|
|
202
|
+
localKey: clash.key,
|
|
203
|
+
takePassword: true,
|
|
204
|
+
})
|
|
205
|
+
findings.push(
|
|
206
|
+
finding(
|
|
207
|
+
'auto',
|
|
208
|
+
'user-adopted',
|
|
209
|
+
'user',
|
|
210
|
+
user.key,
|
|
211
|
+
`user ${clash.key} here becomes the bundle's user ${user.key}, with the password it had there`,
|
|
212
|
+
),
|
|
213
|
+
)
|
|
214
|
+
continue
|
|
215
|
+
}
|
|
216
|
+
const decided = decide('user', user.key, clash.key)
|
|
217
|
+
if (decided) plan.push(decided)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const counts = Object.fromEntries(
|
|
221
|
+
PERSON_KINDS.map((kind) => [kind, { create: 0, update: 0, skip: 0 }]),
|
|
222
|
+
) as PeoplePlan['counts']
|
|
223
|
+
for (const entry of plan) if (entry.action !== 'unchanged') counts[entry.kind][entry.action] += 1
|
|
224
|
+
for (const kind of PERSON_KINDS) {
|
|
225
|
+
const c = counts[kind]
|
|
226
|
+
if (c.create + c.update > 0)
|
|
227
|
+
findings.push(
|
|
228
|
+
finding(
|
|
229
|
+
'auto',
|
|
230
|
+
'people',
|
|
231
|
+
kind,
|
|
232
|
+
kind,
|
|
233
|
+
`${c.create} ${NOUN[kind]}(s) created, ${c.update} updated${c.skip > 0 ? `, ${c.skip} left out` : ''}`,
|
|
234
|
+
),
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
return { findings, plan, counts }
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function memberGroupNamed(db: Db, name: string): Promise<string | undefined> {
|
|
241
|
+
const rows = await db.query<{ unique_id: string }>(
|
|
242
|
+
'SELECT unique_id FROM node WHERE node_object_type = ? AND text = ?',
|
|
243
|
+
[ObjectTypes.MemberGroup, name],
|
|
244
|
+
)
|
|
245
|
+
return rows[0] ? normaliseUuid(String(rows[0].unique_id)) : undefined
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function memberKeyWhere(
|
|
249
|
+
db: Db,
|
|
250
|
+
column: 'email' | 'login_name',
|
|
251
|
+
value: string,
|
|
252
|
+
): Promise<string | undefined> {
|
|
253
|
+
const rows = await db.query<{ unique_id: string }>(
|
|
254
|
+
`SELECT n.unique_id FROM member m JOIN node n ON n.id = m.node_id WHERE LOWER(m.${column}) = LOWER(?)`,
|
|
255
|
+
[value],
|
|
256
|
+
)
|
|
257
|
+
return rows[0] ? normaliseUuid(String(rows[0].unique_id)) : undefined
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** What one applied person was before: enough for a revert to remove what it created. */
|
|
261
|
+
export interface AppliedPerson {
|
|
262
|
+
kind: PersonKind
|
|
263
|
+
key: string
|
|
264
|
+
action: 'create' | 'update'
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Writes the plan, inside the caller's transaction, and records each on the run. */
|
|
268
|
+
export async function applyPeople(
|
|
269
|
+
tx: Db,
|
|
270
|
+
people: BundlePeople,
|
|
271
|
+
plan: readonly PersonPlan[],
|
|
272
|
+
run: { id: string; runs: TransferRunRepository; nodeState: NodeSchemaState; nodeId?: string },
|
|
273
|
+
): Promise<AppliedPerson[]> {
|
|
274
|
+
const applied: AppliedPerson[] = []
|
|
275
|
+
const planFor = new Map(plan.map((p) => [`${p.kind}|${p.key}`, p]))
|
|
276
|
+
const nodes = new NodeRepository(tx)
|
|
277
|
+
const record = async (kind: PersonKind, key: string, action: 'create' | 'update') => {
|
|
278
|
+
applied.push({ kind, key, action })
|
|
279
|
+
await run.runs.record({
|
|
280
|
+
runId: run.id,
|
|
281
|
+
nodeKey: key,
|
|
282
|
+
kind,
|
|
283
|
+
action,
|
|
284
|
+
beforeEventId: null,
|
|
285
|
+
beforePublishedEventId: null,
|
|
286
|
+
eventId: null,
|
|
287
|
+
})
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Member groups first: memberships name them.
|
|
291
|
+
const memberGroupKey = new Map<string, string>()
|
|
292
|
+
for (const group of people.memberGroups) {
|
|
293
|
+
const p = planFor.get(`member-group|${group.key}`)
|
|
294
|
+
if (!p || p.action === 'skip') continue
|
|
295
|
+
memberGroupKey.set(group.key, p.localKey ?? group.key)
|
|
296
|
+
if (p.action === 'create') {
|
|
297
|
+
await nodes.create({
|
|
298
|
+
key: group.key,
|
|
299
|
+
parentId: SystemNodes.Root,
|
|
300
|
+
objectType: ObjectTypes.MemberGroup,
|
|
301
|
+
text: group.name,
|
|
302
|
+
})
|
|
303
|
+
await record('member-group', group.key, 'create')
|
|
304
|
+
} else if (p.action === 'update') {
|
|
305
|
+
const here = await nodes.byKey(group.key)
|
|
306
|
+
if (here) await nodes.rename(here.id, group.name)
|
|
307
|
+
await record('member-group', group.key, 'update')
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const userGroups = new UserGroupRepository(tx)
|
|
312
|
+
const userGroupKey = new Map<string, string>()
|
|
313
|
+
for (const group of people.userGroups) {
|
|
314
|
+
const p = planFor.get(`user-group|${group.key}`)
|
|
315
|
+
if (!p || p.action === 'skip') continue
|
|
316
|
+
userGroupKey.set(group.alias, p.localKey ?? group.key)
|
|
317
|
+
if (p.action !== 'create') continue
|
|
318
|
+
const starts = async (key: string | null) =>
|
|
319
|
+
key && (await nodes.byKey(key)) ? { root: false, key } : { root: false, key: null }
|
|
320
|
+
const saved = await userGroups.save({
|
|
321
|
+
key: group.key,
|
|
322
|
+
alias: group.alias,
|
|
323
|
+
name: group.name,
|
|
324
|
+
description: null,
|
|
325
|
+
icon: group.icon,
|
|
326
|
+
sections: group.sections,
|
|
327
|
+
languages: [],
|
|
328
|
+
hasAccessToAllLanguages: group.hasAccessToAllLanguages || group.languages.length > 0,
|
|
329
|
+
startNodes: {
|
|
330
|
+
document: await starts(group.documentStartNodeKey),
|
|
331
|
+
media: await starts(group.mediaStartNodeKey),
|
|
332
|
+
element: { root: false, key: null },
|
|
333
|
+
},
|
|
334
|
+
permissions: group.permissions,
|
|
335
|
+
granular: [],
|
|
336
|
+
})
|
|
337
|
+
if (saved !== 'saved') throw new Error(`The user group "${group.alias}" could not be created.`)
|
|
338
|
+
await record('user-group', group.key, 'create')
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const members = new MemberRepository(tx, { nodeState: run.nodeState, nodeId: run.nodeId })
|
|
342
|
+
const memberTypes = new ContentTypeRepository(tx, { kind: 'member' })
|
|
343
|
+
for (const member of people.members) {
|
|
344
|
+
const p = planFor.get(`member|${member.key}`)
|
|
345
|
+
if (!p || p.action === 'skip' || p.action === 'unchanged') continue
|
|
346
|
+
const type = await memberTypes.byAlias(member.memberTypeAlias)
|
|
347
|
+
if (!type)
|
|
348
|
+
throw new Error(
|
|
349
|
+
`No member type "${member.memberTypeAlias}", which the check should have refused.`,
|
|
350
|
+
)
|
|
351
|
+
const groupKeys = member.groupKeys.flatMap((k) => memberGroupKey.get(k) ?? [])
|
|
352
|
+
const target = p.localKey ?? member.key
|
|
353
|
+
const here = p.action === 'update' ? await members.byKey(target) : undefined
|
|
354
|
+
const content = memberContent(
|
|
355
|
+
member,
|
|
356
|
+
type.key,
|
|
357
|
+
target,
|
|
358
|
+
here ? await currentValues(members, target) : [],
|
|
359
|
+
)
|
|
360
|
+
const identity = {
|
|
361
|
+
email: member.email,
|
|
362
|
+
username: member.username,
|
|
363
|
+
isApproved: member.isApproved,
|
|
364
|
+
isLockedOut: member.isLockedOut,
|
|
365
|
+
groupKeys,
|
|
366
|
+
...(here && isLocalHash(here.passwordHash)
|
|
367
|
+
? {}
|
|
368
|
+
: { passwordHash: member.passwordHash, passwordConfig: member.passwordConfig }),
|
|
369
|
+
}
|
|
370
|
+
if (here) {
|
|
371
|
+
await members.update(target, content, identity)
|
|
372
|
+
await record('member', target, 'update')
|
|
373
|
+
} else {
|
|
374
|
+
await members.create(content, identity)
|
|
375
|
+
await record('member', member.key, 'create')
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const users = new UserRepository(tx)
|
|
380
|
+
for (const user of people.users) {
|
|
381
|
+
const p = planFor.get(`user|${user.key}`)
|
|
382
|
+
if (!p || p.action === 'skip' || p.action === 'unchanged') continue
|
|
383
|
+
const groupKeys = user.groupAliases.flatMap((alias) => userGroupKey.get(alias) ?? [])
|
|
384
|
+
const target = p.localKey ?? user.key
|
|
385
|
+
const here = p.action === 'update' ? await users.byKey(target) : undefined
|
|
386
|
+
if (here) {
|
|
387
|
+
const outcome = await users.update(target, {
|
|
388
|
+
name: user.name,
|
|
389
|
+
userName: user.userName,
|
|
390
|
+
email: user.email,
|
|
391
|
+
languageIsoCode: user.languageIsoCode,
|
|
392
|
+
groupKeys: [...new Set([...here.groupKeys, ...groupKeys])],
|
|
393
|
+
startNodes: here.startNodes,
|
|
394
|
+
})
|
|
395
|
+
if (outcome !== 'updated')
|
|
396
|
+
throw new Error(`The user ${target} could not be updated (${outcome}).`)
|
|
397
|
+
if (p.takePassword || !isLocalHash(await users.passwordHash(target)))
|
|
398
|
+
await setUserPassword(tx, target, user)
|
|
399
|
+
await record('user', target, 'update')
|
|
400
|
+
} else {
|
|
401
|
+
const created = await users.create({
|
|
402
|
+
key: user.key,
|
|
403
|
+
name: user.name,
|
|
404
|
+
userName: user.userName,
|
|
405
|
+
email: user.email,
|
|
406
|
+
kind: 'Default',
|
|
407
|
+
groupKeys,
|
|
408
|
+
languageIsoCode: user.languageIsoCode,
|
|
409
|
+
})
|
|
410
|
+
if (!created) throw new Error(`The user ${user.key} could not be created.`)
|
|
411
|
+
await setUserPassword(tx, user.key, user)
|
|
412
|
+
await record('user', user.key, 'create')
|
|
413
|
+
}
|
|
414
|
+
await users.setApproved([target], !user.disabled)
|
|
415
|
+
}
|
|
416
|
+
return applied
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
async function setUserPassword(tx: Db, key: string, user: BundleUser): Promise<void> {
|
|
420
|
+
await tx.exec('UPDATE user_account SET password_hash = ?, password_config = ? WHERE key = ?', [
|
|
421
|
+
user.passwordHash,
|
|
422
|
+
user.passwordConfig,
|
|
423
|
+
normaliseUuid(key),
|
|
424
|
+
])
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
async function currentValues(members: MemberRepository, key: string) {
|
|
428
|
+
return (await members.content.byKey(key))?.values ?? []
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** The member's content: the bundle's values over what is here. Pure. */
|
|
432
|
+
function memberContent(
|
|
433
|
+
member: BundleMember,
|
|
434
|
+
contentTypeKey: string,
|
|
435
|
+
key: string,
|
|
436
|
+
current: ReadonlyArray<{
|
|
437
|
+
alias: string
|
|
438
|
+
culture: string | null
|
|
439
|
+
segment: string | null
|
|
440
|
+
value: unknown
|
|
441
|
+
}>,
|
|
442
|
+
) {
|
|
443
|
+
const at = (alias: string, culture: string | null, segment: string | null) =>
|
|
444
|
+
`${alias}|${culture ?? ''}|${segment ?? ''}`
|
|
445
|
+
const merged = new Map(current.map((v) => [at(v.alias, v.culture, v.segment), v]))
|
|
446
|
+
for (const v of member.values)
|
|
447
|
+
merged.set(at(v.property, v.culture, v.segment), {
|
|
448
|
+
alias: v.property,
|
|
449
|
+
culture: v.culture,
|
|
450
|
+
segment: v.segment,
|
|
451
|
+
value: v.value,
|
|
452
|
+
})
|
|
453
|
+
return {
|
|
454
|
+
key,
|
|
455
|
+
contentTypeKey,
|
|
456
|
+
componentKey: null,
|
|
457
|
+
parentKey: null,
|
|
458
|
+
values: [...merged.values()],
|
|
459
|
+
variants: [{ culture: null, segment: null, name: member.name }],
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Removes what a run created, for a revert: members, users and groups. */
|
|
464
|
+
export async function removePerson(tx: Db, kind: PersonKind, key: string): Promise<boolean> {
|
|
465
|
+
switch (kind) {
|
|
466
|
+
case 'member':
|
|
467
|
+
return new MemberRepository(tx).delete(key)
|
|
468
|
+
case 'user': {
|
|
469
|
+
const users = new UserRepository(tx)
|
|
470
|
+
if (!(await users.byKey(key))) return false
|
|
471
|
+
await users.delete(key)
|
|
472
|
+
return true
|
|
473
|
+
}
|
|
474
|
+
case 'member-group': {
|
|
475
|
+
const nodes = new NodeRepository(tx)
|
|
476
|
+
const node = await nodes.byKey(key)
|
|
477
|
+
if (!node || node.objectType !== ObjectTypes.MemberGroup) return false
|
|
478
|
+
await tx.exec('DELETE FROM member_group_member WHERE member_group_id = ?', [node.id])
|
|
479
|
+
await tx.exec('DELETE FROM node WHERE id = ?', [node.id])
|
|
480
|
+
return true
|
|
481
|
+
}
|
|
482
|
+
case 'user-group': {
|
|
483
|
+
const groups = new UserGroupRepository(tx)
|
|
484
|
+
if (!(await groups.byKey(key))) return false
|
|
485
|
+
return groups.delete(key).then(() => true)
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** This site's people, for a bundle: members and their groups, and users and theirs when asked. */
|
|
491
|
+
export async function exportPeople(
|
|
492
|
+
db: Db,
|
|
493
|
+
options: { members: boolean; users: boolean },
|
|
494
|
+
): Promise<BundlePeople> {
|
|
495
|
+
const people: BundlePeople = { memberGroups: [], members: [], userGroups: [], users: [] }
|
|
496
|
+
const state = await currentSchemaState(db)
|
|
497
|
+
if (options.members) {
|
|
498
|
+
const groups = await db.query<{ unique_id: string; text: string }>(
|
|
499
|
+
'SELECT unique_id, text FROM node WHERE node_object_type = ? ORDER BY text',
|
|
500
|
+
[ObjectTypes.MemberGroup],
|
|
501
|
+
)
|
|
502
|
+
people.memberGroups = groups.map((g) => ({
|
|
503
|
+
key: normaliseUuid(String(g.unique_id)),
|
|
504
|
+
name: String(g.text),
|
|
505
|
+
}))
|
|
506
|
+
const members = new MemberRepository(db, {
|
|
507
|
+
nodeState: { version: state.version, revision: state.revision },
|
|
508
|
+
})
|
|
509
|
+
for (let skip = 0; ; skip += 500) {
|
|
510
|
+
const page = await members.filter({ skip, take: 500 })
|
|
511
|
+
for (const member of page.items) {
|
|
512
|
+
const content = await members.content.byKey(member.key)
|
|
513
|
+
people.members.push({
|
|
514
|
+
key: member.key,
|
|
515
|
+
name: member.name,
|
|
516
|
+
memberTypeAlias: member.contentTypeAlias,
|
|
517
|
+
email: member.email,
|
|
518
|
+
username: member.username,
|
|
519
|
+
isApproved: member.isApproved,
|
|
520
|
+
isLockedOut: member.isLockedOut,
|
|
521
|
+
passwordHash: member.passwordHash,
|
|
522
|
+
passwordConfig: member.passwordConfig,
|
|
523
|
+
groupKeys: await members.groupKeys(member.key),
|
|
524
|
+
values: (content?.values ?? []).map((v) => ({
|
|
525
|
+
property: v.alias,
|
|
526
|
+
culture: v.culture,
|
|
527
|
+
segment: v.segment,
|
|
528
|
+
editor: v.editorAlias ?? '',
|
|
529
|
+
value: v.value,
|
|
530
|
+
})),
|
|
531
|
+
})
|
|
532
|
+
}
|
|
533
|
+
if (skip + 500 >= page.total) break
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
if (options.users) {
|
|
537
|
+
const groups = await new UserGroupRepository(db).all()
|
|
538
|
+
people.userGroups = groups.map((g) => ({
|
|
539
|
+
key: g.key,
|
|
540
|
+
alias: g.alias,
|
|
541
|
+
name: g.name,
|
|
542
|
+
icon: g.icon,
|
|
543
|
+
sections: g.sections,
|
|
544
|
+
permissions: g.permissions,
|
|
545
|
+
hasAccessToAllLanguages: g.hasAccessToAllLanguages,
|
|
546
|
+
languages: g.languages,
|
|
547
|
+
documentStartNodeKey: g.startNodes.document.key,
|
|
548
|
+
mediaStartNodeKey: g.startNodes.media.key,
|
|
549
|
+
}))
|
|
550
|
+
const aliasOf = new Map(groups.map((g) => [g.key, g.alias]))
|
|
551
|
+
const users = new UserRepository(db)
|
|
552
|
+
for (const user of await users.all()) {
|
|
553
|
+
if (user.kind !== 'Default') continue
|
|
554
|
+
const [row] = await db.query<{
|
|
555
|
+
password_hash: string | null
|
|
556
|
+
password_config: string | null
|
|
557
|
+
}>('SELECT password_hash, password_config FROM user_account WHERE id = ?', [user.id])
|
|
558
|
+
people.users.push({
|
|
559
|
+
key: user.key,
|
|
560
|
+
name: user.name,
|
|
561
|
+
userName: user.userName,
|
|
562
|
+
email: user.email,
|
|
563
|
+
languageIsoCode: user.languageIsoCode,
|
|
564
|
+
disabled: !user.isApproved,
|
|
565
|
+
passwordHash: row?.password_hash ?? null,
|
|
566
|
+
passwordConfig: row?.password_config ?? null,
|
|
567
|
+
groupAliases: user.groupKeys.flatMap((k) => aliasOf.get(k) ?? []),
|
|
568
|
+
})
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
return people
|
|
572
|
+
}
|