@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,831 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The content-type aggregate repository.
|
|
3
|
+
*
|
|
4
|
+
* A content type is saved as one document by the editor, so it is loaded and
|
|
5
|
+
* written as one aggregate here: the type row, its groups, its properties, its
|
|
6
|
+
* compositions, allowed children and templates.
|
|
7
|
+
*
|
|
8
|
+
* Groups and properties are upserted by key and never deleted-and-reinserted:
|
|
9
|
+
* values reference property-type ids, so a delete would orphan every value ever
|
|
10
|
+
* written. A property absent from the aggregate is **retired** — kept, hidden,
|
|
11
|
+
* values intact — and one that reappears is **revived**.
|
|
12
|
+
*/
|
|
13
|
+
import {
|
|
14
|
+
type ContentTypeAggregate,
|
|
15
|
+
ContentVariation,
|
|
16
|
+
normaliseUuid,
|
|
17
|
+
ObjectTypes,
|
|
18
|
+
type PropertyGroupModel,
|
|
19
|
+
PropertyGroupType,
|
|
20
|
+
type PropertyTypeModel,
|
|
21
|
+
SystemNodes,
|
|
22
|
+
variationFlags,
|
|
23
|
+
variesByCulture,
|
|
24
|
+
variesBySegment,
|
|
25
|
+
} from '@crombolo/core'
|
|
26
|
+
import type { Db } from '../database.ts'
|
|
27
|
+
import { DbDate, fromDbBool } from '../dialect.ts'
|
|
28
|
+
import { pendingUntil } from '../pending.ts'
|
|
29
|
+
import type { NodeSchemaState } from '../schema-state.ts'
|
|
30
|
+
import { NodeRepository } from './nodes.ts'
|
|
31
|
+
|
|
32
|
+
const GROUP_TYPE_NAMES = ['Group', 'Tab'] as const
|
|
33
|
+
|
|
34
|
+
export interface SaveContentTypeOptions {
|
|
35
|
+
userId?: number
|
|
36
|
+
/** The schema state that introduced anything created by this save. */
|
|
37
|
+
sinceStateId?: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A property removed from its type and kept, with its values, for revival. */
|
|
41
|
+
export interface RetiredProperty {
|
|
42
|
+
key: string
|
|
43
|
+
alias: string
|
|
44
|
+
retiredAt: Date
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Content types and media types share one table, told apart by node object type. */
|
|
48
|
+
export type ContentTypeKind = 'document' | 'media' | 'member'
|
|
49
|
+
|
|
50
|
+
export const CONTENT_TYPE_OBJECT_TYPES: Record<
|
|
51
|
+
ContentTypeKind,
|
|
52
|
+
{ item: string; container: string }
|
|
53
|
+
> = {
|
|
54
|
+
document: { item: ObjectTypes.DocumentType, container: ObjectTypes.DocumentTypeContainer },
|
|
55
|
+
media: { item: ObjectTypes.MediaType, container: ObjectTypes.MediaTypeContainer },
|
|
56
|
+
member: { item: ObjectTypes.MemberType, container: ObjectTypes.MemberTypeContainer },
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface ContentTypeRepositoryOptions {
|
|
60
|
+
/** The state this node runs at; elements newer than it are marked pending. */
|
|
61
|
+
nodeState?: NodeSchemaState
|
|
62
|
+
/** Which kind of type this repository reads and writes. Default document. */
|
|
63
|
+
kind?: ContentTypeKind
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export class ContentTypeRepository {
|
|
67
|
+
#db: Db
|
|
68
|
+
#nodes: NodeRepository
|
|
69
|
+
#nodeState: NodeSchemaState | undefined
|
|
70
|
+
#objectType: string
|
|
71
|
+
#kind: ContentTypeKind
|
|
72
|
+
|
|
73
|
+
constructor(db: Db, options: ContentTypeRepositoryOptions = {}) {
|
|
74
|
+
this.#db = db
|
|
75
|
+
this.#nodes = new NodeRepository(db)
|
|
76
|
+
this.#nodeState = options.nodeState
|
|
77
|
+
this.#kind = options.kind ?? 'document'
|
|
78
|
+
this.#objectType = CONTENT_TYPE_OBJECT_TYPES[this.#kind].item
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The node object type this repository's types carry. */
|
|
82
|
+
get objectType(): string {
|
|
83
|
+
return this.#objectType
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
#pending(row: Record<string, unknown>): NodeSchemaState | null {
|
|
87
|
+
if (!this.#nodeState) return null
|
|
88
|
+
return pendingUntil(
|
|
89
|
+
{
|
|
90
|
+
since: row.since_v
|
|
91
|
+
? { version: String(row.since_v), revision: String(row.since_r ?? '0') }
|
|
92
|
+
: null,
|
|
93
|
+
sinceVersion: (row.since_version as string | null) ?? null,
|
|
94
|
+
},
|
|
95
|
+
this.#nodeState,
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
get nodes(): NodeRepository {
|
|
100
|
+
return this.#nodes
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async byKey(key: string): Promise<ContentTypeAggregate | undefined> {
|
|
104
|
+
const node = await this.#nodes.byKey(key)
|
|
105
|
+
if (!node || node.objectType !== this.#objectType) return undefined
|
|
106
|
+
return this.#load(node.id, node.key, node.text ?? '', node.parentId)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Retired types are hidden unless asked for: schema sync revives them by alias. */
|
|
110
|
+
async byAlias(
|
|
111
|
+
alias: string,
|
|
112
|
+
options: { includeRetired?: boolean } = {},
|
|
113
|
+
): Promise<ContentTypeAggregate | undefined> {
|
|
114
|
+
const rows = await this.#db.query<{ unique_id: string }>(
|
|
115
|
+
`SELECT n.unique_id FROM content_type ct JOIN node n ON n.id = ct.node_id
|
|
116
|
+
WHERE ct.alias = ? AND n.node_object_type = ?${options.includeRetired ? '' : ' AND ct.retired_at IS NULL'}`,
|
|
117
|
+
[alias, this.#objectType],
|
|
118
|
+
)
|
|
119
|
+
return rows[0] ? this.byKey(String(rows[0].unique_id)) : undefined
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async aliasExists(alias: string, exceptKey?: string): Promise<boolean> {
|
|
123
|
+
// Built conditionally rather than with `? IS NULL`: Postgres cannot infer a
|
|
124
|
+
// type for a bare parameter used only in a null test.
|
|
125
|
+
const sql = `SELECT COUNT(*) AS n FROM content_type ct
|
|
126
|
+
JOIN node n ON n.id = ct.node_id
|
|
127
|
+
WHERE ct.alias = ?${exceptKey ? ' AND n.unique_id <> ?' : ''}`
|
|
128
|
+
const params = exceptKey ? [alias, normaliseUuid(exceptKey)] : [alias]
|
|
129
|
+
const rows = await this.#db.query<{ n: number }>(sql, params)
|
|
130
|
+
return Number(rows[0]?.n ?? 0) > 0
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Content types permitted at the root of the content tree. */
|
|
134
|
+
async allowedAtRoot(
|
|
135
|
+
skip: number,
|
|
136
|
+
take: number,
|
|
137
|
+
): Promise<{ total: number; items: ContentTypeAggregate[] }> {
|
|
138
|
+
const totals = await this.#db.query<{ n: number }>(
|
|
139
|
+
`SELECT COUNT(*) AS n FROM content_type ct JOIN node n ON n.id = ct.node_id
|
|
140
|
+
WHERE ct.allow_at_root = ? AND ct.retired_at IS NULL AND n.node_object_type = ?`,
|
|
141
|
+
[this.#db.dialect.boolValue(true), this.#objectType],
|
|
142
|
+
)
|
|
143
|
+
const rows = await this.#db.query<{ unique_id: string }>(
|
|
144
|
+
`SELECT n.unique_id FROM content_type ct JOIN node n ON n.id = ct.node_id
|
|
145
|
+
WHERE ct.allow_at_root = ? AND ct.retired_at IS NULL AND n.node_object_type = ?
|
|
146
|
+
ORDER BY n.text LIMIT ? OFFSET ?`,
|
|
147
|
+
[this.#db.dialect.boolValue(true), this.#objectType, take, skip],
|
|
148
|
+
)
|
|
149
|
+
const items: ContentTypeAggregate[] = []
|
|
150
|
+
let hidden = 0
|
|
151
|
+
for (const row of rows) {
|
|
152
|
+
const aggregate = await this.byKey(String(row.unique_id))
|
|
153
|
+
if (!aggregate) continue
|
|
154
|
+
// A pending type is not offered for creation until it is live here.
|
|
155
|
+
if (aggregate.pending) hidden += 1
|
|
156
|
+
else items.push(aggregate)
|
|
157
|
+
}
|
|
158
|
+
return { total: Number(totals[0]?.n ?? 0) - hidden, items }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Content types offered in the library, which Umbraco reads as element types
|
|
163
|
+
* carrying the flag — a non-element type is never offered, whatever it holds.
|
|
164
|
+
*/
|
|
165
|
+
async allowedInLibrary(
|
|
166
|
+
skip: number,
|
|
167
|
+
take: number,
|
|
168
|
+
): Promise<{ total: number; items: ContentTypeAggregate[] }> {
|
|
169
|
+
const where = `WHERE ct.is_element = ? AND ct.allow_in_library = ? AND ct.retired_at IS NULL
|
|
170
|
+
AND n.node_object_type = ?`
|
|
171
|
+
const params = [
|
|
172
|
+
this.#db.dialect.boolValue(true),
|
|
173
|
+
this.#db.dialect.boolValue(true),
|
|
174
|
+
this.#objectType,
|
|
175
|
+
]
|
|
176
|
+
const totals = await this.#db.query<{ n: number }>(
|
|
177
|
+
`SELECT COUNT(*) AS n FROM content_type ct JOIN node n ON n.id = ct.node_id ${where}`,
|
|
178
|
+
params,
|
|
179
|
+
)
|
|
180
|
+
const rows = await this.#db.query<{ unique_id: string }>(
|
|
181
|
+
`SELECT n.unique_id FROM content_type ct JOIN node n ON n.id = ct.node_id ${where}
|
|
182
|
+
ORDER BY n.text LIMIT ? OFFSET ?`,
|
|
183
|
+
[...params, take, skip],
|
|
184
|
+
)
|
|
185
|
+
const items: ContentTypeAggregate[] = []
|
|
186
|
+
let hidden = 0
|
|
187
|
+
for (const row of rows) {
|
|
188
|
+
const aggregate = await this.byKey(String(row.unique_id))
|
|
189
|
+
if (!aggregate) continue
|
|
190
|
+
if (aggregate.pending) hidden += 1
|
|
191
|
+
else items.push(aggregate)
|
|
192
|
+
}
|
|
193
|
+
return { total: Number(totals[0]?.n ?? 0) - hidden, items }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async allowedChildren(
|
|
197
|
+
key: string,
|
|
198
|
+
skip: number,
|
|
199
|
+
take: number,
|
|
200
|
+
): Promise<{ total: number; items: ContentTypeAggregate[] }> {
|
|
201
|
+
const node = await this.#nodes.byKey(key)
|
|
202
|
+
if (!node) return { total: 0, items: [] }
|
|
203
|
+
const rows = await this.#db.query<{ unique_id: string }>(
|
|
204
|
+
`SELECT child.unique_id FROM content_type_allowed_child ac
|
|
205
|
+
JOIN node child ON child.id = ac.allowed_content_type_id
|
|
206
|
+
WHERE ac.content_type_id = ? ORDER BY ac.sort_order`,
|
|
207
|
+
[node.id],
|
|
208
|
+
)
|
|
209
|
+
const items: ContentTypeAggregate[] = []
|
|
210
|
+
for (const row of rows.slice(skip, skip + take)) {
|
|
211
|
+
const aggregate = await this.byKey(String(row.unique_id))
|
|
212
|
+
if (aggregate && !aggregate.pending) items.push(aggregate)
|
|
213
|
+
}
|
|
214
|
+
return { total: rows.length, items }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Creates or updates a content type. See the class comment for retirement. */
|
|
218
|
+
async save(
|
|
219
|
+
aggregate: ContentTypeAggregate,
|
|
220
|
+
options: SaveContentTypeOptions = {},
|
|
221
|
+
): Promise<ContentTypeAggregate> {
|
|
222
|
+
return this.#db.transaction(async (tx) => {
|
|
223
|
+
const repo = new ContentTypeRepository(tx, { nodeState: this.#nodeState, kind: this.#kind })
|
|
224
|
+
const bool = (value: boolean) => tx.dialect.boolValue(value)
|
|
225
|
+
const now = DbDate.toDb(new Date())
|
|
226
|
+
const existing = await repo.nodes.byKey(aggregate.key)
|
|
227
|
+
const parentId = aggregate.parentKey
|
|
228
|
+
? ((await repo.nodes.byKey(aggregate.parentKey))?.id ?? SystemNodes.Root)
|
|
229
|
+
: SystemNodes.Root
|
|
230
|
+
|
|
231
|
+
let nodeId: number
|
|
232
|
+
if (existing) {
|
|
233
|
+
nodeId = existing.id
|
|
234
|
+
await repo.nodes.rename(nodeId, aggregate.name)
|
|
235
|
+
} else {
|
|
236
|
+
nodeId = (
|
|
237
|
+
await repo.nodes.create({
|
|
238
|
+
key: aggregate.key,
|
|
239
|
+
parentId,
|
|
240
|
+
objectType: this.#objectType,
|
|
241
|
+
text: aggregate.name,
|
|
242
|
+
userId: options.userId,
|
|
243
|
+
})
|
|
244
|
+
).id
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const variations = variationFlags(aggregate.variesByCulture, aggregate.variesBySegment)
|
|
248
|
+
const typeValues = [
|
|
249
|
+
aggregate.alias,
|
|
250
|
+
aggregate.icon,
|
|
251
|
+
aggregate.description,
|
|
252
|
+
aggregate.collectionKey,
|
|
253
|
+
bool(aggregate.isElement),
|
|
254
|
+
bool(aggregate.allowedInLibrary),
|
|
255
|
+
bool(aggregate.allowedAsRoot),
|
|
256
|
+
variations,
|
|
257
|
+
bool(aggregate.cleanup.preventCleanup),
|
|
258
|
+
aggregate.cleanup.keepAllVersionsNewerThanDays,
|
|
259
|
+
aggregate.cleanup.keepLatestVersionPerDayForDays,
|
|
260
|
+
aggregate.sinceVersion ?? null,
|
|
261
|
+
]
|
|
262
|
+
if (existing) {
|
|
263
|
+
await tx.exec(
|
|
264
|
+
`UPDATE content_type SET alias = ?, icon = ?, description = ?, list_view = ?, is_element = ?,
|
|
265
|
+
allow_in_library = ?, allow_at_root = ?, variations = ?, prevent_cleanup = ?,
|
|
266
|
+
keep_all_versions_newer_than_days = ?, keep_latest_version_per_day_for_days = ?, since_version = ?, retired_at = NULL
|
|
267
|
+
WHERE node_id = ?`,
|
|
268
|
+
[...typeValues, nodeId],
|
|
269
|
+
)
|
|
270
|
+
} else {
|
|
271
|
+
await tx.exec(
|
|
272
|
+
`INSERT INTO content_type
|
|
273
|
+
(node_id, alias, icon, thumbnail, description, list_view, is_element, allow_in_library, allow_at_root,
|
|
274
|
+
variations, prevent_cleanup, keep_all_versions_newer_than_days, keep_latest_version_per_day_for_days, since_version, since_state_id)
|
|
275
|
+
VALUES (?, ?, ?, 'folder.png', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
276
|
+
[nodeId, ...typeValues, options.sinceStateId ?? null],
|
|
277
|
+
)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ---- groups: upsert by key
|
|
281
|
+
const groupIdByKey = new Map<string, number>()
|
|
282
|
+
for (const container of aggregate.containers) {
|
|
283
|
+
const type = container.type === 'Tab' ? PropertyGroupType.Tab : PropertyGroupType.Group
|
|
284
|
+
const found = await tx.query<{ id: number }>(
|
|
285
|
+
'SELECT id FROM property_type_group WHERE unique_id = ?',
|
|
286
|
+
[container.key],
|
|
287
|
+
)
|
|
288
|
+
if (found[0]) {
|
|
289
|
+
await tx.exec(
|
|
290
|
+
'UPDATE property_type_group SET type = ?, text = ?, alias = ?, sort_order = ? WHERE id = ?',
|
|
291
|
+
[type, container.name, container.alias, container.sortOrder, Number(found[0].id)],
|
|
292
|
+
)
|
|
293
|
+
groupIdByKey.set(container.key, Number(found[0].id))
|
|
294
|
+
} else {
|
|
295
|
+
await tx.exec(
|
|
296
|
+
'INSERT INTO property_type_group (unique_id, content_type_node_id, type, text, alias, sort_order) VALUES (?, ?, ?, ?, ?, ?)',
|
|
297
|
+
[container.key, nodeId, type, container.name, container.alias, container.sortOrder],
|
|
298
|
+
)
|
|
299
|
+
const created = await tx.query<{ id: number }>(
|
|
300
|
+
'SELECT id FROM property_type_group WHERE unique_id = ?',
|
|
301
|
+
[container.key],
|
|
302
|
+
)
|
|
303
|
+
groupIdByKey.set(container.key, Number(created[0]?.id))
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
for (const container of aggregate.containers) {
|
|
307
|
+
const parent = container.parentKey ? groupIdByKey.get(container.parentKey) : undefined
|
|
308
|
+
await tx.exec('UPDATE property_type_group SET parent_id = ? WHERE unique_id = ?', [
|
|
309
|
+
parent ?? null,
|
|
310
|
+
container.key,
|
|
311
|
+
])
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ---- properties: upsert by key; retire the absent; revive the returning
|
|
315
|
+
const kept = new Set<string>()
|
|
316
|
+
for (const property of aggregate.properties) {
|
|
317
|
+
const dataTypeNode = await repo.nodes.byKey(property.dataTypeKey)
|
|
318
|
+
if (!dataTypeNode)
|
|
319
|
+
throw new Error(
|
|
320
|
+
`Unknown data type '${property.dataTypeKey}' on property '${property.alias}'.`,
|
|
321
|
+
)
|
|
322
|
+
// A key the type has never had, but an alias it has (live or retired), is
|
|
323
|
+
// that property coming back: reuse the row, so its values return with it.
|
|
324
|
+
let key = normaliseUuid(property.key)
|
|
325
|
+
const byKey = await tx.query('SELECT id FROM property_type WHERE unique_id = ?', [key])
|
|
326
|
+
if (byKey.length === 0) {
|
|
327
|
+
const sameAlias = await tx.query<{ unique_id: string }>(
|
|
328
|
+
'SELECT unique_id FROM property_type WHERE content_type_id = ? AND alias = ?',
|
|
329
|
+
[nodeId, property.alias],
|
|
330
|
+
)
|
|
331
|
+
if (sameAlias[0]) key = normaliseUuid(String(sameAlias[0].unique_id))
|
|
332
|
+
}
|
|
333
|
+
kept.add(key)
|
|
334
|
+
const groupId = property.containerKey
|
|
335
|
+
? (groupIdByKey.get(property.containerKey) ?? null)
|
|
336
|
+
: null
|
|
337
|
+
const columns = [
|
|
338
|
+
dataTypeNode.id,
|
|
339
|
+
groupId,
|
|
340
|
+
property.alias,
|
|
341
|
+
property.name,
|
|
342
|
+
property.description,
|
|
343
|
+
property.sortOrder,
|
|
344
|
+
bool(property.mandatory),
|
|
345
|
+
property.mandatoryMessage,
|
|
346
|
+
property.regEx,
|
|
347
|
+
property.regExMessage,
|
|
348
|
+
bool(property.labelOnTop),
|
|
349
|
+
variationFlags(property.variesByCulture, property.variesBySegment),
|
|
350
|
+
property.sinceVersion ?? null,
|
|
351
|
+
]
|
|
352
|
+
const found = await tx.query<{ id: number }>(
|
|
353
|
+
'SELECT id FROM property_type WHERE unique_id = ?',
|
|
354
|
+
[key],
|
|
355
|
+
)
|
|
356
|
+
if (found[0]) {
|
|
357
|
+
await tx.exec(
|
|
358
|
+
`UPDATE property_type SET data_type_id = ?, property_type_group_id = ?, alias = ?, name = ?, description = ?,
|
|
359
|
+
sort_order = ?, mandatory = ?, mandatory_message = ?, validation_reg_exp = ?, validation_reg_exp_message = ?,
|
|
360
|
+
label_on_top = ?, variations = ?, since_version = ?, retired_at = NULL
|
|
361
|
+
WHERE id = ?`,
|
|
362
|
+
[...columns, Number(found[0].id)],
|
|
363
|
+
)
|
|
364
|
+
} else {
|
|
365
|
+
await tx.exec(
|
|
366
|
+
`INSERT INTO property_type
|
|
367
|
+
(unique_id, content_type_id, data_type_id, property_type_group_id, alias, name, description, sort_order,
|
|
368
|
+
mandatory, mandatory_message, validation_reg_exp, validation_reg_exp_message, label_on_top, variations, since_version, since_state_id)
|
|
369
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
370
|
+
[key, nodeId, ...columns, options.sinceStateId ?? null],
|
|
371
|
+
)
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
if (this.#kind === 'member') {
|
|
375
|
+
const bool = (v: boolean) => tx.dialect.boolValue(v)
|
|
376
|
+
for (const property of aggregate.properties) {
|
|
377
|
+
// By alias: the row may have been reused under its old key above.
|
|
378
|
+
const row = await tx.query<{ id: number }>(
|
|
379
|
+
'SELECT id FROM property_type WHERE content_type_id = ? AND alias = ?',
|
|
380
|
+
[nodeId, property.alias],
|
|
381
|
+
)
|
|
382
|
+
const id = Number(row[0]?.id)
|
|
383
|
+
if (!id) continue
|
|
384
|
+
await tx.exec('DELETE FROM member_property_type WHERE property_type_id = ?', [id])
|
|
385
|
+
await tx.exec(
|
|
386
|
+
'INSERT INTO member_property_type (property_type_id, member_can_edit, member_can_view, is_sensitive) VALUES (?, ?, ?, ?)',
|
|
387
|
+
[
|
|
388
|
+
id,
|
|
389
|
+
bool(property.memberCanEdit === true),
|
|
390
|
+
bool(property.memberCanView === true),
|
|
391
|
+
bool(property.isSensitive === true),
|
|
392
|
+
],
|
|
393
|
+
)
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
const present = await tx.query(
|
|
397
|
+
'SELECT id, unique_id, retired_at FROM property_type WHERE content_type_id = ?',
|
|
398
|
+
[nodeId],
|
|
399
|
+
)
|
|
400
|
+
for (const row of present) {
|
|
401
|
+
if (kept.has(normaliseUuid(String(row.unique_id)))) continue
|
|
402
|
+
if (row.retired_at !== null && row.retired_at !== undefined) continue
|
|
403
|
+
// Retired: hidden and detached from its group; every value stays.
|
|
404
|
+
await tx.exec(
|
|
405
|
+
'UPDATE property_type SET retired_at = ?, property_type_group_id = NULL WHERE id = ?',
|
|
406
|
+
[now, Number(row.id)],
|
|
407
|
+
)
|
|
408
|
+
}
|
|
409
|
+
const containerKeys = new Set(aggregate.containers.map((c) => c.key))
|
|
410
|
+
for (const group of await tx.query(
|
|
411
|
+
'SELECT id, unique_id FROM property_type_group WHERE content_type_node_id = ?',
|
|
412
|
+
[nodeId],
|
|
413
|
+
)) {
|
|
414
|
+
if (containerKeys.has(String(group.unique_id))) continue
|
|
415
|
+
const inUse = await tx.query<{ n: number }>(
|
|
416
|
+
'SELECT COUNT(*) AS n FROM property_type WHERE property_type_group_id = ?',
|
|
417
|
+
[Number(group.id)],
|
|
418
|
+
)
|
|
419
|
+
if (Number(inUse[0]?.n ?? 0) === 0)
|
|
420
|
+
await tx.exec('DELETE FROM property_type_group WHERE id = ?', [Number(group.id)])
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// ---- relations: replaced wholesale; nothing references these rows
|
|
424
|
+
await tx.exec('DELETE FROM content_type_composition WHERE child_content_type_id = ?', [
|
|
425
|
+
nodeId,
|
|
426
|
+
])
|
|
427
|
+
for (const composition of aggregate.compositions) {
|
|
428
|
+
const parent = await repo.nodes.byKey(composition.contentTypeKey)
|
|
429
|
+
if (parent)
|
|
430
|
+
await tx.exec(
|
|
431
|
+
'INSERT INTO content_type_composition (parent_content_type_id, child_content_type_id) VALUES (?, ?)',
|
|
432
|
+
[parent.id, nodeId],
|
|
433
|
+
)
|
|
434
|
+
}
|
|
435
|
+
await tx.exec('DELETE FROM content_type_allowed_child WHERE content_type_id = ?', [nodeId])
|
|
436
|
+
for (const allowed of aggregate.allowedContentTypes) {
|
|
437
|
+
const child = await repo.nodes.byKey(allowed.contentTypeKey)
|
|
438
|
+
if (child)
|
|
439
|
+
await tx.exec(
|
|
440
|
+
'INSERT INTO content_type_allowed_child (content_type_id, allowed_content_type_id, sort_order) VALUES (?, ?, ?)',
|
|
441
|
+
[nodeId, child.id, allowed.sortOrder],
|
|
442
|
+
)
|
|
443
|
+
}
|
|
444
|
+
await tx.exec('DELETE FROM content_type_template WHERE content_type_node_id = ?', [nodeId])
|
|
445
|
+
for (const componentKey of aggregate.allowedComponentKeys) {
|
|
446
|
+
const template = await repo.nodes.byKey(componentKey)
|
|
447
|
+
if (template)
|
|
448
|
+
await tx.exec(
|
|
449
|
+
'INSERT INTO content_type_template (content_type_node_id, template_node_id, is_default) VALUES (?, ?, ?)',
|
|
450
|
+
[nodeId, template.id, bool(componentKey === aggregate.defaultComponentKey)],
|
|
451
|
+
)
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const saved = await repo.byKey(aggregate.key)
|
|
455
|
+
if (!saved) throw new Error('Content type could not be read back after saving.')
|
|
456
|
+
return saved
|
|
457
|
+
})
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
async retiredProperties(typeKey: string): Promise<RetiredProperty[]> {
|
|
461
|
+
const node = await this.#nodes.byKey(typeKey)
|
|
462
|
+
if (!node) return []
|
|
463
|
+
const rows = await this.#db.query(
|
|
464
|
+
'SELECT unique_id, alias, retired_at FROM property_type WHERE content_type_id = ? AND retired_at IS NOT NULL',
|
|
465
|
+
[node.id],
|
|
466
|
+
)
|
|
467
|
+
return rows.map((row) => ({
|
|
468
|
+
key: normaliseUuid(String(row.unique_id)),
|
|
469
|
+
alias: String(row.alias),
|
|
470
|
+
retiredAt: DbDate.fromDb(row.retired_at) ?? new Date(0),
|
|
471
|
+
}))
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** Whether any document of this type exists, trashed or not — the deletion-safety check. */
|
|
475
|
+
/**
|
|
476
|
+
* Deletes property types retired before `olderThan`, with every value they
|
|
477
|
+
* ever held. The contract step; nothing else in the system deletes values.
|
|
478
|
+
*/
|
|
479
|
+
async purgeRetired(olderThan: Date): Promise<Array<{ type: string; alias: string }>> {
|
|
480
|
+
const rows = await this.#db.query<{ id: number; alias: string; type_alias: string }>(
|
|
481
|
+
`SELECT p.id, p.alias, ct.alias AS type_alias FROM property_type p
|
|
482
|
+
JOIN content_type ct ON ct.node_id = p.content_type_id
|
|
483
|
+
WHERE p.retired_at IS NOT NULL AND p.retired_at < ?`,
|
|
484
|
+
[DbDate.toDb(olderThan)],
|
|
485
|
+
)
|
|
486
|
+
const purged: Array<{ type: string; alias: string }> = []
|
|
487
|
+
await this.#db.transaction(async (tx) => {
|
|
488
|
+
for (const row of rows) {
|
|
489
|
+
await tx.exec('DELETE FROM property_value WHERE property_type_id = ?', [Number(row.id)])
|
|
490
|
+
await tx.exec('DELETE FROM member_property_type WHERE property_type_id = ?', [
|
|
491
|
+
Number(row.id),
|
|
492
|
+
])
|
|
493
|
+
await tx.exec('DELETE FROM property_type WHERE id = ?', [Number(row.id)])
|
|
494
|
+
purged.push({ type: String(row.type_alias), alias: String(row.alias) })
|
|
495
|
+
}
|
|
496
|
+
})
|
|
497
|
+
return purged
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** Several aggregates at once, for the batch endpoint and pickers. */
|
|
501
|
+
async byKeys(keys: readonly string[]): Promise<ContentTypeAggregate[]> {
|
|
502
|
+
const out: ContentTypeAggregate[] = []
|
|
503
|
+
for (const key of keys) {
|
|
504
|
+
const found = await this.byKey(key)
|
|
505
|
+
if (found) out.push(found)
|
|
506
|
+
}
|
|
507
|
+
return out
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** Every live type, cheap enough while types number in the dozens. */
|
|
511
|
+
async all(): Promise<ContentTypeAggregate[]> {
|
|
512
|
+
const rows = await this.#db.query<{ unique_id: string }>(
|
|
513
|
+
`SELECT n.unique_id FROM content_type ct JOIN node n ON n.id = ct.node_id
|
|
514
|
+
WHERE ct.retired_at IS NULL AND n.node_object_type = ? ORDER BY n.text`,
|
|
515
|
+
[this.#objectType],
|
|
516
|
+
)
|
|
517
|
+
return this.byKeys(rows.map((r) => String(r.unique_id)))
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** Every live type's alias by its key, without loading the types themselves. */
|
|
521
|
+
async aliasesByKey(): Promise<Map<string, string>> {
|
|
522
|
+
const rows = await this.#db.query<{ unique_id: string; alias: string }>(
|
|
523
|
+
`SELECT n.unique_id, ct.alias FROM content_type ct JOIN node n ON n.id = ct.node_id
|
|
524
|
+
WHERE ct.retired_at IS NULL AND n.node_object_type = ?`,
|
|
525
|
+
[this.#objectType],
|
|
526
|
+
)
|
|
527
|
+
return new Map(rows.map((r) => [normaliseUuid(String(r.unique_id)), String(r.alias)]))
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
async move(key: string, parentKey: string | null): Promise<boolean> {
|
|
531
|
+
const node = await this.#nodes.byKey(key)
|
|
532
|
+
if (!node || node.objectType !== this.#objectType) return false
|
|
533
|
+
const parentId = parentKey ? (await this.#nodes.byKey(parentKey))?.id : SystemNodes.Root
|
|
534
|
+
if (parentId === undefined) return false
|
|
535
|
+
await this.#nodes.move(node.id, parentId)
|
|
536
|
+
return true
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** A copy with fresh keys everywhere, named "(copy)" with a unique alias. */
|
|
540
|
+
async copy(key: string, parentKey: string | null): Promise<ContentTypeAggregate | undefined> {
|
|
541
|
+
const source = await this.byKey(key)
|
|
542
|
+
if (!source) return undefined
|
|
543
|
+
let alias = `${source.alias}Copy`
|
|
544
|
+
for (let i = 2; await this.aliasExists(alias); i++) alias = `${source.alias}Copy${i}`
|
|
545
|
+
const containerKeys = new Map(source.containers.map((c) => [c.key, crypto.randomUUID()]))
|
|
546
|
+
const copy: ContentTypeAggregate = {
|
|
547
|
+
...source,
|
|
548
|
+
key: crypto.randomUUID(),
|
|
549
|
+
alias,
|
|
550
|
+
name: `${source.name} (copy)`,
|
|
551
|
+
parentKey,
|
|
552
|
+
containers: source.containers.map((c) => ({
|
|
553
|
+
...c,
|
|
554
|
+
key: containerKeys.get(c.key) as string,
|
|
555
|
+
parentKey: c.parentKey ? (containerKeys.get(c.parentKey) ?? null) : null,
|
|
556
|
+
})),
|
|
557
|
+
properties: source.properties.map((p) => ({
|
|
558
|
+
...p,
|
|
559
|
+
key: crypto.randomUUID(),
|
|
560
|
+
containerKey: p.containerKey ? (containerKeys.get(p.containerKey) ?? null) : null,
|
|
561
|
+
})),
|
|
562
|
+
}
|
|
563
|
+
return this.save(copy)
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
async search(
|
|
567
|
+
query: string,
|
|
568
|
+
options: { isElement?: boolean; skip: number; take: number },
|
|
569
|
+
): Promise<{ total: number; items: ContentTypeAggregate[] }> {
|
|
570
|
+
const page = await this.#nodes.search(this.#objectType, query, {
|
|
571
|
+
skip: 0,
|
|
572
|
+
take: 10_000,
|
|
573
|
+
})
|
|
574
|
+
const aggregates = (await this.byKeys(page.items.map((n) => n.key))).filter(
|
|
575
|
+
(a) => options.isElement === undefined || a.isElement === options.isElement,
|
|
576
|
+
)
|
|
577
|
+
return {
|
|
578
|
+
total: aggregates.length,
|
|
579
|
+
items: aggregates.slice(options.skip, options.skip + options.take),
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** Types that compose this one, directly. */
|
|
584
|
+
async compositionReferences(key: string): Promise<ContentTypeAggregate[]> {
|
|
585
|
+
const node = await this.#nodes.byKey(key)
|
|
586
|
+
if (!node) return []
|
|
587
|
+
const rows = await this.#db.query<{ unique_id: string }>(
|
|
588
|
+
`SELECT child.unique_id FROM content_type_composition c JOIN node child ON child.id = c.child_content_type_id
|
|
589
|
+
WHERE c.parent_content_type_id = ?`,
|
|
590
|
+
[node.id],
|
|
591
|
+
)
|
|
592
|
+
return this.byKeys(rows.map((r) => String(r.unique_id)))
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** Types that allow this one as a child. */
|
|
596
|
+
async allowedParents(key: string): Promise<string[]> {
|
|
597
|
+
const node = await this.#nodes.byKey(key)
|
|
598
|
+
if (!node) return []
|
|
599
|
+
const rows = await this.#db.query<{ unique_id: string }>(
|
|
600
|
+
`SELECT parent.unique_id FROM content_type_allowed_child ac JOIN node parent ON parent.id = ac.content_type_id
|
|
601
|
+
WHERE ac.allowed_content_type_id = ?`,
|
|
602
|
+
[node.id],
|
|
603
|
+
)
|
|
604
|
+
return rows.map((r) => normaliseUuid(String(r.unique_id)))
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* What a type may compose, and whether each candidate clashes. The rules are
|
|
609
|
+
* Umbraco's: never itself, never a type that already uses it (no cycles), never
|
|
610
|
+
* a type that has compositions of its own, element types compose only element
|
|
611
|
+
* types; a candidate is incompatible when it brings a property alias the type
|
|
612
|
+
* already has.
|
|
613
|
+
*/
|
|
614
|
+
async availableCompositions(request: {
|
|
615
|
+
key: string | null
|
|
616
|
+
isElement: boolean
|
|
617
|
+
currentPropertyAliases: readonly string[]
|
|
618
|
+
currentCompositeKeys: readonly string[]
|
|
619
|
+
}): Promise<Array<{ type: ContentTypeAggregate; isCompatible: boolean }>> {
|
|
620
|
+
const all = await this.all()
|
|
621
|
+
const self = request.key ? all.find((t) => t.key === request.key) : undefined
|
|
622
|
+
const usesSelf = new Set<string>()
|
|
623
|
+
if (self) {
|
|
624
|
+
const walk = (typeKey: string) => {
|
|
625
|
+
for (const t of all) {
|
|
626
|
+
if (t.compositions.some((c) => c.contentTypeKey === typeKey) && !usesSelf.has(t.key)) {
|
|
627
|
+
usesSelf.add(t.key)
|
|
628
|
+
walk(t.key)
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
walk(self.key)
|
|
633
|
+
}
|
|
634
|
+
const taken = new Set(request.currentPropertyAliases)
|
|
635
|
+
const selected = new Set(request.currentCompositeKeys)
|
|
636
|
+
const out: Array<{ type: ContentTypeAggregate; isCompatible: boolean }> = []
|
|
637
|
+
for (const candidate of all) {
|
|
638
|
+
if (self && candidate.key === self.key) continue
|
|
639
|
+
if (usesSelf.has(candidate.key)) continue
|
|
640
|
+
if (candidate.compositions.length > 0) continue
|
|
641
|
+
if (request.isElement && !candidate.isElement) continue
|
|
642
|
+
const clash =
|
|
643
|
+
!selected.has(candidate.key) && candidate.properties.some((p) => taken.has(p.alias))
|
|
644
|
+
out.push({ type: candidate, isCompatible: !clash })
|
|
645
|
+
}
|
|
646
|
+
return out
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
async contentCount(typeKey: string): Promise<number> {
|
|
650
|
+
const node = await this.#nodes.byKey(typeKey)
|
|
651
|
+
if (!node) return 0
|
|
652
|
+
const rows = await this.#db.query<{ n: number }>(
|
|
653
|
+
'SELECT COUNT(*) AS n FROM content WHERE content_type_id = ?',
|
|
654
|
+
[node.id],
|
|
655
|
+
)
|
|
656
|
+
return Number(rows[0]?.n ?? 0)
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
async delete(key: string): Promise<boolean> {
|
|
660
|
+
const node = await this.#nodes.byKey(key)
|
|
661
|
+
if (!node || node.objectType !== this.#objectType) return false
|
|
662
|
+
return this.#db.transaction(async (tx) => {
|
|
663
|
+
await tx.exec(
|
|
664
|
+
'DELETE FROM property_value WHERE property_type_id IN (SELECT id FROM property_type WHERE content_type_id = ?)',
|
|
665
|
+
[node.id],
|
|
666
|
+
)
|
|
667
|
+
await tx.exec(
|
|
668
|
+
'DELETE FROM member_property_type WHERE property_type_id IN (SELECT id FROM property_type WHERE content_type_id = ?)',
|
|
669
|
+
[node.id],
|
|
670
|
+
)
|
|
671
|
+
await tx.exec('DELETE FROM property_type WHERE content_type_id = ?', [node.id])
|
|
672
|
+
await tx.exec('DELETE FROM property_type_group WHERE content_type_node_id = ?', [node.id])
|
|
673
|
+
await tx.exec(
|
|
674
|
+
'DELETE FROM content_type_composition WHERE child_content_type_id = ? OR parent_content_type_id = ?',
|
|
675
|
+
[node.id, node.id],
|
|
676
|
+
)
|
|
677
|
+
await tx.exec(
|
|
678
|
+
'DELETE FROM content_type_allowed_child WHERE content_type_id = ? OR allowed_content_type_id = ?',
|
|
679
|
+
[node.id, node.id],
|
|
680
|
+
)
|
|
681
|
+
await tx.exec('DELETE FROM content_type_template WHERE content_type_node_id = ?', [node.id])
|
|
682
|
+
await tx.exec('DELETE FROM content_type WHERE node_id = ?', [node.id])
|
|
683
|
+
await tx.exec('DELETE FROM node WHERE id = ?', [node.id])
|
|
684
|
+
return true
|
|
685
|
+
})
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/** The full property set, unioning the type's own with every composition ancestor's. */
|
|
689
|
+
async resolveProperties(key: string): Promise<PropertyTypeModel[]> {
|
|
690
|
+
const visited = new Set<string>()
|
|
691
|
+
const collected: PropertyTypeModel[] = []
|
|
692
|
+
const walk = async (currentKey: string): Promise<void> => {
|
|
693
|
+
if (visited.has(currentKey)) return
|
|
694
|
+
visited.add(currentKey)
|
|
695
|
+
const aggregate = await this.byKey(currentKey)
|
|
696
|
+
if (!aggregate) return
|
|
697
|
+
for (const composition of aggregate.compositions) await walk(composition.contentTypeKey)
|
|
698
|
+
for (const property of aggregate.properties)
|
|
699
|
+
if (!collected.some((p) => p.alias === property.alias)) collected.push(property)
|
|
700
|
+
}
|
|
701
|
+
await walk(key)
|
|
702
|
+
return collected
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
async #load(
|
|
706
|
+
nodeId: number,
|
|
707
|
+
key: string,
|
|
708
|
+
name: string,
|
|
709
|
+
parentId: number,
|
|
710
|
+
): Promise<ContentTypeAggregate | undefined> {
|
|
711
|
+
const typeRows = await this.#db.query(
|
|
712
|
+
`SELECT ct.alias, ct.icon, ct.description, ct.list_view, ct.is_element, ct.allow_in_library, ct.allow_at_root, ct.variations,
|
|
713
|
+
ct.prevent_cleanup, ct.keep_all_versions_newer_than_days, ct.keep_latest_version_per_day_for_days,
|
|
714
|
+
ct.since_version, ss.version AS since_v, ss.revision AS since_r
|
|
715
|
+
FROM content_type ct LEFT JOIN schema_state ss ON ss.id = ct.since_state_id WHERE ct.node_id = ?`,
|
|
716
|
+
[nodeId],
|
|
717
|
+
)
|
|
718
|
+
const type = typeRows[0]
|
|
719
|
+
if (!type) return undefined
|
|
720
|
+
|
|
721
|
+
const groupRows = await this.#db.query(
|
|
722
|
+
`SELECT g.unique_id, g.text, g.alias, g.type, g.sort_order, parent.unique_id AS parent_key
|
|
723
|
+
FROM property_type_group g LEFT JOIN property_type_group parent ON parent.id = g.parent_id
|
|
724
|
+
WHERE g.content_type_node_id = ? ORDER BY g.sort_order, g.id`,
|
|
725
|
+
[nodeId],
|
|
726
|
+
)
|
|
727
|
+
const containers: PropertyGroupModel[] = groupRows.map((row) => ({
|
|
728
|
+
key: normaliseUuid(String(row.unique_id)),
|
|
729
|
+
name: (row.text as string | null) ?? null,
|
|
730
|
+
alias: (row.alias as string | null) ?? null,
|
|
731
|
+
type: GROUP_TYPE_NAMES[Number(row.type)] ?? 'Group',
|
|
732
|
+
sortOrder: Number(row.sort_order),
|
|
733
|
+
parentKey: row.parent_key ? normaliseUuid(String(row.parent_key)) : null,
|
|
734
|
+
}))
|
|
735
|
+
|
|
736
|
+
// Retired properties are hidden here; `retiredProperties()` lists them.
|
|
737
|
+
const propertyRows = await this.#db.query(
|
|
738
|
+
`SELECT p.unique_id, p.alias, p.name, p.description, p.sort_order, p.mandatory, p.mandatory_message,
|
|
739
|
+
p.validation_reg_exp, p.validation_reg_exp_message, p.label_on_top, p.variations,
|
|
740
|
+
dtn.unique_id AS data_type_key, g.unique_id AS container_key,
|
|
741
|
+
p.since_version, ss.version AS since_v, ss.revision AS since_r,
|
|
742
|
+
m.member_can_edit, m.member_can_view, m.is_sensitive
|
|
743
|
+
FROM property_type p
|
|
744
|
+
JOIN node dtn ON dtn.id = p.data_type_id
|
|
745
|
+
LEFT JOIN property_type_group g ON g.id = p.property_type_group_id
|
|
746
|
+
LEFT JOIN schema_state ss ON ss.id = p.since_state_id
|
|
747
|
+
LEFT JOIN member_property_type m ON m.property_type_id = p.id
|
|
748
|
+
WHERE p.content_type_id = ? AND p.retired_at IS NULL
|
|
749
|
+
ORDER BY p.sort_order, p.id`,
|
|
750
|
+
[nodeId],
|
|
751
|
+
)
|
|
752
|
+
const properties: PropertyTypeModel[] = propertyRows.map((row) => ({
|
|
753
|
+
key: normaliseUuid(String(row.unique_id)),
|
|
754
|
+
alias: String(row.alias),
|
|
755
|
+
name: String(row.name),
|
|
756
|
+
description: (row.description as string | null) ?? null,
|
|
757
|
+
dataTypeKey: normaliseUuid(String(row.data_type_key)),
|
|
758
|
+
containerKey: row.container_key ? normaliseUuid(String(row.container_key)) : null,
|
|
759
|
+
sortOrder: Number(row.sort_order),
|
|
760
|
+
variesByCulture: variesByCulture(Number(row.variations)),
|
|
761
|
+
variesBySegment: variesBySegment(Number(row.variations)),
|
|
762
|
+
mandatory: fromDbBool(row.mandatory),
|
|
763
|
+
mandatoryMessage: (row.mandatory_message as string | null) ?? null,
|
|
764
|
+
regEx: (row.validation_reg_exp as string | null) ?? null,
|
|
765
|
+
regExMessage: (row.validation_reg_exp_message as string | null) ?? null,
|
|
766
|
+
labelOnTop: fromDbBool(row.label_on_top),
|
|
767
|
+
sinceVersion: (row.since_version as string | null) ?? null,
|
|
768
|
+
pending: this.#pending(row),
|
|
769
|
+
...(this.#kind === 'member'
|
|
770
|
+
? {
|
|
771
|
+
memberCanEdit: fromDbBool(row.member_can_edit),
|
|
772
|
+
memberCanView: fromDbBool(row.member_can_view),
|
|
773
|
+
isSensitive: fromDbBool(row.is_sensitive),
|
|
774
|
+
}
|
|
775
|
+
: {}),
|
|
776
|
+
}))
|
|
777
|
+
|
|
778
|
+
const compositionRows = await this.#db.query<{ unique_id: string }>(
|
|
779
|
+
'SELECT parent.unique_id FROM content_type_composition c JOIN node parent ON parent.id = c.parent_content_type_id WHERE c.child_content_type_id = ?',
|
|
780
|
+
[nodeId],
|
|
781
|
+
)
|
|
782
|
+
const allowedRows = await this.#db.query(
|
|
783
|
+
'SELECT child.unique_id, ac.sort_order FROM content_type_allowed_child ac JOIN node child ON child.id = ac.allowed_content_type_id WHERE ac.content_type_id = ? ORDER BY ac.sort_order',
|
|
784
|
+
[nodeId],
|
|
785
|
+
)
|
|
786
|
+
const templateRows = await this.#db.query(
|
|
787
|
+
'SELECT tn.unique_id, ctt.is_default FROM content_type_template ctt JOIN node tn ON tn.id = ctt.template_node_id WHERE ctt.content_type_node_id = ?',
|
|
788
|
+
[nodeId],
|
|
789
|
+
)
|
|
790
|
+
const parentNode = parentId > 0 ? await this.#nodes.byId(parentId) : undefined
|
|
791
|
+
const flags = Number(type.variations ?? ContentVariation.Nothing)
|
|
792
|
+
const num = (value: unknown) => (value === null || value === undefined ? null : Number(value))
|
|
793
|
+
|
|
794
|
+
return {
|
|
795
|
+
key,
|
|
796
|
+
name,
|
|
797
|
+
alias: String(type.alias),
|
|
798
|
+
description: (type.description as string | null) ?? null,
|
|
799
|
+
icon: (type.icon as string | null) ?? 'icon-document',
|
|
800
|
+
allowedAsRoot: fromDbBool(type.allow_at_root),
|
|
801
|
+
variesByCulture: variesByCulture(flags),
|
|
802
|
+
variesBySegment: variesBySegment(flags),
|
|
803
|
+
isElement: fromDbBool(type.is_element),
|
|
804
|
+
allowedInLibrary: fromDbBool(type.allow_in_library),
|
|
805
|
+
collectionKey: type.list_view ? normaliseUuid(String(type.list_view)) : null,
|
|
806
|
+
cleanup: {
|
|
807
|
+
preventCleanup: fromDbBool(type.prevent_cleanup),
|
|
808
|
+
keepAllVersionsNewerThanDays: num(type.keep_all_versions_newer_than_days),
|
|
809
|
+
keepLatestVersionPerDayForDays: num(type.keep_latest_version_per_day_for_days),
|
|
810
|
+
},
|
|
811
|
+
properties,
|
|
812
|
+
containers,
|
|
813
|
+
sinceVersion: (type.since_version as string | null) ?? null,
|
|
814
|
+
pending: this.#pending(type),
|
|
815
|
+
compositions: compositionRows.map((row) => ({
|
|
816
|
+
contentTypeKey: normaliseUuid(String(row.unique_id)),
|
|
817
|
+
compositionType: 'Composition' as const,
|
|
818
|
+
})),
|
|
819
|
+
allowedContentTypes: allowedRows.map((row) => ({
|
|
820
|
+
contentTypeKey: normaliseUuid(String(row.unique_id)),
|
|
821
|
+
sortOrder: Number(row.sort_order),
|
|
822
|
+
})),
|
|
823
|
+
allowedComponentKeys: templateRows.map((row) => normaliseUuid(String(row.unique_id))),
|
|
824
|
+
defaultComponentKey:
|
|
825
|
+
templateRows
|
|
826
|
+
.filter((row) => fromDbBool(row.is_default))
|
|
827
|
+
.map((row) => normaliseUuid(String(row.unique_id)))[0] ?? null,
|
|
828
|
+
parentKey: parentNode?.key ?? null,
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
}
|