@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.
Files changed (53) hide show
  1. package/README.md +21 -0
  2. package/drizzle.config.ts +1 -1
  3. package/migrations/0005_menus.sql +44 -0
  4. package/migrations/0006_roles.sql +13 -0
  5. package/migrations/0007_apikey-permissions.sql +4 -0
  6. package/migrations/0008_workflows.sql +49 -0
  7. package/migrations/meta/0005_snapshot.json +2504 -0
  8. package/migrations/meta/0006_snapshot.json +2605 -0
  9. package/migrations/meta/0007_snapshot.json +2605 -0
  10. package/migrations/meta/0008_snapshot.json +2986 -0
  11. package/migrations/meta/_journal.json +28 -0
  12. package/package.json +10 -5
  13. package/src/cli/create-db.ts +30 -0
  14. package/src/cli/migrate.ts +2 -9
  15. package/src/client.ts +8 -1
  16. package/src/errors.ts +50 -0
  17. package/src/index.ts +8 -2
  18. package/src/migrate.ts +21 -0
  19. package/src/pagination.ts +52 -0
  20. package/src/query.ts +1 -5
  21. package/src/repositories/asset-usage.ts +1 -1
  22. package/src/repositories/asset.ts +13 -21
  23. package/src/repositories/content-type.ts +1 -1
  24. package/src/repositories/content.ts +150 -97
  25. package/src/repositories/index.ts +12 -0
  26. package/src/repositories/menu.ts +235 -0
  27. package/src/repositories/role.ts +85 -0
  28. package/src/repositories/space.ts +7 -2
  29. package/src/repositories/user.ts +171 -25
  30. package/src/repositories/webhook.ts +46 -0
  31. package/src/repositories/workflow.ts +306 -0
  32. package/src/schema/assets.ts +108 -0
  33. package/src/schema/auth.ts +166 -0
  34. package/src/schema/content-types.ts +31 -0
  35. package/src/schema/content.ts +133 -0
  36. package/src/schema/index.ts +38 -0
  37. package/src/schema/menus.ts +61 -0
  38. package/src/schema/relations.ts +64 -0
  39. package/src/schema/spaces.ts +20 -0
  40. package/src/schema/webhooks.ts +46 -0
  41. package/src/schema/workflows.ts +92 -0
  42. package/{test/helpers.ts → src/testing-fixtures.ts} +21 -35
  43. package/src/testing.ts +105 -0
  44. package/test/asset-usage.test.ts +3 -3
  45. package/test/menu.test.ts +126 -0
  46. package/test/publish.test.ts +31 -3
  47. package/test/query.test.ts +33 -3
  48. package/test/role.test.ts +81 -0
  49. package/test/tree.test.ts +3 -3
  50. package/test/user.test.ts +126 -0
  51. package/test/webhook.test.ts +48 -0
  52. package/vitest.config.ts +0 -2
  53. 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;
@@ -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 MembershipRow, memberships, type UserRow, users } from '../schema.js';
5
- import type { Paginated, Pagination } from './content.js';
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
- export type SpaceRole = 'owner' | 'admin' | 'editor' | 'author' | 'viewer';
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
- const [items, counted] = await Promise.all([
33
- this.db
34
- .select()
35
- .from(users)
36
- .where(where)
37
- .orderBy(desc(users.createdAt))
38
- .limit(pagination.limit)
39
- .offset(pagination.offset),
40
- this.db.select({ count: sql<number>`count(*)::int` }).from(users).where(where),
41
- ]);
42
-
43
- return {
44
- items,
45
- total: counted[0]?.count ?? 0,
46
- limit: pagination.limit,
47
- offset: pagination.offset,
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
- userId: string,
77
- ): Promise<{ role: string; banned: boolean; spaces: Record<string, SpaceRole> } | null> {
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 && row.spaceRole) spaces[row.spaceId] = row.spaceRole;
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
+ }