@tuturuuu/utils 0.0.3 → 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.
- package/CHANGELOG.md +305 -0
- package/biome.json +5 -0
- package/jsr.json +8 -8
- package/package.json +63 -32
- package/src/__tests__/ai-temp-auth.test.ts +309 -0
- package/src/__tests__/api-proxy-guard.test.ts +1451 -0
- package/src/__tests__/app-url.test.ts +270 -0
- package/src/__tests__/avatar-url.test.ts +97 -0
- package/src/__tests__/color-helper.test.ts +179 -0
- package/src/__tests__/constants.test.ts +351 -0
- package/src/__tests__/crypto.test.ts +107 -0
- package/src/__tests__/date-helper.test.ts +408 -0
- package/src/__tests__/fixtures/task-description-full-featured.json +456 -0
- package/src/__tests__/format.test.ts +317 -0
- package/src/__tests__/html-sanitizer.test.ts +360 -0
- package/src/__tests__/interest-calculator.test.ts +336 -0
- package/src/__tests__/interest-detector.test.ts +222 -0
- package/src/__tests__/label-colors.test.ts +241 -0
- package/src/__tests__/name-helper.test.ts +158 -0
- package/src/__tests__/node-diff.test.ts +576 -0
- package/src/__tests__/notification-service.test.ts +210 -0
- package/src/__tests__/onboarding-helper.test.ts +331 -0
- package/src/__tests__/path-helper.test.ts +152 -0
- package/src/__tests__/permissions.test.tsx +81 -0
- package/src/__tests__/request-emoji-limit.test.ts +172 -0
- package/src/__tests__/search-helper.test.ts +51 -0
- package/src/__tests__/storage-display-name.test.ts +37 -0
- package/src/__tests__/storage-path.test.ts +238 -0
- package/src/__tests__/tag-utils.test.ts +205 -0
- package/src/__tests__/task-description-yjs-state.test.ts +581 -0
- package/src/__tests__/task-helper-board-api-routing.test.ts +94 -0
- package/src/__tests__/task-helper-create-task.test.ts +129 -0
- package/src/__tests__/task-helpers.test.ts +464 -0
- package/src/__tests__/task-overrides.test.ts +305 -0
- package/src/__tests__/task-reorder-cache.test.ts +74 -0
- package/src/__tests__/task-sort-keys.test.ts +36 -0
- package/src/__tests__/task-transformers.test.ts +62 -0
- package/src/__tests__/text-helper.test.ts +776 -0
- package/src/__tests__/time-helper.test.ts +70 -0
- package/src/__tests__/time-tracker-period.test.ts +55 -0
- package/src/__tests__/timezone.test.ts +117 -0
- package/src/__tests__/upstash-rest.test.ts +77 -0
- package/src/__tests__/uuid-helper.test.ts +133 -0
- package/src/__tests__/workspace-helper.test.ts +859 -0
- package/src/__tests__/workspace-limits.test.ts +255 -0
- package/src/__tests__/yjs-helper.test.ts +581 -0
- package/src/abuse-protection/__tests__/backend-rate-limit.test.ts +113 -0
- package/src/abuse-protection/__tests__/edge.test.ts +136 -0
- package/src/abuse-protection/__tests__/index.test.ts +562 -0
- package/src/abuse-protection/__tests__/reputation.test.ts +192 -0
- package/src/abuse-protection/backend-rate-limit.ts +44 -0
- package/src/abuse-protection/constants.ts +117 -0
- package/src/abuse-protection/edge.ts +223 -0
- package/src/abuse-protection/index.ts +1545 -0
- package/src/abuse-protection/reputation.ts +587 -0
- package/src/abuse-protection/types.ts +97 -0
- package/src/abuse-protection/user-agent.ts +124 -0
- package/src/abuse-protection/user-suspension.ts +231 -0
- package/src/ai-temp-auth.ts +315 -0
- package/src/api-proxy-guard.ts +965 -0
- package/src/app-url.ts +96 -0
- package/src/avatar-url.ts +64 -0
- package/src/break-duration.ts +84 -0
- package/src/calendar-auth-token.test.ts +37 -0
- package/src/calendar-auth-token.ts +19 -0
- package/src/calendar-sync-coordination.md +197 -0
- package/src/calendar-utils.test.ts +169 -0
- package/src/calendar-utils.ts +91 -0
- package/src/color-helper.ts +110 -0
- package/src/common/nextjs.tsx +99 -0
- package/src/common/scan.tsx +15 -0
- package/src/configs/reports.ts +160 -0
- package/src/constants.ts +85 -0
- package/src/crypto.ts +21 -0
- package/src/currencies.ts +97 -0
- package/src/date-helper.ts +313 -0
- package/src/editor/convert-to-task.ts +264 -0
- package/src/editor/index.ts +5 -0
- package/src/email/__tests__/client.test.ts +141 -0
- package/src/email/__tests__/validation.test.ts +46 -0
- package/src/email/client.ts +92 -0
- package/src/email/server.ts +128 -0
- package/src/email/validation.ts +11 -0
- package/src/encryption/__tests__/calendar-events.test.ts +411 -0
- package/src/encryption/__tests__/configuration.test.ts +114 -0
- package/src/encryption/__tests__/field-encryption.test.ts +232 -0
- package/src/encryption/__tests__/key-generation.test.ts +30 -0
- package/src/encryption/__tests__/performance-edge-cases.test.ts +187 -0
- package/src/encryption/__tests__/test-helpers.ts +22 -0
- package/src/encryption/__tests__/workspace-key-encryption.test.ts +129 -0
- package/src/encryption/encryption-service.ts +343 -0
- package/src/encryption/index.ts +25 -0
- package/src/encryption/types.ts +57 -0
- package/src/exchange-rates.ts +49 -0
- package/src/feature-flags/__tests__/feature-flags.test.ts +302 -0
- package/src/feature-flags/core.ts +322 -0
- package/src/feature-flags/data.ts +16 -0
- package/src/feature-flags/default.ts +18 -0
- package/src/feature-flags/index.ts +7 -0
- package/src/feature-flags/requestable-features.ts +79 -0
- package/src/feature-flags/types.ts +4 -0
- package/src/fetcher.ts +2 -0
- package/src/finance/index.ts +4 -0
- package/src/finance/interest-calculator.ts +456 -0
- package/src/finance/interest-detector.ts +141 -0
- package/src/finance/transform-invoice-results.ts +219 -0
- package/src/finance/wallet-permissions.test.ts +169 -0
- package/src/finance/wallet-permissions.ts +82 -0
- package/src/format.ts +120 -1
- package/src/generated/platform-build-metadata.ts +11 -0
- package/src/hooks/use-platform.ts +64 -0
- package/src/html-sanitizer.ts +155 -0
- package/src/internal-domains.ts +497 -0
- package/src/keyboard-preset.ts +109 -0
- package/src/label-colors.ts +213 -0
- package/src/launchable-apps.test.ts +126 -0
- package/src/launchable-apps.ts +490 -0
- package/src/name-helper.ts +269 -0
- package/src/next-config.test.ts +234 -0
- package/src/next-config.ts +203 -0
- package/src/node-diff.ts +375 -0
- package/src/notification-service.ts +379 -0
- package/src/nova/scores/__tests__/calculate.test.ts +254 -0
- package/src/nova/scores/calculate.ts +132 -0
- package/src/nova/submissions/check-permission.ts +132 -0
- package/src/onboarding-helper.ts +213 -0
- package/src/path-helper.ts +93 -0
- package/src/permissions.tsx +1170 -0
- package/src/plan-helpers.test.ts +188 -0
- package/src/plan-helpers.ts +80 -0
- package/src/platform-release.test.ts +74 -0
- package/src/platform-release.ts +155 -0
- package/src/portless.ts +124 -0
- package/src/priority-styles.ts +42 -0
- package/src/request-emoji-limit.ts +335 -0
- package/src/search-helper.ts +18 -0
- package/src/search.test.ts +89 -0
- package/src/search.ts +355 -0
- package/src/storage-display-name.ts +30 -0
- package/src/storage-path.ts +147 -0
- package/src/tag-utils.ts +159 -0
- package/src/task/reorder.ts +245 -0
- package/src/task/transformers.ts +149 -0
- package/src/task-date-timezone.ts +133 -0
- package/src/task-description-content.ts +240 -0
- package/src/task-helper/board.ts +193 -0
- package/src/task-helper/bulk-actions.ts +564 -0
- package/src/task-helper/personal-external-staging.ts +21 -0
- package/src/task-helper/recycle-bin.ts +202 -0
- package/src/task-helper/relationships.ts +346 -0
- package/src/task-helper/shared.ts +109 -0
- package/src/task-helper/sort-keys.ts +337 -0
- package/src/task-helper/task-hooks-basic.ts +342 -0
- package/src/task-helper/task-hooks-move.ts +264 -0
- package/src/task-helper/task-operations.ts +278 -0
- package/src/task-helper.ts +12 -0
- package/src/task-helpers.ts +241 -0
- package/src/task-list-status.ts +62 -0
- package/src/task-overrides.ts +82 -0
- package/src/task-snapshot.ts +374 -0
- package/src/text-diff.ts +81 -0
- package/src/text-helper.ts +537 -0
- package/src/time-helper.ts +63 -0
- package/src/time-tracker-period.ts +73 -0
- package/src/timeblock-helper.ts +418 -0
- package/src/timezone.ts +190 -0
- package/src/timezones.json +1271 -0
- package/src/upstash-rest.ts +56 -0
- package/src/user-helper.ts +296 -0
- package/src/uuid-helper.ts +11 -0
- package/src/workspace-handle.ts +10 -0
- package/src/workspace-helper.ts +1408 -0
- package/src/workspace-limits.ts +68 -0
- package/src/yjs-helper.ts +217 -0
- package/src/yjs-task-description.ts +81 -0
- package/tsconfig.json +3 -5
- package/tsconfig.typecheck.json +33 -0
- package/vitest.config.ts +36 -0
- package/dist/index.d.ts +0 -8
- package/dist/index.js +0 -2
- package/dist/index.js.map +0 -1
- package/dist/index.mjs +0 -2
- package/dist/index.mjs.map +0 -1
- package/eslint.config.mjs +0 -20
- package/rollup.config.js +0 -41
- 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
|
+
}
|