feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
228
apps/web/src/lib/dashboard-client.ts
Normal file
228
apps/web/src/lib/dashboard-client.ts
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue