@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,361 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared access to `node`, the universal tree.
|
|
3
|
+
*
|
|
4
|
+
* Every tree in the backoffice — documents, content types, data types, templates —
|
|
5
|
+
* is the same query with a different object type, so it lives here once.
|
|
6
|
+
*/
|
|
7
|
+
import { normaliseUuid, ObjectTypes, type Page, SystemNodes } from '@crombolo/core'
|
|
8
|
+
import type { Db } from '../database.ts'
|
|
9
|
+
import { DbDate, fromDbBool } from '../dialect.ts'
|
|
10
|
+
|
|
11
|
+
export interface NodeRow {
|
|
12
|
+
id: number
|
|
13
|
+
key: string
|
|
14
|
+
parentId: number
|
|
15
|
+
level: number
|
|
16
|
+
path: string
|
|
17
|
+
sortOrder: number
|
|
18
|
+
trashed: boolean
|
|
19
|
+
text: string | null
|
|
20
|
+
objectType: string | null
|
|
21
|
+
createDate: Date | undefined
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function mapNode(row: Record<string, unknown>): NodeRow {
|
|
25
|
+
return {
|
|
26
|
+
id: Number(row.id),
|
|
27
|
+
key: normaliseUuid(String(row.unique_id)),
|
|
28
|
+
parentId: Number(row.parent_id),
|
|
29
|
+
level: Number(row.level),
|
|
30
|
+
path: String(row.path),
|
|
31
|
+
sortOrder: Number(row.sort_order),
|
|
32
|
+
trashed: fromDbBool(row.trashed),
|
|
33
|
+
text: (row.text as string | null) ?? null,
|
|
34
|
+
objectType: row.node_object_type ? normaliseUuid(String(row.node_object_type)) : null,
|
|
35
|
+
createDate: DbDate.fromDb(row.create_date),
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const NODE_COLUMNS =
|
|
40
|
+
'id, unique_id, parent_id, level, path, sort_order, trashed, text, node_object_type, create_date'
|
|
41
|
+
|
|
42
|
+
export interface CreateNodeInit {
|
|
43
|
+
key?: string
|
|
44
|
+
parentId: number
|
|
45
|
+
objectType: string
|
|
46
|
+
text: string
|
|
47
|
+
userId?: number
|
|
48
|
+
sortOrder?: number
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export class NodeRepository {
|
|
52
|
+
#db: Db
|
|
53
|
+
|
|
54
|
+
constructor(db: Db) {
|
|
55
|
+
this.#db = db
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async byKey(key: string): Promise<NodeRow | undefined> {
|
|
59
|
+
const rows = await this.#db.query(`SELECT ${NODE_COLUMNS} FROM node WHERE unique_id = ?`, [
|
|
60
|
+
normaliseUuid(key),
|
|
61
|
+
])
|
|
62
|
+
return rows[0] ? mapNode(rows[0]) : undefined
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async byId(id: number): Promise<NodeRow | undefined> {
|
|
66
|
+
const rows = await this.#db.query(`SELECT ${NODE_COLUMNS} FROM node WHERE id = ?`, [id])
|
|
67
|
+
return rows[0] ? mapNode(rows[0]) : undefined
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async byKeys(keys: readonly string[]): Promise<NodeRow[]> {
|
|
71
|
+
if (keys.length === 0) return []
|
|
72
|
+
const placeholders = keys.map(() => '?').join(', ')
|
|
73
|
+
const rows = await this.#db.query(
|
|
74
|
+
`SELECT ${NODE_COLUMNS} FROM node WHERE unique_id IN (${placeholders})`,
|
|
75
|
+
keys.map(normaliseUuid),
|
|
76
|
+
)
|
|
77
|
+
return rows.map(mapNode)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Creates a node, computing `level` and the materialised `path` from the
|
|
82
|
+
* parent. The path is what makes descendant queries a prefix match.
|
|
83
|
+
*/
|
|
84
|
+
async create(init: CreateNodeInit): Promise<NodeRow> {
|
|
85
|
+
const key = normaliseUuid(init.key ?? crypto.randomUUID())
|
|
86
|
+
const parent = init.parentId === SystemNodes.Root ? undefined : await this.byId(init.parentId)
|
|
87
|
+
const level = parent ? parent.level + 1 : 1
|
|
88
|
+
const sortOrder = init.sortOrder ?? (await this.#nextSortOrder(init.parentId, init.objectType))
|
|
89
|
+
const now = DbDate.toDb(new Date())
|
|
90
|
+
|
|
91
|
+
await this.#db.exec(
|
|
92
|
+
`INSERT INTO node
|
|
93
|
+
(unique_id, parent_id, level, path, sort_order, trashed, node_user, text, node_object_type, create_date)
|
|
94
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
95
|
+
[
|
|
96
|
+
key,
|
|
97
|
+
init.parentId,
|
|
98
|
+
level,
|
|
99
|
+
'',
|
|
100
|
+
sortOrder,
|
|
101
|
+
this.#db.dialect.boolValue(false),
|
|
102
|
+
init.userId ?? null,
|
|
103
|
+
init.text,
|
|
104
|
+
init.objectType,
|
|
105
|
+
now,
|
|
106
|
+
],
|
|
107
|
+
)
|
|
108
|
+
const created = await this.byKey(key)
|
|
109
|
+
if (!created) throw new Error('Node insert did not produce a row.')
|
|
110
|
+
|
|
111
|
+
const path = parent ? `${parent.path},${created.id}` : `${SystemNodes.Root},${created.id}`
|
|
112
|
+
await this.#db.exec('UPDATE node SET path = ? WHERE id = ?', [path, created.id])
|
|
113
|
+
return { ...created, path }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async rename(id: number, text: string): Promise<void> {
|
|
117
|
+
await this.#db.exec('UPDATE node SET text = ? WHERE id = ?', [text, id])
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async delete(id: number): Promise<void> {
|
|
121
|
+
await this.#db.exec('DELETE FROM node WHERE id = ?', [id])
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Root-level items of a type, i.e. those parented to the system root. */
|
|
125
|
+
async roots(objectType: string, skip: number, take: number): Promise<Page<NodeRow>> {
|
|
126
|
+
return this.children(SystemNodes.Root, objectType, skip, take)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async children(
|
|
130
|
+
parentId: number,
|
|
131
|
+
objectType: string,
|
|
132
|
+
skip: number,
|
|
133
|
+
take: number,
|
|
134
|
+
trashed = false,
|
|
135
|
+
): Promise<Page<NodeRow>> {
|
|
136
|
+
const totals = await this.#db.query<{ n: number }>(
|
|
137
|
+
'SELECT COUNT(*) AS n FROM node WHERE parent_id = ? AND node_object_type = ? AND trashed = ?',
|
|
138
|
+
[parentId, objectType, this.#db.dialect.boolValue(trashed)],
|
|
139
|
+
)
|
|
140
|
+
const rows = await this.#db.query(
|
|
141
|
+
`SELECT ${NODE_COLUMNS} FROM node
|
|
142
|
+
WHERE parent_id = ? AND node_object_type = ? AND trashed = ?
|
|
143
|
+
ORDER BY sort_order, id
|
|
144
|
+
LIMIT ? OFFSET ?`,
|
|
145
|
+
[parentId, objectType, this.#db.dialect.boolValue(trashed), take, skip],
|
|
146
|
+
)
|
|
147
|
+
return { total: Number(totals[0]?.n ?? 0), items: rows.map(mapNode) }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Children of several object types at once: a settings tree with folders first, then by name. */
|
|
151
|
+
async childrenOf(
|
|
152
|
+
parentId: number,
|
|
153
|
+
objectTypes: readonly string[],
|
|
154
|
+
skip: number,
|
|
155
|
+
take: number,
|
|
156
|
+
): Promise<Page<NodeRow>> {
|
|
157
|
+
if (objectTypes.length === 0) return { total: 0, items: [] }
|
|
158
|
+
const marks = objectTypes.map(() => '?').join(', ')
|
|
159
|
+
const f = this.#db.dialect.boolValue(false)
|
|
160
|
+
const totals = await this.#db.query<{ n: number }>(
|
|
161
|
+
`SELECT COUNT(*) AS n FROM node WHERE parent_id = ? AND node_object_type IN (${marks}) AND trashed = ?`,
|
|
162
|
+
[parentId, ...objectTypes, f],
|
|
163
|
+
)
|
|
164
|
+
const rows = await this.#db.query(
|
|
165
|
+
`SELECT ${NODE_COLUMNS} FROM node
|
|
166
|
+
WHERE parent_id = ? AND node_object_type IN (${marks}) AND trashed = ?
|
|
167
|
+
ORDER BY CASE WHEN node_object_type = ? THEN 0 ELSE 1 END, text, id
|
|
168
|
+
LIMIT ? OFFSET ?`,
|
|
169
|
+
[parentId, ...objectTypes, f, objectTypes[0], take, skip],
|
|
170
|
+
)
|
|
171
|
+
return { total: Number(totals[0]?.n ?? 0), items: rows.map(mapNode) }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Re-parents a node and its descendants, keeping levels and paths true. */
|
|
175
|
+
async move(id: number, newParentId: number): Promise<void> {
|
|
176
|
+
const node = await this.byId(id)
|
|
177
|
+
if (!node) return
|
|
178
|
+
const parent = newParentId === SystemNodes.Root ? undefined : await this.byId(newParentId)
|
|
179
|
+
const newPath = parent ? `${parent.path},${node.id}` : `${SystemNodes.Root},${node.id}`
|
|
180
|
+
const newLevel = parent ? parent.level + 1 : 1
|
|
181
|
+
const delta = newLevel - node.level
|
|
182
|
+
const oldPath = node.path
|
|
183
|
+
await this.#db.exec('UPDATE node SET parent_id = ?, level = ?, path = ? WHERE id = ?', [
|
|
184
|
+
newParentId,
|
|
185
|
+
newLevel,
|
|
186
|
+
newPath,
|
|
187
|
+
node.id,
|
|
188
|
+
])
|
|
189
|
+
const descendants = await this.#db.query<{ id: number; path: string; level: number }>(
|
|
190
|
+
'SELECT id, path, level FROM node WHERE path LIKE ?',
|
|
191
|
+
[`${oldPath},%`],
|
|
192
|
+
)
|
|
193
|
+
for (const d of descendants) {
|
|
194
|
+
await this.#db.exec('UPDATE node SET path = ?, level = ? WHERE id = ?', [
|
|
195
|
+
`${newPath}${String(d.path).slice(oldPath.length)}`,
|
|
196
|
+
Number(d.level) + delta,
|
|
197
|
+
Number(d.id),
|
|
198
|
+
])
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** A window of a node's siblings around it: `before` above, `after` below, the node itself included. */
|
|
203
|
+
async siblings(
|
|
204
|
+
key: string,
|
|
205
|
+
objectType: string | readonly string[],
|
|
206
|
+
before: number,
|
|
207
|
+
after: number,
|
|
208
|
+
): Promise<{ items: NodeRow[]; totalBefore: number; totalAfter: number } | undefined> {
|
|
209
|
+
const target = await this.byKey(key)
|
|
210
|
+
if (!target) return undefined
|
|
211
|
+
const types = typeof objectType === 'string' ? [objectType] : [...objectType]
|
|
212
|
+
const marks = types.map(() => '?').join(', ')
|
|
213
|
+
const order =
|
|
214
|
+
types.length > 1
|
|
215
|
+
? 'CASE WHEN node_object_type = ? THEN 0 ELSE 1 END, text, id'
|
|
216
|
+
: 'sort_order, id'
|
|
217
|
+
const all = await this.#db.query(
|
|
218
|
+
`SELECT ${NODE_COLUMNS} FROM node WHERE parent_id = ? AND node_object_type IN (${marks}) AND trashed = ?
|
|
219
|
+
ORDER BY ${order}`,
|
|
220
|
+
[
|
|
221
|
+
target.parentId,
|
|
222
|
+
...types,
|
|
223
|
+
this.#db.dialect.boolValue(target.trashed),
|
|
224
|
+
...(types.length > 1 ? [types[0]] : []),
|
|
225
|
+
],
|
|
226
|
+
)
|
|
227
|
+
const rows = all.map(mapNode)
|
|
228
|
+
const index = rows.findIndex((row) => row.id === target.id)
|
|
229
|
+
const start = Math.max(0, index - before)
|
|
230
|
+
const end = Math.min(rows.length, index + after + 1)
|
|
231
|
+
return { items: rows.slice(start, end), totalBefore: start, totalAfter: rows.length - end }
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Name search, case-insensitive, optionally under a parent. */
|
|
235
|
+
async search(
|
|
236
|
+
objectType: string,
|
|
237
|
+
query: string,
|
|
238
|
+
options: { trashed?: boolean; parentId?: number; skip: number; take: number },
|
|
239
|
+
): Promise<Page<NodeRow>> {
|
|
240
|
+
const conditions = ['node_object_type = ?', 'trashed = ?', 'LOWER(text) LIKE ?']
|
|
241
|
+
const params: unknown[] = [
|
|
242
|
+
objectType,
|
|
243
|
+
this.#db.dialect.boolValue(options.trashed ?? false),
|
|
244
|
+
`%${query.toLowerCase().replaceAll('%', '').replaceAll('_', '')}%`,
|
|
245
|
+
]
|
|
246
|
+
if (options.parentId !== undefined) {
|
|
247
|
+
conditions.push('parent_id = ?')
|
|
248
|
+
params.push(options.parentId)
|
|
249
|
+
}
|
|
250
|
+
const where = conditions.join(' AND ')
|
|
251
|
+
const totals = await this.#db.query<{ n: number }>(
|
|
252
|
+
`SELECT COUNT(*) AS n FROM node WHERE ${where}`,
|
|
253
|
+
params,
|
|
254
|
+
)
|
|
255
|
+
const rows = await this.#db.query(
|
|
256
|
+
`SELECT ${NODE_COLUMNS} FROM node WHERE ${where} ORDER BY text, id LIMIT ? OFFSET ?`,
|
|
257
|
+
[...params, options.take, options.skip],
|
|
258
|
+
)
|
|
259
|
+
return { total: Number(totals[0]?.n ?? 0), items: rows.map(mapNode) }
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Ancestors of a node, root first, excluding the node itself. */
|
|
263
|
+
async ancestors(key: string): Promise<NodeRow[]> {
|
|
264
|
+
const node = await this.byKey(key)
|
|
265
|
+
if (!node) return []
|
|
266
|
+
const ids = node.path
|
|
267
|
+
.split(',')
|
|
268
|
+
.map((part) => Number(part))
|
|
269
|
+
.filter((id) => id > 0 && id !== node.id)
|
|
270
|
+
if (ids.length === 0) return []
|
|
271
|
+
const placeholders = ids.map(() => '?').join(', ')
|
|
272
|
+
const rows = await this.#db.query(
|
|
273
|
+
`SELECT ${NODE_COLUMNS} FROM node WHERE id IN (${placeholders}) ORDER BY level`,
|
|
274
|
+
ids,
|
|
275
|
+
)
|
|
276
|
+
return rows.map(mapNode)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Every descendant of a node, parents before children. The materialised
|
|
281
|
+
* `path` makes this one prefix query rather than a walk — the same trick
|
|
282
|
+
* `move` uses to re-parent a branch.
|
|
283
|
+
*
|
|
284
|
+
* Trashed nodes are excluded: a bundle of a subtree should not carry what
|
|
285
|
+
* somebody deleted out of it.
|
|
286
|
+
*/
|
|
287
|
+
async descendants(node: NodeRow, objectTypes: readonly string[]): Promise<NodeRow[]> {
|
|
288
|
+
if (objectTypes.length === 0) return []
|
|
289
|
+
const marks = objectTypes.map(() => '?').join(', ')
|
|
290
|
+
const rows = await this.#db.query(
|
|
291
|
+
`SELECT ${NODE_COLUMNS} FROM node
|
|
292
|
+
WHERE path LIKE ? AND node_object_type IN (${marks}) AND trashed = ?
|
|
293
|
+
ORDER BY level, sort_order, id`,
|
|
294
|
+
[`${node.path},%`, ...objectTypes, this.#db.dialect.boolValue(false)],
|
|
295
|
+
)
|
|
296
|
+
return rows.map(mapNode)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** The children of a node with a given name, for resolving a content path a segment at a time. */
|
|
300
|
+
async childrenNamed(
|
|
301
|
+
parentId: number,
|
|
302
|
+
objectTypes: readonly string[],
|
|
303
|
+
name: string,
|
|
304
|
+
): Promise<NodeRow[]> {
|
|
305
|
+
if (objectTypes.length === 0) return []
|
|
306
|
+
const marks = objectTypes.map(() => '?').join(', ')
|
|
307
|
+
const rows = await this.#db.query(
|
|
308
|
+
`SELECT ${NODE_COLUMNS} FROM node
|
|
309
|
+
WHERE parent_id = ? AND node_object_type IN (${marks}) AND trashed = ?
|
|
310
|
+
AND LOWER(text) = LOWER(?)
|
|
311
|
+
ORDER BY sort_order, id`,
|
|
312
|
+
[parentId, ...objectTypes, this.#db.dialect.boolValue(false), name],
|
|
313
|
+
)
|
|
314
|
+
return rows.map(mapNode)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** The names under a node, so a failed path resolution can say what was there. */
|
|
318
|
+
async childNames(parentId: number, objectTypes: readonly string[]): Promise<string[]> {
|
|
319
|
+
if (objectTypes.length === 0) return []
|
|
320
|
+
const marks = objectTypes.map(() => '?').join(', ')
|
|
321
|
+
const rows = await this.#db.query<{ text: string | null }>(
|
|
322
|
+
`SELECT text FROM node
|
|
323
|
+
WHERE parent_id = ? AND node_object_type IN (${marks}) AND trashed = ?
|
|
324
|
+
ORDER BY sort_order, id`,
|
|
325
|
+
[parentId, ...objectTypes, this.#db.dialect.boolValue(false)],
|
|
326
|
+
)
|
|
327
|
+
return rows.flatMap((row) => (row.text ? [String(row.text)] : []))
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async hasChildren(id: number, objectType: string): Promise<boolean> {
|
|
331
|
+
const rows = await this.#db.query<{ n: number }>(
|
|
332
|
+
'SELECT COUNT(*) AS n FROM node WHERE parent_id = ? AND node_object_type = ?',
|
|
333
|
+
[id, objectType],
|
|
334
|
+
)
|
|
335
|
+
return Number(rows[0]?.n ?? 0) > 0
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Bulk `hasChildren`, so a tree page costs one query instead of one per row. */
|
|
339
|
+
async childCounts(ids: readonly number[], objectType: string): Promise<Map<number, number>> {
|
|
340
|
+
if (ids.length === 0) return new Map()
|
|
341
|
+
const placeholders = ids.map(() => '?').join(', ')
|
|
342
|
+
const rows = await this.#db.query<{ parent_id: number; n: number }>(
|
|
343
|
+
`SELECT parent_id, COUNT(*) AS n FROM node
|
|
344
|
+
WHERE parent_id IN (${placeholders}) AND node_object_type = ?
|
|
345
|
+
GROUP BY parent_id`,
|
|
346
|
+
[...ids, objectType],
|
|
347
|
+
)
|
|
348
|
+
return new Map(rows.map((row) => [Number(row.parent_id), Number(row.n)]))
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async #nextSortOrder(parentId: number, objectType: string): Promise<number> {
|
|
352
|
+
const rows = await this.#db.query<{ next: number | null }>(
|
|
353
|
+
'SELECT MAX(sort_order) AS next FROM node WHERE parent_id = ? AND node_object_type = ?',
|
|
354
|
+
[parentId, objectType],
|
|
355
|
+
)
|
|
356
|
+
const current = rows[0]?.next
|
|
357
|
+
return current === null || current === undefined ? 0 : Number(current) + 1
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export { ObjectTypes, SystemNodes }
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which actions on a node a user asked to be notified of (Umbraco's
|
|
3
|
+
* `umbracoUser2NodeNotify`). Sending the notification e-mails needs e-mail,
|
|
4
|
+
* which the site does not have yet; the subscriptions are kept regardless.
|
|
5
|
+
*/
|
|
6
|
+
import type { Db } from '../database.ts'
|
|
7
|
+
|
|
8
|
+
/** The actions Umbraco offers in the notifications dialog, by letter and alias. */
|
|
9
|
+
export const NOTIFIABLE_ACTIONS: ReadonlyArray<{ actionId: string; alias: string }> = [
|
|
10
|
+
{ actionId: 'Umb.Document.Duplicate', alias: 'copy' },
|
|
11
|
+
{ actionId: 'Umb.Document.Delete', alias: 'delete' },
|
|
12
|
+
{ actionId: 'Umb.Document.Move', alias: 'move' },
|
|
13
|
+
{ actionId: 'Umb.Document.Create', alias: 'create' },
|
|
14
|
+
{ actionId: 'Umb.Document.PublicAccess', alias: 'protect' },
|
|
15
|
+
{ actionId: 'Umb.Document.Publish', alias: 'publish' },
|
|
16
|
+
{ actionId: 'Umb.DocumentRecycleBin.Restore', alias: 'restore' },
|
|
17
|
+
{ actionId: 'Umb.Document.Permissions', alias: 'rights' },
|
|
18
|
+
{ actionId: 'Umb.Document.Rollback', alias: 'rollback' },
|
|
19
|
+
{ actionId: 'Umb.Document.Sort', alias: 'sort' },
|
|
20
|
+
{ actionId: 'Umb.Document.Update', alias: 'update' },
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
export class NotificationRepository {
|
|
24
|
+
#db: Db
|
|
25
|
+
|
|
26
|
+
constructor(db: Db) {
|
|
27
|
+
this.#db = db
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async subscribed(userId: number, nodeId: number): Promise<string[]> {
|
|
31
|
+
const rows = await this.#db.query<{ action: string }>(
|
|
32
|
+
'SELECT action FROM user_notification WHERE user_id = ? AND node_id = ?',
|
|
33
|
+
[userId, nodeId],
|
|
34
|
+
)
|
|
35
|
+
return rows.map((row) => String(row.action))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Replaces the user's subscriptions on a node; unknown action ids are ignored. */
|
|
39
|
+
async set(userId: number, nodeId: number, actionIds: readonly string[]): Promise<void> {
|
|
40
|
+
const known = new Set(NOTIFIABLE_ACTIONS.map((a) => a.actionId))
|
|
41
|
+
await this.#db.transaction(async (tx) => {
|
|
42
|
+
await tx.exec('DELETE FROM user_notification WHERE user_id = ? AND node_id = ?', [
|
|
43
|
+
userId,
|
|
44
|
+
nodeId,
|
|
45
|
+
])
|
|
46
|
+
for (const action of new Set(actionIds))
|
|
47
|
+
if (known.has(action))
|
|
48
|
+
await tx.exec(
|
|
49
|
+
'INSERT INTO user_notification (user_id, node_id, action) VALUES (?, ?, ?)',
|
|
50
|
+
[userId, nodeId, action],
|
|
51
|
+
)
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which pages are protected, and by what.
|
|
3
|
+
*
|
|
4
|
+
* An entry protects the page it names *and everything below it*, so the render
|
|
5
|
+
* path asks "is anything on this node's ancestor path protected?" — which is
|
|
6
|
+
* `entryFor`, resolved against `node.path` rather than by walking parents.
|
|
7
|
+
*
|
|
8
|
+
* Rules are names, not keys: a member group's name or a member's username, as
|
|
9
|
+
* Umbraco stores them, because that is what a signed-in member carries.
|
|
10
|
+
*/
|
|
11
|
+
import { normaliseUuid } from '@crombolo/core'
|
|
12
|
+
import type { Db } from '../database.ts'
|
|
13
|
+
import { DbDate } from '../dialect.ts'
|
|
14
|
+
import { NodeRepository } from './nodes.ts'
|
|
15
|
+
|
|
16
|
+
export const MEMBER_GROUP_RULE = 'MemberRole'
|
|
17
|
+
export const MEMBER_USERNAME_RULE = 'MemberUsername'
|
|
18
|
+
|
|
19
|
+
export interface PublicAccessEntry {
|
|
20
|
+
key: string
|
|
21
|
+
/** The protected page. */
|
|
22
|
+
nodeKey: string
|
|
23
|
+
loginNodeKey: string
|
|
24
|
+
errorNodeKey: string
|
|
25
|
+
memberGroupNames: string[]
|
|
26
|
+
memberUserNames: string[]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface PublicAccessInput {
|
|
30
|
+
nodeKey: string
|
|
31
|
+
loginNodeKey: string
|
|
32
|
+
errorNodeKey: string
|
|
33
|
+
memberGroupNames: readonly string[]
|
|
34
|
+
memberUserNames: readonly string[]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface EntryRow {
|
|
38
|
+
id: number
|
|
39
|
+
key: string
|
|
40
|
+
node_key: string
|
|
41
|
+
login_key: string
|
|
42
|
+
error_key: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const SELECT = `SELECT a.id, a.key,
|
|
46
|
+
n.unique_id AS node_key, l.unique_id AS login_key, e.unique_id AS error_key
|
|
47
|
+
FROM public_access a
|
|
48
|
+
JOIN node n ON n.id = a.node_id
|
|
49
|
+
JOIN node l ON l.id = a.login_node_id
|
|
50
|
+
JOIN node e ON e.id = a.error_node_id`
|
|
51
|
+
|
|
52
|
+
export class PublicAccessRepository {
|
|
53
|
+
#db: Db
|
|
54
|
+
#nodes: NodeRepository
|
|
55
|
+
|
|
56
|
+
constructor(db: Db) {
|
|
57
|
+
this.#db = db
|
|
58
|
+
this.#nodes = new NodeRepository(db)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async #rules(id: number): Promise<{ groups: string[]; users: string[] }> {
|
|
62
|
+
const rows = await this.#db.query<{ rule_type: string; rule_value: string }>(
|
|
63
|
+
'SELECT rule_type, rule_value FROM public_access_rule WHERE public_access_id = ? ORDER BY rule_value',
|
|
64
|
+
[id],
|
|
65
|
+
)
|
|
66
|
+
return {
|
|
67
|
+
groups: rows
|
|
68
|
+
.filter((r) => r.rule_type === MEMBER_GROUP_RULE)
|
|
69
|
+
.map((r) => String(r.rule_value)),
|
|
70
|
+
users: rows
|
|
71
|
+
.filter((r) => r.rule_type === MEMBER_USERNAME_RULE)
|
|
72
|
+
.map((r) => String(r.rule_value)),
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async #hydrate(row: EntryRow): Promise<PublicAccessEntry> {
|
|
77
|
+
const { groups, users } = await this.#rules(Number(row.id))
|
|
78
|
+
return {
|
|
79
|
+
key: normaliseUuid(String(row.key)),
|
|
80
|
+
nodeKey: normaliseUuid(String(row.node_key)),
|
|
81
|
+
loginNodeKey: normaliseUuid(String(row.login_key)),
|
|
82
|
+
errorNodeKey: normaliseUuid(String(row.error_key)),
|
|
83
|
+
memberGroupNames: groups,
|
|
84
|
+
memberUserNames: users,
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The entry protecting this page itself, if any. */
|
|
89
|
+
async byNodeKey(nodeKey: string): Promise<PublicAccessEntry | undefined> {
|
|
90
|
+
const rows = await this.#db.query<EntryRow>(`${SELECT} WHERE n.unique_id = ?`, [
|
|
91
|
+
normaliseUuid(nodeKey),
|
|
92
|
+
])
|
|
93
|
+
return rows[0] ? this.#hydrate(rows[0]) : undefined
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The entry that governs a page: its own, else the nearest protected
|
|
98
|
+
* ancestor's. `undefined` means the page is open to everyone.
|
|
99
|
+
*/
|
|
100
|
+
async entryFor(nodeKey: string): Promise<PublicAccessEntry | undefined> {
|
|
101
|
+
const node = await this.#nodes.byKey(nodeKey)
|
|
102
|
+
if (!node) return undefined
|
|
103
|
+
// `path` is "-1,1050,1063": the ids from the root down. The nearest
|
|
104
|
+
// protected ancestor governs, so the deepest match wins.
|
|
105
|
+
const ids = node.path
|
|
106
|
+
.split(',')
|
|
107
|
+
.map((part) => Number(part))
|
|
108
|
+
.filter((id) => Number.isFinite(id))
|
|
109
|
+
if (ids.length === 0) return undefined
|
|
110
|
+
const holes = ids.map(() => '?').join(', ')
|
|
111
|
+
const rows = await this.#db.query<EntryRow>(
|
|
112
|
+
`${SELECT} WHERE a.node_id IN (${holes}) ORDER BY n.level DESC LIMIT 1`,
|
|
113
|
+
[...ids],
|
|
114
|
+
)
|
|
115
|
+
return rows[0] ? this.#hydrate(rows[0]) : undefined
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Whether an ancestor above this page is protected, which the editor shows. */
|
|
119
|
+
async isProtectedByAncestor(nodeKey: string): Promise<boolean> {
|
|
120
|
+
const entry = await this.entryFor(nodeKey)
|
|
121
|
+
return entry !== undefined && entry.nodeKey !== normaliseUuid(nodeKey)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Creates or replaces the entry for a page. `undefined` means one of the
|
|
126
|
+
* three nodes could not be found.
|
|
127
|
+
*/
|
|
128
|
+
async save(input: PublicAccessInput): Promise<PublicAccessEntry | undefined> {
|
|
129
|
+
const node = await this.#nodes.byKey(input.nodeKey)
|
|
130
|
+
const login = await this.#nodes.byKey(input.loginNodeKey)
|
|
131
|
+
const error = await this.#nodes.byKey(input.errorNodeKey)
|
|
132
|
+
if (!node || !login || !error) return undefined
|
|
133
|
+
const now = DbDate.toDb(new Date())
|
|
134
|
+
await this.#db.transaction(async (tx) => {
|
|
135
|
+
const existing = await tx.query<{ id: number }>(
|
|
136
|
+
'SELECT id FROM public_access WHERE node_id = ?',
|
|
137
|
+
[node.id],
|
|
138
|
+
)
|
|
139
|
+
let id = existing[0] ? Number(existing[0].id) : undefined
|
|
140
|
+
if (id === undefined) {
|
|
141
|
+
const key = crypto.randomUUID()
|
|
142
|
+
await tx.exec(
|
|
143
|
+
`INSERT INTO public_access (key, node_id, login_node_id, error_node_id, create_date, update_date)
|
|
144
|
+
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
145
|
+
[key, node.id, login.id, error.id, now, now],
|
|
146
|
+
)
|
|
147
|
+
const created = await tx.query<{ id: number }>(
|
|
148
|
+
'SELECT id FROM public_access WHERE node_id = ?',
|
|
149
|
+
[node.id],
|
|
150
|
+
)
|
|
151
|
+
id = Number(created[0]?.id)
|
|
152
|
+
} else {
|
|
153
|
+
await tx.exec(
|
|
154
|
+
'UPDATE public_access SET login_node_id = ?, error_node_id = ?, update_date = ? WHERE id = ?',
|
|
155
|
+
[login.id, error.id, now, id],
|
|
156
|
+
)
|
|
157
|
+
await tx.exec('DELETE FROM public_access_rule WHERE public_access_id = ?', [id])
|
|
158
|
+
}
|
|
159
|
+
for (const [type, names] of [
|
|
160
|
+
[MEMBER_GROUP_RULE, input.memberGroupNames],
|
|
161
|
+
[MEMBER_USERNAME_RULE, input.memberUserNames],
|
|
162
|
+
] as const) {
|
|
163
|
+
for (const name of new Set(names.filter((value) => value.trim() !== ''))) {
|
|
164
|
+
await tx.exec(
|
|
165
|
+
'INSERT INTO public_access_rule (public_access_id, rule_type, rule_value, create_date) VALUES (?, ?, ?, ?)',
|
|
166
|
+
[id, type, name, now],
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
})
|
|
171
|
+
return this.byNodeKey(input.nodeKey)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async remove(nodeKey: string): Promise<boolean> {
|
|
175
|
+
const node = await this.#nodes.byKey(nodeKey)
|
|
176
|
+
if (!node) return false
|
|
177
|
+
const rows = await this.#db.query<{ id: number }>(
|
|
178
|
+
'SELECT id FROM public_access WHERE node_id = ?',
|
|
179
|
+
[node.id],
|
|
180
|
+
)
|
|
181
|
+
const id = rows[0]?.id
|
|
182
|
+
if (id === undefined) return false
|
|
183
|
+
await this.#db.exec('DELETE FROM public_access_rule WHERE public_access_id = ?', [id])
|
|
184
|
+
await this.#db.exec('DELETE FROM public_access WHERE id = ?', [id])
|
|
185
|
+
return true
|
|
186
|
+
}
|
|
187
|
+
}
|