@manablox/db 0.1.0 → 0.2.0
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/README.md +21 -0
- package/drizzle.config.ts +1 -1
- package/migrations/0005_menus.sql +44 -0
- package/migrations/0006_roles.sql +13 -0
- package/migrations/0007_apikey-permissions.sql +4 -0
- package/migrations/0008_workflows.sql +49 -0
- package/migrations/meta/0005_snapshot.json +2504 -0
- package/migrations/meta/0006_snapshot.json +2605 -0
- package/migrations/meta/0007_snapshot.json +2605 -0
- package/migrations/meta/0008_snapshot.json +2986 -0
- package/migrations/meta/_journal.json +28 -0
- package/package.json +10 -5
- package/src/cli/create-db.ts +30 -0
- package/src/cli/migrate.ts +2 -9
- package/src/client.ts +8 -1
- package/src/errors.ts +50 -0
- package/src/index.ts +8 -2
- package/src/migrate.ts +21 -0
- package/src/pagination.ts +52 -0
- package/src/query.ts +1 -5
- package/src/repositories/asset-usage.ts +1 -1
- package/src/repositories/asset.ts +13 -21
- package/src/repositories/content-type.ts +1 -1
- package/src/repositories/content.ts +150 -97
- package/src/repositories/index.ts +12 -0
- package/src/repositories/menu.ts +235 -0
- package/src/repositories/role.ts +85 -0
- package/src/repositories/space.ts +7 -2
- package/src/repositories/user.ts +171 -25
- package/src/repositories/webhook.ts +46 -0
- package/src/repositories/workflow.ts +306 -0
- package/src/schema/assets.ts +108 -0
- package/src/schema/auth.ts +166 -0
- package/src/schema/content-types.ts +31 -0
- package/src/schema/content.ts +133 -0
- package/src/schema/index.ts +38 -0
- package/src/schema/menus.ts +61 -0
- package/src/schema/relations.ts +64 -0
- package/src/schema/spaces.ts +20 -0
- package/src/schema/webhooks.ts +46 -0
- package/src/schema/workflows.ts +92 -0
- package/{test/helpers.ts → src/testing-fixtures.ts} +21 -35
- package/src/testing.ts +105 -0
- package/test/asset-usage.test.ts +3 -3
- package/test/menu.test.ts +126 -0
- package/test/publish.test.ts +31 -3
- package/test/query.test.ts +33 -3
- package/test/role.test.ts +81 -0
- package/test/tree.test.ts +3 -3
- package/test/user.test.ts +126 -0
- package/test/webhook.test.ts +48 -0
- package/vitest.config.ts +0 -2
- package/src/schema.ts +0 -513
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { type Loose, ManabloxError } from '@manablox/core';
|
|
3
|
+
import { and, asc, eq, inArray } from 'drizzle-orm';
|
|
4
|
+
import type { Database } from '../client.js';
|
|
5
|
+
import {
|
|
6
|
+
type ContentRow,
|
|
7
|
+
contents,
|
|
8
|
+
type MenuItemRow,
|
|
9
|
+
type MenuRow,
|
|
10
|
+
menuItems,
|
|
11
|
+
menus,
|
|
12
|
+
publishedContents,
|
|
13
|
+
} from '../schema/index.js';
|
|
14
|
+
|
|
15
|
+
export interface MenuWriteData {
|
|
16
|
+
id?: string | undefined;
|
|
17
|
+
spaceId: string;
|
|
18
|
+
name: string;
|
|
19
|
+
machineName: string;
|
|
20
|
+
description?: string | null | undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* One entry as the admin hands over the whole menu: a content entry names a
|
|
25
|
+
* `localizationId`, a link entry a `url`; either may carry a label. `id` is kept when
|
|
26
|
+
* given, so an unchanged entry keeps its identity across saves.
|
|
27
|
+
*/
|
|
28
|
+
export interface MenuItemInput {
|
|
29
|
+
id?: string | undefined;
|
|
30
|
+
localizationId?: string | null | undefined;
|
|
31
|
+
label?: string | null | undefined;
|
|
32
|
+
url?: string | null | undefined;
|
|
33
|
+
children?: MenuItemInput[] | undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface MenuItemNode {
|
|
37
|
+
item: MenuItemRow;
|
|
38
|
+
children: MenuItemNode[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** An entry with its document looked up for one locale; `content` is null for a link. */
|
|
42
|
+
export interface ResolvedMenuItem {
|
|
43
|
+
id: string;
|
|
44
|
+
label: string | null;
|
|
45
|
+
url: string | null;
|
|
46
|
+
localizationId: string | null;
|
|
47
|
+
content: ContentRow | null;
|
|
48
|
+
children: ResolvedMenuItem[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export class MenuRepository {
|
|
52
|
+
constructor(private readonly db: Database) {}
|
|
53
|
+
|
|
54
|
+
async listBySpace(spaceId: string): Promise<MenuRow[]> {
|
|
55
|
+
return this.db.select().from(menus).where(eq(menus.spaceId, spaceId)).orderBy(menus.name);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async findById(id: string): Promise<MenuRow | null> {
|
|
59
|
+
const rows = await this.db.select().from(menus).where(eq(menus.id, id)).limit(1);
|
|
60
|
+
return rows[0] ?? null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async findByMachineName(spaceId: string, machineName: string): Promise<MenuRow | null> {
|
|
64
|
+
const rows = await this.db
|
|
65
|
+
.select()
|
|
66
|
+
.from(menus)
|
|
67
|
+
.where(and(eq(menus.spaceId, spaceId), eq(menus.machineName, machineName)))
|
|
68
|
+
.limit(1);
|
|
69
|
+
return rows[0] ?? null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async create(data: MenuWriteData): Promise<MenuRow> {
|
|
73
|
+
const [row] = await this.db
|
|
74
|
+
.insert(menus)
|
|
75
|
+
.values({
|
|
76
|
+
...(data.id ? { id: data.id } : {}),
|
|
77
|
+
spaceId: data.spaceId,
|
|
78
|
+
name: data.name,
|
|
79
|
+
machineName: data.machineName,
|
|
80
|
+
description: data.description ?? null,
|
|
81
|
+
})
|
|
82
|
+
.returning();
|
|
83
|
+
if (!row) throw new ManabloxError('menu.create.failed');
|
|
84
|
+
return row;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async update(id: string, data: Loose<Omit<MenuWriteData, 'spaceId'>>): Promise<MenuRow> {
|
|
88
|
+
const [row] = await this.db
|
|
89
|
+
.update(menus)
|
|
90
|
+
.set({
|
|
91
|
+
...(data.name !== undefined ? { name: data.name } : {}),
|
|
92
|
+
...(data.machineName !== undefined ? { machineName: data.machineName } : {}),
|
|
93
|
+
...(data.description !== undefined ? { description: data.description } : {}),
|
|
94
|
+
updatedAt: new Date(),
|
|
95
|
+
})
|
|
96
|
+
.where(eq(menus.id, id))
|
|
97
|
+
.returning();
|
|
98
|
+
if (!row) throw ManabloxError.notFound('menu.notFound', { id });
|
|
99
|
+
return row;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async delete(id: string): Promise<void> {
|
|
103
|
+
await this.db.delete(menus).where(eq(menus.id, id));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Every entry of a menu, flat, in tree order within each level. */
|
|
107
|
+
async items(menuId: string): Promise<MenuItemRow[]> {
|
|
108
|
+
return this.db
|
|
109
|
+
.select()
|
|
110
|
+
.from(menuItems)
|
|
111
|
+
.where(eq(menuItems.menuId, menuId))
|
|
112
|
+
.orderBy(asc(menuItems.position), asc(menuItems.id));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async tree(menuId: string): Promise<MenuItemNode[]> {
|
|
116
|
+
return buildTree(await this.items(menuId));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Replaces the whole entry tree in one transaction.
|
|
121
|
+
*
|
|
122
|
+
* A menu is edited as one document and saved as one, so this is simpler and safer than
|
|
123
|
+
* a per-entry API whose partial failures would leave a half-reordered menu behind.
|
|
124
|
+
*/
|
|
125
|
+
async setItems(menuId: string, tree: MenuItemInput[]): Promise<MenuItemNode[]> {
|
|
126
|
+
const rows: (typeof menuItems.$inferInsert)[] = [];
|
|
127
|
+
const flatten = (nodes: MenuItemInput[], parentId: string | null) => {
|
|
128
|
+
nodes.forEach((node, position) => {
|
|
129
|
+
const id = node.id ?? randomUUID();
|
|
130
|
+
rows.push({
|
|
131
|
+
id,
|
|
132
|
+
menuId,
|
|
133
|
+
parentId,
|
|
134
|
+
position,
|
|
135
|
+
localizationId: node.localizationId ?? null,
|
|
136
|
+
label: node.label ?? null,
|
|
137
|
+
url: node.url ?? null,
|
|
138
|
+
});
|
|
139
|
+
flatten(node.children ?? [], id);
|
|
140
|
+
});
|
|
141
|
+
};
|
|
142
|
+
flatten(tree, null);
|
|
143
|
+
|
|
144
|
+
return this.db.transaction(async (tx) => {
|
|
145
|
+
await tx.delete(menuItems).where(eq(menuItems.menuId, menuId));
|
|
146
|
+
if (rows.length) await tx.insert(menuItems).values(rows);
|
|
147
|
+
await tx.update(menus).set({ updatedAt: new Date() }).where(eq(menus.id, menuId));
|
|
148
|
+
return buildTree(rows.map((row) => ({ ...row, position: row.position ?? 0 }) as MenuItemRow));
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Menus that carry the document, for the editor's "used in" hint. */
|
|
153
|
+
async menusReferencing(spaceId: string, localizationId: string): Promise<MenuRow[]> {
|
|
154
|
+
return this.db
|
|
155
|
+
.selectDistinct({
|
|
156
|
+
id: menus.id,
|
|
157
|
+
spaceId: menus.spaceId,
|
|
158
|
+
name: menus.name,
|
|
159
|
+
machineName: menus.machineName,
|
|
160
|
+
description: menus.description,
|
|
161
|
+
createdAt: menus.createdAt,
|
|
162
|
+
updatedAt: menus.updatedAt,
|
|
163
|
+
})
|
|
164
|
+
.from(menuItems)
|
|
165
|
+
.innerJoin(menus, eq(menuItems.menuId, menus.id))
|
|
166
|
+
.where(and(eq(menus.spaceId, spaceId), eq(menuItems.localizationId, localizationId)))
|
|
167
|
+
.orderBy(menus.name);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Drops every entry pointing at a document, in every menu; sub-entries cascade. */
|
|
171
|
+
async removeContent(localizationId: string): Promise<number> {
|
|
172
|
+
const removed = await this.db
|
|
173
|
+
.delete(menuItems)
|
|
174
|
+
.where(eq(menuItems.localizationId, localizationId))
|
|
175
|
+
.returning({ id: menuItems.id });
|
|
176
|
+
return removed.length;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The tree with each content entry's document for one locale. A content entry whose
|
|
181
|
+
* document has no row in that locale — or, on the published table, no published one —
|
|
182
|
+
* comes back with `content: null`; the caller decides whether to show or drop it.
|
|
183
|
+
*/
|
|
184
|
+
async resolve(menu: MenuRow, locale: string, published = false): Promise<ResolvedMenuItem[]> {
|
|
185
|
+
const items = await this.items(menu.id);
|
|
186
|
+
const localizationIds = [
|
|
187
|
+
...new Set(items.flatMap((item) => (item.localizationId ? [item.localizationId] : []))),
|
|
188
|
+
];
|
|
189
|
+
|
|
190
|
+
const table = published ? publishedContents : contents;
|
|
191
|
+
const rows = localizationIds.length
|
|
192
|
+
? await this.db
|
|
193
|
+
.select()
|
|
194
|
+
.from(table)
|
|
195
|
+
.where(
|
|
196
|
+
and(
|
|
197
|
+
eq(table.spaceId, menu.spaceId),
|
|
198
|
+
eq(table.locale, locale),
|
|
199
|
+
inArray(table.localizationId, localizationIds),
|
|
200
|
+
),
|
|
201
|
+
)
|
|
202
|
+
: [];
|
|
203
|
+
const byLocalization = new Map(rows.map((row) => [row.localizationId, row]));
|
|
204
|
+
|
|
205
|
+
const toResolved = (node: MenuItemNode): ResolvedMenuItem => ({
|
|
206
|
+
id: node.item.id,
|
|
207
|
+
label: node.item.label,
|
|
208
|
+
url: node.item.url,
|
|
209
|
+
localizationId: node.item.localizationId,
|
|
210
|
+
content: node.item.localizationId
|
|
211
|
+
? (byLocalization.get(node.item.localizationId) ?? null)
|
|
212
|
+
: null,
|
|
213
|
+
children: node.children.map(toResolved),
|
|
214
|
+
});
|
|
215
|
+
return buildTree(items).map(toResolved);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function buildTree(rows: MenuItemRow[]): MenuItemNode[] {
|
|
220
|
+
const nodes = new Map<string, MenuItemNode>();
|
|
221
|
+
for (const row of rows) nodes.set(row.id, { item: row, children: [] });
|
|
222
|
+
|
|
223
|
+
const roots: MenuItemNode[] = [];
|
|
224
|
+
for (const node of nodes.values()) {
|
|
225
|
+
const parent = node.item.parentId ? nodes.get(node.item.parentId) : undefined;
|
|
226
|
+
(parent ? parent.children : roots).push(node);
|
|
227
|
+
}
|
|
228
|
+
const byPosition = (a: MenuItemNode, b: MenuItemNode) => a.item.position - b.item.position;
|
|
229
|
+
const sort = (list: MenuItemNode[]) => {
|
|
230
|
+
list.sort(byPosition);
|
|
231
|
+
for (const node of list) sort(node.children);
|
|
232
|
+
};
|
|
233
|
+
sort(roots);
|
|
234
|
+
return roots;
|
|
235
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { ManabloxError } from '@manablox/core';
|
|
2
|
+
import { and, eq, sql } from 'drizzle-orm';
|
|
3
|
+
import type { Database } from '../client.js';
|
|
4
|
+
import { memberships, type RoleRow, roles } from '../schema/index.js';
|
|
5
|
+
|
|
6
|
+
export interface RoleWriteData {
|
|
7
|
+
name: string;
|
|
8
|
+
machineName: string;
|
|
9
|
+
description?: string | null;
|
|
10
|
+
permissions: string[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class RoleRepository {
|
|
14
|
+
constructor(private readonly db: Database) {}
|
|
15
|
+
|
|
16
|
+
async listBySpace(spaceId: string): Promise<RoleRow[]> {
|
|
17
|
+
return this.db.select().from(roles).where(eq(roles.spaceId, spaceId)).orderBy(roles.name);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async findById(id: string): Promise<RoleRow | null> {
|
|
21
|
+
const rows = await this.db.select().from(roles).where(eq(roles.id, id)).limit(1);
|
|
22
|
+
return rows[0] ?? null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async findByMachineName(spaceId: string, machineName: string): Promise<RoleRow | null> {
|
|
26
|
+
const rows = await this.db
|
|
27
|
+
.select()
|
|
28
|
+
.from(roles)
|
|
29
|
+
.where(and(eq(roles.spaceId, spaceId), eq(roles.machineName, machineName)))
|
|
30
|
+
.limit(1);
|
|
31
|
+
return rows[0] ?? null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async create(spaceId: string, data: RoleWriteData): Promise<RoleRow> {
|
|
35
|
+
const [row] = await this.db
|
|
36
|
+
.insert(roles)
|
|
37
|
+
.values({ spaceId, ...data })
|
|
38
|
+
.returning();
|
|
39
|
+
if (!row) throw new ManabloxError('role.create.failed');
|
|
40
|
+
return row;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async update(id: string, data: Partial<RoleWriteData>): Promise<RoleRow> {
|
|
44
|
+
const [row] = await this.db
|
|
45
|
+
.update(roles)
|
|
46
|
+
.set({ ...data, updatedAt: new Date() })
|
|
47
|
+
.where(eq(roles.id, id))
|
|
48
|
+
.returning();
|
|
49
|
+
if (!row) throw ManabloxError.notFound('role.notFound', { id });
|
|
50
|
+
return row;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async delete(id: string): Promise<void> {
|
|
54
|
+
await this.db.delete(roles).where(eq(roles.id, id));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** How many members of the role's space hold it, by name. */
|
|
58
|
+
async countMembers(spaceId: string, machineName: string): Promise<number> {
|
|
59
|
+
const rows = await this.db
|
|
60
|
+
.select({ count: sql<number>`count(*)::int` })
|
|
61
|
+
.from(memberships)
|
|
62
|
+
.where(and(eq(memberships.spaceId, spaceId), eq(memberships.role, machineName)));
|
|
63
|
+
return rows[0]?.count ?? 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Drops every grant narrowed to a content type from every role, once the type is
|
|
68
|
+
* gone. Grants are a JSON array, so this is one statement across the roles that carry
|
|
69
|
+
* such a grant rather than a read-modify-write per role.
|
|
70
|
+
*/
|
|
71
|
+
async pruneContentType(typeId: string): Promise<void> {
|
|
72
|
+
const suffix = `:${typeId}`;
|
|
73
|
+
await this.db
|
|
74
|
+
.update(roles)
|
|
75
|
+
.set({
|
|
76
|
+
permissions: sql`(
|
|
77
|
+
select coalesce(jsonb_agg(value), '[]'::jsonb)
|
|
78
|
+
from jsonb_array_elements_text(${roles.permissions}) as value
|
|
79
|
+
where value not like ${`%${suffix}`}
|
|
80
|
+
)`,
|
|
81
|
+
updatedAt: new Date(),
|
|
82
|
+
})
|
|
83
|
+
.where(sql`${roles.permissions}::text like ${`%${suffix}%`}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type Loose, ManabloxError } from '@manablox/core';
|
|
2
|
-
import { eq } from 'drizzle-orm';
|
|
2
|
+
import { eq, inArray } from 'drizzle-orm';
|
|
3
3
|
import type { Database } from '../client.js';
|
|
4
|
-
import { type SpaceRow, spaces } from '../schema.js';
|
|
4
|
+
import { type SpaceRow, spaces } from '../schema/index.js';
|
|
5
5
|
|
|
6
6
|
export interface SpaceWriteData {
|
|
7
7
|
id?: string | undefined;
|
|
@@ -21,6 +21,11 @@ export class SpaceRepository {
|
|
|
21
21
|
return this.db.select().from(spaces).orderBy(spaces.name);
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
async findManyByIds(ids: string[]): Promise<SpaceRow[]> {
|
|
25
|
+
if (ids.length === 0) return [];
|
|
26
|
+
return this.db.select().from(spaces).where(inArray(spaces.id, ids)).orderBy(spaces.name);
|
|
27
|
+
}
|
|
28
|
+
|
|
24
29
|
async findById(id: string): Promise<SpaceRow | null> {
|
|
25
30
|
const rows = await this.db.select().from(spaces).where(eq(spaces.id, id)).limit(1);
|
|
26
31
|
return rows[0] ?? null;
|
package/src/repositories/user.ts
CHANGED
|
@@ -1,10 +1,44 @@
|
|
|
1
1
|
import { ManabloxError } from '@manablox/core';
|
|
2
2
|
import { and, desc, eq, inArray, sql } from 'drizzle-orm';
|
|
3
3
|
import type { Database } from '../client.js';
|
|
4
|
-
import { type
|
|
5
|
-
import type {
|
|
4
|
+
import { type Paginated, paginate } from '../pagination.js';
|
|
5
|
+
import type { Pagination } from '../query.js';
|
|
6
|
+
import {
|
|
7
|
+
accounts,
|
|
8
|
+
type MembershipRow,
|
|
9
|
+
memberships,
|
|
10
|
+
roles,
|
|
11
|
+
type SpaceRow,
|
|
12
|
+
sessions,
|
|
13
|
+
spaces,
|
|
14
|
+
type UserRow,
|
|
15
|
+
users,
|
|
16
|
+
} from '../schema/index.js';
|
|
6
17
|
|
|
7
|
-
|
|
18
|
+
/** The name of a role in a space: one of the built-in five, or a row in `roles`. */
|
|
19
|
+
export type SpaceRole = string;
|
|
20
|
+
|
|
21
|
+
/** What it takes to create an account that can sign in with a password. */
|
|
22
|
+
export interface UserCreateData {
|
|
23
|
+
name: string;
|
|
24
|
+
email: string;
|
|
25
|
+
role: string;
|
|
26
|
+
/** Already hashed; the repository never sees a plaintext password. */
|
|
27
|
+
passwordHash: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface UserUpdateData {
|
|
31
|
+
name?: string;
|
|
32
|
+
email?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* How better-auth 1.7 identifies an email + password credential: sign-in looks for an
|
|
37
|
+
* account with this provider *and* this issuer, so a row missing either is invisible to
|
|
38
|
+
* it and the account can never sign in.
|
|
39
|
+
*/
|
|
40
|
+
const CREDENTIAL_PROVIDER = 'credential';
|
|
41
|
+
const CREDENTIAL_ISSUER = 'local:credential';
|
|
8
42
|
|
|
9
43
|
export class UserRepository {
|
|
10
44
|
constructor(private readonly db: Database) {}
|
|
@@ -29,23 +63,23 @@ export class UserRepository {
|
|
|
29
63
|
? sql`${users.email} ilike ${`%${search}%`} or ${users.name} ilike ${`%${search}%`}`
|
|
30
64
|
: undefined;
|
|
31
65
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
66
|
+
return paginate(this.db, users, { where, orderBy: desc(users.createdAt), pagination });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Users who are not members of a space, matching a search, newest first: the
|
|
71
|
+
* add-member picker's candidates, decided in SQL rather than by loading a page of
|
|
72
|
+
* users and filtering it here.
|
|
73
|
+
*/
|
|
74
|
+
async candidates(spaceId: string, search: string | undefined, limit: number): Promise<UserRow[]> {
|
|
75
|
+
const notMember = sql`not exists (select 1 from ${memberships} where ${memberships.userId} = ${users.id} and ${memberships.spaceId} = ${spaceId})`;
|
|
76
|
+
const where = search
|
|
77
|
+
? and(
|
|
78
|
+
notMember,
|
|
79
|
+
sql`(${users.email} ilike ${`%${search}%`} or ${users.name} ilike ${`%${search}%`})`,
|
|
80
|
+
)
|
|
81
|
+
: notMember;
|
|
82
|
+
return this.db.select().from(users).where(where).orderBy(desc(users.createdAt)).limit(limit);
|
|
49
83
|
}
|
|
50
84
|
|
|
51
85
|
async count(): Promise<number> {
|
|
@@ -53,6 +87,93 @@ export class UserRepository {
|
|
|
53
87
|
return rows[0]?.count ?? 0;
|
|
54
88
|
}
|
|
55
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Inserts the user and its password credential together, so a failure on the second
|
|
92
|
+
* row cannot leave an account nobody can sign in to. The account row is shaped the way
|
|
93
|
+
* better-auth writes it on sign-up, so a sign-in later finds it as its own.
|
|
94
|
+
*/
|
|
95
|
+
async create(data: UserCreateData): Promise<UserRow> {
|
|
96
|
+
return this.db.transaction(async (tx) => {
|
|
97
|
+
const [user] = await tx
|
|
98
|
+
.insert(users)
|
|
99
|
+
.values({ name: data.name, email: data.email, role: data.role })
|
|
100
|
+
.returning();
|
|
101
|
+
if (!user) throw new ManabloxError('user.create.failed');
|
|
102
|
+
await tx.insert(accounts).values({
|
|
103
|
+
userId: user.id,
|
|
104
|
+
accountId: user.id,
|
|
105
|
+
providerId: CREDENTIAL_PROVIDER,
|
|
106
|
+
issuer: CREDENTIAL_ISSUER,
|
|
107
|
+
password: data.passwordHash,
|
|
108
|
+
});
|
|
109
|
+
return user;
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async update(id: string, data: UserUpdateData): Promise<UserRow> {
|
|
114
|
+
const [row] = await this.db
|
|
115
|
+
.update(users)
|
|
116
|
+
.set({ ...data, updatedAt: new Date() })
|
|
117
|
+
.where(eq(users.id, id))
|
|
118
|
+
.returning();
|
|
119
|
+
if (!row) throw ManabloxError.notFound('user.notFound', { id });
|
|
120
|
+
return row;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async delete(id: string): Promise<void> {
|
|
124
|
+
// Sessions, accounts, api keys and memberships cascade in the schema.
|
|
125
|
+
await this.db.delete(users).where(eq(users.id, id));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async setBanned(id: string, banned: boolean, reason: string | null): Promise<UserRow> {
|
|
129
|
+
const [row] = await this.db
|
|
130
|
+
.update(users)
|
|
131
|
+
.set({ banned, banReason: banned ? reason : null, updatedAt: new Date() })
|
|
132
|
+
.where(eq(users.id, id))
|
|
133
|
+
.returning();
|
|
134
|
+
if (!row) throw ManabloxError.notFound('user.notFound', { id });
|
|
135
|
+
return row;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Replaces the password credential, creating it for an account that only ever signed
|
|
140
|
+
* in through another provider.
|
|
141
|
+
*/
|
|
142
|
+
async setPasswordHash(userId: string, passwordHash: string): Promise<void> {
|
|
143
|
+
const updated = await this.db
|
|
144
|
+
.update(accounts)
|
|
145
|
+
.set({ password: passwordHash, updatedAt: new Date() })
|
|
146
|
+
.where(
|
|
147
|
+
and(
|
|
148
|
+
eq(accounts.userId, userId),
|
|
149
|
+
eq(accounts.providerId, CREDENTIAL_PROVIDER),
|
|
150
|
+
eq(accounts.issuer, CREDENTIAL_ISSUER),
|
|
151
|
+
),
|
|
152
|
+
)
|
|
153
|
+
.returning({ id: accounts.id });
|
|
154
|
+
if (updated.length) return;
|
|
155
|
+
await this.db.insert(accounts).values({
|
|
156
|
+
userId,
|
|
157
|
+
accountId: userId,
|
|
158
|
+
providerId: CREDENTIAL_PROVIDER,
|
|
159
|
+
issuer: CREDENTIAL_ISSUER,
|
|
160
|
+
password: passwordHash,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Signs the user out everywhere. */
|
|
165
|
+
async revokeSessions(userId: string): Promise<void> {
|
|
166
|
+
await this.db.delete(sessions).where(eq(sessions.userId, userId));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async countByRole(role: string): Promise<number> {
|
|
170
|
+
const rows = await this.db
|
|
171
|
+
.select({ count: sql<number>`count(*)::int` })
|
|
172
|
+
.from(users)
|
|
173
|
+
.where(eq(users.role, role));
|
|
174
|
+
return rows[0]?.count ?? 0;
|
|
175
|
+
}
|
|
176
|
+
|
|
56
177
|
async setRole(id: string, role: string): Promise<UserRow> {
|
|
57
178
|
const [row] = await this.db
|
|
58
179
|
.update(users)
|
|
@@ -71,36 +192,61 @@ export class UserRepository {
|
|
|
71
192
|
* Read on every authenticated request rather than trusting the role embedded in the
|
|
72
193
|
* session: better-auth caches the session payload (five minutes by default), so a
|
|
73
194
|
* promotion or demotion would otherwise not take effect until that cache expired.
|
|
195
|
+
*
|
|
196
|
+
* A membership naming a custom role joins that role's grants; one naming a built-in
|
|
197
|
+
* role has none here, and the auth package answers those from its own table.
|
|
74
198
|
*/
|
|
75
|
-
async principal(
|
|
76
|
-
|
|
77
|
-
|
|
199
|
+
async principal(userId: string): Promise<{
|
|
200
|
+
role: string;
|
|
201
|
+
banned: boolean;
|
|
202
|
+
spaces: Record<string, SpaceRole>;
|
|
203
|
+
permissions: Record<string, string[]>;
|
|
204
|
+
} | null> {
|
|
78
205
|
const rows = await this.db
|
|
79
206
|
.select({
|
|
80
207
|
role: users.role,
|
|
81
208
|
banned: users.banned,
|
|
82
209
|
spaceId: memberships.spaceId,
|
|
83
210
|
spaceRole: memberships.role,
|
|
211
|
+
grants: roles.permissions,
|
|
84
212
|
})
|
|
85
213
|
.from(users)
|
|
86
214
|
.leftJoin(memberships, eq(memberships.userId, users.id))
|
|
215
|
+
.leftJoin(
|
|
216
|
+
roles,
|
|
217
|
+
and(eq(roles.spaceId, memberships.spaceId), eq(roles.machineName, memberships.role)),
|
|
218
|
+
)
|
|
87
219
|
.where(eq(users.id, userId));
|
|
88
220
|
|
|
89
221
|
const first = rows[0];
|
|
90
222
|
if (!first) return null;
|
|
91
223
|
|
|
92
224
|
const spaces: Record<string, SpaceRole> = {};
|
|
225
|
+
const permissions: Record<string, string[]> = {};
|
|
93
226
|
for (const row of rows) {
|
|
94
|
-
if (row.spaceId
|
|
227
|
+
if (!row.spaceId || !row.spaceRole) continue;
|
|
228
|
+
spaces[row.spaceId] = row.spaceRole;
|
|
229
|
+
if (row.grants) permissions[row.spaceId] = row.grants;
|
|
95
230
|
}
|
|
96
231
|
|
|
97
|
-
return { role: first.role, banned: first.banned, spaces };
|
|
232
|
+
return { role: first.role, banned: first.banned, spaces, permissions };
|
|
98
233
|
}
|
|
99
234
|
|
|
100
235
|
async memberships(userId: string): Promise<MembershipRow[]> {
|
|
101
236
|
return this.db.select().from(memberships).where(eq(memberships.userId, userId));
|
|
102
237
|
}
|
|
103
238
|
|
|
239
|
+
/** The user's memberships with the space each one is in, for a per-user view. */
|
|
240
|
+
async membershipsWithSpaces(userId: string): Promise<Array<MembershipRow & { space: SpaceRow }>> {
|
|
241
|
+
const rows = await this.db
|
|
242
|
+
.select({ membership: memberships, space: spaces })
|
|
243
|
+
.from(memberships)
|
|
244
|
+
.innerJoin(spaces, eq(spaces.id, memberships.spaceId))
|
|
245
|
+
.where(eq(memberships.userId, userId))
|
|
246
|
+
.orderBy(spaces.name);
|
|
247
|
+
return rows.map((row) => ({ ...row.membership, space: row.space }));
|
|
248
|
+
}
|
|
249
|
+
|
|
104
250
|
async membersOf(spaceId: string): Promise<Array<MembershipRow & { user: UserRow }>> {
|
|
105
251
|
const rows = await this.db
|
|
106
252
|
.select({ membership: memberships, user: users })
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { and, eq } from 'drizzle-orm';
|
|
2
|
+
import type { Database } from '../client.js';
|
|
3
|
+
import { webhookDeliveries, webhooks } from '../schema/index.js';
|
|
4
|
+
|
|
5
|
+
export type WebhookRow = typeof webhooks.$inferSelect;
|
|
6
|
+
export type WebhookDeliveryRow = typeof webhookDeliveries.$inferSelect;
|
|
7
|
+
|
|
8
|
+
export interface WebhookDeliveryData {
|
|
9
|
+
webhookId: string;
|
|
10
|
+
event: string;
|
|
11
|
+
payload: Record<string, unknown>;
|
|
12
|
+
status: number | null;
|
|
13
|
+
error: string | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The webhooks of a space and the log of what was sent to them. */
|
|
17
|
+
export class WebhookRepository {
|
|
18
|
+
constructor(private readonly db: Database) {}
|
|
19
|
+
|
|
20
|
+
async findById(id: string): Promise<WebhookRow | null> {
|
|
21
|
+
const rows = await this.db.select().from(webhooks).where(eq(webhooks.id, id)).limit(1);
|
|
22
|
+
return rows[0] ?? null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The switched-on webhooks of a space, for fanning an event out. */
|
|
26
|
+
async findEnabled(spaceId: string): Promise<WebhookRow[]> {
|
|
27
|
+
return this.db
|
|
28
|
+
.select()
|
|
29
|
+
.from(webhooks)
|
|
30
|
+
.where(and(eq(webhooks.spaceId, spaceId), eq(webhooks.enabled, true)));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async recordDelivery(data: WebhookDeliveryData): Promise<WebhookDeliveryRow> {
|
|
34
|
+
const [row] = await this.db.insert(webhookDeliveries).values(data).returning();
|
|
35
|
+
return row as WebhookDeliveryRow;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async deliveries(webhookId: string, limit = 50): Promise<WebhookDeliveryRow[]> {
|
|
39
|
+
return this.db
|
|
40
|
+
.select()
|
|
41
|
+
.from(webhookDeliveries)
|
|
42
|
+
.where(eq(webhookDeliveries.webhookId, webhookId))
|
|
43
|
+
.orderBy(webhookDeliveries.createdAt)
|
|
44
|
+
.limit(limit);
|
|
45
|
+
}
|
|
46
|
+
}
|