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,572 @@
|
|
|
1
|
+
/* eslint-disable no-await-in-loop, max-statements, @typescript-eslint/no-unnecessary-condition */
|
|
2
|
+
/** biome-ignore-all lint/performance/noAwaitInLoops: sequential deletes */
|
|
3
|
+
import type { GenericDataModel, GenericMutationCtx, GenericQueryCtx, MutationBuilder, QueryBuilder } from 'convex/server'
|
|
4
|
+
import type { GenericId } from 'convex/values'
|
|
5
|
+
import type { ZodObject, ZodRawShape } from 'zod/v4'
|
|
6
|
+
|
|
7
|
+
import { customCtx } from 'convex-helpers/server/customFunctions'
|
|
8
|
+
import { zCustomMutation, zCustomQuery, zid } from 'convex-helpers/server/zod4'
|
|
9
|
+
import { z } from 'zod/v4'
|
|
10
|
+
|
|
11
|
+
import type { DbLike, FilterLike, IndexLike, Mb, OrgRole, Qb, Rec } from './types'
|
|
12
|
+
|
|
13
|
+
import { err, getUser, time } from './helpers'
|
|
14
|
+
import { getOrgMember, getOrgRole, requireOrgMember, requireOrgRole } from './org-helpers'
|
|
15
|
+
|
|
16
|
+
interface InviteDocLike {
|
|
17
|
+
[k: string]: unknown
|
|
18
|
+
_creationTime: number
|
|
19
|
+
_id: GenericId<'orgInvite'>
|
|
20
|
+
email: string
|
|
21
|
+
expiresAt: number
|
|
22
|
+
isAdmin: boolean
|
|
23
|
+
orgId: GenericId<'org'>
|
|
24
|
+
token: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface JoinRequestItem {
|
|
28
|
+
request: {
|
|
29
|
+
[k: string]: unknown
|
|
30
|
+
_creationTime: number
|
|
31
|
+
_id: GenericId<'orgJoinRequest'>
|
|
32
|
+
message?: string
|
|
33
|
+
orgId: GenericId<'org'>
|
|
34
|
+
status: string
|
|
35
|
+
userId: GenericId<'users'>
|
|
36
|
+
}
|
|
37
|
+
user: null | OrgUserLike
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface OrgDocLike {
|
|
41
|
+
[k: string]: unknown
|
|
42
|
+
_creationTime: number
|
|
43
|
+
_id: GenericId<'org'>
|
|
44
|
+
avatarId?: GenericId<'_storage'>
|
|
45
|
+
name: string
|
|
46
|
+
slug: string
|
|
47
|
+
updatedAt: number
|
|
48
|
+
userId: GenericId<'users'>
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface OrgMemberItem {
|
|
52
|
+
memberId?: GenericId<'orgMember'>
|
|
53
|
+
role: OrgRole
|
|
54
|
+
user: null | OrgUserLike
|
|
55
|
+
userId: GenericId<'users'>
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface OrgUserLike {
|
|
59
|
+
[k: string]: unknown
|
|
60
|
+
_id: GenericId<'users'>
|
|
61
|
+
email?: string
|
|
62
|
+
image?: string
|
|
63
|
+
name?: string
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const generateToken = () => {
|
|
67
|
+
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
|
68
|
+
let token = ''
|
|
69
|
+
for (let i = 0; i < 32; i += 1) token += chars.charAt(Math.floor(Math.random() * chars.length))
|
|
70
|
+
return token
|
|
71
|
+
},
|
|
72
|
+
SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000,
|
|
73
|
+
makeOrg = <DM extends GenericDataModel, S extends ZodRawShape>({
|
|
74
|
+
cascadeTables,
|
|
75
|
+
getAuthUserId,
|
|
76
|
+
mutation,
|
|
77
|
+
query,
|
|
78
|
+
schema: orgSchema
|
|
79
|
+
}: {
|
|
80
|
+
cascadeTables?: string[]
|
|
81
|
+
getAuthUserId: (ctx: never) => Promise<null | string>
|
|
82
|
+
mutation: MutationBuilder<DM, 'public'>
|
|
83
|
+
query: QueryBuilder<DM, 'public'>
|
|
84
|
+
schema: ZodObject<S>
|
|
85
|
+
}) => {
|
|
86
|
+
const mb = zCustomMutation(
|
|
87
|
+
mutation,
|
|
88
|
+
customCtx(async (c: GenericMutationCtx<DM>) => ({
|
|
89
|
+
user: await getUser({ ctx: c as never, db: c.db as never as DbLike, getAuthUserId })
|
|
90
|
+
}))
|
|
91
|
+
) as never as Mb,
|
|
92
|
+
qb = zCustomQuery(
|
|
93
|
+
query,
|
|
94
|
+
customCtx(async (c: GenericQueryCtx<DM>) => ({
|
|
95
|
+
user: await getUser({ ctx: c as never, db: c.db as never as DbLike, getAuthUserId })
|
|
96
|
+
}))
|
|
97
|
+
) as never as Qb,
|
|
98
|
+
pqb = zCustomQuery(
|
|
99
|
+
query,
|
|
100
|
+
customCtx(() => ({}))
|
|
101
|
+
) as never as Qb,
|
|
102
|
+
m = mb,
|
|
103
|
+
q = qb,
|
|
104
|
+
pq = pqb,
|
|
105
|
+
create = m({
|
|
106
|
+
args: { data: orgSchema },
|
|
107
|
+
handler: async (c: Rec, { data }: { data: Rec }) => {
|
|
108
|
+
const existing = await (c.db as DbLike)
|
|
109
|
+
.query('org')
|
|
110
|
+
.withIndex('by_slug', ((o: IndexLike) => o.eq('slug', data.slug)) as never)
|
|
111
|
+
.unique()
|
|
112
|
+
if (existing) return err('ORG_SLUG_TAKEN')
|
|
113
|
+
const orgId = await (c.db as DbLike).insert('org', {
|
|
114
|
+
avatarId: (data.avatarId as string) ?? undefined,
|
|
115
|
+
name: data.name,
|
|
116
|
+
slug: data.slug,
|
|
117
|
+
userId: (c.user as Rec)._id,
|
|
118
|
+
...time()
|
|
119
|
+
})
|
|
120
|
+
return { orgId } as { orgId: GenericId<'org'> }
|
|
121
|
+
}
|
|
122
|
+
}),
|
|
123
|
+
update = m({
|
|
124
|
+
args: { data: orgSchema.partial(), orgId: zid('org') },
|
|
125
|
+
handler: async (c: Rec, { data, orgId }: { data: Rec; orgId: string }) => {
|
|
126
|
+
await requireOrgRole({ db: c.db, minRole: 'admin', orgId, userId: (c.user as Rec)._id as string })
|
|
127
|
+
const newSlug = data.slug as string | undefined
|
|
128
|
+
if (newSlug !== undefined) {
|
|
129
|
+
const existing = await (c.db as DbLike)
|
|
130
|
+
.query('org')
|
|
131
|
+
.withIndex('by_slug', ((o: IndexLike) => o.eq('slug', newSlug)) as never)
|
|
132
|
+
.unique()
|
|
133
|
+
if (existing && existing._id !== orgId) return err('ORG_SLUG_TAKEN')
|
|
134
|
+
}
|
|
135
|
+
const patchData: Rec = {}
|
|
136
|
+
if (data.name !== undefined) patchData.name = data.name
|
|
137
|
+
if (newSlug !== undefined) patchData.slug = newSlug
|
|
138
|
+
if (data.avatarId !== undefined && data.avatarId !== null) patchData.avatarId = data.avatarId
|
|
139
|
+
await (c.db as DbLike).patch(orgId, { ...patchData, ...time() })
|
|
140
|
+
}
|
|
141
|
+
}),
|
|
142
|
+
get = q({
|
|
143
|
+
args: { orgId: zid('org') },
|
|
144
|
+
handler: async (c: Rec, { orgId }: { orgId: string }): Promise<null | OrgDocLike> => {
|
|
145
|
+
await requireOrgMember({ db: c.db, orgId, userId: (c.user as Rec)._id as string })
|
|
146
|
+
return (c.db as DbLike).get(orgId) as Promise<null | OrgDocLike>
|
|
147
|
+
}
|
|
148
|
+
}),
|
|
149
|
+
getBySlug = pq({
|
|
150
|
+
args: { slug: z.string() },
|
|
151
|
+
handler: async (c: Rec, { slug }: { slug: string }): Promise<null | OrgDocLike> =>
|
|
152
|
+
(c.db as DbLike)
|
|
153
|
+
.query('org')
|
|
154
|
+
.withIndex('by_slug', ((o: IndexLike) => o.eq('slug', slug)) as never)
|
|
155
|
+
.unique() as Promise<null | OrgDocLike>
|
|
156
|
+
}),
|
|
157
|
+
getPublic = pq({
|
|
158
|
+
args: { slug: z.string() },
|
|
159
|
+
handler: async (
|
|
160
|
+
c: Rec,
|
|
161
|
+
{ slug }: { slug: string }
|
|
162
|
+
): Promise<null | { _id: GenericId<'org'>; avatarId?: GenericId<'_storage'>; name: string; slug: string }> => {
|
|
163
|
+
const orgDoc = await (c.db as DbLike)
|
|
164
|
+
.query('org')
|
|
165
|
+
.withIndex('by_slug', ((o: IndexLike) => o.eq('slug', slug)) as never)
|
|
166
|
+
.unique()
|
|
167
|
+
if (!orgDoc) return null
|
|
168
|
+
return {
|
|
169
|
+
_id: orgDoc._id as GenericId<'org'>,
|
|
170
|
+
avatarId: orgDoc.avatarId as GenericId<'_storage'> | undefined,
|
|
171
|
+
name: orgDoc.name as string,
|
|
172
|
+
slug: orgDoc.slug as string
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}),
|
|
176
|
+
myOrgs = q({
|
|
177
|
+
args: {},
|
|
178
|
+
handler: async (c: Rec): Promise<{ org: OrgDocLike; role: OrgRole }[]> => {
|
|
179
|
+
const uid = (c.user as Rec)._id as string,
|
|
180
|
+
db = c.db as DbLike,
|
|
181
|
+
ownedOrgs = await db
|
|
182
|
+
.query('org')
|
|
183
|
+
.withIndex('by_user', ((o: IndexLike) => o.eq('userId', uid)) as never)
|
|
184
|
+
.collect(),
|
|
185
|
+
memberships = await db
|
|
186
|
+
.query('orgMember')
|
|
187
|
+
.withIndex('by_user', ((o: IndexLike) => o.eq('userId', uid)) as never)
|
|
188
|
+
.collect(),
|
|
189
|
+
memberOrgIds = memberships.map((x: Rec) => x.orgId as string),
|
|
190
|
+
memberOrgResults = await Promise.all(memberOrgIds.map(async (id: string) => db.get(id))),
|
|
191
|
+
memberOrgs: Rec[] = []
|
|
192
|
+
for (const orgDoc of memberOrgResults) if (orgDoc) memberOrgs.push(orgDoc)
|
|
193
|
+
const ownedIds = new Set(ownedOrgs.map((o: Rec) => o._id as string)),
|
|
194
|
+
result: { org: OrgDocLike; role: OrgRole }[] = []
|
|
195
|
+
for (const o of ownedOrgs) result.push({ org: o as OrgDocLike, role: 'owner' })
|
|
196
|
+
for (const o of memberOrgs)
|
|
197
|
+
if (!ownedIds.has(o._id as string)) {
|
|
198
|
+
const member = memberships.find((x: Rec) => x.orgId === o._id),
|
|
199
|
+
role: OrgRole = member?.isAdmin ? 'admin' : 'member'
|
|
200
|
+
result.push({ org: o as OrgDocLike, role })
|
|
201
|
+
}
|
|
202
|
+
return result
|
|
203
|
+
}
|
|
204
|
+
}),
|
|
205
|
+
remove = m({
|
|
206
|
+
args: { orgId: zid('org') },
|
|
207
|
+
|
|
208
|
+
handler: async (c: Rec, { orgId }: { orgId: string }) => {
|
|
209
|
+
const db = c.db as DbLike,
|
|
210
|
+
orgDoc = await db.get(orgId)
|
|
211
|
+
if (!orgDoc) return err('NOT_FOUND')
|
|
212
|
+
if (orgDoc.userId !== (c.user as Rec)._id) return err('FORBIDDEN')
|
|
213
|
+
if (cascadeTables)
|
|
214
|
+
for (const table of cascadeTables) {
|
|
215
|
+
const docs = await db
|
|
216
|
+
.query(table)
|
|
217
|
+
.filter((o: FilterLike) => o.eq(o.field('orgId'), orgId))
|
|
218
|
+
.collect()
|
|
219
|
+
await Promise.all(docs.map(async (d: Rec) => db.delete(d._id as string)))
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const joinRequests = await db
|
|
223
|
+
.query('orgJoinRequest')
|
|
224
|
+
.withIndex('by_org', ((o: IndexLike) => o.eq('orgId', orgId)) as never)
|
|
225
|
+
.collect()
|
|
226
|
+
await Promise.all(joinRequests.map(async (r: Rec) => db.delete(r._id as string)))
|
|
227
|
+
const invites = await db
|
|
228
|
+
.query('orgInvite')
|
|
229
|
+
.withIndex('by_org', ((o: IndexLike) => o.eq('orgId', orgId)) as never)
|
|
230
|
+
.collect()
|
|
231
|
+
await Promise.all(invites.map(async (i: Rec) => db.delete(i._id as string)))
|
|
232
|
+
const orgMembers = await db
|
|
233
|
+
.query('orgMember')
|
|
234
|
+
.withIndex('by_org', ((o: IndexLike) => o.eq('orgId', orgId)) as never)
|
|
235
|
+
.collect()
|
|
236
|
+
await Promise.all(orgMembers.map(async (x: Rec) => db.delete(x._id as string)))
|
|
237
|
+
await db.delete(orgId)
|
|
238
|
+
}
|
|
239
|
+
}),
|
|
240
|
+
isSlugAvailable = pq({
|
|
241
|
+
args: { slug: z.string() },
|
|
242
|
+
handler: async (c: Rec, { slug }: { slug: string }) => {
|
|
243
|
+
const existing = await (c.db as DbLike)
|
|
244
|
+
.query('org')
|
|
245
|
+
.withIndex('by_slug', ((o: IndexLike) => o.eq('slug', slug)) as never)
|
|
246
|
+
.unique()
|
|
247
|
+
return { available: !existing } as { available: boolean }
|
|
248
|
+
}
|
|
249
|
+
}),
|
|
250
|
+
membership = q({
|
|
251
|
+
args: { orgId: zid('org') },
|
|
252
|
+
handler: async (
|
|
253
|
+
c: Rec,
|
|
254
|
+
{ orgId }: { orgId: string }
|
|
255
|
+
): Promise<null | { memberId: GenericId<'orgMember'> | null; role: OrgRole }> => {
|
|
256
|
+
const db = c.db as DbLike,
|
|
257
|
+
orgDoc = await db.get(orgId)
|
|
258
|
+
if (!orgDoc) return err('NOT_FOUND')
|
|
259
|
+
const userId = (c.user as Rec)._id as string,
|
|
260
|
+
member = await getOrgMember({ db, orgId, userId }),
|
|
261
|
+
role = getOrgRole({ member, org: orgDoc, userId })
|
|
262
|
+
if (!role) return null
|
|
263
|
+
return { memberId: ((member as null | Rec)?._id as GenericId<'orgMember'>) ?? null, role }
|
|
264
|
+
}
|
|
265
|
+
}),
|
|
266
|
+
members = q({
|
|
267
|
+
args: { orgId: zid('org') },
|
|
268
|
+
handler: async (c: Rec, { orgId }: { orgId: string }): Promise<OrgMemberItem[]> => {
|
|
269
|
+
const db = c.db as DbLike,
|
|
270
|
+
userId = (c.user as Rec)._id as string
|
|
271
|
+
await requireOrgMember({ db, orgId, userId })
|
|
272
|
+
const orgDoc = await db.get(orgId)
|
|
273
|
+
if (!orgDoc) return err('NOT_FOUND')
|
|
274
|
+
const result: OrgMemberItem[] = [],
|
|
275
|
+
ownerUser = await db.get(orgDoc.userId as string)
|
|
276
|
+
result.push({
|
|
277
|
+
role: 'owner',
|
|
278
|
+
user: ownerUser as null | OrgUserLike,
|
|
279
|
+
userId: orgDoc.userId as GenericId<'users'>
|
|
280
|
+
})
|
|
281
|
+
const memberDocs = await db
|
|
282
|
+
.query('orgMember')
|
|
283
|
+
.withIndex('by_org', ((o: IndexLike) => o.eq('orgId', orgId)) as never)
|
|
284
|
+
.collect(),
|
|
285
|
+
userDocs = await Promise.all(memberDocs.map(async (x: Rec) => db.get(x.userId as string)))
|
|
286
|
+
for (let i = 0; i < memberDocs.length; i += 1) {
|
|
287
|
+
const memberDoc = memberDocs[i],
|
|
288
|
+
userDoc = userDocs[i]
|
|
289
|
+
if (memberDoc)
|
|
290
|
+
result.push({
|
|
291
|
+
memberId: memberDoc._id as GenericId<'orgMember'>,
|
|
292
|
+
role: memberDoc.isAdmin ? 'admin' : 'member',
|
|
293
|
+
user: (userDoc as null | OrgUserLike) ?? null,
|
|
294
|
+
userId: memberDoc.userId as GenericId<'users'>
|
|
295
|
+
})
|
|
296
|
+
}
|
|
297
|
+
return result
|
|
298
|
+
}
|
|
299
|
+
}),
|
|
300
|
+
setAdmin = m({
|
|
301
|
+
args: { isAdmin: z.boolean(), memberId: zid('orgMember') },
|
|
302
|
+
handler: async (c: Rec, { isAdmin, memberId }: { isAdmin: boolean; memberId: string }) => {
|
|
303
|
+
const db = c.db as DbLike,
|
|
304
|
+
memberDoc = await db.get(memberId)
|
|
305
|
+
if (!memberDoc) return err('NOT_FOUND')
|
|
306
|
+
const orgDoc = await db.get(memberDoc.orgId as string)
|
|
307
|
+
if (!orgDoc) return err('NOT_FOUND')
|
|
308
|
+
if (orgDoc.userId !== (c.user as Rec)._id) return err('FORBIDDEN')
|
|
309
|
+
if (memberDoc.userId === orgDoc.userId) return err('CANNOT_MODIFY_OWNER')
|
|
310
|
+
await db.patch(memberId, { isAdmin, ...time() })
|
|
311
|
+
}
|
|
312
|
+
}),
|
|
313
|
+
removeMember = m({
|
|
314
|
+
args: { memberId: zid('orgMember') },
|
|
315
|
+
handler: async (c: Rec, { memberId }: { memberId: string }) => {
|
|
316
|
+
const db = c.db as DbLike,
|
|
317
|
+
memberDoc = await db.get(memberId)
|
|
318
|
+
if (!memberDoc) return err('NOT_FOUND')
|
|
319
|
+
const orgDoc = await db.get(memberDoc.orgId as string)
|
|
320
|
+
if (!orgDoc) return err('NOT_FOUND')
|
|
321
|
+
if (memberDoc.userId === orgDoc.userId) return err('CANNOT_MODIFY_OWNER')
|
|
322
|
+
const { role } = await requireOrgRole({
|
|
323
|
+
db,
|
|
324
|
+
minRole: 'admin',
|
|
325
|
+
orgId: memberDoc.orgId as string,
|
|
326
|
+
userId: (c.user as Rec)._id as string
|
|
327
|
+
})
|
|
328
|
+
if (role === 'admin' && memberDoc.isAdmin) return err('CANNOT_MODIFY_ADMIN')
|
|
329
|
+
await db.delete(memberId)
|
|
330
|
+
}
|
|
331
|
+
}),
|
|
332
|
+
leave = m({
|
|
333
|
+
args: { orgId: zid('org') },
|
|
334
|
+
handler: async (c: Rec, { orgId }: { orgId: string }) => {
|
|
335
|
+
const db = c.db as DbLike,
|
|
336
|
+
orgDoc = await db.get(orgId)
|
|
337
|
+
if (!orgDoc) return err('NOT_FOUND')
|
|
338
|
+
const userId = (c.user as Rec)._id as string
|
|
339
|
+
if (orgDoc.userId === userId) return err('MUST_TRANSFER_OWNERSHIP')
|
|
340
|
+
const member = await getOrgMember({ db, orgId, userId })
|
|
341
|
+
if (!member) return err('NOT_ORG_MEMBER')
|
|
342
|
+
await db.delete((member as Rec)._id as string)
|
|
343
|
+
}
|
|
344
|
+
}),
|
|
345
|
+
transferOwnership = m({
|
|
346
|
+
args: { newOwnerId: zid('users'), orgId: zid('org') },
|
|
347
|
+
handler: async (c: Rec, { newOwnerId, orgId }: { newOwnerId: string; orgId: string }) => {
|
|
348
|
+
const db = c.db as DbLike,
|
|
349
|
+
orgDoc = await db.get(orgId)
|
|
350
|
+
if (!orgDoc) return err('NOT_FOUND')
|
|
351
|
+
if (orgDoc.userId !== (c.user as Rec)._id) return err('FORBIDDEN')
|
|
352
|
+
const targetMember = await getOrgMember({ db, orgId, userId: newOwnerId })
|
|
353
|
+
if (!targetMember) return err('NOT_ORG_MEMBER')
|
|
354
|
+
if (!(targetMember as Rec).isAdmin) return err('TARGET_MUST_BE_ADMIN')
|
|
355
|
+
await db.patch(orgId, { userId: newOwnerId, ...time() })
|
|
356
|
+
await db.delete((targetMember as Rec)._id as string)
|
|
357
|
+
await db.insert('orgMember', {
|
|
358
|
+
isAdmin: true,
|
|
359
|
+
orgId,
|
|
360
|
+
userId: (c.user as Rec)._id,
|
|
361
|
+
...time()
|
|
362
|
+
})
|
|
363
|
+
}
|
|
364
|
+
}),
|
|
365
|
+
invite = m({
|
|
366
|
+
args: { email: z.email(), isAdmin: z.boolean(), orgId: zid('org') },
|
|
367
|
+
handler: async (c: Rec, { email, isAdmin, orgId }: { email: string; isAdmin: boolean; orgId: string }) => {
|
|
368
|
+
await requireOrgRole({ db: c.db, minRole: 'admin', orgId, userId: (c.user as Rec)._id as string })
|
|
369
|
+
const token = generateToken(),
|
|
370
|
+
expiresAt = Date.now() + SEVEN_DAYS_MS,
|
|
371
|
+
inviteId = await (c.db as DbLike).insert('orgInvite', {
|
|
372
|
+
email,
|
|
373
|
+
expiresAt,
|
|
374
|
+
isAdmin,
|
|
375
|
+
orgId,
|
|
376
|
+
token
|
|
377
|
+
})
|
|
378
|
+
return { inviteId, token } as { inviteId: GenericId<'orgInvite'>; token: string }
|
|
379
|
+
}
|
|
380
|
+
}),
|
|
381
|
+
acceptInvite = m({
|
|
382
|
+
args: { token: z.string() },
|
|
383
|
+
handler: async (c: Rec, { token }: { token: string }) => {
|
|
384
|
+
const db = c.db as DbLike,
|
|
385
|
+
userId = (c.user as Rec)._id as string,
|
|
386
|
+
inviteDoc = await db
|
|
387
|
+
.query('orgInvite')
|
|
388
|
+
.withIndex('by_token', ((o: IndexLike) => o.eq('token', token)) as never)
|
|
389
|
+
.unique()
|
|
390
|
+
if (!inviteDoc) return err('INVALID_INVITE')
|
|
391
|
+
if ((inviteDoc.expiresAt as number) < Date.now()) return err('INVITE_EXPIRED')
|
|
392
|
+
const existingMember = await getOrgMember({ db, orgId: inviteDoc.orgId as string, userId }),
|
|
393
|
+
orgDoc = await db.get(inviteDoc.orgId as string)
|
|
394
|
+
if (!orgDoc) return err('NOT_FOUND')
|
|
395
|
+
if (existingMember || orgDoc.userId === userId) return err('ALREADY_ORG_MEMBER')
|
|
396
|
+
const pendingRequest = await db
|
|
397
|
+
.query('orgJoinRequest')
|
|
398
|
+
.withIndex('by_org_status', ((o: IndexLike) =>
|
|
399
|
+
o.eq('orgId', inviteDoc.orgId).eq('status', 'pending')) as never)
|
|
400
|
+
.filter((o: FilterLike) => o.eq(o.field('userId'), userId))
|
|
401
|
+
.unique()
|
|
402
|
+
if (pendingRequest) await db.patch(pendingRequest._id as string, { status: 'approved', ...time() })
|
|
403
|
+
await db.insert('orgMember', {
|
|
404
|
+
isAdmin: inviteDoc.isAdmin,
|
|
405
|
+
orgId: inviteDoc.orgId,
|
|
406
|
+
userId,
|
|
407
|
+
...time()
|
|
408
|
+
})
|
|
409
|
+
await db.delete(inviteDoc._id as string)
|
|
410
|
+
return { orgId: inviteDoc.orgId } as { orgId: GenericId<'org'> }
|
|
411
|
+
}
|
|
412
|
+
}),
|
|
413
|
+
revokeInvite = m({
|
|
414
|
+
args: { inviteId: zid('orgInvite') },
|
|
415
|
+
handler: async (c: Rec, { inviteId }: { inviteId: string }) => {
|
|
416
|
+
const db = c.db as DbLike,
|
|
417
|
+
inviteDoc = await db.get(inviteId)
|
|
418
|
+
if (!inviteDoc) return err('NOT_FOUND')
|
|
419
|
+
await requireOrgRole({
|
|
420
|
+
db,
|
|
421
|
+
minRole: 'admin',
|
|
422
|
+
orgId: inviteDoc.orgId as string,
|
|
423
|
+
userId: (c.user as Rec)._id as string
|
|
424
|
+
})
|
|
425
|
+
await db.delete(inviteId)
|
|
426
|
+
}
|
|
427
|
+
}),
|
|
428
|
+
pendingInvites = q({
|
|
429
|
+
args: { orgId: zid('org') },
|
|
430
|
+
handler: async (c: Rec, { orgId }: { orgId: string }): Promise<InviteDocLike[]> => {
|
|
431
|
+
await requireOrgRole({ db: c.db, minRole: 'admin', orgId, userId: (c.user as Rec)._id as string })
|
|
432
|
+
return (c.db as DbLike)
|
|
433
|
+
.query('orgInvite')
|
|
434
|
+
.withIndex('by_org', ((o: IndexLike) => o.eq('orgId', orgId)) as never)
|
|
435
|
+
.collect() as Promise<InviteDocLike[]>
|
|
436
|
+
}
|
|
437
|
+
}),
|
|
438
|
+
requestJoin = m({
|
|
439
|
+
args: { message: z.string().optional(), orgId: zid('org') },
|
|
440
|
+
handler: async (c: Rec, { message, orgId }: { message?: string; orgId: string }) => {
|
|
441
|
+
const db = c.db as DbLike,
|
|
442
|
+
userId = (c.user as Rec)._id as string,
|
|
443
|
+
orgDoc = await db.get(orgId)
|
|
444
|
+
if (!orgDoc) return err('NOT_FOUND')
|
|
445
|
+
const existingMember = await getOrgMember({ db, orgId, userId })
|
|
446
|
+
if (existingMember || orgDoc.userId === userId) return err('ALREADY_ORG_MEMBER')
|
|
447
|
+
const existingRequest = await db
|
|
448
|
+
.query('orgJoinRequest')
|
|
449
|
+
.withIndex('by_org_status', ((o: IndexLike) => o.eq('orgId', orgId).eq('status', 'pending')) as never)
|
|
450
|
+
.filter((o: FilterLike) => o.eq(o.field('userId'), userId))
|
|
451
|
+
.unique()
|
|
452
|
+
if (existingRequest) return err('JOIN_REQUEST_EXISTS')
|
|
453
|
+
const requestId = await db.insert('orgJoinRequest', {
|
|
454
|
+
message,
|
|
455
|
+
orgId,
|
|
456
|
+
status: 'pending',
|
|
457
|
+
userId
|
|
458
|
+
})
|
|
459
|
+
return { requestId } as { requestId: GenericId<'orgJoinRequest'> }
|
|
460
|
+
}
|
|
461
|
+
}),
|
|
462
|
+
approveJoinRequest = m({
|
|
463
|
+
args: { isAdmin: z.boolean().optional(), requestId: zid('orgJoinRequest') },
|
|
464
|
+
handler: async (c: Rec, { isAdmin, requestId }: { isAdmin?: boolean; requestId: string }) => {
|
|
465
|
+
const db = c.db as DbLike,
|
|
466
|
+
requestDoc = await db.get(requestId)
|
|
467
|
+
if (!requestDoc) return err('NOT_FOUND')
|
|
468
|
+
await requireOrgRole({
|
|
469
|
+
db,
|
|
470
|
+
minRole: 'admin',
|
|
471
|
+
orgId: requestDoc.orgId as string,
|
|
472
|
+
userId: (c.user as Rec)._id as string
|
|
473
|
+
})
|
|
474
|
+
await db.insert('orgMember', {
|
|
475
|
+
isAdmin: isAdmin ?? false,
|
|
476
|
+
orgId: requestDoc.orgId,
|
|
477
|
+
userId: requestDoc.userId,
|
|
478
|
+
...time()
|
|
479
|
+
})
|
|
480
|
+
await db.patch(requestId, { status: 'approved' })
|
|
481
|
+
}
|
|
482
|
+
}),
|
|
483
|
+
rejectJoinRequest = m({
|
|
484
|
+
args: { requestId: zid('orgJoinRequest') },
|
|
485
|
+
handler: async (c: Rec, { requestId }: { requestId: string }) => {
|
|
486
|
+
const db = c.db as DbLike,
|
|
487
|
+
requestDoc = await db.get(requestId)
|
|
488
|
+
if (!requestDoc) return err('NOT_FOUND')
|
|
489
|
+
await requireOrgRole({
|
|
490
|
+
db,
|
|
491
|
+
minRole: 'admin',
|
|
492
|
+
orgId: requestDoc.orgId as string,
|
|
493
|
+
userId: (c.user as Rec)._id as string
|
|
494
|
+
})
|
|
495
|
+
await db.patch(requestId, { status: 'rejected' })
|
|
496
|
+
}
|
|
497
|
+
}),
|
|
498
|
+
cancelJoinRequest = m({
|
|
499
|
+
args: { requestId: zid('orgJoinRequest') },
|
|
500
|
+
handler: async (c: Rec, { requestId }: { requestId: string }) => {
|
|
501
|
+
const db = c.db as DbLike,
|
|
502
|
+
requestDoc = await db.get(requestId)
|
|
503
|
+
if (!requestDoc) return err('NOT_FOUND')
|
|
504
|
+
if (requestDoc.userId !== (c.user as Rec)._id) return err('FORBIDDEN')
|
|
505
|
+
if (requestDoc.status !== 'pending') return err('NOT_FOUND')
|
|
506
|
+
await db.delete(requestId)
|
|
507
|
+
}
|
|
508
|
+
}),
|
|
509
|
+
pendingJoinRequests = q({
|
|
510
|
+
args: { orgId: zid('org') },
|
|
511
|
+
handler: async (c: Rec, { orgId }: { orgId: string }): Promise<JoinRequestItem[]> => {
|
|
512
|
+
const db = c.db as DbLike
|
|
513
|
+
await requireOrgRole({ db, minRole: 'admin', orgId, userId: (c.user as Rec)._id as string })
|
|
514
|
+
const requests = await db
|
|
515
|
+
.query('orgJoinRequest')
|
|
516
|
+
.withIndex('by_org_status', ((o: IndexLike) => o.eq('orgId', orgId).eq('status', 'pending')) as never)
|
|
517
|
+
.collect(),
|
|
518
|
+
users = await Promise.all(requests.map(async (r: Rec) => db.get(r.userId as string))),
|
|
519
|
+
result: JoinRequestItem[] = []
|
|
520
|
+
for (let i = 0; i < requests.length; i += 1) {
|
|
521
|
+
const req = requests[i],
|
|
522
|
+
usr = users[i]
|
|
523
|
+
if (req) result.push({ request: req as JoinRequestItem['request'], user: (usr as null | OrgUserLike) ?? null })
|
|
524
|
+
}
|
|
525
|
+
return result
|
|
526
|
+
}
|
|
527
|
+
}),
|
|
528
|
+
myJoinRequest = q({
|
|
529
|
+
args: { orgId: zid('org') },
|
|
530
|
+
handler: async (c: Rec, { orgId }: { orgId: string }) =>
|
|
531
|
+
(c.db as DbLike)
|
|
532
|
+
.query('orgJoinRequest')
|
|
533
|
+
.withIndex('by_org_status', ((o: IndexLike) => o.eq('orgId', orgId).eq('status', 'pending')) as never)
|
|
534
|
+
.filter((o: FilterLike) => o.eq(o.field('userId'), (c.user as Rec)._id))
|
|
535
|
+
.unique() as Promise<null | {
|
|
536
|
+
_id: GenericId<'orgJoinRequest'>
|
|
537
|
+
message?: string
|
|
538
|
+
orgId: GenericId<'org'>
|
|
539
|
+
status: string
|
|
540
|
+
userId: GenericId<'users'>
|
|
541
|
+
}>
|
|
542
|
+
})
|
|
543
|
+
return {
|
|
544
|
+
acceptInvite,
|
|
545
|
+
approveJoinRequest,
|
|
546
|
+
cancelJoinRequest,
|
|
547
|
+
create,
|
|
548
|
+
get,
|
|
549
|
+
getBySlug,
|
|
550
|
+
getPublic,
|
|
551
|
+
invite,
|
|
552
|
+
isSlugAvailable,
|
|
553
|
+
leave,
|
|
554
|
+
members,
|
|
555
|
+
membership,
|
|
556
|
+
myJoinRequest,
|
|
557
|
+
myOrgs,
|
|
558
|
+
pendingInvites,
|
|
559
|
+
pendingJoinRequests,
|
|
560
|
+
rejectJoinRequest,
|
|
561
|
+
remove,
|
|
562
|
+
removeMember,
|
|
563
|
+
requestJoin,
|
|
564
|
+
revokeInvite,
|
|
565
|
+
setAdmin,
|
|
566
|
+
transferOwnership,
|
|
567
|
+
update
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
export { makeOrg }
|
|
572
|
+
export type { InviteDocLike, JoinRequestItem, OrgDocLike, OrgMemberItem, OrgUserLike }
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { ZodObject, ZodRawShape } from 'zod/v4'
|
|
2
|
+
|
|
3
|
+
import { zodOutputToConvexFields as z2c } from 'convex-helpers/server/zod4'
|
|
4
|
+
import { defineTable } from 'convex/server'
|
|
5
|
+
import { v } from 'convex/values'
|
|
6
|
+
|
|
7
|
+
const baseTable = <T extends ZodRawShape>(s: ZodObject<T>) =>
|
|
8
|
+
defineTable({ ...z2c(s.shape), updatedAt: v.optional(v.number()) }),
|
|
9
|
+
ownedTable = <T extends ZodRawShape>(s: ZodObject<T>) =>
|
|
10
|
+
defineTable({ ...z2c(s.shape), updatedAt: v.number(), userId: v.id('users') }).index('by_user', ['userId' as never]),
|
|
11
|
+
orgTable = <T extends ZodRawShape>(s: ZodObject<T>) =>
|
|
12
|
+
defineTable({
|
|
13
|
+
...z2c(s.shape),
|
|
14
|
+
orgId: v.id('org'),
|
|
15
|
+
updatedAt: v.number(),
|
|
16
|
+
userId: v.id('users')
|
|
17
|
+
})
|
|
18
|
+
.index('by_org', ['orgId' as never])
|
|
19
|
+
.index('by_org_user', ['orgId' as never, 'userId' as never]),
|
|
20
|
+
orgChildTable = <T extends ZodRawShape>(
|
|
21
|
+
s: ZodObject<T>,
|
|
22
|
+
parent: {
|
|
23
|
+
foreignKey: string
|
|
24
|
+
table: string
|
|
25
|
+
}
|
|
26
|
+
) =>
|
|
27
|
+
defineTable({
|
|
28
|
+
...z2c(s.shape),
|
|
29
|
+
orgId: v.id('org'),
|
|
30
|
+
updatedAt: v.number(),
|
|
31
|
+
userId: v.id('users')
|
|
32
|
+
})
|
|
33
|
+
.index('by_org', ['orgId' as never])
|
|
34
|
+
.index('by_parent', [parent.foreignKey as never]),
|
|
35
|
+
childTable = <T extends ZodRawShape>(s: ZodObject<T>, indexField: string, indexName?: string) =>
|
|
36
|
+
defineTable({
|
|
37
|
+
...z2c(s.shape),
|
|
38
|
+
updatedAt: v.number()
|
|
39
|
+
}).index(indexName ?? `by_${indexField}`, [indexField as never]),
|
|
40
|
+
orgTables = () => ({
|
|
41
|
+
org: defineTable({
|
|
42
|
+
avatarId: v.optional(v.id('_storage')),
|
|
43
|
+
name: v.string(),
|
|
44
|
+
slug: v.string(),
|
|
45
|
+
updatedAt: v.number(),
|
|
46
|
+
userId: v.id('users')
|
|
47
|
+
})
|
|
48
|
+
.index('by_slug', ['slug'])
|
|
49
|
+
.index('by_user', ['userId']),
|
|
50
|
+
orgInvite: defineTable({
|
|
51
|
+
email: v.string(),
|
|
52
|
+
expiresAt: v.number(),
|
|
53
|
+
isAdmin: v.boolean(),
|
|
54
|
+
orgId: v.id('org'),
|
|
55
|
+
token: v.string()
|
|
56
|
+
})
|
|
57
|
+
.index('by_org', ['orgId'])
|
|
58
|
+
.index('by_token', ['token']),
|
|
59
|
+
orgJoinRequest: defineTable({
|
|
60
|
+
message: v.optional(v.string()),
|
|
61
|
+
orgId: v.id('org'),
|
|
62
|
+
status: v.union(v.literal('pending'), v.literal('approved'), v.literal('rejected')),
|
|
63
|
+
userId: v.id('users')
|
|
64
|
+
})
|
|
65
|
+
.index('by_org', ['orgId'])
|
|
66
|
+
.index('by_org_status', ['orgId', 'status'])
|
|
67
|
+
.index('by_user', ['userId']),
|
|
68
|
+
orgMember: defineTable({
|
|
69
|
+
isAdmin: v.boolean(),
|
|
70
|
+
orgId: v.id('org'),
|
|
71
|
+
updatedAt: v.number(),
|
|
72
|
+
userId: v.id('users')
|
|
73
|
+
})
|
|
74
|
+
.index('by_org', ['orgId'])
|
|
75
|
+
.index('by_org_user', ['orgId', 'userId'])
|
|
76
|
+
.index('by_user', ['userId'])
|
|
77
|
+
}),
|
|
78
|
+
uploadTables = () => ({
|
|
79
|
+
uploadChunk: defineTable({
|
|
80
|
+
chunkIndex: v.number(),
|
|
81
|
+
storageId: v.id('_storage'),
|
|
82
|
+
totalChunks: v.number(),
|
|
83
|
+
uploadId: v.string(),
|
|
84
|
+
userId: v.id('users')
|
|
85
|
+
})
|
|
86
|
+
.index('by_upload', ['uploadId'])
|
|
87
|
+
.index('by_user', ['userId']),
|
|
88
|
+
uploadRateLimit: defineTable({
|
|
89
|
+
timestamp: v.number(),
|
|
90
|
+
userId: v.id('users')
|
|
91
|
+
}).index('by_user', ['userId']),
|
|
92
|
+
uploadSession: defineTable({
|
|
93
|
+
completedChunks: v.number(),
|
|
94
|
+
contentType: v.string(),
|
|
95
|
+
fileName: v.string(),
|
|
96
|
+
finalStorageId: v.optional(v.id('_storage')),
|
|
97
|
+
status: v.union(v.literal('pending'), v.literal('assembling'), v.literal('completed'), v.literal('failed')),
|
|
98
|
+
totalChunks: v.number(),
|
|
99
|
+
totalSize: v.number(),
|
|
100
|
+
uploadId: v.string(),
|
|
101
|
+
userId: v.id('users')
|
|
102
|
+
})
|
|
103
|
+
.index('by_upload_id', ['uploadId'])
|
|
104
|
+
.index('by_user', ['userId'])
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
export { baseTable, childTable, orgChildTable, orgTable, orgTables, ownedTable, uploadTables }
|