Add lib/tenant.ts as the single source of truth for multi- vs single-tenant deployment. Gate space creation (server + signup UI), add open-join to the one space, hide the marketing shell, and route brand name + base URL through config. Also commit the _migrations_applied table into the canonical schema so a fresh instance is reproducible from schema.sql alone. - lib/tenant.ts: resolveTenantConfig (pure) + tenantConfig() + appBaseUrl() - lib/auth-actions.ts: createSpace refused when !allowSpaceCreation; joinSpace open-join resolves the configured/sole space with no invite code - proxy.ts: marketing paths redirect to /login when showMarketing is off - app/signup/page.tsx: single-tenant hides create/join chooser, brand=siteName - app/layout.tsx + 4 link builders: brand/base-URL via config (no hackerspace.sh literal) - scripts/schema.sql: public._migrations_applied tracking table - .env.example: documented single-tenant + first-admin bootstrap vars - package.json: pnpm setup
132 lines
4.9 KiB
TypeScript
132 lines
4.9 KiB
TypeScript
// Shared notifications-outbox helpers used by every enqueue site (the Stripe
|
|
// webhook, equipment/classes/forms server actions). Resolving the recipient
|
|
// from a member id and writing the idempotent outbox row are the only two
|
|
// things every caller needs to do; rendering and dedupe-key construction stay
|
|
// in lib/notifications-logic.ts (pure, unit-tested).
|
|
//
|
|
// enqueueNotification is BEST-EFFORT: it never throws into the caller. The
|
|
// money path can write its ledger and finalize status even if the
|
|
// notifications table or email infra is wedged; a missed enqueue is
|
|
// acceptable, a wedged calling path is not. The (space_id, dedupe_key)
|
|
// unique index makes duplicate enqueues a no-op via ignoreDuplicates.
|
|
|
|
import type { createAdminClient } from '@/lib/supabase/admin'
|
|
import { captureException } from '@/lib/observability/capture'
|
|
import { appBaseUrl } from '@/lib/tenant'
|
|
|
|
type AdminClient = ReturnType<typeof createAdminClient>
|
|
|
|
export type MemberContact = {
|
|
email: string | null
|
|
displayName: string | null
|
|
}
|
|
|
|
// Look up a member's email + display_name, scoped to the given space. Used
|
|
// by every enqueue site to derive a recipient from a member id. Returns null
|
|
// if the member is not in that space (cross-tenant safety: the (id, space_id)
|
|
// filter pins the lookup) OR if the lookup itself fails (best-effort: never
|
|
// throws into the calling action, so a transient DB error cannot turn a
|
|
// successful domain mutation into a client-visible error).
|
|
export async function resolveMemberContact(
|
|
admin: AdminClient,
|
|
spaceId: string,
|
|
memberId: string,
|
|
): Promise<MemberContact | null> {
|
|
try {
|
|
const { data } = await admin
|
|
.from('space_members')
|
|
.select('email, display_name')
|
|
.eq('id', memberId)
|
|
.eq('space_id', spaceId)
|
|
.maybeSingle()
|
|
if (!data) return null
|
|
return {
|
|
email: (data.email as string | null) ?? null,
|
|
displayName: (data.display_name as string | null) ?? null,
|
|
}
|
|
} catch (e) {
|
|
console.error('[notifications] resolveMemberContact threw:', e instanceof Error ? e.message : e)
|
|
return null
|
|
}
|
|
}
|
|
|
|
export type EnqueueParams = {
|
|
spaceId: string
|
|
// null when the notification is not tied to a single member (e.g. a future
|
|
// admin broadcast). For member-personal notifications this is the affected
|
|
// member; for fan-outs (one row per recipient admin) it is the admin's id.
|
|
memberId: string | null
|
|
type: string
|
|
recipient: string
|
|
subject: string
|
|
bodyHtml: string
|
|
bodyText: string
|
|
dedupeKey: string
|
|
}
|
|
|
|
// Idempotent best-effort outbox write. Never throws. The (space_id,
|
|
// dedupe_key) unique index collapses replays.
|
|
export async function enqueueNotification(
|
|
admin: AdminClient,
|
|
params: EnqueueParams,
|
|
): Promise<void> {
|
|
try {
|
|
if (!params.recipient || !params.recipient.includes('@')) return
|
|
const { error } = await admin.from('notifications').upsert(
|
|
{
|
|
space_id: params.spaceId,
|
|
member_id: params.memberId,
|
|
type: params.type,
|
|
channel: 'email',
|
|
recipient: params.recipient,
|
|
subject: params.subject,
|
|
body_html: params.bodyHtml,
|
|
body_text: params.bodyText,
|
|
status: 'pending',
|
|
dedupe_key: params.dedupeKey,
|
|
},
|
|
{ onConflict: 'space_id,dedupe_key', ignoreDuplicates: true },
|
|
)
|
|
if (error) {
|
|
console.error(`[notifications] enqueue ${params.type} failed:`, error.message)
|
|
captureException(error, {
|
|
surface: 'notifications/enqueue',
|
|
tags: { space: params.spaceId, type: params.type },
|
|
})
|
|
}
|
|
} catch (e) {
|
|
console.error(
|
|
`[notifications] enqueue ${params.type} threw:`,
|
|
e instanceof Error ? e.message : e,
|
|
)
|
|
captureException(e, {
|
|
surface: 'notifications/enqueue',
|
|
tags: { space: params.spaceId, type: params.type, stage: 'throw' },
|
|
})
|
|
}
|
|
}
|
|
|
|
// Read the space's name once per caller. Used for brand-neutral copy that
|
|
// injects the space name (this is a generic multi-space platform; never
|
|
// hard-code a tenant). Returns empty string on miss or on lookup failure
|
|
// (best-effort, same rationale as resolveMemberContact); the renderers all
|
|
// fall back to "your hackerspace" so the email still composes.
|
|
export async function getSpaceName(admin: AdminClient, spaceId: string): Promise<string> {
|
|
try {
|
|
const { data } = await admin.from('spaces').select('name').eq('id', spaceId).maybeSingle()
|
|
return (data?.name as string | null) ?? ''
|
|
} catch (e) {
|
|
console.error('[notifications] getSpaceName threw:', e instanceof Error ? e.message : e)
|
|
return ''
|
|
}
|
|
}
|
|
|
|
// Build the member portal URL the way every enqueue site does. Header host
|
|
// takes precedence (lets a request to a non-canonical host link back to
|
|
// itself); otherwise falls back to the configured base URL (appBaseUrl, which
|
|
// resolves NEXT_PUBLIC_APP_URL). Same shape as the existing dues path.
|
|
export function buildManageUrl(host?: string | null, proto?: string | null): string {
|
|
if (host) return `${proto || 'https'}://${host}/me`
|
|
return `${appBaseUrl()}/me`
|
|
}
|