d3ro-voice/apps/web/src/lib/dashboard-client.ts
2026-08-29 18:33:45 +09:00

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)
}
}