@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,427 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Members: content nodes with a sign-in facet.
|
|
3
|
+
*
|
|
4
|
+
* A member is stored exactly as a document is — `node` + `content` +
|
|
5
|
+
* `content_version` + values — so member types, properties and versioning are
|
|
6
|
+
* machinery that already exists. What makes it a member is the `member` row
|
|
7
|
+
* beside it: e-mail, username, password hash, approval and lockout. Group
|
|
8
|
+
* membership is `member_group_member`, and the rules that protect a page name a
|
|
9
|
+
* group by its **name**, so `roles` reads names rather than keys.
|
|
10
|
+
*
|
|
11
|
+
* Passwords arrive already hashed. Hashing belongs to `@crombolo/auth`, and
|
|
12
|
+
* `data` depends on nothing but types.
|
|
13
|
+
*/
|
|
14
|
+
import { normaliseUuid, ObjectTypes } from '@crombolo/core'
|
|
15
|
+
import type { Db } from '../database.ts'
|
|
16
|
+
import { DbDate, fromDbBool } from '../dialect.ts'
|
|
17
|
+
import {
|
|
18
|
+
DocumentRepository,
|
|
19
|
+
type DocumentRepositoryOptions,
|
|
20
|
+
type SaveDocumentInput,
|
|
21
|
+
} from './documents.ts'
|
|
22
|
+
import { NodeRepository } from './nodes.ts'
|
|
23
|
+
|
|
24
|
+
/** The sign-in facet beside a member's content. */
|
|
25
|
+
export interface MemberIdentity {
|
|
26
|
+
email: string
|
|
27
|
+
username: string
|
|
28
|
+
isApproved: boolean
|
|
29
|
+
isLockedOut: boolean
|
|
30
|
+
failedPasswordAttempts: number
|
|
31
|
+
lastLoginDate: Date | undefined
|
|
32
|
+
lastLockoutDate: Date | undefined
|
|
33
|
+
lastPasswordChangeDate: Date | undefined
|
|
34
|
+
emailConfirmedDate: Date | undefined
|
|
35
|
+
/** Rotated whenever a credential changes; a session carrying a stale one is over. */
|
|
36
|
+
securityStamp: string | null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface MemberRecord extends MemberIdentity {
|
|
40
|
+
key: string
|
|
41
|
+
name: string
|
|
42
|
+
/** Member group names, which is the currency public-access rules deal in. */
|
|
43
|
+
roles: string[]
|
|
44
|
+
contentTypeKey: string
|
|
45
|
+
contentTypeAlias: string
|
|
46
|
+
contentTypeIcon: string | null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface MemberCredentials extends MemberRecord {
|
|
50
|
+
passwordHash: string | null
|
|
51
|
+
passwordConfig: string | null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** What a create or an update writes to the facet; omitted fields are left alone. */
|
|
55
|
+
export interface MemberIdentityInput {
|
|
56
|
+
email: string
|
|
57
|
+
username: string
|
|
58
|
+
isApproved: boolean
|
|
59
|
+
isLockedOut: boolean
|
|
60
|
+
passwordHash?: string | null
|
|
61
|
+
passwordConfig?: string | null
|
|
62
|
+
/** Group **keys**; the membership table stores node ids. */
|
|
63
|
+
groupKeys?: readonly string[]
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface MemberFilter {
|
|
67
|
+
memberTypeKey?: string | null
|
|
68
|
+
memberGroupName?: string | null
|
|
69
|
+
isApproved?: boolean
|
|
70
|
+
isLockedOut?: boolean
|
|
71
|
+
/** Matched against e-mail, username and name. */
|
|
72
|
+
filter?: string
|
|
73
|
+
orderBy?: string
|
|
74
|
+
orderDirection?: 'Ascending' | 'Descending'
|
|
75
|
+
skip: number
|
|
76
|
+
take: number
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Umbraco's `MemberFilterRepository` order columns; anything else is username. */
|
|
80
|
+
const ORDER_COLUMNS: Record<string, string> = {
|
|
81
|
+
email: 'm.email',
|
|
82
|
+
name: 'n.text',
|
|
83
|
+
username: 'm.login_name',
|
|
84
|
+
isapproved: 'm.is_approved',
|
|
85
|
+
islockedout: 'm.is_locked_out',
|
|
86
|
+
lastlogindate: 'm.last_login_date',
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface MemberRow {
|
|
90
|
+
unique_id: string
|
|
91
|
+
text: string | null
|
|
92
|
+
email: string
|
|
93
|
+
login_name: string
|
|
94
|
+
password: string | null
|
|
95
|
+
password_config: string | null
|
|
96
|
+
security_stamp_token: string | null
|
|
97
|
+
email_confirmed_date: unknown
|
|
98
|
+
failed_password_attempts: number
|
|
99
|
+
is_locked_out: unknown
|
|
100
|
+
is_approved: unknown
|
|
101
|
+
last_login_date: unknown
|
|
102
|
+
last_lockout_date: unknown
|
|
103
|
+
last_password_change_date: unknown
|
|
104
|
+
type_key: string
|
|
105
|
+
type_alias: string
|
|
106
|
+
type_icon: string | null
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const SELECT = `SELECT n.unique_id, n.text, m.*,
|
|
110
|
+
tn.unique_id AS type_key, ct.alias AS type_alias, ct.icon AS type_icon
|
|
111
|
+
FROM member m
|
|
112
|
+
JOIN node n ON n.id = m.node_id
|
|
113
|
+
JOIN content c ON c.node_id = m.node_id
|
|
114
|
+
JOIN content_type ct ON ct.node_id = c.content_type_id
|
|
115
|
+
JOIN node tn ON tn.id = ct.node_id`
|
|
116
|
+
|
|
117
|
+
export class MemberRepository {
|
|
118
|
+
#db: Db
|
|
119
|
+
#nodes: NodeRepository
|
|
120
|
+
readonly content: DocumentRepository
|
|
121
|
+
|
|
122
|
+
constructor(db: Db, options: DocumentRepositoryOptions = {}) {
|
|
123
|
+
this.#db = db
|
|
124
|
+
this.#nodes = new NodeRepository(db)
|
|
125
|
+
this.content = new DocumentRepository(db, { ...options, kind: 'member' })
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async #rolesOf(nodeId: number): Promise<string[]> {
|
|
129
|
+
const rows = await this.#db.query<{ text: string | null }>(
|
|
130
|
+
`SELECT g.text FROM member_group_member mg JOIN node g ON g.id = mg.member_group_id
|
|
131
|
+
WHERE mg.member_id = ? ORDER BY g.text`,
|
|
132
|
+
[nodeId],
|
|
133
|
+
)
|
|
134
|
+
return rows.map((row) => String(row.text ?? '')).filter(Boolean)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async #groupKeysOf(nodeId: number): Promise<string[]> {
|
|
138
|
+
const rows = await this.#db.query<{ unique_id: string }>(
|
|
139
|
+
`SELECT g.unique_id FROM member_group_member mg JOIN node g ON g.id = mg.member_group_id
|
|
140
|
+
WHERE mg.member_id = ? ORDER BY g.text`,
|
|
141
|
+
[nodeId],
|
|
142
|
+
)
|
|
143
|
+
return rows.map((row) => normaliseUuid(String(row.unique_id)))
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async #hydrate(row: MemberRow): Promise<MemberCredentials> {
|
|
147
|
+
const node = await this.#nodes.byKey(normaliseUuid(String(row.unique_id)))
|
|
148
|
+
return {
|
|
149
|
+
key: normaliseUuid(String(row.unique_id)),
|
|
150
|
+
name: row.text ?? '',
|
|
151
|
+
email: row.email,
|
|
152
|
+
username: row.login_name,
|
|
153
|
+
passwordHash: row.password,
|
|
154
|
+
passwordConfig: row.password_config,
|
|
155
|
+
securityStamp: row.security_stamp_token,
|
|
156
|
+
isApproved: fromDbBool(row.is_approved),
|
|
157
|
+
isLockedOut: fromDbBool(row.is_locked_out),
|
|
158
|
+
failedPasswordAttempts: Number(row.failed_password_attempts ?? 0),
|
|
159
|
+
lastLoginDate: DbDate.fromDb(row.last_login_date),
|
|
160
|
+
lastLockoutDate: DbDate.fromDb(row.last_lockout_date),
|
|
161
|
+
lastPasswordChangeDate: DbDate.fromDb(row.last_password_change_date),
|
|
162
|
+
emailConfirmedDate: DbDate.fromDb(row.email_confirmed_date),
|
|
163
|
+
roles: node ? await this.#rolesOf(node.id) : [],
|
|
164
|
+
contentTypeKey: normaliseUuid(String(row.type_key)),
|
|
165
|
+
contentTypeAlias: String(row.type_alias),
|
|
166
|
+
contentTypeIcon: row.type_icon,
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async #one(where: string, parameters: unknown[]): Promise<MemberCredentials | undefined> {
|
|
171
|
+
const rows = await this.#db.query<MemberRow>(`${SELECT} WHERE ${where}`, parameters)
|
|
172
|
+
return rows[0] ? this.#hydrate(rows[0]) : undefined
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
byKey(key: string): Promise<MemberCredentials | undefined> {
|
|
176
|
+
return this.#one('n.unique_id = ?', [normaliseUuid(key)])
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
byUsername(username: string): Promise<MemberCredentials | undefined> {
|
|
180
|
+
return this.#one('m.login_name = ?', [username])
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
byEmail(email: string): Promise<MemberCredentials | undefined> {
|
|
184
|
+
return this.#one('m.email = ?', [email])
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The group keys a member belongs to, for the editor's Groups picker. */
|
|
188
|
+
async groupKeys(key: string): Promise<string[]> {
|
|
189
|
+
const node = await this.#nodes.byKey(key)
|
|
190
|
+
return node ? this.#groupKeysOf(node.id) : []
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Whether a username or an e-mail is taken by anyone other than `exceptKey`. */
|
|
194
|
+
async taken(field: 'username' | 'email', value: string, exceptKey?: string): Promise<boolean> {
|
|
195
|
+
const column = field === 'username' ? 'm.login_name' : 'm.email'
|
|
196
|
+
const rows = await this.#db.query<{ unique_id: string }>(
|
|
197
|
+
`SELECT n.unique_id FROM member m JOIN node n ON n.id = m.node_id WHERE ${column} = ?`,
|
|
198
|
+
[value],
|
|
199
|
+
)
|
|
200
|
+
return rows.some(
|
|
201
|
+
(row) => !exceptKey || normaliseUuid(String(row.unique_id)) !== normaliseUuid(exceptKey),
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async #writeGroups(nodeId: number, groupKeys: readonly string[]): Promise<void> {
|
|
206
|
+
await this.#db.exec('DELETE FROM member_group_member WHERE member_id = ?', [nodeId])
|
|
207
|
+
for (const key of groupKeys) {
|
|
208
|
+
const group = await this.#nodes.byKey(key)
|
|
209
|
+
if (!group || group.objectType !== ObjectTypes.MemberGroup) continue
|
|
210
|
+
await this.#db.exec(
|
|
211
|
+
'INSERT INTO member_group_member (member_id, member_group_id) VALUES (?, ?)',
|
|
212
|
+
[nodeId, group.id],
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async create(
|
|
218
|
+
content: SaveDocumentInput,
|
|
219
|
+
identity: MemberIdentityInput,
|
|
220
|
+
): Promise<MemberCredentials> {
|
|
221
|
+
// Members never nest: the parent is always the tree root.
|
|
222
|
+
const created = await this.content.create({ ...content, parentKey: null, componentKey: null })
|
|
223
|
+
const node = await this.#nodes.byKey(created.key)
|
|
224
|
+
if (!node) throw new Error(`the member node ${created.key} vanished while being created`)
|
|
225
|
+
const now = new Date()
|
|
226
|
+
await this.#db.exec(
|
|
227
|
+
`INSERT INTO member (node_id, email, login_name, password, password_config,
|
|
228
|
+
security_stamp_token, failed_password_attempts, is_locked_out,
|
|
229
|
+
is_approved, last_password_change_date)
|
|
230
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
231
|
+
[
|
|
232
|
+
node.id,
|
|
233
|
+
identity.email,
|
|
234
|
+
identity.username,
|
|
235
|
+
identity.passwordHash ?? null,
|
|
236
|
+
identity.passwordConfig ?? null,
|
|
237
|
+
crypto.randomUUID(),
|
|
238
|
+
0,
|
|
239
|
+
this.#db.dialect.boolValue(identity.isLockedOut),
|
|
240
|
+
this.#db.dialect.boolValue(identity.isApproved),
|
|
241
|
+
identity.passwordHash ? DbDate.toDb(now) : null,
|
|
242
|
+
],
|
|
243
|
+
)
|
|
244
|
+
await this.#writeGroups(node.id, identity.groupKeys ?? [])
|
|
245
|
+
const member = await this.byKey(created.key)
|
|
246
|
+
if (!member) throw new Error(`the member ${created.key} could not be read back`)
|
|
247
|
+
return member
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Saves content and facet together. A new password rotates the security
|
|
252
|
+
* stamp, which ends every session the member had.
|
|
253
|
+
*/
|
|
254
|
+
async update(
|
|
255
|
+
key: string,
|
|
256
|
+
content: SaveDocumentInput,
|
|
257
|
+
identity: MemberIdentityInput,
|
|
258
|
+
): Promise<MemberCredentials | undefined> {
|
|
259
|
+
const node = await this.#nodes.byKey(key)
|
|
260
|
+
if (!node || node.objectType !== ObjectTypes.Member) return undefined
|
|
261
|
+
const updated = await this.content.update({ ...content, key, componentKey: null })
|
|
262
|
+
if (!updated) return undefined
|
|
263
|
+
const now = new Date()
|
|
264
|
+
const sets = ['email = ?', 'login_name = ?', 'is_approved = ?', 'is_locked_out = ?']
|
|
265
|
+
const values: unknown[] = [
|
|
266
|
+
identity.email,
|
|
267
|
+
identity.username,
|
|
268
|
+
this.#db.dialect.boolValue(identity.isApproved),
|
|
269
|
+
this.#db.dialect.boolValue(identity.isLockedOut),
|
|
270
|
+
]
|
|
271
|
+
if (!identity.isLockedOut) {
|
|
272
|
+
sets.push('failed_password_attempts = ?')
|
|
273
|
+
values.push(0)
|
|
274
|
+
}
|
|
275
|
+
if (identity.passwordHash) {
|
|
276
|
+
sets.push(
|
|
277
|
+
'password = ?',
|
|
278
|
+
'password_config = ?',
|
|
279
|
+
'last_password_change_date = ?',
|
|
280
|
+
'security_stamp_token = ?',
|
|
281
|
+
)
|
|
282
|
+
values.push(
|
|
283
|
+
identity.passwordHash,
|
|
284
|
+
identity.passwordConfig ?? null,
|
|
285
|
+
DbDate.toDb(now),
|
|
286
|
+
crypto.randomUUID(),
|
|
287
|
+
)
|
|
288
|
+
}
|
|
289
|
+
await this.#db.exec(`UPDATE member SET ${sets.join(', ')} WHERE node_id = ?`, [
|
|
290
|
+
...values,
|
|
291
|
+
node.id,
|
|
292
|
+
])
|
|
293
|
+
if (identity.groupKeys) await this.#writeGroups(node.id, identity.groupKeys)
|
|
294
|
+
return this.byKey(key)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Records a successful sign-in, clearing whatever failures came before it. */
|
|
298
|
+
async recordLogin(key: string): Promise<void> {
|
|
299
|
+
const node = await this.#nodes.byKey(key)
|
|
300
|
+
if (!node) return
|
|
301
|
+
await this.#db.exec(
|
|
302
|
+
'UPDATE member SET last_login_date = ?, failed_password_attempts = 0 WHERE node_id = ?',
|
|
303
|
+
[DbDate.toDb(new Date()), node.id],
|
|
304
|
+
)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Records a failed sign-in and locks the account out once `maxAttempts` is
|
|
309
|
+
* reached, as Umbraco's lockout does.
|
|
310
|
+
*/
|
|
311
|
+
async recordFailedLogin(key: string, maxAttempts: number): Promise<{ lockedOut: boolean }> {
|
|
312
|
+
const node = await this.#nodes.byKey(key)
|
|
313
|
+
if (!node) return { lockedOut: false }
|
|
314
|
+
const rows = await this.#db.query<{ failed_password_attempts: number }>(
|
|
315
|
+
'SELECT failed_password_attempts FROM member WHERE node_id = ?',
|
|
316
|
+
[node.id],
|
|
317
|
+
)
|
|
318
|
+
const attempts = Number(rows[0]?.failed_password_attempts ?? 0) + 1
|
|
319
|
+
const lockedOut = maxAttempts > 0 && attempts >= maxAttempts
|
|
320
|
+
await this.#db.exec(
|
|
321
|
+
`UPDATE member SET failed_password_attempts = ?, is_locked_out = ?${lockedOut ? ', last_lockout_date = ?' : ''}
|
|
322
|
+
WHERE node_id = ?`,
|
|
323
|
+
lockedOut
|
|
324
|
+
? [attempts, this.#db.dialect.boolValue(true), DbDate.toDb(new Date()), node.id]
|
|
325
|
+
: [attempts, this.#db.dialect.boolValue(false), node.id],
|
|
326
|
+
)
|
|
327
|
+
return { lockedOut }
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async delete(key: string): Promise<boolean> {
|
|
331
|
+
const node = await this.#nodes.byKey(key)
|
|
332
|
+
if (!node || node.objectType !== ObjectTypes.Member) return false
|
|
333
|
+
await this.#db.exec('DELETE FROM member_group_member WHERE member_id = ?', [node.id])
|
|
334
|
+
await this.#db.exec(
|
|
335
|
+
'DELETE FROM external_login_token WHERE external_login_id IN (SELECT id FROM external_login WHERE user_or_member_key = ?)',
|
|
336
|
+
[node.key],
|
|
337
|
+
)
|
|
338
|
+
await this.#db.exec('DELETE FROM external_login WHERE user_or_member_key = ?', [node.key])
|
|
339
|
+
await this.#db.exec('DELETE FROM member WHERE node_id = ?', [node.id])
|
|
340
|
+
return this.content.delete(key)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** The Members collection: Umbraco's member filter, with the same columns and defaults. */
|
|
344
|
+
async filter(criteria: MemberFilter): Promise<{ total: number; items: MemberCredentials[] }> {
|
|
345
|
+
const wheres: string[] = []
|
|
346
|
+
const values: unknown[] = []
|
|
347
|
+
if (criteria.memberTypeKey) {
|
|
348
|
+
wheres.push('tn.unique_id = ?')
|
|
349
|
+
values.push(normaliseUuid(criteria.memberTypeKey))
|
|
350
|
+
}
|
|
351
|
+
if (criteria.isApproved !== undefined) {
|
|
352
|
+
wheres.push('m.is_approved = ?')
|
|
353
|
+
values.push(this.#db.dialect.boolValue(criteria.isApproved))
|
|
354
|
+
}
|
|
355
|
+
if (criteria.isLockedOut !== undefined) {
|
|
356
|
+
wheres.push('m.is_locked_out = ?')
|
|
357
|
+
values.push(this.#db.dialect.boolValue(criteria.isLockedOut))
|
|
358
|
+
}
|
|
359
|
+
if (criteria.memberGroupName) {
|
|
360
|
+
wheres.push(
|
|
361
|
+
`EXISTS (SELECT 1 FROM member_group_member mg JOIN node g ON g.id = mg.member_group_id
|
|
362
|
+
WHERE mg.member_id = m.node_id AND g.text = ?)`,
|
|
363
|
+
)
|
|
364
|
+
values.push(criteria.memberGroupName)
|
|
365
|
+
}
|
|
366
|
+
const text = criteria.filter?.trim()
|
|
367
|
+
if (text) {
|
|
368
|
+
wheres.push('(LOWER(m.email) LIKE ? OR LOWER(m.login_name) LIKE ? OR LOWER(n.text) LIKE ?)')
|
|
369
|
+
const like = `%${text.toLowerCase()}%`
|
|
370
|
+
values.push(like, like, like)
|
|
371
|
+
}
|
|
372
|
+
const where = wheres.length > 0 ? `WHERE ${wheres.join(' AND ')}` : ''
|
|
373
|
+
const order = ORDER_COLUMNS[(criteria.orderBy ?? '').toLowerCase()] ?? ORDER_COLUMNS.username
|
|
374
|
+
const direction = criteria.orderDirection === 'Descending' ? 'DESC' : 'ASC'
|
|
375
|
+
|
|
376
|
+
const counted = await this.#db.query<{ total: number }>(
|
|
377
|
+
`SELECT COUNT(*) AS total FROM member m
|
|
378
|
+
JOIN node n ON n.id = m.node_id
|
|
379
|
+
JOIN content c ON c.node_id = m.node_id
|
|
380
|
+
JOIN content_type ct ON ct.node_id = c.content_type_id
|
|
381
|
+
JOIN node tn ON tn.id = ct.node_id
|
|
382
|
+
${where}`,
|
|
383
|
+
values,
|
|
384
|
+
)
|
|
385
|
+
const rows = await this.#db.query<MemberRow>(
|
|
386
|
+
`${SELECT} ${where} ORDER BY ${order} ${direction}, n.id LIMIT ? OFFSET ?`,
|
|
387
|
+
[...values, criteria.take, criteria.skip],
|
|
388
|
+
)
|
|
389
|
+
const items = []
|
|
390
|
+
for (const row of rows) items.push(await this.#hydrate(row))
|
|
391
|
+
return { total: Number(counted[0]?.total ?? 0), items }
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** The member picker's search: name, username or e-mail, within the types allowed. */
|
|
395
|
+
async search(
|
|
396
|
+
query: string,
|
|
397
|
+
options: { allowedMemberTypeKeys?: readonly string[]; skip: number; take: number },
|
|
398
|
+
): Promise<{ total: number; items: MemberCredentials[] }> {
|
|
399
|
+
const allowed = options.allowedMemberTypeKeys ?? []
|
|
400
|
+
const page = await this.filter({
|
|
401
|
+
filter: query,
|
|
402
|
+
skip: 0,
|
|
403
|
+
take: Number.MAX_SAFE_INTEGER,
|
|
404
|
+
orderBy: 'name',
|
|
405
|
+
})
|
|
406
|
+
const items =
|
|
407
|
+
allowed.length === 0
|
|
408
|
+
? page.items
|
|
409
|
+
: page.items.filter((member) =>
|
|
410
|
+
allowed.map(normaliseUuid).includes(normaliseUuid(member.contentTypeKey)),
|
|
411
|
+
)
|
|
412
|
+
return {
|
|
413
|
+
total: items.length,
|
|
414
|
+
items: items.slice(options.skip, options.skip + options.take),
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** Several members by key, in the order asked, for the item lookup. */
|
|
419
|
+
async items(keys: readonly string[]): Promise<MemberCredentials[]> {
|
|
420
|
+
const found = []
|
|
421
|
+
for (const key of keys) {
|
|
422
|
+
const member = await this.byKey(key)
|
|
423
|
+
if (member) found.push(member)
|
|
424
|
+
}
|
|
425
|
+
return found
|
|
426
|
+
}
|
|
427
|
+
}
|