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.
- package/README.md +926 -0
- package/dist/components/index.mjs +937 -0
- package/dist/error-D4GuI0ot.mjs +71 -0
- package/dist/file-field-BqVgy8xY.mjs +205 -0
- package/dist/form-BXJK_j10.d.mts +99 -0
- package/dist/index.d.mts +433 -0
- package/dist/index.mjs +1 -0
- package/dist/index2.d.mts +5 -0
- package/dist/index3.d.mts +35 -0
- package/dist/index4.d.mts +101 -0
- package/dist/index5.d.mts +842 -0
- package/dist/next/index.mjs +151 -0
- package/dist/org-CmJBb8z-.d.mts +56 -0
- package/dist/react/index.mjs +158 -0
- package/dist/retry.d.mts +12 -0
- package/dist/retry.mjs +35 -0
- package/dist/schema.d.mts +23 -0
- package/dist/schema.mjs +15 -0
- package/dist/server/index.mjs +2572 -0
- package/dist/types-DWBVRtit.d.mts +322 -0
- package/dist/use-online-status-CMr73Jlk.mjs +155 -0
- package/dist/use-upload-DtELytQi.mjs +95 -0
- package/dist/zod.d.mts +18 -0
- package/dist/zod.mjs +87 -0
- package/package.json +40 -0
- package/src/components/editors-section.tsx +86 -0
- package/src/components/fields.tsx +884 -0
- package/src/components/file-field.tsx +234 -0
- package/src/components/form.tsx +191 -0
- package/src/components/index.ts +11 -0
- package/src/components/offline-indicator.tsx +15 -0
- package/src/components/org-avatar.tsx +13 -0
- package/src/components/permission-guard.tsx +36 -0
- package/src/components/role-badge.tsx +14 -0
- package/src/components/suspense-wrap.tsx +8 -0
- package/src/index.ts +40 -0
- package/src/next/active-org.ts +33 -0
- package/src/next/auth.ts +9 -0
- package/src/next/image.ts +134 -0
- package/src/next/index.ts +3 -0
- package/src/react/form-meta.ts +53 -0
- package/src/react/form.ts +201 -0
- package/src/react/index.ts +8 -0
- package/src/react/org.tsx +96 -0
- package/src/react/use-active-org.ts +48 -0
- package/src/react/use-bulk-selection.ts +47 -0
- package/src/react/use-online-status.ts +21 -0
- package/src/react/use-optimistic.ts +54 -0
- package/src/react/use-upload.ts +101 -0
- package/src/retry.ts +47 -0
- package/src/schema.ts +30 -0
- package/src/server/cache-crud.ts +175 -0
- package/src/server/check-schema.ts +29 -0
- package/src/server/child.ts +98 -0
- package/src/server/crud.ts +384 -0
- package/src/server/db.ts +7 -0
- package/src/server/error.ts +39 -0
- package/src/server/file.ts +372 -0
- package/src/server/helpers.ts +214 -0
- package/src/server/index.ts +12 -0
- package/src/server/org-crud.ts +307 -0
- package/src/server/org-helpers.ts +54 -0
- package/src/server/org.ts +572 -0
- package/src/server/schema-helpers.ts +107 -0
- package/src/server/setup.ts +138 -0
- package/src/server/test-crud.ts +211 -0
- package/src/server/test.ts +554 -0
- package/src/server/types.ts +392 -0
- package/src/server/unique.ts +28 -0
- 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 }
|