lazyconvex 0.0.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 (70) hide show
  1. package/README.md +926 -0
  2. package/dist/components/index.mjs +937 -0
  3. package/dist/error-D4GuI0ot.mjs +71 -0
  4. package/dist/file-field-BqVgy8xY.mjs +205 -0
  5. package/dist/form-BXJK_j10.d.mts +99 -0
  6. package/dist/index.d.mts +433 -0
  7. package/dist/index.mjs +1 -0
  8. package/dist/index2.d.mts +5 -0
  9. package/dist/index3.d.mts +35 -0
  10. package/dist/index4.d.mts +101 -0
  11. package/dist/index5.d.mts +842 -0
  12. package/dist/next/index.mjs +151 -0
  13. package/dist/org-CmJBb8z-.d.mts +56 -0
  14. package/dist/react/index.mjs +158 -0
  15. package/dist/retry.d.mts +12 -0
  16. package/dist/retry.mjs +35 -0
  17. package/dist/schema.d.mts +23 -0
  18. package/dist/schema.mjs +15 -0
  19. package/dist/server/index.mjs +2572 -0
  20. package/dist/types-DWBVRtit.d.mts +322 -0
  21. package/dist/use-online-status-CMr73Jlk.mjs +155 -0
  22. package/dist/use-upload-DtELytQi.mjs +95 -0
  23. package/dist/zod.d.mts +18 -0
  24. package/dist/zod.mjs +87 -0
  25. package/package.json +40 -0
  26. package/src/components/editors-section.tsx +86 -0
  27. package/src/components/fields.tsx +884 -0
  28. package/src/components/file-field.tsx +234 -0
  29. package/src/components/form.tsx +191 -0
  30. package/src/components/index.ts +11 -0
  31. package/src/components/offline-indicator.tsx +15 -0
  32. package/src/components/org-avatar.tsx +13 -0
  33. package/src/components/permission-guard.tsx +36 -0
  34. package/src/components/role-badge.tsx +14 -0
  35. package/src/components/suspense-wrap.tsx +8 -0
  36. package/src/index.ts +40 -0
  37. package/src/next/active-org.ts +33 -0
  38. package/src/next/auth.ts +9 -0
  39. package/src/next/image.ts +134 -0
  40. package/src/next/index.ts +3 -0
  41. package/src/react/form-meta.ts +53 -0
  42. package/src/react/form.ts +201 -0
  43. package/src/react/index.ts +8 -0
  44. package/src/react/org.tsx +96 -0
  45. package/src/react/use-active-org.ts +48 -0
  46. package/src/react/use-bulk-selection.ts +47 -0
  47. package/src/react/use-online-status.ts +21 -0
  48. package/src/react/use-optimistic.ts +54 -0
  49. package/src/react/use-upload.ts +101 -0
  50. package/src/retry.ts +47 -0
  51. package/src/schema.ts +30 -0
  52. package/src/server/cache-crud.ts +175 -0
  53. package/src/server/check-schema.ts +29 -0
  54. package/src/server/child.ts +98 -0
  55. package/src/server/crud.ts +384 -0
  56. package/src/server/db.ts +7 -0
  57. package/src/server/error.ts +39 -0
  58. package/src/server/file.ts +372 -0
  59. package/src/server/helpers.ts +214 -0
  60. package/src/server/index.ts +12 -0
  61. package/src/server/org-crud.ts +307 -0
  62. package/src/server/org-helpers.ts +54 -0
  63. package/src/server/org.ts +572 -0
  64. package/src/server/schema-helpers.ts +107 -0
  65. package/src/server/setup.ts +138 -0
  66. package/src/server/test-crud.ts +211 -0
  67. package/src/server/test.ts +554 -0
  68. package/src/server/types.ts +392 -0
  69. package/src/server/unique.ts +28 -0
  70. package/src/zod.ts +141 -0
@@ -0,0 +1,554 @@
1
+ /* eslint-disable no-await-in-loop, max-depth, max-statements, @typescript-eslint/no-unnecessary-condition */
2
+ /** biome-ignore-all lint/performance/noAwaitInLoops: sequential deletes */
3
+ import type { GenericDataModel, MutationBuilder, QueryBuilder } from 'convex/server'
4
+
5
+ import { v } from 'convex/values'
6
+
7
+ import type { DbLike, FilterLike, IndexLike, Rec } from './types'
8
+
9
+ interface TestAuthConfig<DM extends GenericDataModel = GenericDataModel> {
10
+ getAuthUserId: (ctx: unknown) => Promise<null | string>
11
+ mutation: MutationBuilder<DM, 'public'>
12
+ query: QueryBuilder<DM, 'public'>
13
+ }
14
+
15
+ const TEST_EMAIL = 'test@playwright.local',
16
+ BATCH_SIZE = 50,
17
+ isTestMode = () => process.env.CONVEX_TEST_MODE === 'true',
18
+ generateToken = () => {
19
+ const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
20
+ let token = ''
21
+ for (let i = 0; i < 32; i += 1) token += chars.charAt(Math.floor(Math.random() * chars.length))
22
+ return token
23
+ },
24
+ getOrgMembership = async (db: DbLike, orgId: string, userId: string) => {
25
+ const orgDoc = await db.get(orgId)
26
+ if (!orgDoc) return null
27
+ const isOwner = orgDoc.userId === userId,
28
+ member = await db
29
+ .query('orgMember')
30
+ .withIndex('by_org_user', ((q: IndexLike) => q.eq('orgId', orgId).eq('userId', userId)) as never)
31
+ .unique()
32
+ if (!(isOwner || member)) return null
33
+ return { isAdmin: isOwner || member?.isAdmin === true, isOwner, member, orgDoc }
34
+ },
35
+ makeTestAuth = <DM extends GenericDataModel>(config: TestAuthConfig<DM>) => {
36
+ const { mutation: rawMut, query: rawQry } = config,
37
+ mutation = rawMut as unknown as (opts: Rec) => Rec,
38
+ query = rawQry as unknown as (opts: Rec) => Rec,
39
+ getAuthUserIdOrTest = async (ctx: unknown): Promise<null | string> => {
40
+ if (!isTestMode()) return config.getAuthUserId(ctx)
41
+ const c = ctx as { auth: { getUserIdentity: () => Promise<unknown> }; db: DbLike },
42
+ identity = await c.auth.getUserIdentity()
43
+ if (identity === null) {
44
+ const u = await c.db
45
+ .query('users')
46
+ .filter(((q: FilterLike) => q.eq(q.field('email'), TEST_EMAIL)) as never)
47
+ .first()
48
+ return u?._id as null | string
49
+ }
50
+ return ((identity as Rec).subject as string).split('|')[0] ?? null
51
+ },
52
+ ensureTestUser = mutation({
53
+ args: {},
54
+ handler: async (ctx: { db: DbLike }) => {
55
+ if (!isTestMode()) return null
56
+ const u = await ctx.db
57
+ .query('users')
58
+ .filter(((q: FilterLike) => q.eq(q.field('email'), TEST_EMAIL)) as never)
59
+ .first()
60
+ if (u) return u._id
61
+ return ctx.db.insert('users', {
62
+ email: TEST_EMAIL,
63
+ emailVerificationTime: Date.now(),
64
+ name: 'Test User'
65
+ })
66
+ }
67
+ }),
68
+ getTestUser = query({
69
+ args: {},
70
+ handler: async (ctx: { db: DbLike }) => {
71
+ if (!isTestMode()) return null
72
+ const u = await ctx.db
73
+ .query('users')
74
+ .filter(((q: FilterLike) => q.eq(q.field('email'), TEST_EMAIL)) as never)
75
+ .first()
76
+ return u?._id ?? null
77
+ }
78
+ }),
79
+ createTestUser = mutation({
80
+ args: { email: v.string(), name: v.string() },
81
+ handler: async (ctx: { db: DbLike }, { email, name }: { email: string; name: string }) => {
82
+ if (!isTestMode()) return null
83
+ const existing = await ctx.db
84
+ .query('users')
85
+ .filter(((q: FilterLike) => q.eq(q.field('email'), email)) as never)
86
+ .first()
87
+ if (existing) return existing._id
88
+ return ctx.db.insert('users', { email, emailVerificationTime: Date.now(), name })
89
+ }
90
+ }),
91
+ getTestUserByEmail = query({
92
+ args: { email: v.string() },
93
+ handler: async (ctx: { db: DbLike }, { email }: { email: string }) => {
94
+ if (!isTestMode()) return null
95
+ const u = await ctx.db
96
+ .query('users')
97
+ .filter(((q: FilterLike) => q.eq(q.field('email'), email)) as never)
98
+ .first()
99
+ return u?._id ?? null
100
+ }
101
+ }),
102
+ addTestOrgMember = mutation({
103
+ args: { isAdmin: v.boolean(), orgId: v.id('org'), userId: v.id('users') },
104
+ handler: async (
105
+ ctx: { db: DbLike },
106
+ { isAdmin, orgId, userId }: { isAdmin: boolean; orgId: string; userId: string }
107
+ ) => {
108
+ if (!isTestMode()) return null
109
+ const existing = await ctx.db
110
+ .query('orgMember')
111
+ .withIndex('by_org_user', ((q: IndexLike) => q.eq('orgId', orgId).eq('userId', userId)) as never)
112
+ .unique()
113
+ if (existing) {
114
+ await ctx.db.patch(existing._id as string, { isAdmin, updatedAt: Date.now() })
115
+ return existing._id
116
+ }
117
+ return ctx.db.insert('orgMember', { isAdmin, orgId, updatedAt: Date.now(), userId })
118
+ }
119
+ }),
120
+ removeTestOrgMember = mutation({
121
+ args: { orgId: v.id('org'), userId: v.id('users') },
122
+ handler: async (ctx: { db: DbLike }, { orgId, userId }: { orgId: string; userId: string }) => {
123
+ if (!isTestMode()) return false
124
+ const member = await ctx.db
125
+ .query('orgMember')
126
+ .withIndex('by_org_user', ((q: IndexLike) => q.eq('orgId', orgId).eq('userId', userId)) as never)
127
+ .unique()
128
+ if (member) {
129
+ await ctx.db.delete(member._id as string)
130
+ return true
131
+ }
132
+ return false
133
+ }
134
+ }),
135
+ cleanupTestUsers = mutation({
136
+ args: { emailPrefix: v.string() },
137
+ handler: async (ctx: { db: DbLike }, { emailPrefix }: { emailPrefix: string }) => {
138
+ if (!isTestMode()) return { count: 0 }
139
+ const users = await ctx.db.query('users').collect()
140
+ let count = 0
141
+ for (const u of users)
142
+ if ((u.email as string)?.startsWith(emailPrefix) && u.email !== TEST_EMAIL) {
143
+ await ctx.db.delete(u._id as string)
144
+ count += 1
145
+ }
146
+ return { count }
147
+ }
148
+ }),
149
+ cleanupOrgTestData = mutation({
150
+ args: { slugPrefix: v.string(), tables: v.optional(v.array(v.string())) },
151
+ handler: async (ctx: { db: DbLike }, { slugPrefix, tables }: { slugPrefix: string; tables?: string[] }) => {
152
+ if (!isTestMode()) return { count: 0, done: true }
153
+ const allOrgs = await ctx.db.query('org').collect(),
154
+ orgIds: string[] = []
155
+ for (const o of allOrgs) if ((o.slug as string).startsWith(slugPrefix)) orgIds.push(o._id as string)
156
+ if (orgIds.length === 0) return { count: 0, done: true }
157
+ let count = 0
158
+ for (const orgId of orgIds) {
159
+ if (tables)
160
+ for (const table of tables) {
161
+ const docs = await ctx.db.query(table).take(BATCH_SIZE * 2)
162
+ for (const d of docs)
163
+ if (d.orgId === orgId) {
164
+ await ctx.db.delete(d._id as string)
165
+ count += 1
166
+ }
167
+ }
168
+
169
+ const requests = await ctx.db
170
+ .query('orgJoinRequest')
171
+ .withIndex('by_org', ((q: IndexLike) => q.eq('orgId', orgId)) as never)
172
+ .collect()
173
+ for (const r of requests) {
174
+ await ctx.db.delete(r._id as string)
175
+ count += 1
176
+ }
177
+ const invites = await ctx.db
178
+ .query('orgInvite')
179
+ .withIndex('by_org', ((q: IndexLike) => q.eq('orgId', orgId)) as never)
180
+ .collect()
181
+ for (const i of invites) {
182
+ await ctx.db.delete(i._id as string)
183
+ count += 1
184
+ }
185
+ const members = await ctx.db
186
+ .query('orgMember')
187
+ .withIndex('by_org', ((q: IndexLike) => q.eq('orgId', orgId)) as never)
188
+ .collect()
189
+ for (const m of members) {
190
+ await ctx.db.delete(m._id as string)
191
+ count += 1
192
+ }
193
+ await ctx.db.delete(orgId)
194
+ count += 1
195
+ }
196
+ return { count, done: true }
197
+ }
198
+ }),
199
+ inviteAsUser = mutation({
200
+ args: { email: v.string(), isAdmin: v.boolean(), orgId: v.id('org'), userId: v.id('users') },
201
+ handler: async (
202
+ ctx: { db: DbLike },
203
+ { email, isAdmin, orgId, userId }: { email: string; isAdmin: boolean; orgId: string; userId: string }
204
+ ) => {
205
+ if (!isTestMode()) return null
206
+ const membership = await getOrgMembership(ctx.db, orgId, userId)
207
+ if (!membership) return { code: 'NOT_ORG_MEMBER' }
208
+ if (!membership.isAdmin) return { code: 'INSUFFICIENT_ORG_ROLE' }
209
+ const token = generateToken(),
210
+ inviteId = await ctx.db.insert('orgInvite', {
211
+ email,
212
+ expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000,
213
+ isAdmin,
214
+ orgId,
215
+ token
216
+ })
217
+ return { inviteId, token }
218
+ }
219
+ }),
220
+ acceptInviteAsUser = mutation({
221
+ args: { token: v.string(), userId: v.id('users') },
222
+ handler: async (ctx: { db: DbLike }, { token, userId }: { token: string; userId: string }) => {
223
+ if (!isTestMode()) return null
224
+ const inviteDoc = await ctx.db
225
+ .query('orgInvite')
226
+ .withIndex('by_token', ((q: IndexLike) => q.eq('token', token)) as never)
227
+ .unique()
228
+ if (!inviteDoc) return { code: 'INVALID_INVITE' }
229
+ if ((inviteDoc.expiresAt as number) < Date.now()) return { code: 'INVITE_EXPIRED' }
230
+ const orgDoc = await ctx.db.get(inviteDoc.orgId as string)
231
+ if (!orgDoc) return { code: 'NOT_FOUND' }
232
+ if (orgDoc.userId === userId) return { code: 'ALREADY_ORG_MEMBER' }
233
+ const existingMember = await ctx.db
234
+ .query('orgMember')
235
+ .withIndex('by_org_user', ((q: IndexLike) => q.eq('orgId', inviteDoc.orgId).eq('userId', userId)) as never)
236
+ .unique()
237
+ if (existingMember) return { code: 'ALREADY_ORG_MEMBER' }
238
+ const pendingRequest = await ctx.db
239
+ .query('orgJoinRequest')
240
+ .withIndex('by_org_status', ((q: IndexLike) =>
241
+ q.eq('orgId', inviteDoc.orgId).eq('status', 'pending')) as never)
242
+ .filter(((q: FilterLike) => q.eq(q.field('userId'), userId)) as never)
243
+ .unique()
244
+ if (pendingRequest) await ctx.db.patch(pendingRequest._id as string, { status: 'approved' as const })
245
+ await ctx.db.insert('orgMember', {
246
+ isAdmin: inviteDoc.isAdmin,
247
+ orgId: inviteDoc.orgId,
248
+ updatedAt: Date.now(),
249
+ userId
250
+ })
251
+ await ctx.db.delete(inviteDoc._id as string)
252
+ return { orgId: inviteDoc.orgId }
253
+ }
254
+ }),
255
+ setAdminAsUser = mutation({
256
+ args: { isAdmin: v.boolean(), memberId: v.id('orgMember'), userId: v.id('users') },
257
+ handler: async (
258
+ ctx: { db: DbLike },
259
+ { isAdmin, memberId, userId }: { isAdmin: boolean; memberId: string; userId: string }
260
+ ) => {
261
+ if (!isTestMode()) return null
262
+ const memberDoc = await ctx.db.get(memberId)
263
+ if (!memberDoc) return { code: 'NOT_FOUND' }
264
+ const orgDoc = await ctx.db.get(memberDoc.orgId as string)
265
+ if (!orgDoc) return { code: 'NOT_FOUND' }
266
+ if (orgDoc.userId !== userId) return { code: 'INSUFFICIENT_ORG_ROLE' }
267
+ if (memberDoc.userId === orgDoc.userId) return { code: 'CANNOT_MODIFY_OWNER' }
268
+ await ctx.db.patch(memberId, { isAdmin, updatedAt: Date.now() })
269
+ return { success: true }
270
+ }
271
+ }),
272
+ removeMemberAsUser = mutation({
273
+ args: { memberId: v.id('orgMember'), userId: v.id('users') },
274
+ handler: async (ctx: { db: DbLike }, { memberId, userId }: { memberId: string; userId: string }) => {
275
+ if (!isTestMode()) return null
276
+ const memberDoc = await ctx.db.get(memberId)
277
+ if (!memberDoc) return { code: 'NOT_FOUND' }
278
+ const membership = await getOrgMembership(ctx.db, memberDoc.orgId as string, userId)
279
+ if (!membership) return { code: 'NOT_ORG_MEMBER' }
280
+ if (memberDoc.userId === membership.orgDoc.userId) return { code: 'CANNOT_MODIFY_OWNER' }
281
+ if (!membership.isAdmin) return { code: 'INSUFFICIENT_ORG_ROLE' }
282
+ if (!membership.isOwner && memberDoc.isAdmin) return { code: 'CANNOT_MODIFY_ADMIN' }
283
+ await ctx.db.delete(memberId)
284
+ return { success: true }
285
+ }
286
+ }),
287
+ transferOwnershipAsUser = mutation({
288
+ args: { newOwnerId: v.id('users'), orgId: v.id('org'), userId: v.id('users') },
289
+ handler: async (
290
+ ctx: { db: DbLike },
291
+ { newOwnerId, orgId, userId }: { newOwnerId: string; orgId: string; userId: string }
292
+ ) => {
293
+ if (!isTestMode()) return null
294
+ const orgDoc = await ctx.db.get(orgId)
295
+ if (!orgDoc) return { code: 'NOT_FOUND' }
296
+ if (orgDoc.userId !== userId) return { code: 'INSUFFICIENT_ORG_ROLE' }
297
+ const targetMember = await ctx.db
298
+ .query('orgMember')
299
+ .withIndex('by_org_user', ((q: IndexLike) => q.eq('orgId', orgId).eq('userId', newOwnerId)) as never)
300
+ .unique()
301
+ if (!targetMember) return { code: 'NOT_ORG_MEMBER' }
302
+ if (!targetMember.isAdmin) return { code: 'TARGET_MUST_BE_ADMIN' }
303
+ await ctx.db.patch(orgId, { updatedAt: Date.now(), userId: newOwnerId })
304
+ await ctx.db.delete(targetMember._id as string)
305
+ await ctx.db.insert('orgMember', { isAdmin: true, orgId, updatedAt: Date.now(), userId })
306
+ return { success: true }
307
+ }
308
+ }),
309
+ updateOrgAsUser = mutation({
310
+ args: {
311
+ data: v.object({ name: v.optional(v.string()), slug: v.optional(v.string()) }),
312
+ orgId: v.id('org'),
313
+ userId: v.id('users')
314
+ },
315
+ handler: async (
316
+ ctx: { db: DbLike },
317
+ { data, orgId, userId }: { data: { name?: string; slug?: string }; orgId: string; userId: string }
318
+ ) => {
319
+ if (!isTestMode()) return null
320
+ const membership = await getOrgMembership(ctx.db, orgId, userId)
321
+ if (!membership) return { code: 'NOT_ORG_MEMBER' }
322
+ if (!membership.isAdmin) return { code: 'INSUFFICIENT_ORG_ROLE' }
323
+ if (data.slug !== undefined) {
324
+ const existing = await ctx.db
325
+ .query('org')
326
+ .withIndex('by_slug', ((q: IndexLike) => q.eq('slug', data.slug)) as never)
327
+ .unique()
328
+ if (existing && existing._id !== orgId) return { code: 'ORG_SLUG_TAKEN' }
329
+ }
330
+ await ctx.db.patch(orgId, { ...data, updatedAt: Date.now() })
331
+ return { success: true }
332
+ }
333
+ }),
334
+ deleteOrgAsUser = mutation({
335
+ args: { cascadeTables: v.optional(v.array(v.string())), orgId: v.id('org'), userId: v.id('users') },
336
+ handler: async (
337
+ ctx: { db: DbLike },
338
+ { cascadeTables, orgId, userId }: { cascadeTables?: string[]; orgId: string; userId: string }
339
+ ) => {
340
+ if (!isTestMode()) return null
341
+ const orgDoc = await ctx.db.get(orgId)
342
+ if (!orgDoc) return { code: 'NOT_FOUND' }
343
+ if (orgDoc.userId !== userId) return { code: 'INSUFFICIENT_ORG_ROLE' }
344
+ if (cascadeTables)
345
+ for (const table of cascadeTables) {
346
+ const docs = await ctx.db
347
+ .query(table)
348
+ .filter(((q: FilterLike) => q.eq(q.field('orgId'), orgId)) as never)
349
+ .collect()
350
+ for (const d of docs) await ctx.db.delete(d._id as string)
351
+ }
352
+
353
+ const requests = await ctx.db
354
+ .query('orgJoinRequest')
355
+ .withIndex('by_org', ((q: IndexLike) => q.eq('orgId', orgId)) as never)
356
+ .collect()
357
+ for (const r of requests) await ctx.db.delete(r._id as string)
358
+ const invites = await ctx.db
359
+ .query('orgInvite')
360
+ .withIndex('by_org', ((q: IndexLike) => q.eq('orgId', orgId)) as never)
361
+ .collect()
362
+ for (const i of invites) await ctx.db.delete(i._id as string)
363
+ const orgMembers = await ctx.db
364
+ .query('orgMember')
365
+ .withIndex('by_org', ((q: IndexLike) => q.eq('orgId', orgId)) as never)
366
+ .collect()
367
+ for (const m of orgMembers) await ctx.db.delete(m._id as string)
368
+ await ctx.db.delete(orgId)
369
+ return { success: true }
370
+ }
371
+ }),
372
+ leaveOrgAsUser = mutation({
373
+ args: { orgId: v.id('org'), userId: v.id('users') },
374
+ handler: async (ctx: { db: DbLike }, { orgId, userId }: { orgId: string; userId: string }) => {
375
+ if (!isTestMode()) return null
376
+ const orgDoc = await ctx.db.get(orgId)
377
+ if (!orgDoc) return { code: 'NOT_FOUND' }
378
+ if (orgDoc.userId === userId) return { code: 'MUST_TRANSFER_OWNERSHIP' }
379
+ const member = await ctx.db
380
+ .query('orgMember')
381
+ .withIndex('by_org_user', ((q: IndexLike) => q.eq('orgId', orgId).eq('userId', userId)) as never)
382
+ .unique()
383
+ if (!member) return { code: 'NOT_ORG_MEMBER' }
384
+ await ctx.db.delete(member._id as string)
385
+ return { success: true }
386
+ }
387
+ }),
388
+ requestJoinAsUser = mutation({
389
+ args: { message: v.optional(v.string()), orgId: v.id('org'), userId: v.id('users') },
390
+ handler: async (
391
+ ctx: { db: DbLike },
392
+ { message, orgId, userId }: { message?: string; orgId: string; userId: string }
393
+ ) => {
394
+ if (!isTestMode()) return null
395
+ const orgDoc = await ctx.db.get(orgId)
396
+ if (!orgDoc) return { code: 'NOT_FOUND' }
397
+ if (orgDoc.userId === userId) return { code: 'ALREADY_ORG_MEMBER' }
398
+ const existingMember = await ctx.db
399
+ .query('orgMember')
400
+ .withIndex('by_org_user', ((q: IndexLike) => q.eq('orgId', orgId).eq('userId', userId)) as never)
401
+ .unique()
402
+ if (existingMember) return { code: 'ALREADY_ORG_MEMBER' }
403
+ const existingRequest = await ctx.db
404
+ .query('orgJoinRequest')
405
+ .withIndex('by_org_status', ((q: IndexLike) => q.eq('orgId', orgId).eq('status', 'pending')) as never)
406
+ .filter(((q: FilterLike) => q.eq(q.field('userId'), userId)) as never)
407
+ .unique()
408
+ if (existingRequest) return { code: 'JOIN_REQUEST_EXISTS' }
409
+ const requestId = await ctx.db.insert('orgJoinRequest', {
410
+ message,
411
+ orgId,
412
+ status: 'pending',
413
+ userId
414
+ })
415
+ return { requestId }
416
+ }
417
+ }),
418
+ approveJoinRequestAsUser = mutation({
419
+ args: { isAdmin: v.boolean(), requestId: v.id('orgJoinRequest'), userId: v.id('users') },
420
+ handler: async (
421
+ ctx: { db: DbLike },
422
+ { isAdmin, requestId, userId }: { isAdmin: boolean; requestId: string; userId: string }
423
+ ) => {
424
+ if (!isTestMode()) return null
425
+ const request = await ctx.db.get(requestId)
426
+ if (request?.status !== 'pending') return { code: 'NOT_FOUND' }
427
+ const membership = await getOrgMembership(ctx.db, request.orgId as string, userId)
428
+ if (!membership) return { code: 'NOT_ORG_MEMBER' }
429
+ if (!membership.isAdmin) return { code: 'INSUFFICIENT_ORG_ROLE' }
430
+ const existingMember = await ctx.db
431
+ .query('orgMember')
432
+ .withIndex('by_org_user', ((q: IndexLike) =>
433
+ q.eq('orgId', request.orgId).eq('userId', request.userId)) as never)
434
+ .unique()
435
+ if (existingMember) return { code: 'ALREADY_ORG_MEMBER' }
436
+ await ctx.db.patch(requestId, { status: 'approved' })
437
+ await ctx.db.insert('orgMember', {
438
+ isAdmin,
439
+ orgId: request.orgId,
440
+ updatedAt: Date.now(),
441
+ userId: request.userId
442
+ })
443
+ return { success: true }
444
+ }
445
+ }),
446
+ rejectJoinRequestAsUser = mutation({
447
+ args: { requestId: v.id('orgJoinRequest'), userId: v.id('users') },
448
+ handler: async (ctx: { db: DbLike }, { requestId, userId }: { requestId: string; userId: string }) => {
449
+ if (!isTestMode()) return null
450
+ const request = await ctx.db.get(requestId)
451
+ if (request?.status !== 'pending') return { code: 'NOT_FOUND' }
452
+ const membership = await getOrgMembership(ctx.db, request.orgId as string, userId)
453
+ if (!membership) return { code: 'NOT_ORG_MEMBER' }
454
+ if (!membership.isAdmin) return { code: 'INSUFFICIENT_ORG_ROLE' }
455
+ await ctx.db.patch(requestId, { status: 'rejected' })
456
+ return { success: true }
457
+ }
458
+ }),
459
+ cancelJoinRequestAsUser = mutation({
460
+ args: { requestId: v.id('orgJoinRequest'), userId: v.id('users') },
461
+ handler: async (ctx: { db: DbLike }, { requestId, userId }: { requestId: string; userId: string }) => {
462
+ if (!isTestMode()) return null
463
+ const requestDoc = await ctx.db.get(requestId)
464
+ if (!requestDoc) return { code: 'NOT_FOUND' }
465
+ if (requestDoc.userId !== userId) return { code: 'FORBIDDEN' }
466
+ if (requestDoc.status !== 'pending') return { code: 'NOT_FOUND' }
467
+ await ctx.db.delete(requestId)
468
+ return { success: true }
469
+ }
470
+ }),
471
+ pendingInvitesAsUser = query({
472
+ args: { orgId: v.id('org'), userId: v.id('users') },
473
+ handler: async (ctx: { db: DbLike }, { orgId, userId }: { orgId: string; userId: string }) => {
474
+ if (!isTestMode()) return null
475
+ const membership = await getOrgMembership(ctx.db, orgId, userId)
476
+ if (!membership) return { code: 'NOT_ORG_MEMBER' }
477
+ if (!membership.isAdmin) return { code: 'INSUFFICIENT_ORG_ROLE' }
478
+ return ctx.db
479
+ .query('orgInvite')
480
+ .withIndex('by_org', ((q: IndexLike) => q.eq('orgId', orgId)) as never)
481
+ .collect()
482
+ }
483
+ }),
484
+ pendingJoinRequestsAsUser = query({
485
+ args: { orgId: v.id('org'), userId: v.id('users') },
486
+ handler: async (ctx: { db: DbLike }, { orgId, userId }: { orgId: string; userId: string }) => {
487
+ if (!isTestMode()) return null
488
+ const membership = await getOrgMembership(ctx.db, orgId, userId)
489
+ if (!membership) return { code: 'NOT_ORG_MEMBER' }
490
+ if (!membership.isAdmin) return { code: 'INSUFFICIENT_ORG_ROLE' }
491
+ return ctx.db
492
+ .query('orgJoinRequest')
493
+ .withIndex('by_org_status', ((q: IndexLike) => q.eq('orgId', orgId).eq('status', 'pending')) as never)
494
+ .collect()
495
+ }
496
+ }),
497
+ createExpiredInvite = mutation({
498
+ args: { email: v.string(), isAdmin: v.boolean(), orgId: v.id('org') },
499
+ handler: async (
500
+ ctx: { db: DbLike },
501
+ { email, isAdmin, orgId }: { email: string; isAdmin: boolean; orgId: string }
502
+ ) => {
503
+ if (!isTestMode()) return null
504
+ const token = generateToken(),
505
+ inviteId = await ctx.db.insert('orgInvite', {
506
+ email,
507
+ expiresAt: Date.now() - 1000,
508
+ isAdmin,
509
+ orgId,
510
+ token
511
+ })
512
+ return { inviteId, token }
513
+ }
514
+ }),
515
+ getJoinRequest = query({
516
+ args: { requestId: v.id('orgJoinRequest') },
517
+ handler: async (ctx: { db: DbLike }, { requestId }: { requestId: string }) => {
518
+ if (!isTestMode()) return null
519
+ return ctx.db.get(requestId)
520
+ }
521
+ })
522
+ return {
523
+ acceptInviteAsUser,
524
+ addTestOrgMember,
525
+ approveJoinRequestAsUser,
526
+ cancelJoinRequestAsUser,
527
+ cleanupOrgTestData,
528
+ cleanupTestUsers,
529
+ createExpiredInvite,
530
+ createTestUser,
531
+ deleteOrgAsUser,
532
+ ensureTestUser,
533
+ getAuthUserIdOrTest,
534
+ getJoinRequest,
535
+ getTestUser,
536
+ getTestUserByEmail,
537
+ inviteAsUser,
538
+ isTestMode,
539
+ leaveOrgAsUser,
540
+ pendingInvitesAsUser,
541
+ pendingJoinRequestsAsUser,
542
+ rejectJoinRequestAsUser,
543
+ removeMemberAsUser,
544
+ removeTestOrgMember,
545
+ requestJoinAsUser,
546
+ setAdminAsUser,
547
+ TEST_EMAIL,
548
+ transferOwnershipAsUser,
549
+ updateOrgAsUser
550
+ }
551
+ }
552
+
553
+ export type { TestAuthConfig }
554
+ export { isTestMode, makeTestAuth, TEST_EMAIL }