mirror of
https://github.com/linsa-io/linsa.git
synced 2026-01-12 12:20:23 +01:00
feat: Add Cloudflare StreamPlayer component and update schema with billing and access control
- Introduced `CloudflareStreamPlayer` React component for embedding streams - Updated `package.json` with new dependencies: `@cloudflare/stream-react` and `stripe` - Extended database schema with user tiers, Stripe billing, storage, and archive management - Added access control logic in `access.ts` for user tiers and feature permissions - Enhanced billing logic with archive storage limits and subscription checks
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
import { getFlowgladServer } from "./flowglad"
|
||||
import { getAuth } from "./auth"
|
||||
import { db } from "@/db/connection"
|
||||
import { stripe_subscriptions, storage_usage } from "@/db/schema"
|
||||
import { eq, and, gte, lte } from "drizzle-orm"
|
||||
|
||||
// Usage limits
|
||||
const GUEST_FREE_REQUESTS = 5
|
||||
@@ -234,3 +237,274 @@ export async function getBillingSummary(request: Request) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Archive Storage Billing (Stripe-based)
|
||||
// =============================================================================
|
||||
|
||||
const ARCHIVE_LIMITS = {
|
||||
free: { archives: 0, storageBytes: 0 },
|
||||
paid: { archives: 10, storageBytes: 1073741824 }, // 1GB
|
||||
}
|
||||
|
||||
type StorageCheckResult = {
|
||||
allowed: boolean
|
||||
archivesRemaining: number
|
||||
archivesLimit: number
|
||||
bytesRemaining: number
|
||||
bytesLimit: number
|
||||
reason?: "no_subscription" | "archive_limit" | "storage_limit"
|
||||
isPaid: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can create a new archive (requires active Stripe subscription)
|
||||
*/
|
||||
export async function checkArchiveAllowed(
|
||||
request: Request,
|
||||
fileSizeBytes: number = 0
|
||||
): Promise<StorageCheckResult> {
|
||||
const auth = getAuth()
|
||||
const session = await auth.api.getSession({ headers: request.headers })
|
||||
|
||||
if (!session?.user) {
|
||||
return {
|
||||
allowed: false,
|
||||
archivesRemaining: 0,
|
||||
archivesLimit: 0,
|
||||
bytesRemaining: 0,
|
||||
bytesLimit: 0,
|
||||
reason: "no_subscription",
|
||||
isPaid: false,
|
||||
}
|
||||
}
|
||||
|
||||
const database = db()
|
||||
|
||||
try {
|
||||
// Check for active Stripe subscription
|
||||
const [subscription] = await database
|
||||
.select()
|
||||
.from(stripe_subscriptions)
|
||||
.where(
|
||||
and(
|
||||
eq(stripe_subscriptions.user_id, session.user.id),
|
||||
eq(stripe_subscriptions.status, "active")
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!subscription) {
|
||||
return {
|
||||
allowed: false,
|
||||
archivesRemaining: 0,
|
||||
archivesLimit: 0,
|
||||
bytesRemaining: 0,
|
||||
bytesLimit: 0,
|
||||
reason: "no_subscription",
|
||||
isPaid: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Get usage for current billing period
|
||||
const now = new Date()
|
||||
const [usage] = await database
|
||||
.select()
|
||||
.from(storage_usage)
|
||||
.where(
|
||||
and(
|
||||
eq(storage_usage.user_id, session.user.id),
|
||||
lte(storage_usage.period_start, now),
|
||||
gte(storage_usage.period_end, now)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
const archivesUsed = usage?.archives_used ?? 0
|
||||
const archivesLimit = usage?.archives_limit ?? ARCHIVE_LIMITS.paid.archives
|
||||
const bytesUsed = usage?.storage_bytes_used ?? 0
|
||||
const bytesLimit =
|
||||
usage?.storage_bytes_limit ?? ARCHIVE_LIMITS.paid.storageBytes
|
||||
|
||||
const archivesRemaining = Math.max(0, archivesLimit - archivesUsed)
|
||||
const bytesRemaining = Math.max(0, bytesLimit - bytesUsed)
|
||||
|
||||
if (archivesRemaining <= 0) {
|
||||
return {
|
||||
allowed: false,
|
||||
archivesRemaining: 0,
|
||||
archivesLimit,
|
||||
bytesRemaining,
|
||||
bytesLimit,
|
||||
reason: "archive_limit",
|
||||
isPaid: true,
|
||||
}
|
||||
}
|
||||
|
||||
if (fileSizeBytes > 0 && fileSizeBytes > bytesRemaining) {
|
||||
return {
|
||||
allowed: false,
|
||||
archivesRemaining,
|
||||
archivesLimit,
|
||||
bytesRemaining,
|
||||
bytesLimit,
|
||||
reason: "storage_limit",
|
||||
isPaid: true,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: true,
|
||||
archivesRemaining,
|
||||
archivesLimit,
|
||||
bytesRemaining,
|
||||
bytesLimit,
|
||||
isPaid: true,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[billing] Error checking archive allowed:", error)
|
||||
return {
|
||||
allowed: false,
|
||||
archivesRemaining: 0,
|
||||
archivesLimit: 0,
|
||||
bytesRemaining: 0,
|
||||
bytesLimit: 0,
|
||||
reason: "no_subscription",
|
||||
isPaid: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record storage usage after creating an archive
|
||||
*/
|
||||
export async function recordStorageUsage(
|
||||
request: Request,
|
||||
fileSizeBytes: number
|
||||
): Promise<void> {
|
||||
const auth = getAuth()
|
||||
const session = await auth.api.getSession({ headers: request.headers })
|
||||
|
||||
if (!session?.user) {
|
||||
return
|
||||
}
|
||||
|
||||
const database = db()
|
||||
|
||||
try {
|
||||
const now = new Date()
|
||||
const [usage] = await database
|
||||
.select()
|
||||
.from(storage_usage)
|
||||
.where(
|
||||
and(
|
||||
eq(storage_usage.user_id, session.user.id),
|
||||
lte(storage_usage.period_start, now),
|
||||
gte(storage_usage.period_end, now)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (usage) {
|
||||
await database
|
||||
.update(storage_usage)
|
||||
.set({
|
||||
archives_used: usage.archives_used + 1,
|
||||
storage_bytes_used: usage.storage_bytes_used + fileSizeBytes,
|
||||
updated_at: now,
|
||||
})
|
||||
.where(eq(storage_usage.id, usage.id))
|
||||
|
||||
console.log(
|
||||
`[billing] Recorded storage: +1 archive, +${fileSizeBytes} bytes`
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[billing] Error recording storage usage:", error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get archive billing summary for UI display
|
||||
*/
|
||||
export async function getArchiveBillingSummary(request: Request) {
|
||||
const auth = getAuth()
|
||||
const session = await auth.api.getSession({ headers: request.headers })
|
||||
|
||||
if (!session?.user) {
|
||||
return {
|
||||
isGuest: true,
|
||||
isPaid: false,
|
||||
planName: "Guest",
|
||||
}
|
||||
}
|
||||
|
||||
const database = db()
|
||||
|
||||
try {
|
||||
const [subscription] = await database
|
||||
.select()
|
||||
.from(stripe_subscriptions)
|
||||
.where(
|
||||
and(
|
||||
eq(stripe_subscriptions.user_id, session.user.id),
|
||||
eq(stripe_subscriptions.status, "active")
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (subscription) {
|
||||
const now = new Date()
|
||||
const [usage] = await database
|
||||
.select()
|
||||
.from(storage_usage)
|
||||
.where(
|
||||
and(
|
||||
eq(storage_usage.user_id, session.user.id),
|
||||
lte(storage_usage.period_start, now),
|
||||
gte(storage_usage.period_end, now)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
return {
|
||||
isGuest: false,
|
||||
isPaid: true,
|
||||
planName: "Archive",
|
||||
storage: {
|
||||
archivesUsed: usage?.archives_used ?? 0,
|
||||
archivesLimit: usage?.archives_limit ?? ARCHIVE_LIMITS.paid.archives,
|
||||
bytesUsed: usage?.storage_bytes_used ?? 0,
|
||||
bytesLimit:
|
||||
usage?.storage_bytes_limit ?? ARCHIVE_LIMITS.paid.storageBytes,
|
||||
},
|
||||
currentPeriodEnd: subscription.current_period_end,
|
||||
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isGuest: false,
|
||||
isPaid: false,
|
||||
planName: "Free",
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[billing] Error getting archive summary:", error)
|
||||
return {
|
||||
isGuest: false,
|
||||
isPaid: false,
|
||||
planName: "Free",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format bytes to human readable string
|
||||
*/
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return "0 B"
|
||||
const k = 1024
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB"]
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user