@crombolo/data 0.0.0-stage → 0.9.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/LICENSE +21 -0
- package/NOTICE +69 -0
- package/package.json +31 -4
- package/src/change-report.ts +166 -0
- package/src/cluster.ts +108 -0
- package/src/database.ts +121 -0
- package/src/dialect.ts +128 -0
- package/src/index.ts +60 -0
- package/src/locks.ts +103 -0
- package/src/maintenance.ts +51 -0
- package/src/migrations.ts +431 -0
- package/src/node-ref.ts +107 -0
- package/src/pending.ts +25 -0
- package/src/repositories/bundles.ts +191 -0
- package/src/repositories/content-types.ts +831 -0
- package/src/repositories/data-types.ts +222 -0
- package/src/repositories/dictionary.ts +259 -0
- package/src/repositories/documents.ts +2389 -0
- package/src/repositories/domains.ts +108 -0
- package/src/repositories/folders.ts +120 -0
- package/src/repositories/form-entries.ts +314 -0
- package/src/repositories/form-workflows.ts +213 -0
- package/src/repositories/languages.ts +108 -0
- package/src/repositories/log-searches.ts +33 -0
- package/src/repositories/members.ts +427 -0
- package/src/repositories/nodes.ts +361 -0
- package/src/repositories/notifications.ts +54 -0
- package/src/repositories/public-access.ts +187 -0
- package/src/repositories/redirects.ts +274 -0
- package/src/repositories/references.ts +162 -0
- package/src/repositories/tags.ts +93 -0
- package/src/repositories/templates.ts +114 -0
- package/src/repositories/transfer-runs.ts +190 -0
- package/src/repositories/user-data.ts +90 -0
- package/src/repositories/users.ts +898 -0
- package/src/schema/assistant.ts +74 -0
- package/src/schema/built-in-content-types.ts +106 -0
- package/src/schema/built-in-media-types.ts +335 -0
- package/src/schema/built-in-member-types.ts +61 -0
- package/src/schema/bundle-components.ts +49 -0
- package/src/schema/bundles-rename.ts +32 -0
- package/src/schema/change-report.ts +46 -0
- package/src/schema/client-id-prefix.ts +43 -0
- package/src/schema/content-editing.ts +50 -0
- package/src/schema/content-seed.ts +586 -0
- package/src/schema/content.ts +263 -0
- package/src/schema/created-packages.ts +56 -0
- package/src/schema/elements.ts +62 -0
- package/src/schema/form-permission-prefix.ts +50 -0
- package/src/schema/form-permissions.ts +26 -0
- package/src/schema/form-workflows.ts +54 -0
- package/src/schema/forms.ts +63 -0
- package/src/schema/group-permissions.ts +20 -0
- package/src/schema/identity.ts +176 -0
- package/src/schema/log-viewer.ts +68 -0
- package/src/schema/member-property-type.ts +30 -0
- package/src/schema/members.ts +97 -0
- package/src/schema/plan.ts +73 -0
- package/src/schema/public-access.ts +56 -0
- package/src/schema/recycle-bin.ts +21 -0
- package/src/schema/redirects.ts +57 -0
- package/src/schema/rich-text-ui.ts +34 -0
- package/src/schema/seed.ts +298 -0
- package/src/schema/server-role.ts +21 -0
- package/src/schema/since-version.ts +26 -0
- package/src/schema/transfer-runs.ts +71 -0
- package/src/schema/upgrade-report.ts +43 -0
- package/src/schema/users-localisation.ts +85 -0
- package/src/schema/values.ts +108 -0
- package/src/schema-ops.ts +35 -0
- package/src/schema-state.ts +196 -0
- package/README.md +0 -3
|
@@ -0,0 +1,898 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backoffice users and user groups: accounts, membership, sections, languages,
|
|
3
|
+
* start nodes and permission verbs. Password hashing and the rules about who
|
|
4
|
+
* may change what live with the callers; this is storage.
|
|
5
|
+
*/
|
|
6
|
+
import { type GroupGrant, normaliseUuid, type StartNodePath } from '@crombolo/core'
|
|
7
|
+
import type { Db } from '../database.ts'
|
|
8
|
+
import { DbDate, fromDbBool } from '../dialect.ts'
|
|
9
|
+
import { mapNode, type NodeRow } from './nodes.ts'
|
|
10
|
+
|
|
11
|
+
export type UserKind = 'Default' | 'Api'
|
|
12
|
+
export type StartNodeTree = 'document' | 'media' | 'element'
|
|
13
|
+
|
|
14
|
+
/** Umbraco's `UserStartNodeDto.StartNodeTypeValue`. */
|
|
15
|
+
const START_NODE_TYPE: Record<StartNodeTree, number> = { document: 1, media: 2, element: 3 }
|
|
16
|
+
const ROOT_ID = -1
|
|
17
|
+
|
|
18
|
+
/** A user's own start nodes in one tree: root access, and/or specific nodes by key. */
|
|
19
|
+
export interface StartNodeSelection {
|
|
20
|
+
root: boolean
|
|
21
|
+
keys: string[]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A group's start node in one tree: the root, one node, or none. */
|
|
25
|
+
export interface GroupStartNode {
|
|
26
|
+
root: boolean
|
|
27
|
+
key: string | null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface UserRecord {
|
|
31
|
+
id: number
|
|
32
|
+
key: string
|
|
33
|
+
name: string
|
|
34
|
+
userName: string
|
|
35
|
+
email: string
|
|
36
|
+
languageIsoCode: string | null
|
|
37
|
+
avatar: string | null
|
|
38
|
+
kind: UserKind
|
|
39
|
+
isApproved: boolean
|
|
40
|
+
isLockedOut: boolean
|
|
41
|
+
failedLoginAttempts: number
|
|
42
|
+
lastLoginDate: Date | undefined
|
|
43
|
+
lastLockoutDate: Date | undefined
|
|
44
|
+
lastPasswordChangeDate: Date | undefined
|
|
45
|
+
invitedDate: Date | undefined
|
|
46
|
+
createDate: Date
|
|
47
|
+
updateDate: Date
|
|
48
|
+
groupKeys: string[]
|
|
49
|
+
startNodes: Record<StartNodeTree, StartNodeSelection>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type UserState = 'Active' | 'Disabled' | 'LockedOut' | 'Invited' | 'Inactive'
|
|
53
|
+
|
|
54
|
+
/** Umbraco's `User.UserState`. */
|
|
55
|
+
export function userState(user: UserRecord): UserState {
|
|
56
|
+
if (!user.lastLoginDate && !user.isApproved && user.invitedDate) return 'Invited'
|
|
57
|
+
if (user.isLockedOut) return 'LockedOut'
|
|
58
|
+
if (!user.isApproved) return 'Disabled'
|
|
59
|
+
if (!user.lastLoginDate) return 'Inactive'
|
|
60
|
+
return 'Active'
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface GranularPermission {
|
|
64
|
+
/** The node (or property type) the verb applies to; null for a context-only verb. */
|
|
65
|
+
key: string | null
|
|
66
|
+
permission: string
|
|
67
|
+
context: string
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface UserGroupRecord {
|
|
71
|
+
id: number
|
|
72
|
+
key: string
|
|
73
|
+
alias: string
|
|
74
|
+
name: string
|
|
75
|
+
description: string | null
|
|
76
|
+
icon: string | null
|
|
77
|
+
/** Stored application aliases (`content`), or a package section's alias as given. */
|
|
78
|
+
sections: string[]
|
|
79
|
+
languages: string[]
|
|
80
|
+
hasAccessToAllLanguages: boolean
|
|
81
|
+
startNodes: Record<StartNodeTree, GroupStartNode>
|
|
82
|
+
permissions: string[]
|
|
83
|
+
granular: GranularPermission[]
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface UserGroupInput {
|
|
87
|
+
key: string
|
|
88
|
+
alias: string
|
|
89
|
+
name: string
|
|
90
|
+
description: string | null
|
|
91
|
+
icon: string | null
|
|
92
|
+
sections: string[]
|
|
93
|
+
languages: string[]
|
|
94
|
+
hasAccessToAllLanguages: boolean
|
|
95
|
+
startNodes: Record<StartNodeTree, GroupStartNode>
|
|
96
|
+
permissions: string[]
|
|
97
|
+
granular: GranularPermission[]
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface UserInput {
|
|
101
|
+
key: string
|
|
102
|
+
name: string
|
|
103
|
+
userName: string
|
|
104
|
+
email: string
|
|
105
|
+
kind: UserKind
|
|
106
|
+
groupKeys: string[]
|
|
107
|
+
languageIsoCode: string | null
|
|
108
|
+
/** Invited users are created unapproved, with an invitation date. */
|
|
109
|
+
invited?: boolean
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface UserUpdate {
|
|
113
|
+
name: string
|
|
114
|
+
userName: string
|
|
115
|
+
email: string
|
|
116
|
+
languageIsoCode: string | null
|
|
117
|
+
groupKeys: string[]
|
|
118
|
+
startNodes: Record<StartNodeTree, StartNodeSelection>
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** What access checks need about a user: their groups' grants and every start node's position. */
|
|
122
|
+
export interface UserAccessData {
|
|
123
|
+
groups: GroupGrant[]
|
|
124
|
+
languages: string[]
|
|
125
|
+
hasAccessToAllLanguages: boolean
|
|
126
|
+
groupStartNodes: Record<StartNodeTree, StartNodePath[]>
|
|
127
|
+
userStartNodes: Record<StartNodeTree, StartNodePath[]>
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const USER_COLUMNS = `id, key, user_name, login, email, language, avatar, kind, is_approved,
|
|
131
|
+
is_locked_out, failed_login_attempts, last_login_date, last_lockout_date,
|
|
132
|
+
last_password_change_date, invited_date, create_date, update_date`
|
|
133
|
+
|
|
134
|
+
const TREES: StartNodeTree[] = ['document', 'media', 'element']
|
|
135
|
+
|
|
136
|
+
const inList = (values: readonly unknown[]) => values.map(() => '?').join(', ')
|
|
137
|
+
|
|
138
|
+
export class UserRepository {
|
|
139
|
+
#db: Db
|
|
140
|
+
|
|
141
|
+
constructor(db: Db) {
|
|
142
|
+
this.#db = db
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async #nodeKeys(ids: readonly number[]): Promise<Map<number, NodeRow>> {
|
|
146
|
+
const wanted = [...new Set(ids.filter((id) => id > 0))]
|
|
147
|
+
if (wanted.length === 0) return new Map()
|
|
148
|
+
const rows = await this.#db.query(
|
|
149
|
+
`SELECT id, unique_id, parent_id, level, path, sort_order, trashed, text, node_object_type, create_date
|
|
150
|
+
FROM node WHERE id IN (${inList(wanted)})`,
|
|
151
|
+
wanted,
|
|
152
|
+
)
|
|
153
|
+
return new Map(rows.map((row) => [Number(row.id), mapNode(row)]))
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Node ids for keys; undefined when any key is not a node. */
|
|
157
|
+
async nodeIds(keys: readonly string[]): Promise<Map<string, number> | undefined> {
|
|
158
|
+
if (keys.length === 0) return new Map()
|
|
159
|
+
const wanted = keys.map(normaliseUuid)
|
|
160
|
+
const rows = await this.#db.query<{ id: number; unique_id: string }>(
|
|
161
|
+
`SELECT id, unique_id FROM node WHERE unique_id IN (${inList(wanted)})`,
|
|
162
|
+
wanted,
|
|
163
|
+
)
|
|
164
|
+
const map = new Map(rows.map((r) => [normaliseUuid(String(r.unique_id)), Number(r.id)]))
|
|
165
|
+
return wanted.every((k) => map.has(k)) ? map : undefined
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async #hydrate(rows: Record<string, unknown>[]): Promise<UserRecord[]> {
|
|
169
|
+
if (rows.length === 0) return []
|
|
170
|
+
const ids = rows.map((r) => Number(r.id))
|
|
171
|
+
const memberships = await this.#db.query<{ user_id: number; key: string }>(
|
|
172
|
+
`SELECT m.user_id, g.key FROM user_group_member m JOIN user_group g ON g.id = m.user_group_id
|
|
173
|
+
WHERE m.user_id IN (${inList(ids)}) ORDER BY g.id`,
|
|
174
|
+
ids,
|
|
175
|
+
)
|
|
176
|
+
const starts = await this.#db.query<{
|
|
177
|
+
user_id: number
|
|
178
|
+
start_node: number
|
|
179
|
+
start_node_type: number
|
|
180
|
+
}>(
|
|
181
|
+
`SELECT user_id, start_node, start_node_type FROM user_start_node WHERE user_id IN (${inList(ids)})`,
|
|
182
|
+
ids,
|
|
183
|
+
)
|
|
184
|
+
const nodes = await this.#nodeKeys(starts.map((s) => Number(s.start_node)))
|
|
185
|
+
return rows.map((row) => {
|
|
186
|
+
const id = Number(row.id)
|
|
187
|
+
const startNodes = Object.fromEntries(
|
|
188
|
+
TREES.map((tree) => {
|
|
189
|
+
const mine = starts.filter(
|
|
190
|
+
(s) => Number(s.user_id) === id && Number(s.start_node_type) === START_NODE_TYPE[tree],
|
|
191
|
+
)
|
|
192
|
+
return [
|
|
193
|
+
tree,
|
|
194
|
+
{
|
|
195
|
+
root: mine.some((s) => Number(s.start_node) === ROOT_ID),
|
|
196
|
+
keys: mine.flatMap((s) => {
|
|
197
|
+
const node = nodes.get(Number(s.start_node))
|
|
198
|
+
return node ? [node.key] : []
|
|
199
|
+
}),
|
|
200
|
+
},
|
|
201
|
+
]
|
|
202
|
+
}),
|
|
203
|
+
) as Record<StartNodeTree, StartNodeSelection>
|
|
204
|
+
return {
|
|
205
|
+
id,
|
|
206
|
+
key: normaliseUuid(String(row.key)),
|
|
207
|
+
name: String(row.user_name),
|
|
208
|
+
userName: String(row.login),
|
|
209
|
+
email: String(row.email),
|
|
210
|
+
languageIsoCode: (row.language as string | null) ?? null,
|
|
211
|
+
avatar: (row.avatar as string | null) ?? null,
|
|
212
|
+
kind: Number(row.kind) === 1 ? 'Api' : 'Default',
|
|
213
|
+
isApproved: fromDbBool(row.is_approved),
|
|
214
|
+
isLockedOut: fromDbBool(row.is_locked_out),
|
|
215
|
+
failedLoginAttempts: Number(row.failed_login_attempts ?? 0),
|
|
216
|
+
lastLoginDate: DbDate.fromDb(row.last_login_date),
|
|
217
|
+
lastLockoutDate: DbDate.fromDb(row.last_lockout_date),
|
|
218
|
+
lastPasswordChangeDate: DbDate.fromDb(row.last_password_change_date),
|
|
219
|
+
invitedDate: DbDate.fromDb(row.invited_date),
|
|
220
|
+
createDate: DbDate.fromDb(row.create_date) ?? new Date(0),
|
|
221
|
+
updateDate: DbDate.fromDb(row.update_date) ?? new Date(0),
|
|
222
|
+
groupKeys: memberships
|
|
223
|
+
.filter((m) => Number(m.user_id) === id)
|
|
224
|
+
.map((m) => normaliseUuid(String(m.key))),
|
|
225
|
+
startNodes,
|
|
226
|
+
}
|
|
227
|
+
})
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async all(): Promise<UserRecord[]> {
|
|
231
|
+
return this.#hydrate(
|
|
232
|
+
await this.#db.query(`SELECT ${USER_COLUMNS} FROM user_account ORDER BY id`),
|
|
233
|
+
)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async byKeys(keys: readonly string[]): Promise<UserRecord[]> {
|
|
237
|
+
const wanted = keys.filter((k) => /^[0-9a-f-]{32,36}$/i.test(k)).map(normaliseUuid)
|
|
238
|
+
if (wanted.length === 0) return []
|
|
239
|
+
return this.#hydrate(
|
|
240
|
+
await this.#db.query(
|
|
241
|
+
`SELECT ${USER_COLUMNS} FROM user_account WHERE key IN (${inList(wanted)}) ORDER BY id`,
|
|
242
|
+
wanted,
|
|
243
|
+
),
|
|
244
|
+
)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async byKey(key: string): Promise<UserRecord | undefined> {
|
|
248
|
+
return (await this.byKeys([key]))[0]
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async byId(id: number): Promise<UserRecord | undefined> {
|
|
252
|
+
return (
|
|
253
|
+
await this.#hydrate(
|
|
254
|
+
await this.#db.query(`SELECT ${USER_COLUMNS} FROM user_account WHERE id = ?`, [id]),
|
|
255
|
+
)
|
|
256
|
+
)[0]
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async byUserName(userName: string): Promise<UserRecord | undefined> {
|
|
260
|
+
return (
|
|
261
|
+
await this.#hydrate(
|
|
262
|
+
await this.#db.query(`SELECT ${USER_COLUMNS} FROM user_account WHERE login = ?`, [
|
|
263
|
+
userName,
|
|
264
|
+
]),
|
|
265
|
+
)
|
|
266
|
+
)[0]
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async byEmail(email: string): Promise<UserRecord | undefined> {
|
|
270
|
+
return (
|
|
271
|
+
await this.#hydrate(
|
|
272
|
+
await this.#db.query(`SELECT ${USER_COLUMNS} FROM user_account WHERE email = ?`, [email]),
|
|
273
|
+
)
|
|
274
|
+
)[0]
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async #groupIds(keys: readonly string[]): Promise<number[] | undefined> {
|
|
278
|
+
if (keys.length === 0) return []
|
|
279
|
+
const wanted = keys.map(normaliseUuid)
|
|
280
|
+
const rows = await this.#db.query<{ id: number }>(
|
|
281
|
+
`SELECT id FROM user_group WHERE key IN (${inList(wanted)})`,
|
|
282
|
+
wanted,
|
|
283
|
+
)
|
|
284
|
+
return rows.length === new Set(wanted).size ? rows.map((r) => Number(r.id)) : undefined
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async #setGroups(userId: number, groupIds: readonly number[]): Promise<void> {
|
|
288
|
+
await this.#db.exec('DELETE FROM user_group_member WHERE user_id = ?', [userId])
|
|
289
|
+
for (const groupId of new Set(groupIds))
|
|
290
|
+
await this.#db.exec('INSERT INTO user_group_member (user_id, user_group_id) VALUES (?, ?)', [
|
|
291
|
+
userId,
|
|
292
|
+
groupId,
|
|
293
|
+
])
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Creates the user; undefined when a group does not exist. */
|
|
297
|
+
async create(input: UserInput): Promise<UserRecord | undefined> {
|
|
298
|
+
return this.#db.transaction(async (tx) => {
|
|
299
|
+
const repo = new UserRepository(tx)
|
|
300
|
+
const groupIds = await repo.#groupIds(input.groupKeys)
|
|
301
|
+
if (!groupIds) return undefined
|
|
302
|
+
const now = DbDate.toDb(new Date())
|
|
303
|
+
const bool = (v: boolean) => tx.dialect.boolValue(v)
|
|
304
|
+
await tx.exec(
|
|
305
|
+
`INSERT INTO user_account
|
|
306
|
+
(key, user_name, login, email, security_stamp, language, kind, is_locked_out, is_approved,
|
|
307
|
+
failed_login_attempts, invited_date, create_date, update_date)
|
|
308
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
309
|
+
[
|
|
310
|
+
normaliseUuid(input.key),
|
|
311
|
+
input.name,
|
|
312
|
+
input.userName,
|
|
313
|
+
input.email,
|
|
314
|
+
crypto.randomUUID(),
|
|
315
|
+
input.languageIsoCode,
|
|
316
|
+
input.kind === 'Api' ? 1 : 0,
|
|
317
|
+
bool(false),
|
|
318
|
+
bool(!input.invited),
|
|
319
|
+
0,
|
|
320
|
+
input.invited ? now : null,
|
|
321
|
+
now,
|
|
322
|
+
now,
|
|
323
|
+
],
|
|
324
|
+
)
|
|
325
|
+
const created = await repo.byKey(input.key)
|
|
326
|
+
if (!created) return undefined
|
|
327
|
+
await repo.#setGroups(created.id, groupIds)
|
|
328
|
+
return repo.byKey(input.key)
|
|
329
|
+
})
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** 'missing-group' or 'missing-node' when a reference does not resolve. */
|
|
333
|
+
async update(
|
|
334
|
+
key: string,
|
|
335
|
+
change: UserUpdate,
|
|
336
|
+
): Promise<'updated' | 'not-found' | 'missing-group' | Exclude<StartNodeTree, never>> {
|
|
337
|
+
return this.#db.transaction(async (tx) => {
|
|
338
|
+
const repo = new UserRepository(tx)
|
|
339
|
+
const user = await repo.byKey(key)
|
|
340
|
+
if (!user) return 'not-found'
|
|
341
|
+
const groupIds = await repo.#groupIds(change.groupKeys)
|
|
342
|
+
if (!groupIds) return 'missing-group'
|
|
343
|
+
const nodeIds: Array<{ tree: StartNodeTree; id: number }> = []
|
|
344
|
+
for (const tree of TREES) {
|
|
345
|
+
const selection = change.startNodes[tree]
|
|
346
|
+
if (selection.root) nodeIds.push({ tree, id: ROOT_ID })
|
|
347
|
+
const ids = await repo.nodeIds(selection.keys)
|
|
348
|
+
if (!ids) return tree
|
|
349
|
+
for (const id of ids.values()) nodeIds.push({ tree, id })
|
|
350
|
+
}
|
|
351
|
+
await tx.exec(
|
|
352
|
+
'UPDATE user_account SET user_name = ?, login = ?, email = ?, language = ?, update_date = ? WHERE id = ?',
|
|
353
|
+
[
|
|
354
|
+
change.name,
|
|
355
|
+
change.userName,
|
|
356
|
+
change.email,
|
|
357
|
+
change.languageIsoCode,
|
|
358
|
+
DbDate.toDb(new Date()),
|
|
359
|
+
user.id,
|
|
360
|
+
],
|
|
361
|
+
)
|
|
362
|
+
await repo.#setGroups(user.id, groupIds)
|
|
363
|
+
await tx.exec('DELETE FROM user_start_node WHERE user_id = ?', [user.id])
|
|
364
|
+
for (const { tree, id } of nodeIds)
|
|
365
|
+
await tx.exec(
|
|
366
|
+
'INSERT INTO user_start_node (user_id, start_node, start_node_type) VALUES (?, ?, ?)',
|
|
367
|
+
[user.id, id, START_NODE_TYPE[tree]],
|
|
368
|
+
)
|
|
369
|
+
return 'updated'
|
|
370
|
+
})
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async setGroups(userKeys: readonly string[], groupKeys: readonly string[]): Promise<boolean> {
|
|
374
|
+
return this.#db.transaction(async (tx) => {
|
|
375
|
+
const repo = new UserRepository(tx)
|
|
376
|
+
const groupIds = await repo.#groupIds(groupKeys)
|
|
377
|
+
if (!groupIds) return false
|
|
378
|
+
for (const user of await repo.byKeys(userKeys)) await repo.#setGroups(user.id, groupIds)
|
|
379
|
+
return true
|
|
380
|
+
})
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async addToGroup(groupKey: string, userKeys: readonly string[]): Promise<void> {
|
|
384
|
+
const [groupId] = (await this.#groupIds([groupKey])) ?? []
|
|
385
|
+
if (groupId === undefined) return
|
|
386
|
+
for (const user of await this.byKeys(userKeys))
|
|
387
|
+
if (!(await this.#groupIds(user.groupKeys))?.includes(groupId))
|
|
388
|
+
await this.#db.exec(
|
|
389
|
+
'INSERT INTO user_group_member (user_id, user_group_id) VALUES (?, ?)',
|
|
390
|
+
[user.id, groupId],
|
|
391
|
+
)
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async removeFromGroup(groupKey: string, userKeys: readonly string[]): Promise<void> {
|
|
395
|
+
const [groupId] = (await this.#groupIds([groupKey])) ?? []
|
|
396
|
+
if (groupId === undefined) return
|
|
397
|
+
for (const user of await this.byKeys(userKeys))
|
|
398
|
+
await this.#db.exec('DELETE FROM user_group_member WHERE user_id = ? AND user_group_id = ?', [
|
|
399
|
+
user.id,
|
|
400
|
+
groupId,
|
|
401
|
+
])
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async setApproved(keys: readonly string[], approved: boolean): Promise<void> {
|
|
405
|
+
for (const user of await this.byKeys(keys))
|
|
406
|
+
await this.#db.exec('UPDATE user_account SET is_approved = ?, update_date = ? WHERE id = ?', [
|
|
407
|
+
this.#db.dialect.boolValue(approved),
|
|
408
|
+
DbDate.toDb(new Date()),
|
|
409
|
+
user.id,
|
|
410
|
+
])
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
async unlock(keys: readonly string[]): Promise<void> {
|
|
414
|
+
for (const user of await this.byKeys(keys))
|
|
415
|
+
await this.#db.exec(
|
|
416
|
+
'UPDATE user_account SET is_locked_out = ?, failed_login_attempts = 0, update_date = ? WHERE id = ?',
|
|
417
|
+
[this.#db.dialect.boolValue(false), DbDate.toDb(new Date()), user.id],
|
|
418
|
+
)
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
async setLanguage(key: string, isoCode: string): Promise<void> {
|
|
422
|
+
await this.#db.exec('UPDATE user_account SET language = ?, update_date = ? WHERE key = ?', [
|
|
423
|
+
isoCode,
|
|
424
|
+
DbDate.toDb(new Date()),
|
|
425
|
+
normaliseUuid(key),
|
|
426
|
+
])
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async setAvatar(key: string, avatar: string | null): Promise<void> {
|
|
430
|
+
await this.#db.exec('UPDATE user_account SET avatar = ?, update_date = ? WHERE key = ?', [
|
|
431
|
+
avatar,
|
|
432
|
+
DbDate.toDb(new Date()),
|
|
433
|
+
normaliseUuid(key),
|
|
434
|
+
])
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
async passwordHash(key: string): Promise<string | null> {
|
|
438
|
+
const rows = await this.#db.query<{ password_hash: string | null }>(
|
|
439
|
+
'SELECT password_hash FROM user_account WHERE key = ?',
|
|
440
|
+
[normaliseUuid(key)],
|
|
441
|
+
)
|
|
442
|
+
return rows[0]?.password_hash ?? null
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** An accepted invitation: the user becomes approved and gets their password. */
|
|
446
|
+
async approve(key: string): Promise<void> {
|
|
447
|
+
await this.setApproved([key], true)
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** Deletes the user and everything that is only theirs. */
|
|
451
|
+
async delete(key: string): Promise<void> {
|
|
452
|
+
const user = await this.byKey(key)
|
|
453
|
+
if (!user) return
|
|
454
|
+
await this.#db.transaction(async (tx) => {
|
|
455
|
+
for (const table of [
|
|
456
|
+
'user_group_member',
|
|
457
|
+
'user_start_node',
|
|
458
|
+
'user_data',
|
|
459
|
+
'user_token',
|
|
460
|
+
'user_client_credential',
|
|
461
|
+
'user_notification',
|
|
462
|
+
'auth_code',
|
|
463
|
+
'auth_token',
|
|
464
|
+
'user_login_session',
|
|
465
|
+
])
|
|
466
|
+
await tx.exec(`DELETE FROM ${table} WHERE user_id = ?`, [user.id])
|
|
467
|
+
await tx.exec('DELETE FROM user_account WHERE id = ?', [user.id])
|
|
468
|
+
})
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** Stores a single-use token's hash for `purpose`, replacing any earlier one. */
|
|
472
|
+
async saveToken(
|
|
473
|
+
userId: number,
|
|
474
|
+
purpose: string,
|
|
475
|
+
tokenHash: string,
|
|
476
|
+
expiresAt: Date,
|
|
477
|
+
): Promise<void> {
|
|
478
|
+
await this.#db.exec('DELETE FROM user_token WHERE user_id = ? AND purpose = ?', [
|
|
479
|
+
userId,
|
|
480
|
+
purpose,
|
|
481
|
+
])
|
|
482
|
+
await this.#db.exec(
|
|
483
|
+
'INSERT INTO user_token (token_hash, user_id, purpose, expires_at) VALUES (?, ?, ?, ?)',
|
|
484
|
+
[tokenHash, userId, purpose, DbDate.toDb(expiresAt)],
|
|
485
|
+
)
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** Whether the token is the user's live one for `purpose`; consuming removes it. */
|
|
489
|
+
async checkToken(
|
|
490
|
+
userId: number,
|
|
491
|
+
purpose: string,
|
|
492
|
+
tokenHash: string,
|
|
493
|
+
consume: boolean,
|
|
494
|
+
now = new Date(),
|
|
495
|
+
): Promise<boolean> {
|
|
496
|
+
const rows = await this.#db.query<{ expires_at: unknown }>(
|
|
497
|
+
'SELECT expires_at FROM user_token WHERE token_hash = ? AND user_id = ? AND purpose = ?',
|
|
498
|
+
[tokenHash, userId, purpose],
|
|
499
|
+
)
|
|
500
|
+
const expires = DbDate.fromDb(rows[0]?.expires_at)
|
|
501
|
+
if (!expires || expires.getTime() < now.getTime()) return false
|
|
502
|
+
if (consume) await this.#db.exec('DELETE FROM user_token WHERE token_hash = ?', [tokenHash])
|
|
503
|
+
return true
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
async clientCredentials(userId: number): Promise<string[]> {
|
|
507
|
+
const rows = await this.#db.query<{ client_id: string }>(
|
|
508
|
+
'SELECT client_id FROM user_client_credential WHERE user_id = ? ORDER BY client_id',
|
|
509
|
+
[userId],
|
|
510
|
+
)
|
|
511
|
+
return rows.map((r) => String(r.client_id))
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
async clientCredentialOwner(
|
|
515
|
+
clientId: string,
|
|
516
|
+
): Promise<{ userId: number; secretHash: string } | undefined> {
|
|
517
|
+
const rows = await this.#db.query<{ user_id: number; secret_hash: string }>(
|
|
518
|
+
'SELECT user_id, secret_hash FROM user_client_credential WHERE client_id = ?',
|
|
519
|
+
[clientId],
|
|
520
|
+
)
|
|
521
|
+
return rows[0]
|
|
522
|
+
? { userId: Number(rows[0].user_id), secretHash: String(rows[0].secret_hash) }
|
|
523
|
+
: undefined
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
async addClientCredential(
|
|
527
|
+
userId: number,
|
|
528
|
+
clientId: string,
|
|
529
|
+
secretHash: string,
|
|
530
|
+
): Promise<boolean> {
|
|
531
|
+
if (await this.clientCredentialOwner(clientId)) return false
|
|
532
|
+
await this.#db.exec(
|
|
533
|
+
'INSERT INTO user_client_credential (client_id, user_id, secret_hash, create_date) VALUES (?, ?, ?, ?)',
|
|
534
|
+
[clientId, userId, secretHash, DbDate.toDb(new Date())],
|
|
535
|
+
)
|
|
536
|
+
return true
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
async removeClientCredential(userId: number, clientId: string): Promise<void> {
|
|
540
|
+
await this.#db.exec('DELETE FROM user_client_credential WHERE user_id = ? AND client_id = ?', [
|
|
541
|
+
userId,
|
|
542
|
+
clientId,
|
|
543
|
+
])
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/** The user's groups' grants, languages and start node positions, for access checks. */
|
|
547
|
+
async access(userId: number): Promise<UserAccessData> {
|
|
548
|
+
const user = await this.byId(userId)
|
|
549
|
+
const groups = user ? await new UserGroupRepository(this.#db).byKeys(user.groupKeys) : []
|
|
550
|
+
const nodeIds: Record<string, number> = {}
|
|
551
|
+
for (const group of groups)
|
|
552
|
+
for (const tree of TREES) {
|
|
553
|
+
const key = group.startNodes[tree].key
|
|
554
|
+
if (key) nodeIds[key] = 0
|
|
555
|
+
}
|
|
556
|
+
for (const tree of TREES) for (const key of user?.startNodes[tree].keys ?? []) nodeIds[key] = 0
|
|
557
|
+
const keys = Object.keys(nodeIds)
|
|
558
|
+
const rows =
|
|
559
|
+
keys.length > 0
|
|
560
|
+
? await this.#db.query(
|
|
561
|
+
`SELECT id, unique_id, parent_id, level, path, sort_order, trashed, text, node_object_type, create_date
|
|
562
|
+
FROM node WHERE unique_id IN (${inList(keys)})`,
|
|
563
|
+
keys,
|
|
564
|
+
)
|
|
565
|
+
: []
|
|
566
|
+
const byKey = new Map(rows.map((r) => [normaliseUuid(String(r.unique_id)), mapNode(r)]))
|
|
567
|
+
const chains = await this.#chains([...byKey.values()])
|
|
568
|
+
const pathOf = (key: string): StartNodePath | undefined => {
|
|
569
|
+
const node = byKey.get(key)
|
|
570
|
+
return node && !node.trashed ? chains.get(node.id) : undefined
|
|
571
|
+
}
|
|
572
|
+
const positions = (selection: { root: boolean; keys: readonly string[] }): StartNodePath[] => [
|
|
573
|
+
...(selection.root ? [[] as StartNodePath] : []),
|
|
574
|
+
...selection.keys.flatMap((key) => {
|
|
575
|
+
const path = pathOf(key)
|
|
576
|
+
return path ? [path] : []
|
|
577
|
+
}),
|
|
578
|
+
]
|
|
579
|
+
const languageRows =
|
|
580
|
+
groups.length > 0
|
|
581
|
+
? await this.#db.query<{ iso_code: string }>(
|
|
582
|
+
`SELECT DISTINCT l.iso_code FROM user_group_language gl
|
|
583
|
+
JOIN language l ON l.id = gl.language_id
|
|
584
|
+
WHERE gl.user_group_id IN (${inList(groups.map((g) => g.id))})`,
|
|
585
|
+
groups.map((g) => g.id),
|
|
586
|
+
)
|
|
587
|
+
: []
|
|
588
|
+
return {
|
|
589
|
+
groups: groups.map((group) => {
|
|
590
|
+
const granular = new Map<string, string[]>()
|
|
591
|
+
for (const row of group.granular)
|
|
592
|
+
if (row.context === 'Document' && row.key) {
|
|
593
|
+
const verbs = granular.get(row.key) ?? []
|
|
594
|
+
if (row.permission) verbs.push(row.permission)
|
|
595
|
+
granular.set(row.key, verbs)
|
|
596
|
+
}
|
|
597
|
+
return { key: group.key, alias: group.alias, defaults: group.permissions, granular }
|
|
598
|
+
}),
|
|
599
|
+
languages: languageRows.map((l) => String(l.iso_code)),
|
|
600
|
+
hasAccessToAllLanguages: groups.some((g) => g.hasAccessToAllLanguages),
|
|
601
|
+
groupStartNodes: Object.fromEntries(
|
|
602
|
+
TREES.map((tree) => [
|
|
603
|
+
tree,
|
|
604
|
+
groups.flatMap((g) =>
|
|
605
|
+
positions({
|
|
606
|
+
root: g.startNodes[tree].root,
|
|
607
|
+
keys: g.startNodes[tree].key ? [g.startNodes[tree].key as string] : [],
|
|
608
|
+
}),
|
|
609
|
+
),
|
|
610
|
+
]),
|
|
611
|
+
) as Record<StartNodeTree, StartNodePath[]>,
|
|
612
|
+
userStartNodes: Object.fromEntries(
|
|
613
|
+
TREES.map((tree) => [tree, user ? positions(user.startNodes[tree]) : []]),
|
|
614
|
+
) as Record<StartNodeTree, StartNodePath[]>,
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/** Each node's ancestor keys then its own, root first. */
|
|
619
|
+
async #chains(nodes: readonly NodeRow[]): Promise<Map<number, string[]>> {
|
|
620
|
+
const ancestorIds = nodes.flatMap((n) =>
|
|
621
|
+
n.path
|
|
622
|
+
.split(',')
|
|
623
|
+
.map(Number)
|
|
624
|
+
.filter((id) => id > 0),
|
|
625
|
+
)
|
|
626
|
+
const all = await this.#nodeKeys(ancestorIds)
|
|
627
|
+
return new Map(
|
|
628
|
+
nodes.map((n) => [
|
|
629
|
+
n.id,
|
|
630
|
+
n.path
|
|
631
|
+
.split(',')
|
|
632
|
+
.map(Number)
|
|
633
|
+
.filter((id) => id > 0)
|
|
634
|
+
.flatMap((id) => {
|
|
635
|
+
const node = all.get(id)
|
|
636
|
+
return node ? [node.key] : []
|
|
637
|
+
}),
|
|
638
|
+
]),
|
|
639
|
+
)
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
const GROUP_COLUMNS = `id, key, alias, name, description, icon, has_access_to_all_languages,
|
|
644
|
+
start_content_id, start_media_id, start_element_id`
|
|
645
|
+
|
|
646
|
+
const GROUP_START_COLUMN: Record<StartNodeTree, string> = {
|
|
647
|
+
document: 'start_content_id',
|
|
648
|
+
media: 'start_media_id',
|
|
649
|
+
element: 'start_element_id',
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
export class UserGroupRepository {
|
|
653
|
+
#db: Db
|
|
654
|
+
|
|
655
|
+
constructor(db: Db) {
|
|
656
|
+
this.#db = db
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
async #hydrate(rows: Record<string, unknown>[]): Promise<UserGroupRecord[]> {
|
|
660
|
+
if (rows.length === 0) return []
|
|
661
|
+
const ids = rows.map((r) => Number(r.id))
|
|
662
|
+
const keys = rows.map((r) => normaliseUuid(String(r.key)))
|
|
663
|
+
const sections = await this.#db.query<{ user_group_id: number; app_alias: string }>(
|
|
664
|
+
`SELECT user_group_id, app_alias FROM user_group_section WHERE user_group_id IN (${inList(ids)})`,
|
|
665
|
+
ids,
|
|
666
|
+
)
|
|
667
|
+
const languages = await this.#db.query<{ user_group_id: number; iso_code: string }>(
|
|
668
|
+
`SELECT gl.user_group_id, l.iso_code FROM user_group_language gl
|
|
669
|
+
JOIN language l ON l.id = gl.language_id WHERE gl.user_group_id IN (${inList(ids)})
|
|
670
|
+
ORDER BY l.iso_code`,
|
|
671
|
+
ids,
|
|
672
|
+
)
|
|
673
|
+
const permissions = await this.#db.query<{ user_group_key: string; permission: string }>(
|
|
674
|
+
`SELECT user_group_key, permission FROM user_group_permission
|
|
675
|
+
WHERE user_group_key IN (${inList(keys)}) ORDER BY id`,
|
|
676
|
+
keys,
|
|
677
|
+
)
|
|
678
|
+
const granular = await this.#db.query<{
|
|
679
|
+
user_group_key: string
|
|
680
|
+
unique_id: string | null
|
|
681
|
+
permission: string
|
|
682
|
+
context: string
|
|
683
|
+
}>(
|
|
684
|
+
`SELECT user_group_key, unique_id, permission, context FROM user_group_granular_permission
|
|
685
|
+
WHERE user_group_key IN (${inList(keys)}) ORDER BY id`,
|
|
686
|
+
keys,
|
|
687
|
+
)
|
|
688
|
+
const startIds = rows.flatMap((r) =>
|
|
689
|
+
TREES.map((tree) => Number(r[GROUP_START_COLUMN[tree]] ?? 0)),
|
|
690
|
+
)
|
|
691
|
+
const nodes = new Map<number, string>()
|
|
692
|
+
const positive = [...new Set(startIds.filter((id) => id > 0))]
|
|
693
|
+
if (positive.length > 0)
|
|
694
|
+
for (const row of await this.#db.query<{ id: number; unique_id: string }>(
|
|
695
|
+
`SELECT id, unique_id FROM node WHERE id IN (${inList(positive)})`,
|
|
696
|
+
positive,
|
|
697
|
+
))
|
|
698
|
+
nodes.set(Number(row.id), normaliseUuid(String(row.unique_id)))
|
|
699
|
+
|
|
700
|
+
return rows.map((row) => {
|
|
701
|
+
const id = Number(row.id)
|
|
702
|
+
const key = normaliseUuid(String(row.key))
|
|
703
|
+
const start = (tree: StartNodeTree): GroupStartNode => {
|
|
704
|
+
const value = row[GROUP_START_COLUMN[tree]]
|
|
705
|
+
if (value === null || value === undefined) return { root: false, key: null }
|
|
706
|
+
const nodeId = Number(value)
|
|
707
|
+
if (nodeId === ROOT_ID) return { root: true, key: null }
|
|
708
|
+
return { root: false, key: nodes.get(nodeId) ?? null }
|
|
709
|
+
}
|
|
710
|
+
return {
|
|
711
|
+
id,
|
|
712
|
+
key,
|
|
713
|
+
alias: String(row.alias),
|
|
714
|
+
name: String(row.name),
|
|
715
|
+
description: (row.description as string | null) ?? null,
|
|
716
|
+
icon: (row.icon as string | null) ?? null,
|
|
717
|
+
sections: sections
|
|
718
|
+
.filter((s) => Number(s.user_group_id) === id)
|
|
719
|
+
.map((s) => String(s.app_alias)),
|
|
720
|
+
languages: languages
|
|
721
|
+
.filter((l) => Number(l.user_group_id) === id)
|
|
722
|
+
.map((l) => String(l.iso_code)),
|
|
723
|
+
hasAccessToAllLanguages: fromDbBool(row.has_access_to_all_languages),
|
|
724
|
+
startNodes: {
|
|
725
|
+
document: start('document'),
|
|
726
|
+
media: start('media'),
|
|
727
|
+
element: start('element'),
|
|
728
|
+
},
|
|
729
|
+
permissions: permissions
|
|
730
|
+
.filter((p) => normaliseUuid(String(p.user_group_key)) === key)
|
|
731
|
+
.map((p) => String(p.permission)),
|
|
732
|
+
granular: granular
|
|
733
|
+
.filter((g) => normaliseUuid(String(g.user_group_key)) === key)
|
|
734
|
+
.map((g) => ({
|
|
735
|
+
key: g.unique_id ? normaliseUuid(String(g.unique_id)) : null,
|
|
736
|
+
permission: String(g.permission),
|
|
737
|
+
context: String(g.context),
|
|
738
|
+
})),
|
|
739
|
+
}
|
|
740
|
+
})
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
async all(): Promise<UserGroupRecord[]> {
|
|
744
|
+
return this.#hydrate(
|
|
745
|
+
await this.#db.query(`SELECT ${GROUP_COLUMNS} FROM user_group ORDER BY name`),
|
|
746
|
+
)
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
async byKeys(keys: readonly string[]): Promise<UserGroupRecord[]> {
|
|
750
|
+
const wanted = keys.filter((k) => /^[0-9a-f-]{32,36}$/i.test(k)).map(normaliseUuid)
|
|
751
|
+
if (wanted.length === 0) return []
|
|
752
|
+
return this.#hydrate(
|
|
753
|
+
await this.#db.query(
|
|
754
|
+
`SELECT ${GROUP_COLUMNS} FROM user_group WHERE key IN (${inList(wanted)}) ORDER BY name`,
|
|
755
|
+
wanted,
|
|
756
|
+
),
|
|
757
|
+
)
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
async byKey(key: string): Promise<UserGroupRecord | undefined> {
|
|
761
|
+
return (await this.byKeys([key]))[0]
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
async byAlias(alias: string): Promise<UserGroupRecord | undefined> {
|
|
765
|
+
return (
|
|
766
|
+
await this.#hydrate(
|
|
767
|
+
await this.#db.query(`SELECT ${GROUP_COLUMNS} FROM user_group WHERE alias = ?`, [alias]),
|
|
768
|
+
)
|
|
769
|
+
)[0]
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/** How many users each group has, by group key. */
|
|
773
|
+
async memberCounts(): Promise<Map<string, number>> {
|
|
774
|
+
const rows = await this.#db.query<{ key: string; n: number }>(
|
|
775
|
+
`SELECT g.key, COUNT(m.user_id) AS n FROM user_group g
|
|
776
|
+
LEFT JOIN user_group_member m ON m.user_group_id = g.id GROUP BY g.key`,
|
|
777
|
+
)
|
|
778
|
+
return new Map(rows.map((r) => [normaliseUuid(String(r.key)), Number(r.n)]))
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* Creates or replaces the group. 'missing-node' names the tree whose start
|
|
783
|
+
* node does not exist; 'missing-language' a language that does not.
|
|
784
|
+
*/
|
|
785
|
+
async save(
|
|
786
|
+
input: UserGroupInput,
|
|
787
|
+
): Promise<'saved' | 'missing-language' | { missingNode: StartNodeTree }> {
|
|
788
|
+
return this.#db.transaction(async (tx) => {
|
|
789
|
+
const users = new UserRepository(tx)
|
|
790
|
+
const starts: Record<StartNodeTree, number | null> = {
|
|
791
|
+
document: null,
|
|
792
|
+
media: null,
|
|
793
|
+
element: null,
|
|
794
|
+
}
|
|
795
|
+
for (const tree of TREES) {
|
|
796
|
+
const start = input.startNodes[tree]
|
|
797
|
+
if (start.root) starts[tree] = ROOT_ID
|
|
798
|
+
else if (start.key) {
|
|
799
|
+
const ids = await users.nodeIds([start.key])
|
|
800
|
+
if (!ids) return { missingNode: tree }
|
|
801
|
+
starts[tree] = ids.get(normaliseUuid(start.key)) ?? null
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
const languageIds: number[] = []
|
|
805
|
+
if (input.languages.length > 0) {
|
|
806
|
+
const rows = await tx.query<{ id: number; iso_code: string }>(
|
|
807
|
+
`SELECT id, iso_code FROM language WHERE iso_code IN (${inList(input.languages)})`,
|
|
808
|
+
input.languages,
|
|
809
|
+
)
|
|
810
|
+
if (rows.length !== new Set(input.languages).size) return 'missing-language'
|
|
811
|
+
languageIds.push(...rows.map((r) => Number(r.id)))
|
|
812
|
+
}
|
|
813
|
+
const key = normaliseUuid(input.key)
|
|
814
|
+
const now = DbDate.toDb(new Date())
|
|
815
|
+
const bool = (v: boolean) => tx.dialect.boolValue(v)
|
|
816
|
+
const existing = await new UserGroupRepository(tx).byKey(key)
|
|
817
|
+
if (existing)
|
|
818
|
+
await tx.exec(
|
|
819
|
+
`UPDATE user_group SET alias = ?, name = ?, description = ?, icon = ?, has_access_to_all_languages = ?,
|
|
820
|
+
start_content_id = ?, start_media_id = ?, start_element_id = ?, update_date = ? WHERE id = ?`,
|
|
821
|
+
[
|
|
822
|
+
input.alias,
|
|
823
|
+
input.name,
|
|
824
|
+
input.description,
|
|
825
|
+
input.icon,
|
|
826
|
+
bool(input.hasAccessToAllLanguages),
|
|
827
|
+
starts.document,
|
|
828
|
+
starts.media,
|
|
829
|
+
starts.element,
|
|
830
|
+
now,
|
|
831
|
+
existing.id,
|
|
832
|
+
],
|
|
833
|
+
)
|
|
834
|
+
else
|
|
835
|
+
await tx.exec(
|
|
836
|
+
`INSERT INTO user_group (key, alias, name, description, icon, has_access_to_all_languages,
|
|
837
|
+
start_content_id, start_media_id, start_element_id, create_date, update_date)
|
|
838
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
839
|
+
[
|
|
840
|
+
key,
|
|
841
|
+
input.alias,
|
|
842
|
+
input.name,
|
|
843
|
+
input.description,
|
|
844
|
+
input.icon,
|
|
845
|
+
bool(input.hasAccessToAllLanguages),
|
|
846
|
+
starts.document,
|
|
847
|
+
starts.media,
|
|
848
|
+
starts.element,
|
|
849
|
+
now,
|
|
850
|
+
now,
|
|
851
|
+
],
|
|
852
|
+
)
|
|
853
|
+
const id = Number(
|
|
854
|
+
(await tx.query<{ id: number }>('SELECT id FROM user_group WHERE key = ?', [key]))[0]?.id,
|
|
855
|
+
)
|
|
856
|
+
await tx.exec('DELETE FROM user_group_section WHERE user_group_id = ?', [id])
|
|
857
|
+
for (const section of new Set(input.sections))
|
|
858
|
+
await tx.exec('INSERT INTO user_group_section (user_group_id, app_alias) VALUES (?, ?)', [
|
|
859
|
+
id,
|
|
860
|
+
section,
|
|
861
|
+
])
|
|
862
|
+
await tx.exec('DELETE FROM user_group_language WHERE user_group_id = ?', [id])
|
|
863
|
+
for (const languageId of languageIds)
|
|
864
|
+
await tx.exec(
|
|
865
|
+
'INSERT INTO user_group_language (user_group_id, language_id) VALUES (?, ?)',
|
|
866
|
+
[id, languageId],
|
|
867
|
+
)
|
|
868
|
+
await tx.exec('DELETE FROM user_group_permission WHERE user_group_key = ?', [key])
|
|
869
|
+
for (const permission of new Set(input.permissions))
|
|
870
|
+
await tx.exec(
|
|
871
|
+
'INSERT INTO user_group_permission (user_group_key, permission) VALUES (?, ?)',
|
|
872
|
+
[key, permission],
|
|
873
|
+
)
|
|
874
|
+
await tx.exec('DELETE FROM user_group_granular_permission WHERE user_group_key = ?', [key])
|
|
875
|
+
for (const row of input.granular)
|
|
876
|
+
await tx.exec(
|
|
877
|
+
'INSERT INTO user_group_granular_permission (user_group_key, unique_id, permission, context) VALUES (?, ?, ?, ?)',
|
|
878
|
+
[key, row.key ? normaliseUuid(row.key) : null, row.permission, row.context],
|
|
879
|
+
)
|
|
880
|
+
return 'saved'
|
|
881
|
+
})
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
async delete(key: string): Promise<void> {
|
|
885
|
+
const group = await this.byKey(key)
|
|
886
|
+
if (!group) return
|
|
887
|
+
await this.#db.transaction(async (tx) => {
|
|
888
|
+
await tx.exec('DELETE FROM user_group_member WHERE user_group_id = ?', [group.id])
|
|
889
|
+
await tx.exec('DELETE FROM user_group_section WHERE user_group_id = ?', [group.id])
|
|
890
|
+
await tx.exec('DELETE FROM user_group_language WHERE user_group_id = ?', [group.id])
|
|
891
|
+
await tx.exec('DELETE FROM user_group_permission WHERE user_group_key = ?', [group.key])
|
|
892
|
+
await tx.exec('DELETE FROM user_group_granular_permission WHERE user_group_key = ?', [
|
|
893
|
+
group.key,
|
|
894
|
+
])
|
|
895
|
+
await tx.exec('DELETE FROM user_group WHERE id = ?', [group.id])
|
|
896
|
+
})
|
|
897
|
+
}
|
|
898
|
+
}
|