@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,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Culture and hostnames (Umbraco's `umbracoDomain`): hostnames that make a
|
|
3
|
+
* document a site root, each with a culture, plus an optional wildcard that only
|
|
4
|
+
* sets the culture of a branch. The wildcard is stored as Umbraco stores it, a
|
|
5
|
+
* row named `*<node id>`.
|
|
6
|
+
*/
|
|
7
|
+
import type { Db } from '../database.ts'
|
|
8
|
+
|
|
9
|
+
export interface DomainAssignment {
|
|
10
|
+
defaultIsoCode: string | null
|
|
11
|
+
domains: Array<{ domainName: string; isoCode: string }>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface DomainRow {
|
|
15
|
+
nodeId: number
|
|
16
|
+
domainName: string
|
|
17
|
+
isoCode: string | null
|
|
18
|
+
isWildcard: boolean
|
|
19
|
+
sortOrder: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type SetDomainsResult =
|
|
23
|
+
| { status: 'ok' }
|
|
24
|
+
| { status: 'invalidLanguage'; isoCode: string }
|
|
25
|
+
| { status: 'invalidName'; domainName: string }
|
|
26
|
+
| { status: 'conflict'; domainName: string }
|
|
27
|
+
|
|
28
|
+
/** `https://Example.com/en/` → `example.com/en`; what matching and uniqueness compare. */
|
|
29
|
+
export function normaliseDomainName(name: string): string {
|
|
30
|
+
return name
|
|
31
|
+
.trim()
|
|
32
|
+
.replace(/^[a-z]+:\/\//i, '')
|
|
33
|
+
.replace(/\/+$/, '')
|
|
34
|
+
.toLowerCase()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const VALID_DOMAIN = /^[a-z0-9.-]+(:\d+)?(\/[a-z0-9._~%-]+)*$/i
|
|
38
|
+
|
|
39
|
+
export class DomainRepository {
|
|
40
|
+
#db: Db
|
|
41
|
+
|
|
42
|
+
constructor(db: Db) {
|
|
43
|
+
this.#db = db
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async all(): Promise<DomainRow[]> {
|
|
47
|
+
const rows = await this.#db.query(
|
|
48
|
+
`SELECT d.node_id, d.domain_name, d.sort_order, l.iso_code FROM domain d
|
|
49
|
+
LEFT JOIN language l ON l.id = d.language_id ORDER BY d.node_id, d.sort_order, d.id`,
|
|
50
|
+
)
|
|
51
|
+
return rows.map((row) => ({
|
|
52
|
+
nodeId: Number(row.node_id),
|
|
53
|
+
domainName: String(row.domain_name),
|
|
54
|
+
isoCode: (row.iso_code as string | null) ?? null,
|
|
55
|
+
isWildcard: String(row.domain_name).startsWith('*'),
|
|
56
|
+
sortOrder: Number(row.sort_order),
|
|
57
|
+
}))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async forNode(nodeId: number): Promise<DomainAssignment> {
|
|
61
|
+
const rows = (await this.all()).filter((row) => row.nodeId === nodeId)
|
|
62
|
+
return {
|
|
63
|
+
defaultIsoCode: rows.find((row) => row.isWildcard)?.isoCode ?? null,
|
|
64
|
+
domains: rows
|
|
65
|
+
.filter((row) => !row.isWildcard)
|
|
66
|
+
.map((row) => ({ domainName: row.domainName, isoCode: row.isoCode ?? '' })),
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Replaces a node's domains. Names are unique across the site, and each culture must exist. */
|
|
71
|
+
async setForNode(nodeId: number, assignment: DomainAssignment): Promise<SetDomainsResult> {
|
|
72
|
+
const languages = await this.#db.query<{ id: number; iso_code: string }>(
|
|
73
|
+
'SELECT id, iso_code FROM language',
|
|
74
|
+
)
|
|
75
|
+
const languageId = (iso: string) =>
|
|
76
|
+
languages.find((l) => String(l.iso_code).toLowerCase() === iso.toLowerCase())?.id
|
|
77
|
+
if (assignment.defaultIsoCode && languageId(assignment.defaultIsoCode) === undefined)
|
|
78
|
+
return { status: 'invalidLanguage', isoCode: assignment.defaultIsoCode }
|
|
79
|
+
const names: string[] = []
|
|
80
|
+
for (const domain of assignment.domains) {
|
|
81
|
+
if (languageId(domain.isoCode) === undefined)
|
|
82
|
+
return { status: 'invalidLanguage', isoCode: domain.isoCode }
|
|
83
|
+
const name = normaliseDomainName(domain.domainName)
|
|
84
|
+
if (!VALID_DOMAIN.test(name)) return { status: 'invalidName', domainName: domain.domainName }
|
|
85
|
+
if (names.includes(name)) return { status: 'conflict', domainName: domain.domainName }
|
|
86
|
+
names.push(name)
|
|
87
|
+
}
|
|
88
|
+
const taken = (await this.all()).filter(
|
|
89
|
+
(row) => !row.isWildcard && row.nodeId !== nodeId && names.includes(row.domainName),
|
|
90
|
+
)
|
|
91
|
+
if (taken[0]) return { status: 'conflict', domainName: taken[0].domainName }
|
|
92
|
+
|
|
93
|
+
await this.#db.transaction(async (tx) => {
|
|
94
|
+
await tx.exec('DELETE FROM domain WHERE node_id = ?', [nodeId])
|
|
95
|
+
if (assignment.defaultIsoCode)
|
|
96
|
+
await tx.exec(
|
|
97
|
+
'INSERT INTO domain (node_id, language_id, domain_name, sort_order) VALUES (?, ?, ?, ?)',
|
|
98
|
+
[nodeId, languageId(assignment.defaultIsoCode), `*${nodeId}`, 0],
|
|
99
|
+
)
|
|
100
|
+
for (const [index, domain] of assignment.domains.entries())
|
|
101
|
+
await tx.exec(
|
|
102
|
+
'INSERT INTO domain (node_id, language_id, domain_name, sort_order) VALUES (?, ?, ?, ?)',
|
|
103
|
+
[nodeId, languageId(domain.isoCode), names[index], index],
|
|
104
|
+
)
|
|
105
|
+
})
|
|
106
|
+
return { status: 'ok' }
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Folders in the settings trees: nodes of a container object type whose only
|
|
3
|
+
* data is a name. Files place types in them by `folder = "A/B"`; the sync makes
|
|
4
|
+
* the path exist; the backoffice's folder actions rewrite the files.
|
|
5
|
+
*/
|
|
6
|
+
import type { Page } from '@crombolo/core'
|
|
7
|
+
import { SystemNodes } from '@crombolo/core'
|
|
8
|
+
import type { Db } from '../database.ts'
|
|
9
|
+
import { NodeRepository, type NodeRow } from './nodes.ts'
|
|
10
|
+
|
|
11
|
+
export interface FolderModel {
|
|
12
|
+
key: string
|
|
13
|
+
name: string
|
|
14
|
+
parentKey: string | null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class FolderRepository {
|
|
18
|
+
#db: Db
|
|
19
|
+
#nodes: NodeRepository
|
|
20
|
+
#objectType: string
|
|
21
|
+
/** The object type of the things the folders hold, for emptiness checks. */
|
|
22
|
+
#itemType: string
|
|
23
|
+
|
|
24
|
+
constructor(db: Db, containerObjectType: string, itemObjectType: string) {
|
|
25
|
+
this.#db = db
|
|
26
|
+
this.#nodes = new NodeRepository(db)
|
|
27
|
+
this.#objectType = containerObjectType
|
|
28
|
+
this.#itemType = itemObjectType
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async byKey(key: string): Promise<FolderModel | undefined> {
|
|
32
|
+
const node = await this.#nodes.byKey(key)
|
|
33
|
+
if (!node || node.objectType !== this.#objectType) return undefined
|
|
34
|
+
return this.#toModel(node)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async #toModel(node: NodeRow): Promise<FolderModel> {
|
|
38
|
+
const parent = node.parentId > 0 ? await this.#nodes.byId(node.parentId) : undefined
|
|
39
|
+
return {
|
|
40
|
+
key: node.key,
|
|
41
|
+
name: node.text ?? '',
|
|
42
|
+
parentKey: parent && parent.objectType === this.#objectType ? parent.key : null,
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async create(init: {
|
|
47
|
+
key?: string
|
|
48
|
+
name: string
|
|
49
|
+
parentKey: string | null
|
|
50
|
+
}): Promise<FolderModel> {
|
|
51
|
+
const parentId = init.parentKey
|
|
52
|
+
? ((await this.#nodes.byKey(init.parentKey))?.id ?? SystemNodes.Root)
|
|
53
|
+
: SystemNodes.Root
|
|
54
|
+
const node = await this.#nodes.create({
|
|
55
|
+
key: init.key,
|
|
56
|
+
parentId,
|
|
57
|
+
objectType: this.#objectType,
|
|
58
|
+
text: init.name,
|
|
59
|
+
})
|
|
60
|
+
return this.#toModel(node)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async rename(key: string, name: string): Promise<boolean> {
|
|
64
|
+
const node = await this.#nodes.byKey(key)
|
|
65
|
+
if (!node || node.objectType !== this.#objectType) return false
|
|
66
|
+
await this.#nodes.rename(node.id, name)
|
|
67
|
+
return true
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Deletes an empty folder; 'not-empty' when anything is still inside. */
|
|
71
|
+
async delete(key: string): Promise<'deleted' | 'not-empty' | 'not-found'> {
|
|
72
|
+
const node = await this.#nodes.byKey(key)
|
|
73
|
+
if (!node || node.objectType !== this.#objectType) return 'not-found'
|
|
74
|
+
const inside = await this.#nodes.childrenOf(node.id, [this.#objectType, this.#itemType], 0, 1)
|
|
75
|
+
if (inside.total > 0) return 'not-empty'
|
|
76
|
+
await this.#nodes.delete(node.id)
|
|
77
|
+
return 'deleted'
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Makes `A/B/C` exist, creating what is missing; returns the leaf's key, or null for an empty path. */
|
|
81
|
+
async ensurePath(names: readonly string[]): Promise<string | null> {
|
|
82
|
+
let parentId: number = SystemNodes.Root
|
|
83
|
+
let key: string | null = null
|
|
84
|
+
for (const name of names) {
|
|
85
|
+
const found = await this.#db.query<{ unique_id: string; id: number }>(
|
|
86
|
+
'SELECT unique_id, id FROM node WHERE parent_id = ? AND node_object_type = ? AND text = ?',
|
|
87
|
+
[parentId, this.#objectType, name],
|
|
88
|
+
)
|
|
89
|
+
if (found[0]) {
|
|
90
|
+
parentId = Number(found[0].id)
|
|
91
|
+
key = String(found[0].unique_id).toLowerCase()
|
|
92
|
+
} else {
|
|
93
|
+
const created = await this.#nodes.create({
|
|
94
|
+
parentId,
|
|
95
|
+
objectType: this.#objectType,
|
|
96
|
+
text: name,
|
|
97
|
+
})
|
|
98
|
+
parentId = created.id
|
|
99
|
+
key = created.key
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return key
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The folder names above a node, outermost first. */
|
|
106
|
+
async pathOf(nodeKey: string): Promise<string[]> {
|
|
107
|
+
const ancestors = await this.#nodes.ancestors(nodeKey)
|
|
108
|
+
return ancestors.filter((a) => a.objectType === this.#objectType).map((a) => a.text ?? '')
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
children(parentKey: string | null, skip: number, take: number): Promise<Page<NodeRow>> {
|
|
112
|
+
return this.#childrenWhere(parentKey, [this.#objectType], skip, take)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async #childrenWhere(parentKey: string | null, types: string[], skip: number, take: number) {
|
|
116
|
+
const parentId = parentKey ? (await this.#nodes.byKey(parentKey))?.id : SystemNodes.Root
|
|
117
|
+
if (parentId === undefined) return { total: 0, items: [] }
|
|
118
|
+
return this.#nodes.childrenOf(parentId, types, skip, take)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Form entries: what visitors submitted. `docs/18-forms.md`.
|
|
3
|
+
*
|
|
4
|
+
* Keyed by the form's UUID rather than a foreign key, because the definition is
|
|
5
|
+
* a file. An entry outlives its definition on purpose.
|
|
6
|
+
*
|
|
7
|
+
* Values are stored as text whatever the field collects — a number as its
|
|
8
|
+
* decimal string, a checkbox as `true`/`false`, a multiple choice as one row
|
|
9
|
+
* per selection. The field type in the definition says how to read it back,
|
|
10
|
+
* which keeps this table free of a type column that could disagree with the
|
|
11
|
+
* file.
|
|
12
|
+
*/
|
|
13
|
+
import { randomUUID } from 'node:crypto'
|
|
14
|
+
import type { Db } from '../database.ts'
|
|
15
|
+
import { DbDate, fromDbBool } from '../dialect.ts'
|
|
16
|
+
|
|
17
|
+
/** Where an entry is in its life: approval is the only transition. */
|
|
18
|
+
export type FormEntryState = 'submitted' | 'approved' | 'rejected'
|
|
19
|
+
|
|
20
|
+
export const FORM_ENTRY_STATES: readonly FormEntryState[] = ['submitted', 'approved', 'rejected']
|
|
21
|
+
|
|
22
|
+
export interface FormEntryValue {
|
|
23
|
+
fieldAlias: string
|
|
24
|
+
/** One row per selection for a multi-value field, in the order submitted. */
|
|
25
|
+
values: string[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface FormEntry {
|
|
29
|
+
id: string
|
|
30
|
+
formKey: string
|
|
31
|
+
/** The alias the form had when this was submitted, for an orphaned entry. */
|
|
32
|
+
formAlias: string
|
|
33
|
+
state: FormEntryState
|
|
34
|
+
culture: string | null
|
|
35
|
+
/** The page the form was submitted from, when it was on one. */
|
|
36
|
+
pageKey: string | null
|
|
37
|
+
/** A salted hash, never an address: enough to rate-limit, not a liability. */
|
|
38
|
+
ipHash: string | null
|
|
39
|
+
userAgent: string | null
|
|
40
|
+
spam: boolean
|
|
41
|
+
createDate: Date
|
|
42
|
+
updateDate: Date
|
|
43
|
+
values: FormEntryValue[]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type FormEntryInput = Omit<
|
|
47
|
+
FormEntry,
|
|
48
|
+
'id' | 'createDate' | 'updateDate' | 'state' | 'spam'
|
|
49
|
+
> & {
|
|
50
|
+
state?: FormEntryState
|
|
51
|
+
spam?: boolean
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface FormEntryQuery {
|
|
55
|
+
formKey: string
|
|
56
|
+
state?: FormEntryState
|
|
57
|
+
/** Inclusive; the entries view defaults to the last month. */
|
|
58
|
+
from?: Date
|
|
59
|
+
to?: Date
|
|
60
|
+
/** Matched against any value, which is what the list's search box does. */
|
|
61
|
+
search?: string
|
|
62
|
+
includeSpam?: boolean
|
|
63
|
+
skip?: number
|
|
64
|
+
take?: number
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface EntryRow {
|
|
68
|
+
id: string
|
|
69
|
+
form_key: string
|
|
70
|
+
form_alias: string
|
|
71
|
+
state: string
|
|
72
|
+
culture: string | null
|
|
73
|
+
page_key: string | null
|
|
74
|
+
ip_hash: string | null
|
|
75
|
+
user_agent: string | null
|
|
76
|
+
spam: unknown
|
|
77
|
+
create_date: unknown
|
|
78
|
+
update_date: unknown
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface ValueRow {
|
|
82
|
+
entry_id: string
|
|
83
|
+
field_alias: string
|
|
84
|
+
sort_order: number
|
|
85
|
+
value: string | null
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const SELECT = `SELECT id, form_key, form_alias, state, culture, page_key, ip_hash,
|
|
89
|
+
user_agent, spam, create_date, update_date
|
|
90
|
+
FROM form_entry`
|
|
91
|
+
|
|
92
|
+
const state = (value: string): FormEntryState =>
|
|
93
|
+
FORM_ENTRY_STATES.includes(value as FormEntryState) ? (value as FormEntryState) : 'submitted'
|
|
94
|
+
|
|
95
|
+
const hydrate = (row: EntryRow, values: FormEntryValue[]): FormEntry => ({
|
|
96
|
+
id: row.id,
|
|
97
|
+
formKey: row.form_key,
|
|
98
|
+
formAlias: row.form_alias,
|
|
99
|
+
state: state(row.state),
|
|
100
|
+
culture: row.culture,
|
|
101
|
+
pageKey: row.page_key,
|
|
102
|
+
ipHash: row.ip_hash,
|
|
103
|
+
userAgent: row.user_agent,
|
|
104
|
+
spam: fromDbBool(row.spam),
|
|
105
|
+
createDate: DbDate.fromDb(row.create_date) ?? new Date(0),
|
|
106
|
+
updateDate: DbDate.fromDb(row.update_date) ?? new Date(0),
|
|
107
|
+
values,
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
/** Value rows into one entry per id, each field's selections in order. */
|
|
111
|
+
function group(rows: readonly ValueRow[]): Map<string, FormEntryValue[]> {
|
|
112
|
+
const byEntry = new Map<string, Map<string, string[]>>()
|
|
113
|
+
for (const row of rows) {
|
|
114
|
+
const fields = byEntry.get(row.entry_id) ?? new Map<string, string[]>()
|
|
115
|
+
byEntry.set(row.entry_id, fields)
|
|
116
|
+
const values = fields.get(row.field_alias) ?? []
|
|
117
|
+
fields.set(row.field_alias, values)
|
|
118
|
+
values.push(row.value ?? '')
|
|
119
|
+
}
|
|
120
|
+
const out = new Map<string, FormEntryValue[]>()
|
|
121
|
+
for (const [id, fields] of byEntry)
|
|
122
|
+
out.set(
|
|
123
|
+
id,
|
|
124
|
+
[...fields].map(([fieldAlias, values]) => ({ fieldAlias, values })),
|
|
125
|
+
)
|
|
126
|
+
return out
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export class FormEntryRepository {
|
|
130
|
+
#db: Db
|
|
131
|
+
constructor(db: Db) {
|
|
132
|
+
this.#db = db
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The values for a page of entries, in one query rather than one each. */
|
|
136
|
+
async #valuesFor(ids: readonly string[]): Promise<Map<string, FormEntryValue[]>> {
|
|
137
|
+
if (ids.length === 0) return new Map()
|
|
138
|
+
const placeholders = ids.map(() => '?').join(', ')
|
|
139
|
+
const rows = await this.#db.query<ValueRow>(
|
|
140
|
+
`SELECT entry_id, field_alias, sort_order, value
|
|
141
|
+
FROM form_entry_value
|
|
142
|
+
WHERE entry_id IN (${placeholders})
|
|
143
|
+
ORDER BY field_alias, sort_order`,
|
|
144
|
+
[...ids],
|
|
145
|
+
)
|
|
146
|
+
return group(rows)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async byId(id: string): Promise<FormEntry | undefined> {
|
|
150
|
+
const rows = await this.#db.query<EntryRow>(`${SELECT} WHERE id = ?`, [id])
|
|
151
|
+
const row = rows[0]
|
|
152
|
+
if (!row) return undefined
|
|
153
|
+
return hydrate(row, (await this.#valuesFor([id])).get(id) ?? [])
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async list(query: FormEntryQuery): Promise<{ total: number; items: FormEntry[] }> {
|
|
157
|
+
const where: string[] = ['form_key = ?']
|
|
158
|
+
const params: unknown[] = [query.formKey]
|
|
159
|
+
if (query.state) {
|
|
160
|
+
where.push('state = ?')
|
|
161
|
+
params.push(query.state)
|
|
162
|
+
}
|
|
163
|
+
if (query.from) {
|
|
164
|
+
where.push('create_date >= ?')
|
|
165
|
+
params.push(DbDate.toDb(query.from))
|
|
166
|
+
}
|
|
167
|
+
if (query.to) {
|
|
168
|
+
where.push('create_date <= ?')
|
|
169
|
+
params.push(DbDate.toDb(query.to))
|
|
170
|
+
}
|
|
171
|
+
// Spam is stored and flagged rather than dropped — a false positive that
|
|
172
|
+
// silently discarded an enquiry is worse than one to review — so it is
|
|
173
|
+
// hidden from the list by default rather than absent from the table.
|
|
174
|
+
if (!query.includeSpam) {
|
|
175
|
+
where.push('spam = ?')
|
|
176
|
+
params.push(this.#db.dialect.boolValue(false))
|
|
177
|
+
}
|
|
178
|
+
if (query.search) {
|
|
179
|
+
where.push(`id IN (SELECT entry_id FROM form_entry_value WHERE LOWER(value) LIKE ?)`)
|
|
180
|
+
params.push(`%${query.search.toLowerCase()}%`)
|
|
181
|
+
}
|
|
182
|
+
const clause = `WHERE ${where.join(' AND ')}`
|
|
183
|
+
|
|
184
|
+
const counted = await this.#db.query<{ total: number }>(
|
|
185
|
+
`SELECT COUNT(*) AS total FROM form_entry ${clause}`,
|
|
186
|
+
params,
|
|
187
|
+
)
|
|
188
|
+
const rows = await this.#db.query<EntryRow>(
|
|
189
|
+
`${SELECT} ${clause} ORDER BY create_date DESC, id LIMIT ? OFFSET ?`,
|
|
190
|
+
[...params, query.take ?? 100, query.skip ?? 0],
|
|
191
|
+
)
|
|
192
|
+
const values = await this.#valuesFor(rows.map((row) => row.id))
|
|
193
|
+
return {
|
|
194
|
+
total: Number(counted[0]?.total ?? 0),
|
|
195
|
+
items: rows.map((row) => hydrate(row, values.get(row.id) ?? [])),
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** How many entries a form has, for a form with a hard cap. */
|
|
200
|
+
async count(formKey: string, options: { includeSpam?: boolean } = {}): Promise<number> {
|
|
201
|
+
const rows = options.includeSpam
|
|
202
|
+
? await this.#db.query<{ total: number }>(
|
|
203
|
+
'SELECT COUNT(*) AS total FROM form_entry WHERE form_key = ?',
|
|
204
|
+
[formKey],
|
|
205
|
+
)
|
|
206
|
+
: await this.#db.query<{ total: number }>(
|
|
207
|
+
'SELECT COUNT(*) AS total FROM form_entry WHERE form_key = ? AND spam = ?',
|
|
208
|
+
[formKey, this.#db.dialect.boolValue(false)],
|
|
209
|
+
)
|
|
210
|
+
return Number(rows[0]?.total ?? 0)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async create(input: FormEntryInput, id: string = randomUUID()): Promise<FormEntry> {
|
|
214
|
+
const now = new Date()
|
|
215
|
+
await this.#db.exec(
|
|
216
|
+
`INSERT INTO form_entry
|
|
217
|
+
(id, form_key, form_alias, state, culture, page_key, ip_hash, user_agent,
|
|
218
|
+
spam, create_date, update_date)
|
|
219
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
220
|
+
[
|
|
221
|
+
id,
|
|
222
|
+
input.formKey,
|
|
223
|
+
input.formAlias,
|
|
224
|
+
input.state ?? 'submitted',
|
|
225
|
+
input.culture,
|
|
226
|
+
input.pageKey,
|
|
227
|
+
input.ipHash,
|
|
228
|
+
input.userAgent,
|
|
229
|
+
this.#db.dialect.boolValue(input.spam ?? false),
|
|
230
|
+
DbDate.toDb(now),
|
|
231
|
+
DbDate.toDb(now),
|
|
232
|
+
],
|
|
233
|
+
)
|
|
234
|
+
await this.#writeValues(id, input.values)
|
|
235
|
+
return (await this.byId(id)) as FormEntry
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async #writeValues(id: string, values: readonly FormEntryValue[]): Promise<void> {
|
|
239
|
+
for (const value of values) {
|
|
240
|
+
// An empty list still means "this field was answered with nothing", which
|
|
241
|
+
// is different from the field being absent; one empty row records it.
|
|
242
|
+
const list = value.values.length === 0 ? [''] : value.values
|
|
243
|
+
for (const [sort, text] of list.entries())
|
|
244
|
+
await this.#db.exec(
|
|
245
|
+
`INSERT INTO form_entry_value (entry_id, field_alias, sort_order, value)
|
|
246
|
+
VALUES (?, ?, ?, ?)`,
|
|
247
|
+
[id, value.fieldAlias, sort, text],
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Approve or reject. Returns undefined when there is no such entry, so a
|
|
254
|
+
* caller can tell "gone" from "done".
|
|
255
|
+
*/
|
|
256
|
+
async setState(id: string, next: FormEntryState): Promise<FormEntry | undefined> {
|
|
257
|
+
const existing = await this.byId(id)
|
|
258
|
+
if (!existing) return undefined
|
|
259
|
+
await this.#db.exec('UPDATE form_entry SET state = ?, update_date = ? WHERE id = ?', [
|
|
260
|
+
next,
|
|
261
|
+
DbDate.toDb(new Date()),
|
|
262
|
+
id,
|
|
263
|
+
])
|
|
264
|
+
return this.byId(id)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Marks an entry as spam, or clears the mark after a person looked. */
|
|
268
|
+
async setSpam(id: string, spam: boolean): Promise<FormEntry | undefined> {
|
|
269
|
+
const existing = await this.byId(id)
|
|
270
|
+
if (!existing) return undefined
|
|
271
|
+
await this.#db.exec('UPDATE form_entry SET spam = ?, update_date = ? WHERE id = ?', [
|
|
272
|
+
this.#db.dialect.boolValue(spam),
|
|
273
|
+
DbDate.toDb(new Date()),
|
|
274
|
+
id,
|
|
275
|
+
])
|
|
276
|
+
return this.byId(id)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Replaces an entry's values, for the backoffice's edit of one. */
|
|
280
|
+
async setValues(id: string, values: readonly FormEntryValue[]): Promise<FormEntry | undefined> {
|
|
281
|
+
const existing = await this.byId(id)
|
|
282
|
+
if (!existing) return undefined
|
|
283
|
+
await this.#db.exec('DELETE FROM form_entry_value WHERE entry_id = ?', [id])
|
|
284
|
+
await this.#writeValues(id, values)
|
|
285
|
+
await this.#db.exec('UPDATE form_entry SET update_date = ? WHERE id = ?', [
|
|
286
|
+
DbDate.toDb(new Date()),
|
|
287
|
+
id,
|
|
288
|
+
])
|
|
289
|
+
return this.byId(id)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async remove(id: string): Promise<boolean> {
|
|
293
|
+
const existing = await this.byId(id)
|
|
294
|
+
if (!existing) return false
|
|
295
|
+
// Explicit rather than relying on the cascade: SQLite enforces foreign keys
|
|
296
|
+
// only when the pragma is on, and this must not depend on that.
|
|
297
|
+
await this.#db.exec('DELETE FROM form_entry_value WHERE entry_id = ?', [id])
|
|
298
|
+
await this.#db.exec('DELETE FROM form_entry WHERE id = ?', [id])
|
|
299
|
+
return true
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Every form that has entries, with how many — including orphaned ones. */
|
|
303
|
+
async forms(): Promise<{ formKey: string; formAlias: string; total: number }[]> {
|
|
304
|
+
const rows = await this.#db.query<{ form_key: string; form_alias: string; total: number }>(
|
|
305
|
+
`SELECT form_key, MAX(form_alias) AS form_alias, COUNT(*) AS total
|
|
306
|
+
FROM form_entry GROUP BY form_key ORDER BY MAX(form_alias)`,
|
|
307
|
+
)
|
|
308
|
+
return rows.map((row) => ({
|
|
309
|
+
formKey: row.form_key,
|
|
310
|
+
formAlias: row.form_alias,
|
|
311
|
+
total: Number(row.total),
|
|
312
|
+
}))
|
|
313
|
+
}
|
|
314
|
+
}
|