@m5kdev/backend 0.1.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 (113) hide show
  1. package/LICENSE +621 -0
  2. package/README.md +22 -0
  3. package/package.json +205 -0
  4. package/src/lib/posthog.ts +5 -0
  5. package/src/lib/sentry.ts +8 -0
  6. package/src/modules/access/access.repository.ts +36 -0
  7. package/src/modules/access/access.service.ts +81 -0
  8. package/src/modules/access/access.test.ts +216 -0
  9. package/src/modules/access/access.utils.ts +46 -0
  10. package/src/modules/ai/ai.db.ts +38 -0
  11. package/src/modules/ai/ai.prompt.ts +47 -0
  12. package/src/modules/ai/ai.repository.ts +53 -0
  13. package/src/modules/ai/ai.router.ts +148 -0
  14. package/src/modules/ai/ai.service.ts +310 -0
  15. package/src/modules/ai/ai.trpc.ts +22 -0
  16. package/src/modules/ai/ideogram/ideogram.constants.ts +170 -0
  17. package/src/modules/ai/ideogram/ideogram.dto.ts +64 -0
  18. package/src/modules/ai/ideogram/ideogram.prompt.ts +858 -0
  19. package/src/modules/ai/ideogram/ideogram.repository.ts +39 -0
  20. package/src/modules/ai/ideogram/ideogram.service.ts +14 -0
  21. package/src/modules/auth/auth.db.ts +224 -0
  22. package/src/modules/auth/auth.dto.ts +47 -0
  23. package/src/modules/auth/auth.lib.ts +349 -0
  24. package/src/modules/auth/auth.middleware.ts +62 -0
  25. package/src/modules/auth/auth.repository.ts +672 -0
  26. package/src/modules/auth/auth.service.ts +261 -0
  27. package/src/modules/auth/auth.trpc.ts +208 -0
  28. package/src/modules/auth/auth.utils.ts +117 -0
  29. package/src/modules/base/base.abstract.ts +62 -0
  30. package/src/modules/base/base.dto.ts +206 -0
  31. package/src/modules/base/base.grants.test.ts +861 -0
  32. package/src/modules/base/base.grants.ts +199 -0
  33. package/src/modules/base/base.repository.ts +433 -0
  34. package/src/modules/base/base.service.ts +154 -0
  35. package/src/modules/base/base.types.ts +7 -0
  36. package/src/modules/billing/billing.db.ts +27 -0
  37. package/src/modules/billing/billing.repository.ts +328 -0
  38. package/src/modules/billing/billing.router.ts +77 -0
  39. package/src/modules/billing/billing.service.ts +177 -0
  40. package/src/modules/billing/billing.trpc.ts +17 -0
  41. package/src/modules/clay/clay.repository.ts +29 -0
  42. package/src/modules/clay/clay.service.ts +61 -0
  43. package/src/modules/connect/connect.db.ts +32 -0
  44. package/src/modules/connect/connect.dto.ts +44 -0
  45. package/src/modules/connect/connect.linkedin.ts +70 -0
  46. package/src/modules/connect/connect.oauth.ts +288 -0
  47. package/src/modules/connect/connect.repository.ts +65 -0
  48. package/src/modules/connect/connect.router.ts +76 -0
  49. package/src/modules/connect/connect.service.ts +171 -0
  50. package/src/modules/connect/connect.trpc.ts +26 -0
  51. package/src/modules/connect/connect.types.ts +27 -0
  52. package/src/modules/crypto/crypto.db.ts +15 -0
  53. package/src/modules/crypto/crypto.repository.ts +13 -0
  54. package/src/modules/crypto/crypto.service.ts +57 -0
  55. package/src/modules/email/email.service.ts +222 -0
  56. package/src/modules/file/file.repository.ts +95 -0
  57. package/src/modules/file/file.router.ts +108 -0
  58. package/src/modules/file/file.service.ts +186 -0
  59. package/src/modules/recurrence/recurrence.db.ts +79 -0
  60. package/src/modules/recurrence/recurrence.repository.ts +70 -0
  61. package/src/modules/recurrence/recurrence.service.ts +105 -0
  62. package/src/modules/recurrence/recurrence.trpc.ts +82 -0
  63. package/src/modules/social/social.dto.ts +22 -0
  64. package/src/modules/social/social.linkedin.test.ts +277 -0
  65. package/src/modules/social/social.linkedin.ts +593 -0
  66. package/src/modules/social/social.service.ts +112 -0
  67. package/src/modules/social/social.types.ts +43 -0
  68. package/src/modules/tag/tag.db.ts +41 -0
  69. package/src/modules/tag/tag.dto.ts +18 -0
  70. package/src/modules/tag/tag.repository.ts +222 -0
  71. package/src/modules/tag/tag.service.ts +48 -0
  72. package/src/modules/tag/tag.trpc.ts +62 -0
  73. package/src/modules/uploads/0581796b-8845-420d-bd95-cd7de79f6d37.webm +0 -0
  74. package/src/modules/uploads/33b1e649-6727-4bd0-94d0-a0b363646865.webm +0 -0
  75. package/src/modules/uploads/49a8c4c0-54d7-4c94-bef4-c93c029f9ed0.webm +0 -0
  76. package/src/modules/uploads/50e31e38-a2f0-47ca-8b7d-2d7fcad9267d.webm +0 -0
  77. package/src/modules/uploads/72ac8cf9-c3a7-4cd8-8a78-6d8e137a4c7e.webm +0 -0
  78. package/src/modules/uploads/75293649-d966-46cd-a675-67518958ae9c.png +0 -0
  79. package/src/modules/uploads/88b7b867-ce15-4891-bf73-81305a7de1f7.wav +0 -0
  80. package/src/modules/uploads/a5d6fee8-6a59-42c6-9d4a-ac8a3c5e7245.webm +0 -0
  81. package/src/modules/uploads/c13a9785-ca5a-4983-af30-b338ed76d370.webm +0 -0
  82. package/src/modules/uploads/caa1a5a7-71ba-4381-902d-7e2cafdf6dcb.webm +0 -0
  83. package/src/modules/uploads/cbeb0b81-374d-445b-914b-40ace7c8e031.webm +0 -0
  84. package/src/modules/uploads/d626aa82-b10f-493f-aee7-87bfb3361dfc.webm +0 -0
  85. package/src/modules/uploads/d7de4c16-de0c-495d-9612-e72260a6ecca.png +0 -0
  86. package/src/modules/uploads/e532e38a-6421-400e-8a5f-8e7bc8ce411b.wav +0 -0
  87. package/src/modules/uploads/e86ec867-6adf-4c51-84e0-00b0836625e8.webm +0 -0
  88. package/src/modules/utils/applyPagination.ts +13 -0
  89. package/src/modules/utils/applySorting.ts +21 -0
  90. package/src/modules/utils/getConditionsFromFilters.ts +216 -0
  91. package/src/modules/video/video.service.ts +89 -0
  92. package/src/modules/webhook/webhook.constants.ts +9 -0
  93. package/src/modules/webhook/webhook.db.ts +15 -0
  94. package/src/modules/webhook/webhook.dto.ts +9 -0
  95. package/src/modules/webhook/webhook.repository.ts +68 -0
  96. package/src/modules/webhook/webhook.router.ts +29 -0
  97. package/src/modules/webhook/webhook.service.ts +78 -0
  98. package/src/modules/workflow/workflow.db.ts +29 -0
  99. package/src/modules/workflow/workflow.repository.ts +171 -0
  100. package/src/modules/workflow/workflow.service.ts +56 -0
  101. package/src/modules/workflow/workflow.trpc.ts +26 -0
  102. package/src/modules/workflow/workflow.types.ts +30 -0
  103. package/src/modules/workflow/workflow.utils.ts +259 -0
  104. package/src/test/stubs/utils.ts +2 -0
  105. package/src/trpc/context.ts +21 -0
  106. package/src/trpc/index.ts +3 -0
  107. package/src/trpc/procedures.ts +43 -0
  108. package/src/trpc/utils.ts +20 -0
  109. package/src/types.ts +22 -0
  110. package/src/utils/errors.ts +148 -0
  111. package/src/utils/logger.ts +8 -0
  112. package/src/utils/posthog.ts +43 -0
  113. package/src/utils/types.ts +5 -0
@@ -0,0 +1,672 @@
1
+ import { and, count, desc, eq, gte, ne } from "drizzle-orm";
2
+ import type { LibSQLDatabase } from "drizzle-orm/libsql";
3
+ import { ok } from "neverthrow";
4
+ import { v4 as uuidv4 } from "uuid";
5
+ import * as auth from "#modules/auth/auth.db";
6
+ import type {
7
+ AccountClaim,
8
+ AccountClaimMagicLink,
9
+ AccountClaimMagicLinkOutput,
10
+ AccountClaimOutput,
11
+ Waitlist,
12
+ WaitlistOutput,
13
+ } from "#modules/auth/auth.dto";
14
+ import type { ServerResultAsync } from "#modules/base/base.dto";
15
+ import { BaseRepository } from "#modules/base/base.repository";
16
+
17
+ const schema = { ...auth };
18
+ type Schema = typeof schema;
19
+ type Orm = LibSQLDatabase<Schema>;
20
+ type UserRow = typeof auth.users.$inferSelect;
21
+
22
+ export class AuthRepository extends BaseRepository<Orm, Schema, Record<string, never>> {
23
+ async getUserWaitlistCount(userId: string, tx?: Orm): ServerResultAsync<number> {
24
+ return this.throwableAsync(async () => {
25
+ const db = tx ?? this.orm;
26
+ const [waitlist] = await db
27
+ .select({ count: count() })
28
+ .from(this.schema.waitlist)
29
+ .where(eq(this.schema.waitlist.userId, userId));
30
+
31
+ return ok(waitlist.count ?? 0);
32
+ });
33
+ }
34
+
35
+ async getOnboarding(userId: string, tx?: Orm): ServerResultAsync<number> {
36
+ return this.throwableAsync(async () => {
37
+ const db = tx ?? this.orm;
38
+ const [user] = await db
39
+ .select({ onboarding: this.schema.users.onboarding })
40
+ .from(this.schema.users)
41
+ .where(eq(this.schema.users.id, userId))
42
+ .limit(1);
43
+ if (!user) return this.error("FORBIDDEN");
44
+
45
+ return ok(user.onboarding ?? 0);
46
+ });
47
+ }
48
+
49
+ async setOnboarding(userId: string, onboarding: number, tx?: Orm): ServerResultAsync<number> {
50
+ return this.throwableAsync(async () => {
51
+ const db = tx ?? this.orm;
52
+ await db
53
+ .update(this.schema.users)
54
+ .set({ onboarding })
55
+ .where(eq(this.schema.users.id, userId));
56
+ return ok(onboarding);
57
+ });
58
+ }
59
+
60
+ async getPreferences(userId: string, tx?: Orm): ServerResultAsync<Record<string, unknown>> {
61
+ return this.throwableAsync(async () => {
62
+ const db = tx ?? this.orm;
63
+ const [user] = await db
64
+ .select({ preferences: this.schema.users.preferences })
65
+ .from(this.schema.users)
66
+ .where(eq(this.schema.users.id, userId))
67
+ .limit(1);
68
+ if (!user) return this.error("FORBIDDEN");
69
+ const json = user.preferences
70
+ ? (JSON.parse(user.preferences) as Record<string, unknown>)
71
+ : {};
72
+ return ok(json);
73
+ });
74
+ }
75
+
76
+ async setPreferences(
77
+ userId: string,
78
+ preferences: Record<string, unknown>,
79
+ tx?: Orm
80
+ ): ServerResultAsync<Record<string, unknown>> {
81
+ return this.throwableAsync(async () => {
82
+ const db = tx ?? this.orm;
83
+ await db
84
+ .update(this.schema.users)
85
+ .set({ preferences: JSON.stringify(preferences) })
86
+ .where(eq(this.schema.users.id, userId));
87
+ return ok(preferences);
88
+ });
89
+ }
90
+
91
+ async getMetadata(userId: string, tx?: Orm): ServerResultAsync<Record<string, unknown>> {
92
+ return this.throwableAsync(async () => {
93
+ const db = tx ?? this.orm;
94
+ const [user] = await db
95
+ .select({ metadata: this.schema.users.metadata })
96
+ .from(this.schema.users)
97
+ .where(eq(this.schema.users.id, userId))
98
+ .limit(1);
99
+ if (!user) return this.error("FORBIDDEN");
100
+
101
+ return ok(user.metadata);
102
+ });
103
+ }
104
+
105
+ async setMetadata(
106
+ userId: string,
107
+ metadata: Record<string, unknown>,
108
+ tx?: Orm
109
+ ): ServerResultAsync<Record<string, unknown>> {
110
+ return this.throwableAsync(async () => {
111
+ const db = tx ?? this.orm;
112
+ const [user] = await db
113
+ .select({ metadata: this.schema.users.metadata })
114
+ .from(this.schema.users)
115
+ .where(eq(this.schema.users.id, userId))
116
+ .limit(1);
117
+ if (!user) return this.error("FORBIDDEN");
118
+ await db
119
+ .update(this.schema.users)
120
+ .set({
121
+ metadata: {
122
+ ...user.metadata,
123
+ ...metadata,
124
+ },
125
+ })
126
+ .where(eq(this.schema.users.id, userId));
127
+ return ok(metadata);
128
+ });
129
+ }
130
+
131
+ async getFlags(userId: string, tx?: Orm): ServerResultAsync<string[]> {
132
+ return this.throwableAsync(async () => {
133
+ const db = tx ?? this.orm;
134
+ const [user] = await db
135
+ .select({ flags: this.schema.users.flags })
136
+ .from(this.schema.users)
137
+ .where(eq(this.schema.users.id, userId))
138
+ .limit(1);
139
+ if (!user) return this.error("FORBIDDEN");
140
+ const json = user.flags ? (JSON.parse(user.flags) as string[]) : [];
141
+
142
+ return ok(json);
143
+ });
144
+ }
145
+
146
+ async setFlags(userId: string, flags: string[], tx?: Orm): ServerResultAsync<string[]> {
147
+ return this.throwableAsync(async () => {
148
+ const db = tx ?? this.orm;
149
+ await db
150
+ .update(this.schema.users)
151
+ .set({ flags: JSON.stringify(flags) })
152
+ .where(eq(this.schema.users.id, userId));
153
+ return ok(flags);
154
+ });
155
+ }
156
+
157
+ async listAdminWaitlist(tx?: Orm): ServerResultAsync<WaitlistOutput[]> {
158
+ return this.throwableAsync(async () => {
159
+ const db = tx ?? this.orm;
160
+ const waitlist = await db
161
+ .select({
162
+ id: this.schema.waitlist.id,
163
+ name: this.schema.waitlist.name,
164
+ email: this.schema.waitlist.email,
165
+ createdAt: this.schema.waitlist.createdAt,
166
+ updatedAt: this.schema.waitlist.updatedAt,
167
+ status: this.schema.waitlist.status,
168
+ })
169
+ .from(this.schema.waitlist)
170
+ .where(eq(this.schema.waitlist.type, "WAITLIST"))
171
+ .orderBy(desc(this.schema.waitlist.createdAt));
172
+ return ok(waitlist);
173
+ });
174
+ }
175
+
176
+ async listWaitlist(userId: string, tx?: Orm): ServerResultAsync<Waitlist[]> {
177
+ return this.throwableAsync(async () => {
178
+ const db = tx ?? this.orm;
179
+ const waitlist = await db
180
+ .select()
181
+ .from(this.schema.waitlist)
182
+ .where(
183
+ and(eq(this.schema.waitlist.userId, userId), eq(this.schema.waitlist.type, "WAITLIST"))
184
+ );
185
+ return ok(waitlist);
186
+ });
187
+ }
188
+
189
+ async addToWaitlist(email: string, tx?: Orm): ServerResultAsync<WaitlistOutput> {
190
+ return this.throwableAsync(async () => {
191
+ const db = tx ?? this.orm;
192
+ const [waitlist] = await db.insert(this.schema.waitlist).values({ email }).returning();
193
+ return ok(waitlist);
194
+ });
195
+ }
196
+
197
+ async inviteFromWaitlist(id: string, tx?: Orm): ServerResultAsync<Waitlist> {
198
+ return this.throwableAsync(async () => {
199
+ const db = tx ?? this.orm;
200
+ const [waitlist] = await db
201
+ .update(this.schema.waitlist)
202
+ .set({
203
+ status: "INVITED",
204
+ code: uuidv4(),
205
+ expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 14),
206
+ })
207
+ .where(eq(this.schema.waitlist.id, id))
208
+ .returning();
209
+ return ok(waitlist);
210
+ });
211
+ }
212
+
213
+ async inviteToWaitlist(
214
+ { email, userId, name }: { email: string; userId: string; name?: string },
215
+ tx?: Orm
216
+ ): ServerResultAsync<Waitlist> {
217
+ return this.throwableAsync(async () => {
218
+ const db = tx ?? this.orm;
219
+ const [waitlist] = await db
220
+ .insert(this.schema.waitlist)
221
+ .values({
222
+ email,
223
+ name,
224
+ status: "INVITED",
225
+ code: uuidv4(),
226
+ expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24),
227
+ userId: userId,
228
+ })
229
+ .returning();
230
+ return ok(waitlist);
231
+ });
232
+ }
233
+
234
+ async createInvitationCode(
235
+ { userId, name }: { userId: string; name?: string },
236
+ tx?: Orm
237
+ ): ServerResultAsync<Waitlist> {
238
+ return this.throwableAsync(async () => {
239
+ const db = tx ?? this.orm;
240
+ const [waitlist] = await db
241
+ .insert(this.schema.waitlist)
242
+ .values({
243
+ name,
244
+ status: "INVITED",
245
+ code: uuidv4(),
246
+ expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24),
247
+ userId: userId,
248
+ })
249
+ .returning();
250
+ return ok(waitlist);
251
+ });
252
+ }
253
+
254
+ async joinWaitlist(email: string, tx?: Orm): ServerResultAsync<WaitlistOutput> {
255
+ return this.throwableAsync(async () => {
256
+ const db = tx ?? this.orm;
257
+ const [waitlist] = await db.insert(this.schema.waitlist).values({ email }).returning();
258
+ return ok(waitlist);
259
+ });
260
+ }
261
+
262
+ async removeFromWaitlist(id: string, tx?: Orm): ServerResultAsync<WaitlistOutput> {
263
+ return this.throwableAsync(async () => {
264
+ const db = tx ?? this.orm;
265
+ const [waitlist] = await db
266
+ .update(this.schema.waitlist)
267
+ .set({ status: "REMOVED" })
268
+ .where(eq(this.schema.waitlist.id, id))
269
+ .returning();
270
+ return ok(waitlist);
271
+ });
272
+ }
273
+
274
+ async validateWaitlistCode(code: string, tx?: Orm): ServerResultAsync<{ status: string }> {
275
+ return this.throwableAsync(async () => {
276
+ const db = tx ?? this.orm;
277
+ const [waitlist] = await db
278
+ .select()
279
+ .from(this.schema.waitlist)
280
+ .where(and(eq(this.schema.waitlist.code, code), eq(this.schema.waitlist.type, "WAITLIST")))
281
+ .limit(1);
282
+ if (!waitlist) return ok({ status: "NOT_FOUND" });
283
+ if (waitlist.expiresAt && waitlist.expiresAt < new Date()) return ok({ status: "EXPIRED" });
284
+ if (waitlist.status !== "INVITED") return ok({ status: "INVALID" });
285
+ return ok({ status: "VALID" });
286
+ });
287
+ }
288
+
289
+ async createAccountClaimCode(
290
+ { userId, expiresInHours = 24 * 14 }: { userId: string; expiresInHours?: number },
291
+ tx?: Orm
292
+ ): ServerResultAsync<AccountClaim> {
293
+ return this.throwableAsync(async () => {
294
+ const db = tx ?? this.orm;
295
+ const [claim] = await db
296
+ .insert(this.schema.waitlist)
297
+ .values({
298
+ type: "ACCOUNT_CLAIM",
299
+ claimUserId: userId,
300
+ code: uuidv4(),
301
+ status: "INVITED",
302
+ expiresAt: new Date(Date.now() + 1000 * 60 * 60 * expiresInHours),
303
+ })
304
+ .returning();
305
+ return ok(claim);
306
+ });
307
+ }
308
+
309
+ async createClaimableProvisionedUser({
310
+ name,
311
+ email,
312
+ metadata = {},
313
+ onboarding = 0,
314
+ role = "user",
315
+ expiresInHours = 24 * 14,
316
+ }: {
317
+ name: string;
318
+ email: string;
319
+ metadata?: Record<string, unknown>;
320
+ onboarding?: number;
321
+ role?: "user" | "admin" | "agent";
322
+ expiresInHours?: number;
323
+ }): ServerResultAsync<{ user: UserRow; claim: AccountClaim }> {
324
+ return this.throwableAsync(async () => {
325
+ const normalizedEmail = email.toLowerCase();
326
+ const [existingUser] = await this.orm
327
+ .select({ id: this.schema.users.id })
328
+ .from(this.schema.users)
329
+ .where(eq(this.schema.users.email, normalizedEmail))
330
+ .limit(1);
331
+ if (existingUser) {
332
+ return this.error("CONFLICT", "Email already in use");
333
+ }
334
+
335
+ const created = await this.orm.transaction(async (tx) => {
336
+ const [user] = await tx
337
+ .insert(this.schema.users)
338
+ .values({
339
+ name,
340
+ email: normalizedEmail,
341
+ emailVerified: false,
342
+ role,
343
+ onboarding,
344
+ metadata,
345
+ })
346
+ .returning();
347
+ if (!user) throw new Error("Failed to create user");
348
+
349
+ const organizationId = uuidv4();
350
+ const [organization] = await tx
351
+ .insert(this.schema.organizations)
352
+ .values({
353
+ id: organizationId,
354
+ name: organizationId,
355
+ slug: organizationId,
356
+ })
357
+ .returning();
358
+ if (!organization) throw new Error("Failed to create organization");
359
+
360
+ const [member] = await tx
361
+ .insert(this.schema.members)
362
+ .values({
363
+ userId: user.id,
364
+ organizationId: organization.id,
365
+ role: "owner",
366
+ })
367
+ .returning();
368
+ if (!member) throw new Error("Failed to create organization membership");
369
+
370
+ const [team] = await tx
371
+ .insert(this.schema.teams)
372
+ .values({
373
+ name: organization.id,
374
+ organizationId: organization.id,
375
+ })
376
+ .returning();
377
+ if (!team) throw new Error("Failed to create team");
378
+
379
+ const [teamMember] = await tx
380
+ .insert(this.schema.teamMembers)
381
+ .values({
382
+ userId: user.id,
383
+ teamId: team.id,
384
+ role: "owner",
385
+ })
386
+ .returning();
387
+ if (!teamMember) throw new Error("Failed to create team membership");
388
+
389
+ const [claim] = await tx
390
+ .insert(this.schema.waitlist)
391
+ .values({
392
+ type: "ACCOUNT_CLAIM",
393
+ claimUserId: user.id,
394
+ code: uuidv4(),
395
+ status: "INVITED",
396
+ expiresAt: new Date(Date.now() + 1000 * 60 * 60 * expiresInHours),
397
+ })
398
+ .returning();
399
+ if (!claim) throw new Error("Failed to create account claim");
400
+
401
+ return { user, claim };
402
+ });
403
+
404
+ return ok(created);
405
+ });
406
+ }
407
+
408
+ async listAccountClaims(tx?: Orm): ServerResultAsync<AccountClaimOutput[]> {
409
+ return this.throwableAsync(async () => {
410
+ const db = tx ?? this.orm;
411
+ const claims = await db
412
+ .select({
413
+ id: this.schema.waitlist.id,
414
+ claimUserId: this.schema.waitlist.claimUserId,
415
+ status: this.schema.waitlist.status,
416
+ expiresAt: this.schema.waitlist.expiresAt,
417
+ claimedAt: this.schema.waitlist.claimedAt,
418
+ claimedEmail: this.schema.waitlist.claimedEmail,
419
+ createdAt: this.schema.waitlist.createdAt,
420
+ updatedAt: this.schema.waitlist.updatedAt,
421
+ })
422
+ .from(this.schema.waitlist)
423
+ .where(eq(this.schema.waitlist.type, "ACCOUNT_CLAIM"))
424
+ .orderBy(desc(this.schema.waitlist.createdAt));
425
+ return ok(claims);
426
+ });
427
+ }
428
+
429
+ async validateAccountClaimCode(code: string, tx?: Orm): ServerResultAsync<{ status: string }> {
430
+ return this.throwableAsync(async () => {
431
+ const db = tx ?? this.orm;
432
+ const [claim] = await db
433
+ .select()
434
+ .from(this.schema.waitlist)
435
+ .where(
436
+ and(eq(this.schema.waitlist.code, code), eq(this.schema.waitlist.type, "ACCOUNT_CLAIM"))
437
+ )
438
+ .limit(1);
439
+ if (!claim) return ok({ status: "NOT_FOUND" });
440
+ if (claim.expiresAt && claim.expiresAt < new Date()) return ok({ status: "EXPIRED" });
441
+ if (claim.status !== "INVITED") return ok({ status: "INVALID" });
442
+ return ok({ status: "VALID" });
443
+ });
444
+ }
445
+
446
+ async findAccountClaimByCode(code: string, tx?: Orm): ServerResultAsync<AccountClaim | null> {
447
+ return this.throwableAsync(async () => {
448
+ const db = tx ?? this.orm;
449
+ const [claim] = await db
450
+ .select()
451
+ .from(this.schema.waitlist)
452
+ .where(
453
+ and(
454
+ eq(this.schema.waitlist.code, code),
455
+ eq(this.schema.waitlist.type, "ACCOUNT_CLAIM"),
456
+ eq(this.schema.waitlist.status, "INVITED"),
457
+ gte(this.schema.waitlist.expiresAt, new Date())
458
+ )
459
+ )
460
+ .limit(1);
461
+ return ok(claim ?? null);
462
+ });
463
+ }
464
+
465
+ async findAccountClaimById(id: string, tx?: Orm): ServerResultAsync<AccountClaim | null> {
466
+ return this.throwableAsync(async () => {
467
+ const db = tx ?? this.orm;
468
+ const [claim] = await db
469
+ .select()
470
+ .from(this.schema.waitlist)
471
+ .where(and(eq(this.schema.waitlist.id, id), eq(this.schema.waitlist.type, "ACCOUNT_CLAIM")))
472
+ .limit(1);
473
+ return ok(claim ?? null);
474
+ });
475
+ }
476
+
477
+ async findPendingAccountClaimForUser(
478
+ userId: string,
479
+ tx?: Orm
480
+ ): ServerResultAsync<AccountClaim | null> {
481
+ return this.throwableAsync(async () => {
482
+ const db = tx ?? this.orm;
483
+ const [claim] = await db
484
+ .select()
485
+ .from(this.schema.waitlist)
486
+ .where(
487
+ and(
488
+ eq(this.schema.waitlist.type, "ACCOUNT_CLAIM"),
489
+ eq(this.schema.waitlist.claimUserId, userId),
490
+ eq(this.schema.waitlist.status, "INVITED"),
491
+ gte(this.schema.waitlist.expiresAt, new Date())
492
+ )
493
+ )
494
+ .orderBy(desc(this.schema.waitlist.createdAt))
495
+ .limit(1);
496
+ return ok(claim ?? null);
497
+ });
498
+ }
499
+
500
+ async setAccountClaimEmail(
501
+ { userId, email }: { userId: string; email: string },
502
+ tx?: Orm
503
+ ): ServerResultAsync<{ status: boolean }> {
504
+ return this.throwableAsync(async () => {
505
+ const db = tx ?? this.orm;
506
+ const normalizedEmail = email.toLowerCase();
507
+ const [existingUser] = await db
508
+ .select({ id: this.schema.users.id })
509
+ .from(this.schema.users)
510
+ .where(and(eq(this.schema.users.email, normalizedEmail), ne(this.schema.users.id, userId)))
511
+ .limit(1);
512
+ if (existingUser) {
513
+ return this.error("BAD_REQUEST", "Email is already in use");
514
+ }
515
+
516
+ const [claim] = await db
517
+ .select({ id: this.schema.waitlist.id })
518
+ .from(this.schema.waitlist)
519
+ .where(
520
+ and(
521
+ eq(this.schema.waitlist.type, "ACCOUNT_CLAIM"),
522
+ eq(this.schema.waitlist.claimUserId, userId),
523
+ eq(this.schema.waitlist.status, "INVITED"),
524
+ gte(this.schema.waitlist.expiresAt, new Date())
525
+ )
526
+ )
527
+ .orderBy(desc(this.schema.waitlist.createdAt))
528
+ .limit(1);
529
+ if (!claim) {
530
+ return this.error("BAD_REQUEST", "No pending claim found");
531
+ }
532
+
533
+ await db
534
+ .update(this.schema.users)
535
+ .set({
536
+ email: normalizedEmail,
537
+ emailVerified: false,
538
+ updatedAt: new Date(),
539
+ })
540
+ .where(eq(this.schema.users.id, userId));
541
+
542
+ await db
543
+ .update(this.schema.waitlist)
544
+ .set({
545
+ claimedEmail: normalizedEmail,
546
+ updatedAt: new Date(),
547
+ })
548
+ .where(eq(this.schema.waitlist.id, claim.id));
549
+
550
+ return ok({ status: true });
551
+ });
552
+ }
553
+
554
+ async acceptAccountClaim(userId: string, tx?: Orm): ServerResultAsync<{ status: boolean }> {
555
+ return this.throwableAsync(async () => {
556
+ const db = tx ?? this.orm;
557
+ const [claim] = await db
558
+ .select({ id: this.schema.waitlist.id })
559
+ .from(this.schema.waitlist)
560
+ .where(
561
+ and(
562
+ eq(this.schema.waitlist.type, "ACCOUNT_CLAIM"),
563
+ eq(this.schema.waitlist.claimUserId, userId),
564
+ eq(this.schema.waitlist.status, "INVITED"),
565
+ gte(this.schema.waitlist.expiresAt, new Date())
566
+ )
567
+ )
568
+ .orderBy(desc(this.schema.waitlist.createdAt))
569
+ .limit(1);
570
+ if (!claim) return ok({ status: true });
571
+
572
+ const [user] = await db
573
+ .select({ email: this.schema.users.email })
574
+ .from(this.schema.users)
575
+ .where(eq(this.schema.users.id, userId))
576
+ .limit(1);
577
+
578
+ await db
579
+ .update(this.schema.waitlist)
580
+ .set({
581
+ status: "ACCEPTED",
582
+ claimedAt: new Date(),
583
+ claimedEmail: user?.email ?? null,
584
+ updatedAt: new Date(),
585
+ })
586
+ .where(eq(this.schema.waitlist.id, claim.id));
587
+ return ok({ status: true });
588
+ });
589
+ }
590
+
591
+ async createAccountClaimMagicLink(
592
+ {
593
+ claimId,
594
+ userId,
595
+ email,
596
+ token,
597
+ url,
598
+ expiresAt,
599
+ }: {
600
+ claimId: string;
601
+ userId: string;
602
+ email: string;
603
+ token: string;
604
+ url: string;
605
+ expiresAt?: Date;
606
+ },
607
+ tx?: Orm
608
+ ): ServerResultAsync<AccountClaimMagicLink> {
609
+ return this.throwableAsync(async () => {
610
+ const db = tx ?? this.orm;
611
+ const [link] = await db
612
+ .insert(this.schema.accountClaimMagicLinks)
613
+ .values({
614
+ claimId,
615
+ userId,
616
+ email,
617
+ token,
618
+ url,
619
+ expiresAt: expiresAt ?? null,
620
+ })
621
+ .returning();
622
+ return ok(link);
623
+ });
624
+ }
625
+
626
+ async listAccountClaimMagicLinks(
627
+ claimId: string,
628
+ tx?: Orm
629
+ ): ServerResultAsync<AccountClaimMagicLinkOutput[]> {
630
+ return this.throwableAsync(async () => {
631
+ const db = tx ?? this.orm;
632
+ const links = await db
633
+ .select({
634
+ id: this.schema.accountClaimMagicLinks.id,
635
+ claimId: this.schema.accountClaimMagicLinks.claimId,
636
+ userId: this.schema.accountClaimMagicLinks.userId,
637
+ email: this.schema.accountClaimMagicLinks.email,
638
+ url: this.schema.accountClaimMagicLinks.url,
639
+ expiresAt: this.schema.accountClaimMagicLinks.expiresAt,
640
+ createdAt: this.schema.accountClaimMagicLinks.createdAt,
641
+ })
642
+ .from(this.schema.accountClaimMagicLinks)
643
+ .where(eq(this.schema.accountClaimMagicLinks.claimId, claimId))
644
+ .orderBy(desc(this.schema.accountClaimMagicLinks.createdAt));
645
+ return ok(links);
646
+ });
647
+ }
648
+
649
+ async latestAccountClaimMagicLink(
650
+ claimId: string,
651
+ tx?: Orm
652
+ ): ServerResultAsync<AccountClaimMagicLinkOutput | null> {
653
+ return this.throwableAsync(async () => {
654
+ const db = tx ?? this.orm;
655
+ const [link] = await db
656
+ .select({
657
+ id: this.schema.accountClaimMagicLinks.id,
658
+ claimId: this.schema.accountClaimMagicLinks.claimId,
659
+ userId: this.schema.accountClaimMagicLinks.userId,
660
+ email: this.schema.accountClaimMagicLinks.email,
661
+ url: this.schema.accountClaimMagicLinks.url,
662
+ expiresAt: this.schema.accountClaimMagicLinks.expiresAt,
663
+ createdAt: this.schema.accountClaimMagicLinks.createdAt,
664
+ })
665
+ .from(this.schema.accountClaimMagicLinks)
666
+ .where(eq(this.schema.accountClaimMagicLinks.claimId, claimId))
667
+ .orderBy(desc(this.schema.accountClaimMagicLinks.createdAt))
668
+ .limit(1);
669
+ return ok(link ?? null);
670
+ });
671
+ }
672
+ }