228 lines
7.5 KiB
TypeScript
228 lines
7.5 KiB
TypeScript
import type { D3roSupabaseClient, Subscription } from '@d3ro/api-client'
|
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
|
|
export interface DashboardRecentEntry {
|
|
id: string
|
|
title: string | null
|
|
text: string
|
|
durationSeconds: number
|
|
mode: string
|
|
createdAt: string
|
|
}
|
|
|
|
export interface DashboardStats {
|
|
totalSessions: number
|
|
totalRecordingSeconds: number
|
|
totalWordCount: number
|
|
todaySessions: number
|
|
todayRecordingSeconds: number
|
|
todayWordCount: number
|
|
streakDays: number
|
|
recentHistory: DashboardRecentEntry[]
|
|
generatedAt: string
|
|
}
|
|
|
|
export interface DashboardSubscription {
|
|
tier: Subscription['tier']
|
|
status: string | null
|
|
provider: Subscription['provider']
|
|
overageCredits: number
|
|
currentPeriodEnd: string | null
|
|
cancelAt: string | null
|
|
autoRenewing: boolean | null
|
|
}
|
|
|
|
export interface DashboardSnapshot {
|
|
stats: DashboardStats
|
|
subscription: DashboardSubscription
|
|
}
|
|
|
|
export type DashboardClientErrorCode =
|
|
| 'auth'
|
|
| 'invalid-response'
|
|
| 'network'
|
|
| 'not-initialized'
|
|
| 'server'
|
|
|
|
export class DashboardClientError extends Error {
|
|
constructor(
|
|
public readonly code: DashboardClientErrorCode,
|
|
message: string
|
|
) {
|
|
super(message)
|
|
this.name = 'DashboardClientError'
|
|
}
|
|
}
|
|
|
|
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
|
const TIERS = new Set(['free', 'pro', 'pro_plus'])
|
|
const PROVIDERS = new Set(['none', 'stripe', 'payple', 'google_play', 'app_store', 'admin'])
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
}
|
|
|
|
function nonNegativeNumber(value: unknown): number | null {
|
|
const numeric = typeof value === 'number' ? value : Number(value)
|
|
return Number.isFinite(numeric) && numeric >= 0 ? numeric : null
|
|
}
|
|
|
|
function nonNegativeInteger(value: unknown): number | null {
|
|
const numeric = nonNegativeNumber(value)
|
|
return numeric !== null && Number.isSafeInteger(numeric) ? numeric : null
|
|
}
|
|
|
|
function normalizeDate(value: unknown, nullable = false): string | null {
|
|
if (nullable && value === null) return null
|
|
if (typeof value !== 'string' || !Number.isFinite(Date.parse(value))) return null
|
|
return new Date(value).toISOString()
|
|
}
|
|
|
|
export function normalizeDashboardStats(value: unknown): DashboardStats {
|
|
if (!isRecord(value) || !Array.isArray(value.recent_history)) {
|
|
throw new DashboardClientError('invalid-response', 'Dashboard response shape is invalid')
|
|
}
|
|
|
|
const totalSessions = nonNegativeInteger(value.total_sessions)
|
|
const totalRecordingSeconds = nonNegativeNumber(value.total_recording_seconds)
|
|
const totalWordCount = nonNegativeInteger(value.total_word_count)
|
|
const todaySessions = nonNegativeInteger(value.today_sessions)
|
|
const todayRecordingSeconds = nonNegativeNumber(value.today_recording_seconds)
|
|
const todayWordCount = nonNegativeInteger(value.today_word_count)
|
|
const streakDays = nonNegativeInteger(value.streak_days)
|
|
const generatedAt = normalizeDate(value.generated_at)
|
|
|
|
if (
|
|
totalSessions === null
|
|
|| totalRecordingSeconds === null
|
|
|| totalWordCount === null
|
|
|| todaySessions === null
|
|
|| todayRecordingSeconds === null
|
|
|| todayWordCount === null
|
|
|| streakDays === null
|
|
|| generatedAt === null
|
|
) {
|
|
throw new DashboardClientError('invalid-response', 'Dashboard metrics are invalid')
|
|
}
|
|
|
|
const recentHistory = value.recent_history.map((candidate): DashboardRecentEntry => {
|
|
if (!isRecord(candidate)) {
|
|
throw new DashboardClientError('invalid-response', 'Dashboard history row is invalid')
|
|
}
|
|
const durationSeconds = nonNegativeNumber(candidate.duration_seconds)
|
|
const createdAt = normalizeDate(candidate.created_at)
|
|
if (
|
|
typeof candidate.id !== 'string'
|
|
|| (candidate.title !== null && typeof candidate.title !== 'string')
|
|
|| typeof candidate.text !== 'string'
|
|
|| typeof candidate.mode !== 'string'
|
|
|| durationSeconds === null
|
|
|| createdAt === null
|
|
) {
|
|
throw new DashboardClientError('invalid-response', 'Dashboard history row is invalid')
|
|
}
|
|
return {
|
|
id: candidate.id,
|
|
title: candidate.title,
|
|
text: candidate.text,
|
|
durationSeconds,
|
|
mode: candidate.mode,
|
|
createdAt
|
|
}
|
|
})
|
|
|
|
return {
|
|
totalSessions,
|
|
totalRecordingSeconds,
|
|
totalWordCount,
|
|
todaySessions,
|
|
todayRecordingSeconds,
|
|
todayWordCount,
|
|
streakDays,
|
|
recentHistory,
|
|
generatedAt
|
|
}
|
|
}
|
|
|
|
function normalizeSubscription(value: unknown): DashboardSubscription {
|
|
if (!isRecord(value)) {
|
|
throw new DashboardClientError('not-initialized', 'Subscription row is missing')
|
|
}
|
|
const overageCredits = nonNegativeInteger(value.overage_credits)
|
|
const periodEnd = normalizeDate(value.current_period_end, true)
|
|
const cancelAt = normalizeDate(value.cancel_at, true)
|
|
if (
|
|
typeof value.tier !== 'string'
|
|
|| !TIERS.has(value.tier)
|
|
|| (value.status !== null && typeof value.status !== 'string')
|
|
|| typeof value.provider !== 'string'
|
|
|| !PROVIDERS.has(value.provider)
|
|
|| overageCredits === null
|
|
|| (value.current_period_end !== null && periodEnd === null)
|
|
|| (value.cancel_at !== null && cancelAt === null)
|
|
|| (value.auto_renewing !== null && typeof value.auto_renewing !== 'boolean')
|
|
) {
|
|
throw new DashboardClientError('invalid-response', 'Subscription response shape is invalid')
|
|
}
|
|
return {
|
|
tier: value.tier as Subscription['tier'],
|
|
status: value.status,
|
|
provider: value.provider as Subscription['provider'],
|
|
overageCredits,
|
|
currentPeriodEnd: periodEnd,
|
|
cancelAt,
|
|
autoRenewing: value.auto_renewing
|
|
}
|
|
}
|
|
|
|
function mapDashboardError(error: unknown): DashboardClientError {
|
|
if (error instanceof DashboardClientError) return error
|
|
const candidate = error as { code?: unknown; message?: unknown }
|
|
const databaseCode = typeof candidate?.code === 'string' ? candidate.code : ''
|
|
const message = typeof candidate?.message === 'string' ? candidate.message : 'Dashboard request failed'
|
|
const normalized = message.toLowerCase()
|
|
if (
|
|
error instanceof TypeError
|
|
|| normalized.includes('failed to fetch')
|
|
|| normalized.includes('network request failed')
|
|
|| normalized.includes('networkerror')
|
|
) {
|
|
return new DashboardClientError('network', message)
|
|
}
|
|
if (databaseCode === 'PGRST301' || databaseCode === '42501' || normalized.includes('jwt')) {
|
|
return new DashboardClientError('auth', message)
|
|
}
|
|
return new DashboardClientError('server', message)
|
|
}
|
|
|
|
export async function loadDashboardSnapshot(
|
|
client: D3roSupabaseClient,
|
|
userId: string
|
|
): Promise<DashboardSnapshot> {
|
|
if (!UUID_PATTERN.test(userId)) {
|
|
throw new DashboardClientError('auth', 'A valid authenticated user is required')
|
|
}
|
|
|
|
try {
|
|
const untypedClient = client as unknown as SupabaseClient
|
|
const [statsResult, subscriptionResult] = await Promise.all([
|
|
untypedClient.rpc('mobile_dashboard_stats'),
|
|
client
|
|
.from('subscriptions')
|
|
.select('tier,status,provider,overage_credits,current_period_end,cancel_at,auto_renewing')
|
|
.eq('user_id', userId)
|
|
.maybeSingle()
|
|
])
|
|
if (statsResult.error) throw statsResult.error
|
|
if (subscriptionResult.error) throw subscriptionResult.error
|
|
if (!subscriptionResult.data) {
|
|
throw new DashboardClientError('not-initialized', 'Subscription row is missing')
|
|
}
|
|
return {
|
|
stats: normalizeDashboardStats(statsResult.data),
|
|
subscription: normalizeSubscription(subscriptionResult.data)
|
|
}
|
|
} catch (error) {
|
|
throw mapDashboardError(error)
|
|
}
|
|
}
|