@tuturuuu/utils 0.0.2 → 0.6.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 (186) hide show
  1. package/CHANGELOG.md +305 -0
  2. package/biome.json +5 -0
  3. package/jsr.json +8 -8
  4. package/package.json +63 -32
  5. package/src/__tests__/ai-temp-auth.test.ts +309 -0
  6. package/src/__tests__/api-proxy-guard.test.ts +1451 -0
  7. package/src/__tests__/app-url.test.ts +270 -0
  8. package/src/__tests__/avatar-url.test.ts +97 -0
  9. package/src/__tests__/color-helper.test.ts +179 -0
  10. package/src/__tests__/constants.test.ts +351 -0
  11. package/src/__tests__/crypto.test.ts +107 -0
  12. package/src/__tests__/date-helper.test.ts +408 -0
  13. package/src/__tests__/fixtures/task-description-full-featured.json +456 -0
  14. package/src/__tests__/format.test.ts +317 -0
  15. package/src/__tests__/html-sanitizer.test.ts +360 -0
  16. package/src/__tests__/interest-calculator.test.ts +336 -0
  17. package/src/__tests__/interest-detector.test.ts +222 -0
  18. package/src/__tests__/label-colors.test.ts +241 -0
  19. package/src/__tests__/name-helper.test.ts +158 -0
  20. package/src/__tests__/node-diff.test.ts +576 -0
  21. package/src/__tests__/notification-service.test.ts +210 -0
  22. package/src/__tests__/onboarding-helper.test.ts +331 -0
  23. package/src/__tests__/path-helper.test.ts +152 -0
  24. package/src/__tests__/permissions.test.tsx +81 -0
  25. package/src/__tests__/request-emoji-limit.test.ts +172 -0
  26. package/src/__tests__/search-helper.test.ts +51 -0
  27. package/src/__tests__/storage-display-name.test.ts +37 -0
  28. package/src/__tests__/storage-path.test.ts +238 -0
  29. package/src/__tests__/tag-utils.test.ts +205 -0
  30. package/src/__tests__/task-description-yjs-state.test.ts +581 -0
  31. package/src/__tests__/task-helper-board-api-routing.test.ts +94 -0
  32. package/src/__tests__/task-helper-create-task.test.ts +129 -0
  33. package/src/__tests__/task-helpers.test.ts +464 -0
  34. package/src/__tests__/task-overrides.test.ts +305 -0
  35. package/src/__tests__/task-reorder-cache.test.ts +74 -0
  36. package/src/__tests__/task-sort-keys.test.ts +36 -0
  37. package/src/__tests__/task-transformers.test.ts +62 -0
  38. package/src/__tests__/text-helper.test.ts +776 -0
  39. package/src/__tests__/time-helper.test.ts +70 -0
  40. package/src/__tests__/time-tracker-period.test.ts +55 -0
  41. package/src/__tests__/timezone.test.ts +117 -0
  42. package/src/__tests__/upstash-rest.test.ts +77 -0
  43. package/src/__tests__/uuid-helper.test.ts +133 -0
  44. package/src/__tests__/workspace-helper.test.ts +859 -0
  45. package/src/__tests__/workspace-limits.test.ts +255 -0
  46. package/src/__tests__/yjs-helper.test.ts +581 -0
  47. package/src/abuse-protection/__tests__/backend-rate-limit.test.ts +113 -0
  48. package/src/abuse-protection/__tests__/edge.test.ts +136 -0
  49. package/src/abuse-protection/__tests__/index.test.ts +562 -0
  50. package/src/abuse-protection/__tests__/reputation.test.ts +192 -0
  51. package/src/abuse-protection/backend-rate-limit.ts +44 -0
  52. package/src/abuse-protection/constants.ts +117 -0
  53. package/src/abuse-protection/edge.ts +223 -0
  54. package/src/abuse-protection/index.ts +1545 -0
  55. package/src/abuse-protection/reputation.ts +587 -0
  56. package/src/abuse-protection/types.ts +97 -0
  57. package/src/abuse-protection/user-agent.ts +124 -0
  58. package/src/abuse-protection/user-suspension.ts +231 -0
  59. package/src/ai-temp-auth.ts +315 -0
  60. package/src/api-proxy-guard.ts +965 -0
  61. package/src/app-url.ts +96 -0
  62. package/src/avatar-url.ts +64 -0
  63. package/src/break-duration.ts +84 -0
  64. package/src/calendar-auth-token.test.ts +37 -0
  65. package/src/calendar-auth-token.ts +19 -0
  66. package/src/calendar-sync-coordination.md +197 -0
  67. package/src/calendar-utils.test.ts +169 -0
  68. package/src/calendar-utils.ts +91 -0
  69. package/src/color-helper.ts +110 -0
  70. package/src/common/nextjs.tsx +99 -0
  71. package/src/common/scan.tsx +15 -0
  72. package/src/configs/reports.ts +160 -0
  73. package/src/constants.ts +85 -0
  74. package/src/crypto.ts +21 -0
  75. package/src/currencies.ts +97 -0
  76. package/src/date-helper.ts +313 -0
  77. package/src/editor/convert-to-task.ts +264 -0
  78. package/src/editor/index.ts +5 -0
  79. package/src/email/__tests__/client.test.ts +141 -0
  80. package/src/email/__tests__/validation.test.ts +46 -0
  81. package/src/email/client.ts +92 -0
  82. package/src/email/server.ts +128 -0
  83. package/src/email/validation.ts +11 -0
  84. package/src/encryption/__tests__/calendar-events.test.ts +411 -0
  85. package/src/encryption/__tests__/configuration.test.ts +114 -0
  86. package/src/encryption/__tests__/field-encryption.test.ts +232 -0
  87. package/src/encryption/__tests__/key-generation.test.ts +30 -0
  88. package/src/encryption/__tests__/performance-edge-cases.test.ts +187 -0
  89. package/src/encryption/__tests__/test-helpers.ts +22 -0
  90. package/src/encryption/__tests__/workspace-key-encryption.test.ts +129 -0
  91. package/src/encryption/encryption-service.ts +343 -0
  92. package/src/encryption/index.ts +25 -0
  93. package/src/encryption/types.ts +57 -0
  94. package/src/exchange-rates.ts +49 -0
  95. package/src/feature-flags/__tests__/feature-flags.test.ts +302 -0
  96. package/src/feature-flags/core.ts +322 -0
  97. package/src/feature-flags/data.ts +16 -0
  98. package/src/feature-flags/default.ts +18 -0
  99. package/src/feature-flags/index.ts +7 -0
  100. package/src/feature-flags/requestable-features.ts +79 -0
  101. package/src/feature-flags/types.ts +4 -0
  102. package/src/fetcher.ts +2 -0
  103. package/src/finance/index.ts +4 -0
  104. package/src/finance/interest-calculator.ts +456 -0
  105. package/src/finance/interest-detector.ts +141 -0
  106. package/src/finance/transform-invoice-results.ts +219 -0
  107. package/src/finance/wallet-permissions.test.ts +169 -0
  108. package/src/finance/wallet-permissions.ts +82 -0
  109. package/src/format.ts +122 -3
  110. package/src/generated/platform-build-metadata.ts +11 -0
  111. package/src/hooks/use-platform.ts +64 -0
  112. package/src/html-sanitizer.ts +155 -0
  113. package/src/internal-domains.ts +497 -0
  114. package/src/keyboard-preset.ts +109 -0
  115. package/src/label-colors.ts +213 -0
  116. package/src/launchable-apps.test.ts +126 -0
  117. package/src/launchable-apps.ts +490 -0
  118. package/src/name-helper.ts +269 -0
  119. package/src/next-config.test.ts +234 -0
  120. package/src/next-config.ts +203 -0
  121. package/src/node-diff.ts +375 -0
  122. package/src/notification-service.ts +379 -0
  123. package/src/nova/scores/__tests__/calculate.test.ts +254 -0
  124. package/src/nova/scores/calculate.ts +132 -0
  125. package/src/nova/submissions/check-permission.ts +132 -0
  126. package/src/onboarding-helper.ts +213 -0
  127. package/src/path-helper.ts +93 -0
  128. package/src/permissions.tsx +1170 -0
  129. package/src/plan-helpers.test.ts +188 -0
  130. package/src/plan-helpers.ts +80 -0
  131. package/src/platform-release.test.ts +74 -0
  132. package/src/platform-release.ts +155 -0
  133. package/src/portless.ts +124 -0
  134. package/src/priority-styles.ts +42 -0
  135. package/src/request-emoji-limit.ts +335 -0
  136. package/src/search-helper.ts +18 -0
  137. package/src/search.test.ts +89 -0
  138. package/src/search.ts +355 -0
  139. package/src/storage-display-name.ts +30 -0
  140. package/src/storage-path.ts +147 -0
  141. package/src/tag-utils.ts +159 -0
  142. package/src/task/reorder.ts +245 -0
  143. package/src/task/transformers.ts +149 -0
  144. package/src/task-date-timezone.ts +133 -0
  145. package/src/task-description-content.ts +240 -0
  146. package/src/task-helper/board.ts +193 -0
  147. package/src/task-helper/bulk-actions.ts +564 -0
  148. package/src/task-helper/personal-external-staging.ts +21 -0
  149. package/src/task-helper/recycle-bin.ts +202 -0
  150. package/src/task-helper/relationships.ts +346 -0
  151. package/src/task-helper/shared.ts +109 -0
  152. package/src/task-helper/sort-keys.ts +337 -0
  153. package/src/task-helper/task-hooks-basic.ts +342 -0
  154. package/src/task-helper/task-hooks-move.ts +264 -0
  155. package/src/task-helper/task-operations.ts +278 -0
  156. package/src/task-helper.ts +12 -0
  157. package/src/task-helpers.ts +241 -0
  158. package/src/task-list-status.ts +62 -0
  159. package/src/task-overrides.ts +82 -0
  160. package/src/task-snapshot.ts +374 -0
  161. package/src/text-diff.ts +81 -0
  162. package/src/text-helper.ts +537 -0
  163. package/src/time-helper.ts +63 -0
  164. package/src/time-tracker-period.ts +73 -0
  165. package/src/timeblock-helper.ts +418 -0
  166. package/src/timezone.ts +190 -0
  167. package/src/timezones.json +1271 -0
  168. package/src/upstash-rest.ts +56 -0
  169. package/src/user-helper.ts +296 -0
  170. package/src/uuid-helper.ts +11 -0
  171. package/src/workspace-handle.ts +10 -0
  172. package/src/workspace-helper.ts +1408 -0
  173. package/src/workspace-limits.ts +68 -0
  174. package/src/yjs-helper.ts +217 -0
  175. package/src/yjs-task-description.ts +81 -0
  176. package/tsconfig.json +3 -5
  177. package/tsconfig.typecheck.json +33 -0
  178. package/vitest.config.ts +36 -0
  179. package/dist/index.d.ts +0 -8
  180. package/dist/index.js +0 -2
  181. package/dist/index.js.map +0 -1
  182. package/dist/index.mjs +0 -2
  183. package/dist/index.mjs.map +0 -1
  184. package/eslint.config.mjs +0 -20
  185. package/rollup.config.js +0 -41
  186. package/src/index.ts +0 -1
@@ -0,0 +1,1408 @@
1
+ import { ENABLE_GUEST_SELF_JOIN_FROM_WORKSPACE_USER_EMAIL_CONFIG_ID } from '@tuturuuu/internal-api/workspace-config-ids';
2
+ import {
3
+ createAdminClient,
4
+ createClient,
5
+ } from '@tuturuuu/supabase/next/server';
6
+ import type { SupabaseUser } from '@tuturuuu/supabase/next/user';
7
+ import type { TypedSupabaseClient } from '@tuturuuu/supabase/types';
8
+ import type {
9
+ PermissionId,
10
+ Workspace,
11
+ WorkspaceProductTier,
12
+ } from '@tuturuuu/types';
13
+ import type { WorkspaceSecret } from '@tuturuuu/types/primitives/WorkspaceSecret';
14
+ import type { NextRequest } from 'next/server';
15
+ import {
16
+ PERSONAL_WORKSPACE_SLUG,
17
+ ROOT_WORKSPACE_ID,
18
+ resolveWorkspaceId,
19
+ } from './constants';
20
+ import { isValidTuturuuuEmail } from './email/client';
21
+ import { permissions as rolePermissions } from './permissions';
22
+
23
+ export class WorkspaceAuthError extends Error {
24
+ constructor(message = 'User not authenticated') {
25
+ super(message);
26
+ this.name = 'WorkspaceAuthError';
27
+ }
28
+ }
29
+
30
+ export class WorkspaceAccessError extends Error {
31
+ constructor(message = 'Workspace access denied') {
32
+ super(message);
33
+ this.name = 'WorkspaceAccessError';
34
+ }
35
+ }
36
+
37
+ export type WorkspaceMemberType = 'MEMBER' | 'GUEST';
38
+
39
+ /** Use `'ANY'` when any workspace_members row (MEMBER or GUEST) should count as access. */
40
+ export type WorkspaceMembershipRequiredType = WorkspaceMemberType | 'ANY';
41
+
42
+ export type WorkspaceMembershipCheckError =
43
+ | 'membership_lookup_failed'
44
+ | 'membership_missing'
45
+ | 'membership_type_mismatch';
46
+
47
+ export interface WorkspaceMembershipCheckResult {
48
+ ok: boolean;
49
+ error?: WorkspaceMembershipCheckError;
50
+ membershipType?: WorkspaceMemberType;
51
+ }
52
+
53
+ export class WorkspaceRedirectRequiredError extends Error {
54
+ public readonly redirectTo: string;
55
+
56
+ constructor(redirectTo: string, message = 'Workspace redirect required') {
57
+ super(message);
58
+ this.name = 'WorkspaceRedirectRequiredError';
59
+ this.redirectTo = redirectTo;
60
+ }
61
+ }
62
+
63
+ type AuthenticatedWorkspacePrincipal = {
64
+ email?: string | null;
65
+ id: string;
66
+ };
67
+
68
+ // Structured logging utility
69
+ const logWorkspaceError = (
70
+ context: string,
71
+ error: unknown,
72
+ metadata?: Record<string, unknown>
73
+ ) => {
74
+ const logData = {
75
+ context,
76
+ error: error instanceof Error ? error.message : error,
77
+ timestamp: new Date().toISOString(),
78
+ ...metadata,
79
+ };
80
+ console.error(`[WorkspaceHelper] ${context}:`, logData);
81
+ };
82
+
83
+ export function isWorkspaceUuidLiteral(value: string): boolean {
84
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
85
+ value.trim()
86
+ );
87
+ }
88
+
89
+ function isDirectWorkspaceLookupIdentifier(id: string): boolean {
90
+ const normalized = id.trim().toLowerCase();
91
+ const workspaceHandlePattern = /^[a-z0-9](?:[a-z0-9_-]{0,62}[a-z0-9])?$/;
92
+
93
+ return (
94
+ normalized === PERSONAL_WORKSPACE_SLUG.toLowerCase() ||
95
+ normalized === ROOT_WORKSPACE_ID.toLowerCase() ||
96
+ normalized === 'internal' ||
97
+ isWorkspaceUuidLiteral(normalized) ||
98
+ workspaceHandlePattern.test(normalized)
99
+ );
100
+ }
101
+
102
+ async function resolveAuthenticatedPrincipal(
103
+ supabase: TypedSupabaseClient
104
+ ): Promise<{ id: string; email: string | null } | null> {
105
+ if (typeof supabase.auth.getClaims === 'function') {
106
+ try {
107
+ const claimsResult = await supabase.auth.getClaims();
108
+ const claimsData = claimsResult?.data;
109
+ const claimsError = claimsResult?.error;
110
+
111
+ if (!claimsError && claimsData?.claims?.sub) {
112
+ return {
113
+ id: claimsData.claims.sub,
114
+ email:
115
+ typeof claimsData.claims.email === 'string'
116
+ ? claimsData.claims.email
117
+ : null,
118
+ };
119
+ }
120
+ } catch {
121
+ console.warn(
122
+ '[resolveAuthenticatedPrincipal] getClaims is unavailable, falling back to getUser. This may be expected in testing environments or older Supabase clients.'
123
+ );
124
+ // Fall back to getUser when getClaims is unavailable in mocks/older clients.
125
+ }
126
+ }
127
+
128
+ const userResult = await supabase.auth.getUser();
129
+ const user = userResult?.data?.user ?? null;
130
+
131
+ if (!user) {
132
+ return null;
133
+ }
134
+
135
+ return { id: user.id, email: user.email ?? null };
136
+ }
137
+
138
+ /**
139
+ * Type for workspace subscription data from Supabase queries
140
+ */
141
+ interface WorkspaceSubscriptionData {
142
+ created_at: string;
143
+ product_id?: string | null;
144
+ product_tier?: WorkspaceProductTier | null;
145
+ status?: string | null;
146
+ workspace_subscription_products?: {
147
+ tier?: WorkspaceProductTier | null;
148
+ } | null;
149
+ }
150
+
151
+ interface WorkspaceSubscriptionTierLookupRow extends WorkspaceSubscriptionData {
152
+ ws_id: string;
153
+ }
154
+
155
+ interface WorkspaceSubscriptionProductTierRow {
156
+ id: string;
157
+ tier: WorkspaceProductTier | null;
158
+ }
159
+
160
+ /**
161
+ * Extracts the tier from workspace subscription data.
162
+ * Filters for active subscriptions and returns the tier from the most recent one.
163
+ *
164
+ * @param subscriptions - Array of workspace subscription data from Supabase
165
+ * @returns The tier from the most recent active subscription, or null if none found
166
+ */
167
+ export function extractTierFromSubscriptions(
168
+ subscriptions: (WorkspaceSubscriptionData | null)[] | null | undefined
169
+ ): WorkspaceProductTier | null {
170
+ if (!subscriptions) return null;
171
+
172
+ const activeSubscriptions = subscriptions
173
+ .filter(
174
+ (sub): sub is WorkspaceSubscriptionData =>
175
+ sub !== null && sub?.status === 'active'
176
+ )
177
+ .sort(
178
+ (a, b) =>
179
+ new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
180
+ );
181
+
182
+ return (
183
+ activeSubscriptions?.[0]?.product_tier ??
184
+ activeSubscriptions?.[0]?.workspace_subscription_products?.tier ??
185
+ null
186
+ );
187
+ }
188
+
189
+ async function getWorkspaceTierMap(
190
+ workspaceIds: string[]
191
+ ): Promise<Map<string, WorkspaceProductTier | null>> {
192
+ if (workspaceIds.length === 0) return new Map();
193
+
194
+ const sbAdmin = await createAdminClient();
195
+ const { data, error } = await sbAdmin
196
+ .from('workspace_subscriptions')
197
+ .select('ws_id, created_at, status, product_id')
198
+ .in('ws_id', workspaceIds);
199
+
200
+ if (error) {
201
+ logWorkspaceError('Failed to fetch workspace subscription tiers', error, {
202
+ workspaceIds,
203
+ errorCode: error.code,
204
+ errorDetails: error.details,
205
+ });
206
+ return new Map(
207
+ workspaceIds.map((workspaceId) => [workspaceId, null] as const)
208
+ );
209
+ }
210
+
211
+ const productIds = [
212
+ ...new Set(
213
+ ((data ?? []) as WorkspaceSubscriptionTierLookupRow[])
214
+ .map((subscription) => subscription.product_id)
215
+ .filter((productId): productId is string => Boolean(productId))
216
+ ),
217
+ ];
218
+ const productTiersById = new Map<string, WorkspaceProductTier | null>();
219
+
220
+ if (productIds.length > 0) {
221
+ const { data: products, error: productsError } = await sbAdmin
222
+ .schema('private')
223
+ .from('workspace_subscription_products')
224
+ .select('id, tier')
225
+ .in('id', productIds);
226
+
227
+ if (productsError) {
228
+ logWorkspaceError(
229
+ 'Failed to fetch workspace subscription products',
230
+ productsError,
231
+ {
232
+ productIds,
233
+ errorCode: productsError.code,
234
+ errorDetails: productsError.details,
235
+ }
236
+ );
237
+ return new Map(
238
+ workspaceIds.map((workspaceId) => [workspaceId, null] as const)
239
+ );
240
+ }
241
+
242
+ for (const product of (products ??
243
+ []) as WorkspaceSubscriptionProductTierRow[]) {
244
+ productTiersById.set(product.id, product.tier);
245
+ }
246
+ }
247
+
248
+ const subscriptionsByWorkspace = new Map<
249
+ string,
250
+ WorkspaceSubscriptionData[]
251
+ >();
252
+
253
+ for (const subscription of (data ??
254
+ []) as WorkspaceSubscriptionTierLookupRow[]) {
255
+ const current = subscriptionsByWorkspace.get(subscription.ws_id) ?? [];
256
+ current.push({
257
+ ...subscription,
258
+ product_tier: subscription.product_id
259
+ ? (productTiersById.get(subscription.product_id) ?? null)
260
+ : null,
261
+ });
262
+ subscriptionsByWorkspace.set(subscription.ws_id, current);
263
+ }
264
+
265
+ return new Map(
266
+ workspaceIds.map((workspaceId) => [
267
+ workspaceId,
268
+ extractTierFromSubscriptions(
269
+ subscriptionsByWorkspace.get(workspaceId) ?? null
270
+ ),
271
+ ])
272
+ );
273
+ }
274
+
275
+ /**
276
+ * Gets the workspace tier for a user by their creator ID.
277
+ * Useful for API routes to check tier requirements without full workspace context.
278
+ *
279
+ * @param creatorId - The user ID of the workspace creator
280
+ * @param options - Optional configuration
281
+ * @returns The workspace tier or 'FREE' as default
282
+ */
283
+ export async function getWorkspaceTierByCreator(
284
+ creatorId: string,
285
+ options: { useAdmin?: boolean } = {}
286
+ ): Promise<WorkspaceProductTier> {
287
+ const supabase = await (options.useAdmin
288
+ ? createAdminClient()
289
+ : createClient());
290
+
291
+ const { data } = await supabase
292
+ .from('workspaces')
293
+ .select('id')
294
+ .eq('creator_id', creatorId)
295
+ .order('created_at', { ascending: false })
296
+ .limit(1)
297
+ .maybeSingle();
298
+
299
+ if (!data?.id) return 'FREE';
300
+
301
+ const tierMap = await getWorkspaceTierMap([data.id]);
302
+ return tierMap.get(data.id) || 'FREE';
303
+ }
304
+
305
+ /**
306
+ * Gets the workspace tier by workspace ID.
307
+ * Useful for API routes to check tier requirements.
308
+ *
309
+ * @param wsId - The workspace ID to check
310
+ * @param options - Optional configuration
311
+ * @returns The workspace tier or 'FREE' as default
312
+ */
313
+ export async function getWorkspaceTier(
314
+ wsId: string,
315
+ options: { useAdmin?: boolean } = {}
316
+ ): Promise<WorkspaceProductTier> {
317
+ const supabase = await (options.useAdmin
318
+ ? createAdminClient()
319
+ : createClient());
320
+
321
+ const resolvedWorkspaceId = resolveWorkspaceId(wsId);
322
+
323
+ const { data } = await supabase
324
+ .from('workspaces')
325
+ .select('id')
326
+ .eq('id', resolvedWorkspaceId)
327
+ .maybeSingle();
328
+
329
+ if (!data?.id) return 'FREE';
330
+
331
+ const tierMap = await getWorkspaceTierMap([data.id]);
332
+ return tierMap.get(data.id) || 'FREE';
333
+ }
334
+
335
+ /**
336
+ * Fetches a workspace by ID or the 'PERSONAL' keyword.
337
+ *
338
+ * @param id - Workspace ID (UUID) or 'PERSONAL' to fetch the current user's personal workspace.
339
+ *
340
+ * @returns The workspace object with a `joined` boolean indicating membership status.
341
+ *
342
+ * Returns `null` when the user is not authenticated or the workspace cannot be fetched.
343
+ * Callers are responsible for handling navigation/response behavior.
344
+ */
345
+ export async function getWorkspace(
346
+ id: string,
347
+ options: {
348
+ useAdmin?: boolean;
349
+ user?: AuthenticatedWorkspacePrincipal | null;
350
+ } = {}
351
+ ): Promise<
352
+ | (Workspace & {
353
+ joined: boolean;
354
+ tier: WorkspaceProductTier | null;
355
+ })
356
+ | null
357
+ > {
358
+ if (!isDirectWorkspaceLookupIdentifier(id)) {
359
+ return null;
360
+ }
361
+
362
+ const supabase = options.user ? null : await createClient();
363
+ const workspaceClient =
364
+ options.useAdmin || options.user
365
+ ? await createAdminClient({ noCookie: Boolean(options.user) })
366
+ : (supabase as TypedSupabaseClient);
367
+ const principal = options.user
368
+ ? {
369
+ email: options.user.email ?? null,
370
+ id: options.user.id,
371
+ }
372
+ : await resolveAuthenticatedPrincipal(supabase as TypedSupabaseClient);
373
+
374
+ if (!principal) return null;
375
+
376
+ const queryBuilder = workspaceClient
377
+ .from('workspaces')
378
+ .select('*, workspace_members!inner(user_id)');
379
+
380
+ const resolvedWorkspaceId = resolveWorkspaceId(id);
381
+
382
+ if (id.toUpperCase() === 'PERSONAL') {
383
+ queryBuilder
384
+ .eq('personal', true)
385
+ .eq('workspace_members.user_id', principal.id);
386
+ } else {
387
+ if (isWorkspaceUuidLiteral(resolvedWorkspaceId)) {
388
+ queryBuilder.eq('id', resolvedWorkspaceId);
389
+ } else {
390
+ queryBuilder.eq('handle', id.trim().toLowerCase());
391
+ }
392
+ }
393
+
394
+ const { data, error } = await queryBuilder.single();
395
+
396
+ // If there's an error, log it for debugging with structured logging
397
+ if (error) {
398
+ logWorkspaceError('Failed to fetch workspace', error, {
399
+ workspaceId: id,
400
+ userId: principal.id,
401
+ errorCode: error.code,
402
+ errorDetails: error.details,
403
+ });
404
+
405
+ return null;
406
+ }
407
+
408
+ const workspaceJoined = data.workspace_members.some(
409
+ (member) => member.user_id === principal.id
410
+ );
411
+ const tierMap = await getWorkspaceTierMap([data.id]);
412
+ const tier = tierMap.get(data.id) ?? null;
413
+
414
+ const { workspace_members: _, ...rest } = data;
415
+
416
+ const ws = {
417
+ ...rest,
418
+ joined: workspaceJoined,
419
+ tier,
420
+ };
421
+
422
+ return ws as Workspace & {
423
+ joined: boolean;
424
+ tier: WorkspaceProductTier | null;
425
+ };
426
+ }
427
+
428
+ export async function getWorkspaces(
429
+ options: {
430
+ useAdmin?: boolean;
431
+ user?: AuthenticatedWorkspacePrincipal | null;
432
+ } = {}
433
+ ) {
434
+ const supabase = options.user ? null : await createClient();
435
+ const workspacesClient =
436
+ options.useAdmin || options.user
437
+ ? await createAdminClient({ noCookie: Boolean(options.user) })
438
+ : (supabase as TypedSupabaseClient);
439
+ const principal = options.user
440
+ ? {
441
+ email: options.user.email ?? null,
442
+ id: options.user.id,
443
+ }
444
+ : await resolveAuthenticatedPrincipal(supabase as TypedSupabaseClient);
445
+
446
+ if (!principal) return null;
447
+
448
+ const { data, error } = await workspacesClient
449
+ .from('workspaces')
450
+ .select(
451
+ 'id, name, avatar_url, logo_url, personal, created_at, workspace_members!inner(user_id)'
452
+ )
453
+ .eq('workspace_members.user_id', principal.id);
454
+
455
+ if (error) {
456
+ logWorkspaceError('Failed to fetch user workspaces', error, {
457
+ userId: principal.id,
458
+ errorCode: error.code,
459
+ errorDetails: error.details,
460
+ });
461
+ return null;
462
+ }
463
+
464
+ const tierMap = await getWorkspaceTierMap(
465
+ data.map((workspace) => workspace.id)
466
+ );
467
+
468
+ return data.map((ws) => {
469
+ return {
470
+ ...ws,
471
+ tier: tierMap.get(ws.id) ?? null,
472
+ };
473
+ });
474
+ }
475
+
476
+ export async function getWorkspaceInvites() {
477
+ const supabase = await createClient();
478
+ const principal = await resolveAuthenticatedPrincipal(supabase);
479
+
480
+ if (!principal) return null;
481
+
482
+ const invitesQuery = supabase
483
+ .from('workspace_invites')
484
+ .select('...workspaces(id, name), created_at')
485
+ .eq('user_id', principal.id);
486
+
487
+ const emailInvitesQuery = principal.email
488
+ ? supabase
489
+ .from('workspace_email_invites')
490
+ .select('...workspaces(id, name), created_at')
491
+ .ilike('email', `%${principal.email}%`)
492
+ : null;
493
+
494
+ // use promise.all to run both queries in parallel
495
+ const [invites, emailInvites] = await Promise.all([
496
+ invitesQuery,
497
+ emailInvitesQuery,
498
+ ]);
499
+
500
+ if (invites.error || emailInvites?.error) return null;
501
+
502
+ const data = [...invites.data, ...(emailInvites?.data || [])] as Workspace[];
503
+ return data;
504
+ }
505
+
506
+ export async function getUnresolvedInquiriesCount() {
507
+ const supabase = await createClient();
508
+ const principal = await resolveAuthenticatedPrincipal(supabase);
509
+
510
+ if (!principal?.email || !isValidTuturuuuEmail(principal.email))
511
+ return { count: 0, latestDate: null };
512
+
513
+ const sbAdmin = await createAdminClient();
514
+
515
+ const { count } = await sbAdmin
516
+ .from('support_inquiries')
517
+ .select('*', { count: 'exact', head: true })
518
+ .eq('is_resolved', false);
519
+
520
+ const { data: latestInquiry } = await sbAdmin
521
+ .from('support_inquiries')
522
+ .select('created_at')
523
+ .eq('is_resolved', false)
524
+ .order('created_at', { ascending: false })
525
+ .limit(1)
526
+ .maybeSingle();
527
+
528
+ return {
529
+ count: count || 0,
530
+ latestDate: latestInquiry?.created_at || null,
531
+ };
532
+ }
533
+
534
+ export function enforceRootWorkspace(
535
+ wsId: string,
536
+ options: {
537
+ redirectTo?: string;
538
+ } = {}
539
+ ) {
540
+ const resolvedWorkspaceId = resolveWorkspaceId(wsId);
541
+ // Check if the workspace is the root workspace
542
+ if (resolvedWorkspaceId === ROOT_WORKSPACE_ID) return;
543
+
544
+ if (options.redirectTo) {
545
+ throw new WorkspaceRedirectRequiredError(options.redirectTo);
546
+ }
547
+
548
+ throw new WorkspaceAccessError('Root workspace required');
549
+ }
550
+
551
+ export async function enforceRootWorkspaceAdmin(
552
+ wsId: string,
553
+ options: {
554
+ redirectTo?: string;
555
+ } = {}
556
+ ) {
557
+ const resolvedWorkspaceId = resolveWorkspaceId(wsId);
558
+ enforceRootWorkspace(resolvedWorkspaceId, options);
559
+
560
+ const supabase = await createClient();
561
+ const principal = await resolveAuthenticatedPrincipal(supabase);
562
+
563
+ if (!principal) throw new WorkspaceAuthError();
564
+
565
+ const membership = await verifyWorkspaceMembershipType({
566
+ wsId: ROOT_WORKSPACE_ID,
567
+ userId: principal.id,
568
+ supabase,
569
+ });
570
+
571
+ if (membership.error === 'membership_lookup_failed' || !membership.ok) {
572
+ if (options.redirectTo) {
573
+ throw new WorkspaceRedirectRequiredError(options.redirectTo);
574
+ }
575
+
576
+ throw new WorkspaceAccessError('Root workspace admin required');
577
+ }
578
+ }
579
+
580
+ export async function getSecrets({
581
+ wsId,
582
+ forceAdmin = false,
583
+ }: {
584
+ wsId?: string;
585
+ forceAdmin?: boolean;
586
+ }) {
587
+ const supabase = await (forceAdmin ? createAdminClient() : createClient());
588
+ const queryBuilder = supabase.from('workspace_secrets').select('*');
589
+
590
+ const resolvedWorkspaceId = wsId ? resolveWorkspaceId(wsId) : undefined;
591
+
592
+ if (resolvedWorkspaceId) queryBuilder.eq('ws_id', resolvedWorkspaceId);
593
+
594
+ const { data, error } = await queryBuilder.order('created_at', {
595
+ ascending: false,
596
+ });
597
+
598
+ if (error) {
599
+ logWorkspaceError('Failed to fetch workspace secrets', error, {
600
+ workspaceId: wsId,
601
+ errorCode: error.code,
602
+ errorDetails: error.details,
603
+ });
604
+ return null;
605
+ }
606
+
607
+ return data as WorkspaceSecret[];
608
+ }
609
+
610
+ export async function verifyHasSecrets(
611
+ wsId: string,
612
+ requiredSecrets: string[]
613
+ ) {
614
+ const secrets = await getSecrets({ wsId, forceAdmin: true });
615
+ if (!secrets) return false;
616
+
617
+ const allSecretsVerified = requiredSecrets.every((secret) => {
618
+ const { value } = getSecret(secret, secrets) || {};
619
+ return value === 'true';
620
+ });
621
+
622
+ return allSecretsVerified;
623
+ }
624
+
625
+ export function getSecret(
626
+ secretName: string,
627
+ secrets: WorkspaceSecret[]
628
+ ): WorkspaceSecret | undefined {
629
+ return secrets.find(({ name }) => name === secretName);
630
+ }
631
+
632
+ export async function verifySecret({
633
+ wsId,
634
+ forceAdmin = false,
635
+ name,
636
+ value,
637
+ }: {
638
+ wsId: string;
639
+ forceAdmin?: boolean;
640
+ name: string;
641
+ value: string;
642
+ }) {
643
+ const secrets = await getSecrets({ wsId, forceAdmin });
644
+ if (!secrets) return false;
645
+ const secret = getSecret(name, secrets);
646
+ return secret?.value === value;
647
+ }
648
+
649
+ export async function getGuestGroup({ groupId }: { groupId: string }) {
650
+ const supabase = await createClient();
651
+ const principal = await resolveAuthenticatedPrincipal(supabase);
652
+
653
+ if (!principal) {
654
+ console.error('Unauthenticated access attempt in getGuestGroup');
655
+ return null;
656
+ }
657
+
658
+ const { data, error } = await supabase.rpc('check_guest_group', {
659
+ group_id: groupId,
660
+ });
661
+
662
+ if (error) {
663
+ console.log(error);
664
+ return null;
665
+ }
666
+
667
+ return data;
668
+ }
669
+
670
+ export interface PermissionsResult {
671
+ membershipType: WorkspaceMemberType;
672
+ permissions: PermissionId[];
673
+ wsId: string;
674
+ containsPermission(permission: PermissionId): boolean;
675
+ withoutPermission(permission: PermissionId): boolean;
676
+ }
677
+
678
+ async function resolveWorkspaceIdForPermissions({
679
+ authorizationClient,
680
+ principal,
681
+ wsId,
682
+ }: {
683
+ authorizationClient: TypedSupabaseClient;
684
+ principal: { email: string | null; id: string };
685
+ wsId: string;
686
+ }) {
687
+ if (wsId.trim().toLowerCase() !== PERSONAL_WORKSPACE_SLUG) {
688
+ return normalizeWorkspaceId(wsId, authorizationClient);
689
+ }
690
+
691
+ const { data: workspace, error } = await authorizationClient
692
+ .from('workspaces')
693
+ .select('id, workspace_members!inner(user_id, type)')
694
+ .eq('personal', true)
695
+ .eq('workspace_members.user_id', principal.id)
696
+ .eq('workspace_members.type', 'MEMBER')
697
+ .maybeSingle();
698
+
699
+ if (error || !workspace?.id) {
700
+ throw new Error('Personal workspace not found');
701
+ }
702
+
703
+ return workspace.id;
704
+ }
705
+
706
+ export async function getPermissions({
707
+ user,
708
+ wsId,
709
+ request,
710
+ }: {
711
+ user?: AuthenticatedWorkspacePrincipal | null;
712
+ wsId: string;
713
+ request?: Request;
714
+ }): Promise<PermissionsResult | null> {
715
+ const supabase = user
716
+ ? null
717
+ : await (request ? createClient(request) : createClient());
718
+
719
+ const principal = user
720
+ ? { id: user.id, email: user.email ?? null }
721
+ : await resolveAuthenticatedPrincipal(supabase as TypedSupabaseClient);
722
+
723
+ if (!principal) {
724
+ console.error('User not found');
725
+ return null;
726
+ }
727
+
728
+ const userId = principal.id;
729
+
730
+ const sbAdmin = await createAdminClient({ noCookie: Boolean(user) });
731
+ const authorizationClient = user ? sbAdmin : supabase;
732
+
733
+ // Handle "personal" workspace slug by looking up the user's personal workspace
734
+ let resolvedWorkspaceId: string;
735
+ try {
736
+ resolvedWorkspaceId = await resolveWorkspaceIdForPermissions({
737
+ authorizationClient: authorizationClient as TypedSupabaseClient,
738
+ principal,
739
+ wsId,
740
+ });
741
+ } catch {
742
+ return null;
743
+ }
744
+
745
+ const membership = await verifyWorkspaceMembershipType({
746
+ wsId: resolvedWorkspaceId,
747
+ userId,
748
+ supabase: authorizationClient as TypedSupabaseClient,
749
+ requiredType: 'ANY',
750
+ });
751
+
752
+ if (!membership.ok) {
753
+ return null;
754
+ }
755
+
756
+ const membershipType = membership.membershipType ?? 'MEMBER';
757
+
758
+ const permissionsQuery =
759
+ membershipType === 'MEMBER'
760
+ ? sbAdmin
761
+ .from('workspace_role_members')
762
+ .select(
763
+ 'workspace_roles!inner(workspace_role_permissions(permission))'
764
+ )
765
+ .eq('user_id', userId)
766
+ .eq('workspace_roles.ws_id', resolvedWorkspaceId)
767
+ .eq('workspace_roles.workspace_role_permissions.enabled', true)
768
+ : Promise.resolve({ data: [], error: null });
769
+
770
+ const workspaceQuery = sbAdmin
771
+ .from('workspaces')
772
+ .select('creator_id')
773
+ .eq('id', resolvedWorkspaceId)
774
+ .single();
775
+
776
+ const defaultQuery = sbAdmin
777
+ .from('workspace_default_permissions')
778
+ .select('permission')
779
+ .eq('ws_id', resolvedWorkspaceId)
780
+ .eq('member_type', membershipType)
781
+ .eq('enabled', true);
782
+
783
+ const [permissionsRes, workspaceRes, defaultRes] = await Promise.all([
784
+ permissionsQuery,
785
+ workspaceQuery,
786
+ defaultQuery,
787
+ ]);
788
+
789
+ const { data: permissionsData, error: permissionsError } = permissionsRes;
790
+ const { data: workspaceData, error: workspaceError } = workspaceRes;
791
+ const { data: defaultData, error: defaultError } = defaultRes;
792
+
793
+ if (!workspaceData) {
794
+ console.info('Workspace not found in getPermissions', resolvedWorkspaceId);
795
+ return null;
796
+ }
797
+
798
+ if (permissionsError) return null;
799
+ if (workspaceError) return null;
800
+ if (defaultError) return null;
801
+
802
+ const isCreator =
803
+ membershipType === 'MEMBER' && workspaceData.creator_id === userId;
804
+ const hasPermissions =
805
+ permissionsData.length > 0 || defaultData.length > 0 || isCreator;
806
+
807
+ // if (DEV_MODE) {
808
+ // console.log('--------------------');
809
+ // console.log('Is creator', isCreator);
810
+ // console.log('Workspace permissions', permissionsData);
811
+ // console.log('Default permissions', defaultData);
812
+ // console.log('Has permissions', hasPermissions);
813
+ // console.log('--------------------');
814
+ // }
815
+
816
+ if (!isCreator && !hasPermissions) {
817
+ return null;
818
+ }
819
+
820
+ const permissions = isCreator
821
+ ? rolePermissions({
822
+ wsId: resolvedWorkspaceId,
823
+ user: { id: userId } as SupabaseUser,
824
+ }).map(({ id }) => id)
825
+ : [
826
+ // permissions from role memberships
827
+ ...permissionsData.flatMap(
828
+ (m) =>
829
+ m.workspace_roles?.workspace_role_permissions?.map(
830
+ (p) => p.permission
831
+ ) || []
832
+ ),
833
+ // default workspace permissions
834
+ ...defaultData.map((d) => d.permission),
835
+ ].filter((value, index, self) => self.indexOf(value) === index);
836
+
837
+ const isAdmin = permissions.includes('admin');
838
+
839
+ const containsPermission = (permission: PermissionId) => {
840
+ const hasPermission =
841
+ isCreator || isAdmin || permissions.includes(permission);
842
+ // console.log(permission, 'is allowed:', hasPermission);
843
+ return hasPermission;
844
+ };
845
+
846
+ const withoutPermission = (permission: PermissionId) =>
847
+ !containsPermission(permission);
848
+
849
+ return {
850
+ membershipType,
851
+ permissions,
852
+ wsId: resolvedWorkspaceId,
853
+ containsPermission,
854
+ withoutPermission,
855
+ };
856
+ }
857
+
858
+ export async function verifyWorkspaceMembershipType({
859
+ requiredType = 'MEMBER',
860
+ supabase,
861
+ userId,
862
+ wsId,
863
+ }: {
864
+ wsId: string;
865
+ userId: string;
866
+ supabase: TypedSupabaseClient;
867
+ requiredType?: WorkspaceMembershipRequiredType;
868
+ }): Promise<WorkspaceMembershipCheckResult> {
869
+ const { data: membership, error } = await supabase
870
+ .from('workspace_members')
871
+ .select('type')
872
+ .eq('ws_id', wsId)
873
+ .eq('user_id', userId)
874
+ .maybeSingle();
875
+
876
+ if (error) {
877
+ return { ok: false, error: 'membership_lookup_failed' };
878
+ }
879
+
880
+ if (!membership) {
881
+ return { ok: false, error: 'membership_missing' };
882
+ }
883
+
884
+ const membershipType = membership.type;
885
+
886
+ if (requiredType === 'ANY') {
887
+ return {
888
+ ok: true,
889
+ membershipType,
890
+ };
891
+ }
892
+
893
+ if (membershipType !== requiredType) {
894
+ return {
895
+ ok: false,
896
+ error: 'membership_type_mismatch',
897
+ membershipType,
898
+ };
899
+ }
900
+
901
+ return {
902
+ ok: true,
903
+ membershipType,
904
+ };
905
+ }
906
+
907
+ export interface GetWorkspaceUserOptions {
908
+ /**
909
+ * If true (default), automatically creates a missing workspace_user_linked_users entry
910
+ * when a workspace member doesn't have one. This uses the ensure_workspace_user_link RPC.
911
+ */
912
+ autoRepair?: boolean;
913
+ }
914
+
915
+ /**
916
+ * Gets the workspace user link for a specific user in a workspace.
917
+ * Optionally auto-repairs missing links (enabled by default).
918
+ *
919
+ * @param id - The workspace ID (can be a UUID or special identifier like 'personal')
920
+ * @param userId - The platform user ID to look up
921
+ * @param options - Configuration options
922
+ * @returns The workspace user link data or null if not found/repairable
923
+ */
924
+ export async function getWorkspaceUser(
925
+ id: string,
926
+ userId: string,
927
+ options: GetWorkspaceUserOptions = {}
928
+ ) {
929
+ const { autoRepair = true } = options;
930
+ const supabase = await createClient();
931
+
932
+ const resolvedWorkspaceId = resolveWorkspaceId(id);
933
+
934
+ // First attempt to get the workspace user link
935
+ const { data, error } = await supabase
936
+ .from('workspace_user_linked_users')
937
+ .select('*')
938
+ .eq('ws_id', resolvedWorkspaceId)
939
+ .eq('platform_user_id', userId)
940
+ .single();
941
+
942
+ // If found, return it
943
+ if (data && !error) {
944
+ return data;
945
+ }
946
+
947
+ // If not found and auto-repair is disabled, throw
948
+ if (!autoRepair) {
949
+ console.error('Error fetching workspace user:', error);
950
+ return null;
951
+ }
952
+
953
+ const membership = await verifyWorkspaceMembershipType({
954
+ wsId: resolvedWorkspaceId,
955
+ userId,
956
+ supabase,
957
+ requiredType: 'MEMBER',
958
+ });
959
+
960
+ if (!membership.ok) {
961
+ console.error(
962
+ 'User is not a workspace member, cannot create workspace user link:',
963
+ { userId, wsId: resolvedWorkspaceId }
964
+ );
965
+ return null;
966
+ }
967
+
968
+ // Try to repair the missing link using the RPC function
969
+ try {
970
+ const sbAdmin = await createAdminClient();
971
+ // Note: ensure_workspace_user_link is defined in migration 20260112060000
972
+ // Using type assertion since RPC types are generated after migration is applied
973
+ const rpc = sbAdmin.rpc as unknown as (
974
+ fn: string,
975
+ args: Record<string, unknown>
976
+ ) => Promise<{ error: Error | null }>;
977
+ const { error: repairError } = await rpc('ensure_workspace_user_link', {
978
+ target_user_id: userId,
979
+ target_ws_id: resolvedWorkspaceId,
980
+ });
981
+
982
+ if (repairError) {
983
+ console.error(
984
+ '[getWorkspaceUser] Failed to auto-repair workspace user link:',
985
+ repairError
986
+ );
987
+ return null;
988
+ }
989
+
990
+ // Fetch the newly created link
991
+ const { data: repairedData, error: fetchError } = await supabase
992
+ .from('workspace_user_linked_users')
993
+ .select('*')
994
+ .eq('ws_id', resolvedWorkspaceId)
995
+ .eq('platform_user_id', userId)
996
+ .single();
997
+
998
+ if (fetchError || !repairedData) {
999
+ console.error(
1000
+ '[getWorkspaceUser] Failed to fetch repaired workspace user link:',
1001
+ fetchError
1002
+ );
1003
+ return null;
1004
+ }
1005
+
1006
+ return repairedData;
1007
+ } catch (err) {
1008
+ console.error('[getWorkspaceUser] Error during auto-repair:', err);
1009
+ return null;
1010
+ }
1011
+ }
1012
+
1013
+ /**
1014
+ * Check if a workspace ID corresponds to a personal workspace
1015
+ * @param workspaceId - The workspace ID to check
1016
+ * @returns true if the workspace is personal, false otherwise
1017
+ */
1018
+ export async function isPersonalWorkspace(
1019
+ workspaceId: string
1020
+ ): Promise<boolean> {
1021
+ const resolvedWorkspaceId = resolveWorkspaceId(workspaceId).trim();
1022
+
1023
+ if (!isWorkspaceUuidLiteral(resolvedWorkspaceId)) {
1024
+ return false;
1025
+ }
1026
+
1027
+ const supabase = await createClient();
1028
+
1029
+ const { data, error } = await supabase
1030
+ .from('workspaces')
1031
+ .select('personal')
1032
+ .eq('id', resolvedWorkspaceId)
1033
+ .maybeSingle();
1034
+
1035
+ if (error) {
1036
+ console.error('Error checking if workspace is personal:', error);
1037
+ return false;
1038
+ }
1039
+
1040
+ return data?.personal === true;
1041
+ }
1042
+
1043
+ export async function normalizeWorkspaceId(
1044
+ wsId: string,
1045
+ supabase?: TypedSupabaseClient,
1046
+ request?: NextRequest
1047
+ ): Promise<string> {
1048
+ // Use provided client, or create one from request (for mobile Bearer auth)
1049
+ // or fall back to cookie-based client
1050
+ const sb =
1051
+ supabase ??
1052
+ (request != null ? await createClient(request) : await createClient());
1053
+ const resolvedWorkspaceId = resolveWorkspaceId(wsId);
1054
+
1055
+ if (resolvedWorkspaceId === ROOT_WORKSPACE_ID) {
1056
+ return ROOT_WORKSPACE_ID;
1057
+ }
1058
+
1059
+ if (wsId.toLowerCase() === PERSONAL_WORKSPACE_SLUG) {
1060
+ const principal = await resolveAuthenticatedPrincipal(sb);
1061
+
1062
+ if (!principal) {
1063
+ throw new Error('User not authenticated');
1064
+ }
1065
+
1066
+ const userId = principal.id;
1067
+
1068
+ const { data: workspace, error } = await sb
1069
+ .from('workspaces')
1070
+ .select('id, workspace_members!inner(user_id, type)')
1071
+ .eq('personal', true)
1072
+ .eq('workspace_members.user_id', userId)
1073
+ .eq('workspace_members.type', 'MEMBER')
1074
+ .maybeSingle();
1075
+
1076
+ if (error || !workspace) {
1077
+ throw new Error('Personal workspace not found');
1078
+ }
1079
+
1080
+ return workspace.id;
1081
+ }
1082
+
1083
+ if (!isWorkspaceUuidLiteral(resolvedWorkspaceId)) {
1084
+ const handle = wsId.trim().toLowerCase();
1085
+ if (!isDirectWorkspaceLookupIdentifier(handle)) {
1086
+ return resolvedWorkspaceId;
1087
+ }
1088
+
1089
+ const { data: workspaceByHandle } = await sb
1090
+ .from('workspaces')
1091
+ .select('id')
1092
+ .eq('handle', handle)
1093
+ .maybeSingle();
1094
+
1095
+ if (workspaceByHandle?.id) {
1096
+ return workspaceByHandle.id;
1097
+ }
1098
+
1099
+ // Handle resolution should not depend on caller membership because
1100
+ // normalizeWorkspaceId is used by pre-membership flows (invite accept, etc.).
1101
+ const sbAdmin = await createAdminClient();
1102
+ const { data: workspaceByHandleAdmin } = await sbAdmin
1103
+ .from('workspaces')
1104
+ .select('id')
1105
+ .eq('handle', handle)
1106
+ .maybeSingle();
1107
+
1108
+ if (workspaceByHandleAdmin?.id) {
1109
+ return workspaceByHandleAdmin.id;
1110
+ }
1111
+ }
1112
+
1113
+ return resolvedWorkspaceId;
1114
+ }
1115
+
1116
+ /**
1117
+ * Fetches a workspace configuration by ID.
1118
+ *
1119
+ * @param wsId - The workspace ID
1120
+ * @param configId - The configuration ID
1121
+ * @returns The configuration value or null if not found
1122
+ */
1123
+ async function fetchWorkspaceConfigValue(
1124
+ wsId: string,
1125
+ configId: string
1126
+ ): Promise<{ error: unknown; value: string | null }> {
1127
+ const sbAdmin = await createAdminClient();
1128
+
1129
+ // Skip normalization if already a valid UUID (avoids auth check in admin context)
1130
+ const resolvedWorkspaceId = isWorkspaceUuidLiteral(wsId)
1131
+ ? wsId
1132
+ : await normalizeWorkspaceId(wsId);
1133
+
1134
+ const { data, error } = await sbAdmin
1135
+ .from('workspace_configs')
1136
+ .select('value')
1137
+ .eq('ws_id', resolvedWorkspaceId)
1138
+ .eq('id', configId)
1139
+ .maybeSingle();
1140
+
1141
+ return {
1142
+ error,
1143
+ value: data?.value || null,
1144
+ };
1145
+ }
1146
+
1147
+ export async function getWorkspaceConfig(
1148
+ wsId: string,
1149
+ configId: string
1150
+ ): Promise<string | null> {
1151
+ const { error, value } = await fetchWorkspaceConfigValue(wsId, configId);
1152
+
1153
+ if (error) {
1154
+ logWorkspaceError('Failed to fetch workspace config', error, {
1155
+ workspaceId: wsId,
1156
+ configId,
1157
+ errorCode:
1158
+ typeof error === 'object' && 'code' in error ? error.code : undefined,
1159
+ });
1160
+ return null;
1161
+ }
1162
+
1163
+ return value;
1164
+ }
1165
+
1166
+ /** Result of {@link getWorkspaceNonMemberInviteEligibility} (user not in `workspace_members` yet). */
1167
+ export type WorkspaceNonMemberInviteEligibility = {
1168
+ /** Set when `workspace_invites` or `workspace_email_invites` has a row for this user. */
1169
+ hasPendingInvite: boolean;
1170
+ /**
1171
+ * Guest self-join is allowed: workspace config is on and
1172
+ * `resolve_guest_self_join_candidate` returns eligible.
1173
+ */
1174
+ allowGuestSelfJoin: boolean;
1175
+ };
1176
+
1177
+ type GuestSelfJoinCandidateRpcRow = {
1178
+ eligible: boolean;
1179
+ matched_email_source: string | null;
1180
+ reason: string | null;
1181
+ virtual_user_id: string | null;
1182
+ };
1183
+
1184
+ export type GuestSelfJoinCandidateResult = {
1185
+ allowGuestSelfJoin: boolean;
1186
+ candidateEmails: string[];
1187
+ guestSelfJoinEnabled: boolean;
1188
+ matchedEmailSource: string | null;
1189
+ reason: string | null;
1190
+ virtualUserId: string | null;
1191
+ };
1192
+
1193
+ export async function resolveGuestSelfJoinCandidate(
1194
+ supabase: TypedSupabaseClient,
1195
+ params: {
1196
+ authEmail: string | null;
1197
+ rpcSupabase: TypedSupabaseClient;
1198
+ privateEmail?: string | null;
1199
+ userId: string;
1200
+ workspaceId: string;
1201
+ }
1202
+ ): Promise<GuestSelfJoinCandidateResult> {
1203
+ const { authEmail, privateEmail, rpcSupabase, userId, workspaceId } = params;
1204
+ const authEmailNorm = authEmail?.trim().toLowerCase() || null;
1205
+
1206
+ let privateEmailNorm =
1207
+ typeof privateEmail === 'string'
1208
+ ? privateEmail.trim().toLowerCase() || null
1209
+ : null;
1210
+
1211
+ if (privateEmail === undefined) {
1212
+ const { data: privateDetails, error: privateDetailsError } = await supabase
1213
+ .from('user_private_details')
1214
+ .select('email')
1215
+ .eq('user_id', userId)
1216
+ .maybeSingle();
1217
+
1218
+ if (privateDetailsError) {
1219
+ logWorkspaceError(
1220
+ 'Failed to fetch private email for guest self-join candidate',
1221
+ privateDetailsError,
1222
+ {
1223
+ userId,
1224
+ workspaceId,
1225
+ errorCode: privateDetailsError.code,
1226
+ }
1227
+ );
1228
+ throw privateDetailsError;
1229
+ }
1230
+
1231
+ privateEmailNorm = privateDetails?.email?.trim().toLowerCase() || null;
1232
+ }
1233
+
1234
+ const candidateEmails = [
1235
+ ...new Set([authEmailNorm, privateEmailNorm]),
1236
+ ].filter(
1237
+ (email): email is string => typeof email === 'string' && email.length > 0
1238
+ );
1239
+
1240
+ const guestSelfJoinConfig = await fetchWorkspaceConfigValue(
1241
+ workspaceId,
1242
+ ENABLE_GUEST_SELF_JOIN_FROM_WORKSPACE_USER_EMAIL_CONFIG_ID
1243
+ );
1244
+
1245
+ if (guestSelfJoinConfig.error) {
1246
+ logWorkspaceError(
1247
+ 'Failed to fetch guest self-join workspace config',
1248
+ guestSelfJoinConfig.error,
1249
+ {
1250
+ workspaceId,
1251
+ configId: ENABLE_GUEST_SELF_JOIN_FROM_WORKSPACE_USER_EMAIL_CONFIG_ID,
1252
+ }
1253
+ );
1254
+ throw guestSelfJoinConfig.error;
1255
+ }
1256
+
1257
+ const guestSelfJoinEnabled =
1258
+ guestSelfJoinConfig.value?.trim().toLowerCase() === 'true';
1259
+
1260
+ if (!guestSelfJoinEnabled) {
1261
+ return {
1262
+ allowGuestSelfJoin: false,
1263
+ candidateEmails,
1264
+ guestSelfJoinEnabled,
1265
+ matchedEmailSource: null,
1266
+ reason: null,
1267
+ virtualUserId: null,
1268
+ };
1269
+ }
1270
+
1271
+ const { data: guestCandidate, error: guestCandidateError } =
1272
+ await rpcSupabase.rpc('resolve_guest_self_join_candidate', {
1273
+ p_user_id: userId,
1274
+ p_ws_id: workspaceId,
1275
+ });
1276
+
1277
+ if (guestCandidateError) {
1278
+ logWorkspaceError(
1279
+ 'Failed to resolve guest self-join candidate',
1280
+ guestCandidateError,
1281
+ {
1282
+ userId,
1283
+ workspaceId,
1284
+ hasAuthEmail: authEmailNorm !== null,
1285
+ hasPrivateEmail: privateEmailNorm !== null,
1286
+ }
1287
+ );
1288
+ throw guestCandidateError;
1289
+ }
1290
+
1291
+ const candidate = (guestCandidate?.[0] ??
1292
+ null) as GuestSelfJoinCandidateRpcRow | null;
1293
+
1294
+ if (
1295
+ candidate?.reason === 'unauthorized' ||
1296
+ candidate?.reason === 'forbidden'
1297
+ ) {
1298
+ logWorkspaceError(
1299
+ 'Guest self-join candidate RPC denied authorization',
1300
+ new Error(candidate.reason),
1301
+ {
1302
+ userId,
1303
+ workspaceId,
1304
+ reason: candidate.reason,
1305
+ }
1306
+ );
1307
+ }
1308
+
1309
+ return {
1310
+ allowGuestSelfJoin: candidate?.eligible === true,
1311
+ candidateEmails,
1312
+ guestSelfJoinEnabled,
1313
+ matchedEmailSource: candidate?.matched_email_source ?? null,
1314
+ reason: candidate?.reason ?? null,
1315
+ virtualUserId: candidate?.virtual_user_id ?? null,
1316
+ };
1317
+ }
1318
+
1319
+ /**
1320
+ * Whether the workspace invite/accept flow may be shown (matches what
1321
+ * `POST /api/workspaces/:wsId/accept-invite` can accept without `NO_PENDING_INVITE_FOUND`).
1322
+ */
1323
+ export function canShowWorkspaceInviteForNonMember(
1324
+ eligibility: WorkspaceNonMemberInviteEligibility
1325
+ ): boolean {
1326
+ return eligibility.hasPendingInvite || eligibility.allowGuestSelfJoin;
1327
+ }
1328
+
1329
+ /**
1330
+ * For a user who is not yet a member of the workspace, determines if they have a pending
1331
+ * direct/email invite and/or are eligible for guest self-join. Use with
1332
+ * {@link canShowWorkspaceInviteForNonMember} before rendering the invite card.
1333
+ *
1334
+ * @param supabase - Prefer the service-role / admin client so invite rows are visible regardless of RLS.
1335
+ */
1336
+ export async function getWorkspaceNonMemberInviteEligibility(
1337
+ supabase: TypedSupabaseClient,
1338
+ params: {
1339
+ workspaceId: string;
1340
+ userId: string;
1341
+ authEmail: string | null;
1342
+ /** Authenticated request-scoped client used for RPCs that depend on auth.uid(). */
1343
+ rpcSupabase: TypedSupabaseClient;
1344
+ }
1345
+ ): Promise<WorkspaceNonMemberInviteEligibility> {
1346
+ const { workspaceId, userId, authEmail, rpcSupabase } = params;
1347
+ const guestSelfJoinCandidate = await resolveGuestSelfJoinCandidate(supabase, {
1348
+ authEmail,
1349
+ rpcSupabase,
1350
+ userId,
1351
+ workspaceId,
1352
+ });
1353
+
1354
+ const { data: pendingUserInvite, error: pendingUserInviteError } =
1355
+ await supabase
1356
+ .from('workspace_invites')
1357
+ .select('ws_id')
1358
+ .eq('ws_id', workspaceId)
1359
+ .eq('user_id', userId)
1360
+ .maybeSingle();
1361
+
1362
+ if (pendingUserInviteError) {
1363
+ logWorkspaceError(
1364
+ 'Failed to fetch pending workspace invite',
1365
+ pendingUserInviteError,
1366
+ {
1367
+ userId,
1368
+ workspaceId,
1369
+ errorCode: pendingUserInviteError.code,
1370
+ }
1371
+ );
1372
+ throw pendingUserInviteError;
1373
+ }
1374
+
1375
+ const candidateEmails = guestSelfJoinCandidate.candidateEmails;
1376
+
1377
+ const { data: pendingEmailInvites, error: pendingEmailInvitesError } =
1378
+ candidateEmails.length
1379
+ ? await supabase
1380
+ .from('workspace_email_invites')
1381
+ .select('ws_id')
1382
+ .eq('ws_id', workspaceId)
1383
+ .in('email', candidateEmails)
1384
+ : { data: null, error: null };
1385
+
1386
+ if (pendingEmailInvitesError) {
1387
+ logWorkspaceError(
1388
+ 'Failed to fetch pending workspace email invites',
1389
+ pendingEmailInvitesError,
1390
+ {
1391
+ workspaceId,
1392
+ candidateEmailCount: candidateEmails.length,
1393
+ errorCode: pendingEmailInvitesError.code,
1394
+ }
1395
+ );
1396
+ throw pendingEmailInvitesError;
1397
+ }
1398
+
1399
+ const hasPendingEmailInvite =
1400
+ Array.isArray(pendingEmailInvites) && pendingEmailInvites.length > 0;
1401
+
1402
+ const hasPendingInvite = !!(pendingUserInvite || hasPendingEmailInvite);
1403
+
1404
+ return {
1405
+ allowGuestSelfJoin: guestSelfJoinCandidate.allowGuestSelfJoin,
1406
+ hasPendingInvite,
1407
+ };
1408
+ }