Files
Moheeb ZaraandClaude Opus 4.8 1766780bca types: restore Supabase row typing, fix parseInput inference (586→76 tsc errors)
The installed supabase-js (2.99, 5 generics) is incompatible with
@supabase/[email protected] (calls it with 3), collapsing the server client's
schema to `never` — the root cause of ~450 of the 586 hidden type errors
(next.config typescript.ignoreBuildErrors masks them all).

- lib/supabase/server.ts: annotate the factory as SupabaseClient<Database>
  and bridge the ssr/js impedance mismatch at the one documented boundary;
  type the setAll cookie params.
- lib/supabase/proxy.ts, proxy.ts: type the setAll cookie params.
- lib/auth-helpers.ts: parseInput now infers its result from the schema via
  z.infer (was a fragile ZodSchema<T> that widened outputs to {}).
- lib/validations.ts: annotate flexibleDateTime's output type.
- types/database.ts: add class_signup_tx / class_cancel_tx RPC signatures
  (called but absent; pending full regeneration).
- tsconfig.json: target ES2020 (door-log-logic uses BigInt literals).
- vitest.setup.ts: import beforeAll/afterAll instead of relying on globals.

No runtime behavior change; 639 tests still green. ignoreBuildErrors stays
on until the remaining errors reach zero.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-08-01 18:40:33 -07:00

133 lines
4.7 KiB
TypeScript

import type { SupabaseClient } from '@supabase/supabase-js'
import type { Tables } from '@/types/database'
import type { ZodTypeAny, TypeOf } from 'zod'
import { ACTIVE_STATUSES, hasRole, isPrivilegeEligible, type Role } from '@/lib/permissions'
/**
* Loose Supabase client type for shared helpers.
*
* The fully-typed client (`Awaited<ReturnType<typeof createClient>>` from
* `lib/supabase/server.ts`) has a 3-generic shape that the supabase-js
* version drift makes painful to assign across module boundaries. We use
* a wide type at the helper boundary so callers with the precise client
* can pass it through; inside the helper, schema-level type checking on
* `.from(table).insert(row)` is relaxed. Helpers stay tiny and unit-tested,
* so the precision loss is acceptable. Action bodies keep full typing
* because they use the client returned by `createClient()` directly.
*/
export type ServerSupabase = SupabaseClient<any, any, any>
/** Projection of `space_members` that server actions consume. */
export type Member = Pick<
Tables<'space_members'>,
'id' | 'space_id' | 'user_id' | 'role' | 'status' | 'display_name' | 'handle'
>
/**
* Result types: a `ok: boolean` discriminator narrows reliably under
* strict TypeScript. Always `if (!result.ok) return { error: result.error }`.
*/
export type Result<T> = { ok: true; data: T } | { ok: false; error: string }
export type MemberResult = { ok: true; member: Member } | { ok: false; error: string }
/**
* Returns the active member row for the current authenticated user, or
* `null` if the user is not authenticated or has no active membership.
*
* "Active" means one of ACTIVE_STATUSES (current, unverified, late).
* Inactive members are blocked from all server actions.
*/
export async function getAuthMember(supabase: ServerSupabase): Promise<Member | null> {
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) return null
// INVARIANT: one active membership per user (single-space product model;
// see ARCHITECTURE "Known Limitations"). `.single()` is intentional -- if a
// user somehow has 2+ active memberships it fails closed (no action runs)
// rather than silently picking a space. Multi-space would require an
// explicit active-space selector before this can change.
const { data } = await supabase
.from('space_members')
.select('id, space_id, user_id, role, status, display_name, handle')
.eq('user_id', user.id)
.in('status', ACTIVE_STATUSES as unknown as string[])
.single()
return (data as Member | null) ?? null
}
/**
* Like getAuthMember but in MemberResult shape so callers can early-return
* on `!result.ok`.
*/
export async function requireMember(supabase: ServerSupabase): Promise<MemberResult> {
const member = await getAuthMember(supabase)
if (!member) return { ok: false, error: 'Not authenticated or no active membership' }
return { ok: true, member }
}
/**
* Returns the member iff they hold one of the allowed roles.
* Otherwise an error result.
*/
export async function requireMemberWithRole(
supabase: ServerSupabase,
allowed: readonly Role[],
errorLabel = 'Insufficient permissions',
): Promise<MemberResult> {
const r = await requireMember(supabase)
if (!r.ok) return r
// require_approval gate: an 'unverified' member holds NO privileged
// capability until an admin approves them, even if they redeemed a
// role-bearing invite. Without this, redeeming an admin invite into a
// require_approval space granted instant admin.
if (!isPrivilegeEligible(r.member.status)) {
return { ok: false, error: 'Your membership is pending approval.' }
}
if (!hasRole(r.member.role, allowed)) {
return { ok: false, error: errorLabel }
}
return r
}
/**
* Runs a Zod schema against an input. Returns a discriminated Result
* so callers can `if (!v.ok) return { error: v.error }` and have `v.data`
* narrowed to T.
*/
export function parseInput<S extends ZodTypeAny>(
schema: S,
input: unknown,
): Result<TypeOf<S>> {
const parsed = schema.safeParse(input)
if (!parsed.success) {
return { ok: false, error: parsed.error.errors[0]?.message ?? 'Invalid input' }
}
return { ok: true, data: parsed.data }
}
/**
* Insert a row into activity_log. Errors are swallowed: the log is
* advisory and should never block the primary action.
*/
export async function logActivity(
supabase: ServerSupabase,
member: Pick<Member, 'space_id' | 'user_id' | 'display_name'>,
action: string,
entityType: string,
entityId: string | null,
details?: string | null,
): Promise<void> {
await supabase.from('activity_log').insert({
space_id: member.space_id,
user_id: member.user_id,
display_name: member.display_name,
action,
entity_type: entityType,
entity_id: entityId,
details: details ?? null,
})
}