mirror of
https://github.com/linsa-io/linsa.git
synced 2026-01-12 12:20:23 +01:00
.
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
const GEMINI_API_BASE = "https://generativelanguage.googleapis.com/v1beta"
|
||||
export const DEFAULT_GEMINI_IMAGE_MODEL = "gemini-2.5-flash-image-preview"
|
||||
|
||||
type GeminiEnv = {
|
||||
GEMINI_API_KEY?: string
|
||||
GOOGLE_API_KEY?: string
|
||||
}
|
||||
|
||||
const getEnv = (): GeminiEnv => {
|
||||
try {
|
||||
const { getServerContext } = require("@tanstack/react-start/server") as {
|
||||
getServerContext: () => { cloudflare?: { env?: GeminiEnv } } | null
|
||||
}
|
||||
const ctx = getServerContext()
|
||||
if (ctx?.cloudflare?.env) {
|
||||
const env = ctx.cloudflare.env
|
||||
if (env.GEMINI_API_KEY || env.GOOGLE_API_KEY) {
|
||||
return {
|
||||
GEMINI_API_KEY: env.GEMINI_API_KEY ?? env.GOOGLE_API_KEY,
|
||||
GOOGLE_API_KEY: env.GOOGLE_API_KEY,
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore, running outside Cloudflare
|
||||
}
|
||||
|
||||
const key = process.env.GEMINI_API_KEY ?? process.env.GOOGLE_API_KEY
|
||||
return { GEMINI_API_KEY: key, GOOGLE_API_KEY: process.env.GOOGLE_API_KEY }
|
||||
}
|
||||
|
||||
export type GeminiImageRequest = {
|
||||
prompt: string
|
||||
model?: string
|
||||
temperature?: number
|
||||
}
|
||||
|
||||
export type GeminiImageResponse = {
|
||||
base64Image: string
|
||||
mimeType: string
|
||||
rawResponse: unknown
|
||||
}
|
||||
|
||||
export async function generateGeminiImage(
|
||||
params: GeminiImageRequest,
|
||||
): Promise<GeminiImageResponse> {
|
||||
const { GEMINI_API_KEY } = getEnv()
|
||||
|
||||
if (!GEMINI_API_KEY) {
|
||||
throw new Error(
|
||||
"Set GEMINI_API_KEY or GOOGLE_API_KEY to enable Gemini image generation.",
|
||||
)
|
||||
}
|
||||
|
||||
const model = params.model ?? DEFAULT_GEMINI_IMAGE_MODEL
|
||||
|
||||
const body = {
|
||||
contents: [
|
||||
{
|
||||
role: "user",
|
||||
parts: [{ text: params.prompt }],
|
||||
},
|
||||
],
|
||||
generationConfig: {
|
||||
temperature: params.temperature ?? 0.9,
|
||||
},
|
||||
}
|
||||
|
||||
const response = await fetch(`${GEMINI_API_BASE}/models/${model}:generateContent`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-goog-api-key": GEMINI_API_KEY,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
const json = await response.json().catch(() => ({}))
|
||||
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
typeof json?.error?.message === "string"
|
||||
? json.error.message
|
||||
: "Gemini image generation failed"
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
const candidates = Array.isArray(json?.candidates) ? json.candidates : []
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const parts = candidate?.content?.parts ?? []
|
||||
for (const part of parts) {
|
||||
if (part?.inlineData?.data) {
|
||||
return {
|
||||
base64Image: part.inlineData.data,
|
||||
mimeType: part.inlineData.mimeType ?? "image/png",
|
||||
rawResponse: json,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Gemini did not return inline image data.")
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
const OPENAI_API_URL = "https://api.openai.com/v1/images/generations"
|
||||
const DEFAULT_OPENAI_MODEL = "gpt-image-1"
|
||||
|
||||
type OpenAIEnv = {
|
||||
OPENAI_API_KEY?: string
|
||||
}
|
||||
|
||||
const getEnv = (): OpenAIEnv => {
|
||||
try {
|
||||
const { getServerContext } = require("@tanstack/react-start/server") as {
|
||||
getServerContext: () => { cloudflare?: { env?: OpenAIEnv } } | null
|
||||
}
|
||||
const ctx = getServerContext()
|
||||
if (ctx?.cloudflare?.env?.OPENAI_API_KEY) {
|
||||
return { OPENAI_API_KEY: ctx.cloudflare.env.OPENAI_API_KEY }
|
||||
}
|
||||
} catch {
|
||||
// ignore — not running in server context
|
||||
}
|
||||
return { OPENAI_API_KEY: process.env.OPENAI_API_KEY }
|
||||
}
|
||||
|
||||
export type OpenAIImageRequest = {
|
||||
prompt: string
|
||||
model?: string
|
||||
size?: "1024x1024" | "1024x1792" | "1792x1024"
|
||||
}
|
||||
|
||||
export type OpenAIImageResponse = {
|
||||
base64Image: string
|
||||
mimeType: string
|
||||
revisedPrompt?: string
|
||||
}
|
||||
|
||||
export async function generateOpenAIImage(
|
||||
params: OpenAIImageRequest,
|
||||
): Promise<OpenAIImageResponse> {
|
||||
const { OPENAI_API_KEY } = getEnv()
|
||||
if (!OPENAI_API_KEY) {
|
||||
throw new Error("Set OPENAI_API_KEY to enable DALL·E image generation.")
|
||||
}
|
||||
|
||||
const response = await fetch(OPENAI_API_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${OPENAI_API_KEY}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: params.model ?? DEFAULT_OPENAI_MODEL,
|
||||
prompt: params.prompt,
|
||||
size: params.size ?? "1024x1024",
|
||||
response_format: "b64_json",
|
||||
}),
|
||||
})
|
||||
|
||||
const json: any = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
typeof json?.error?.message === "string"
|
||||
? json.error.message
|
||||
: "OpenAI image generation failed"
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
const payload = Array.isArray(json?.data) ? json.data[0] : undefined
|
||||
const base64 = typeof payload?.b64_json === "string" ? payload.b64_json : null
|
||||
if (!base64) {
|
||||
throw new Error("OpenAI returned no image data")
|
||||
}
|
||||
|
||||
return {
|
||||
base64Image: base64,
|
||||
mimeType: "image/png",
|
||||
revisedPrompt: typeof payload?.revised_prompt === "string" ? payload.revised_prompt : undefined,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
|
||||
|
||||
// Get API key from Cloudflare env or process.env
|
||||
const getApiKey = (): string | undefined => {
|
||||
// Try Cloudflare Workers context first
|
||||
try {
|
||||
const { getServerContext } = require("@tanstack/react-start/server")
|
||||
const ctx = getServerContext()
|
||||
if (ctx?.cloudflare?.env?.OPENROUTER_API_KEY) {
|
||||
return ctx.cloudflare.env.OPENROUTER_API_KEY as string
|
||||
}
|
||||
} catch {
|
||||
// Not in Cloudflare context
|
||||
}
|
||||
return process.env.OPENROUTER_API_KEY
|
||||
}
|
||||
|
||||
const getModel = (): string => {
|
||||
try {
|
||||
const { getServerContext } = require("@tanstack/react-start/server")
|
||||
const ctx = getServerContext()
|
||||
if (ctx?.cloudflare?.env?.OPENROUTER_MODEL) {
|
||||
return ctx.cloudflare.env.OPENROUTER_MODEL as string
|
||||
}
|
||||
} catch {
|
||||
// Not in Cloudflare context
|
||||
}
|
||||
return process.env.OPENROUTER_MODEL ?? "google/gemini-2.0-flash-001"
|
||||
}
|
||||
|
||||
export const getOpenRouter = () => {
|
||||
const apiKey = getApiKey()
|
||||
if (!apiKey) {
|
||||
return null
|
||||
}
|
||||
return createOpenRouter({ apiKey })
|
||||
}
|
||||
|
||||
export const getDefaultModel = () => getModel()
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createAuthClient } from "better-auth/react"
|
||||
import { emailOTPClient } from "better-auth/client/plugins"
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: typeof window !== "undefined" ? window.location.origin : undefined,
|
||||
plugins: [emailOTPClient()],
|
||||
})
|
||||
@@ -0,0 +1,145 @@
|
||||
import { betterAuth } from "better-auth"
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle"
|
||||
import { tanstackStartCookies } from "better-auth/tanstack-start"
|
||||
import { emailOTP } from "better-auth/plugins"
|
||||
import { Resend } from "resend"
|
||||
import { authDb } from "@/db/connection"
|
||||
import * as schema from "@/db/schema"
|
||||
|
||||
type AuthEnv = {
|
||||
BETTER_AUTH_SECRET: string
|
||||
APP_BASE_URL?: string
|
||||
RESEND_API_KEY?: string
|
||||
RESEND_FROM_EMAIL?: string
|
||||
}
|
||||
|
||||
// Helper to get Cloudflare env from server context
|
||||
const getCloudflareEnv = (): Partial<AuthEnv> | undefined => {
|
||||
try {
|
||||
const { getServerContext } = require("@tanstack/react-start/server") as {
|
||||
getServerContext: () => { cloudflare?: { env?: Partial<AuthEnv> } } | null
|
||||
}
|
||||
return getServerContext()?.cloudflare?.env
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
// Get env from Cloudflare context or process.env
|
||||
const getEnv = (): AuthEnv => {
|
||||
let BETTER_AUTH_SECRET: string | undefined
|
||||
let APP_BASE_URL: string | undefined
|
||||
let RESEND_API_KEY: string | undefined
|
||||
let RESEND_FROM_EMAIL: string | undefined
|
||||
|
||||
// Try Cloudflare Workers context first (production)
|
||||
const cfEnv = getCloudflareEnv()
|
||||
if (cfEnv) {
|
||||
BETTER_AUTH_SECRET = cfEnv.BETTER_AUTH_SECRET
|
||||
APP_BASE_URL = cfEnv.APP_BASE_URL
|
||||
RESEND_API_KEY = cfEnv.RESEND_API_KEY
|
||||
RESEND_FROM_EMAIL = cfEnv.RESEND_FROM_EMAIL
|
||||
}
|
||||
|
||||
// Fall back to process.env (local dev)
|
||||
BETTER_AUTH_SECRET = BETTER_AUTH_SECRET ?? process.env.BETTER_AUTH_SECRET
|
||||
APP_BASE_URL = APP_BASE_URL ?? process.env.APP_BASE_URL
|
||||
RESEND_API_KEY = RESEND_API_KEY ?? process.env.RESEND_API_KEY
|
||||
RESEND_FROM_EMAIL = RESEND_FROM_EMAIL ?? process.env.RESEND_FROM_EMAIL
|
||||
|
||||
if (!BETTER_AUTH_SECRET) {
|
||||
throw new Error("BETTER_AUTH_SECRET is not configured")
|
||||
}
|
||||
|
||||
return {
|
||||
BETTER_AUTH_SECRET,
|
||||
APP_BASE_URL,
|
||||
RESEND_API_KEY,
|
||||
RESEND_FROM_EMAIL,
|
||||
}
|
||||
}
|
||||
|
||||
export const getAuth = () => {
|
||||
// Note: We create a fresh auth instance per request because Cloudflare Workers
|
||||
// doesn't allow sharing I/O objects (like DB connections) across requests
|
||||
const env = getEnv()
|
||||
const database = authDb()
|
||||
|
||||
// Detect production: if APP_BASE_URL is set and not localhost, we're in production
|
||||
const isProduction =
|
||||
env.APP_BASE_URL && !env.APP_BASE_URL.includes("localhost")
|
||||
const resend = env.RESEND_API_KEY ? new Resend(env.RESEND_API_KEY) : null
|
||||
const fromEmail = env.RESEND_FROM_EMAIL ?? "noreply@example.com"
|
||||
|
||||
console.log("[auth] Config:", {
|
||||
isProduction,
|
||||
hasResendKey: !!env.RESEND_API_KEY,
|
||||
fromEmail,
|
||||
appBaseUrl: env.APP_BASE_URL,
|
||||
})
|
||||
|
||||
return betterAuth({
|
||||
database: drizzleAdapter(database, {
|
||||
provider: "pg",
|
||||
usePlural: true,
|
||||
schema,
|
||||
}),
|
||||
trustedOrigins: [env.APP_BASE_URL ?? "http://localhost:3000"],
|
||||
plugins: [
|
||||
tanstackStartCookies(),
|
||||
emailOTP({
|
||||
async sendVerificationOTP({ email, otp }) {
|
||||
console.log("[auth] sendVerificationOTP called:", {
|
||||
email,
|
||||
isProduction,
|
||||
hasResend: !!resend,
|
||||
})
|
||||
|
||||
if (!isProduction || !resend) {
|
||||
// In dev mode or if Resend not configured, log OTP to terminal
|
||||
console.log("\n" + "=".repeat(50))
|
||||
console.log(`🔐 OTP CODE for ${email}`)
|
||||
console.log(` Code: ${otp}`)
|
||||
console.log("=".repeat(50) + "\n")
|
||||
return
|
||||
}
|
||||
|
||||
// Send email via Resend in production
|
||||
console.log("[auth] Sending email via Resend to:", email)
|
||||
const { error, data } = await resend.emails.send({
|
||||
from: fromEmail,
|
||||
to: email,
|
||||
subject: "Your Linsa verification code",
|
||||
html: `
|
||||
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 400px; margin: 0 auto; padding: 20px; background-color: #050505; color: #ffffff;">
|
||||
<h2 style="color: #ffffff; margin-bottom: 16px; font-weight: 600;">Your verification code</h2>
|
||||
<p style="color: #a1a1aa; margin-bottom: 24px;">Enter this code to sign in to Linsa:</p>
|
||||
<div style="background-color: #18181b; border: 1px solid #27272a; border-radius: 12px; padding: 24px; text-align: center;">
|
||||
<span style="font-size: 36px; font-weight: bold; letter-spacing: 8px; color: #ffffff; font-family: monospace;">${otp}</span>
|
||||
</div>
|
||||
<p style="color: #71717a; font-size: 14px; margin-top: 24px;">This code expires in 5 minutes.</p>
|
||||
<p style="color: #52525b; font-size: 12px; margin-top: 16px;">If you didn't request this code, you can safely ignore this email.</p>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
console.error("[auth] Failed to send OTP email:", error)
|
||||
throw new Error("Failed to send verification email")
|
||||
}
|
||||
|
||||
console.log("[auth] Email sent successfully:", data)
|
||||
},
|
||||
otpLength: 6,
|
||||
expiresIn: 300, // 5 minutes
|
||||
}),
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
// Lazy proxy that calls getAuth() on each access
|
||||
export const auth = new Proxy({} as ReturnType<typeof betterAuth>, {
|
||||
get(_target, prop) {
|
||||
return getAuth()[prop as keyof ReturnType<typeof betterAuth>]
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { BillingWithChecks, Price, UsageMeter, Product } from "@flowglad/server"
|
||||
|
||||
/**
|
||||
* Computes the total usage credits for a given usage meter slug from the current subscription's feature items.
|
||||
*/
|
||||
export function computeUsageTotal(
|
||||
usageMeterSlug: string,
|
||||
currentSubscription:
|
||||
| NonNullable<NonNullable<BillingWithChecks["currentSubscriptions"]>[number]>
|
||||
| undefined,
|
||||
pricingModel: BillingWithChecks["pricingModel"] | undefined,
|
||||
): number {
|
||||
try {
|
||||
if (!currentSubscription || !pricingModel?.usageMeters) return 0
|
||||
|
||||
const experimental = currentSubscription.experimental as
|
||||
| { featureItems?: Array<{ type: string; usageMeterId: string; amount: number }> }
|
||||
| undefined
|
||||
const featureItems = experimental?.featureItems ?? []
|
||||
|
||||
if (featureItems.length === 0) return 0
|
||||
|
||||
// Build lookup map: usageMeterId -> slug
|
||||
const usageMeterById: Record<string, string> = {}
|
||||
for (const meter of pricingModel.usageMeters) {
|
||||
usageMeterById[String(meter.id)] = String(meter.slug)
|
||||
}
|
||||
|
||||
// Sum up usage credits for matching meter
|
||||
let total = 0
|
||||
for (const item of featureItems) {
|
||||
if (item.type !== "usage_credit_grant") continue
|
||||
const meterSlug = usageMeterById[item.usageMeterId]
|
||||
if (meterSlug === usageMeterSlug) {
|
||||
total += item.amount
|
||||
}
|
||||
}
|
||||
|
||||
return total
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a usage meter by its slug from the pricing model.
|
||||
*/
|
||||
export function findUsageMeterBySlug(
|
||||
usageMeterSlug: string,
|
||||
pricingModel: BillingWithChecks["pricingModel"] | undefined,
|
||||
): { id: string; slug: string } | null {
|
||||
if (!pricingModel?.usageMeters) return null
|
||||
|
||||
const usageMeter = pricingModel.usageMeters.find(
|
||||
(meter: UsageMeter) => meter.slug === usageMeterSlug,
|
||||
)
|
||||
|
||||
if (!usageMeter) return null
|
||||
|
||||
return {
|
||||
id: String(usageMeter.id),
|
||||
slug: String(usageMeter.slug),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a usage price by its associated usage meter slug from the pricing model.
|
||||
*/
|
||||
export function findUsagePriceByMeterSlug(
|
||||
usageMeterSlug: string,
|
||||
pricingModel: BillingWithChecks["pricingModel"] | undefined,
|
||||
): Price | null {
|
||||
if (!pricingModel?.products || !pricingModel?.usageMeters) return null
|
||||
|
||||
// Build lookup map: slug -> id
|
||||
const meterIdBySlug = new Map(
|
||||
pricingModel.usageMeters.map((meter: UsageMeter) => [meter.slug, meter.id]),
|
||||
)
|
||||
|
||||
const usageMeterId = meterIdBySlug.get(usageMeterSlug)
|
||||
if (!usageMeterId) return null
|
||||
|
||||
// Find price by meter ID
|
||||
const usagePrice = pricingModel.products
|
||||
.flatMap((product: Product) => product.prices ?? [])
|
||||
.find(
|
||||
(price: Price) => price.type === "usage" && price.usageMeterId === usageMeterId,
|
||||
)
|
||||
|
||||
return usagePrice ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a plan is a default (free) plan by looking up the price by slug.
|
||||
*/
|
||||
export function isDefaultPlanBySlug(
|
||||
pricingModel: BillingWithChecks["pricingModel"] | null | undefined,
|
||||
priceSlug: string | undefined,
|
||||
): boolean {
|
||||
if (!pricingModel?.products || !priceSlug) return false
|
||||
|
||||
for (const product of pricingModel.products) {
|
||||
const price = product.prices?.find((p: Price) => p.slug === priceSlug)
|
||||
if (price) {
|
||||
return product.default === true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import { getFlowgladServer } from "./flowglad"
|
||||
import { getAuth } from "./auth"
|
||||
|
||||
// Usage limits
|
||||
const GUEST_FREE_REQUESTS = 5
|
||||
const AUTH_FREE_REQUESTS_DAILY = 20
|
||||
const PAID_PLAN_REQUESTS = 1000
|
||||
|
||||
// Usage meter slug (configure in Flowglad dashboard)
|
||||
export const AI_REQUESTS_METER = "ai_requests"
|
||||
|
||||
// Price slug for the pro plan (configure in Flowglad dashboard)
|
||||
export const PRO_PLAN_PRICE_SLUG = "pro_monthly"
|
||||
|
||||
type UsageCheckResult = {
|
||||
allowed: boolean
|
||||
remaining: number
|
||||
limit: number
|
||||
reason?: "guest_limit" | "daily_limit" | "subscription_limit" | "no_subscription"
|
||||
isGuest: boolean
|
||||
isPaid: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can make an AI request based on their billing status.
|
||||
*
|
||||
* Tiers:
|
||||
* - Guest (no auth): 5 free requests total (stored in cookie/localStorage)
|
||||
* - Authenticated free: 20 free requests per day
|
||||
* - Pro plan ($7.99/mo): 1000 requests per billing period
|
||||
*/
|
||||
export async function checkUsageAllowed(request: Request): Promise<UsageCheckResult> {
|
||||
const auth = getAuth()
|
||||
const session = await auth.api.getSession({ headers: request.headers })
|
||||
|
||||
// Guest user - check local/cookie based limit
|
||||
if (!session?.user) {
|
||||
// For guests, we'll track on client side via localStorage
|
||||
// Server just knows they're a guest with limited access
|
||||
return {
|
||||
allowed: true, // Client will enforce limit
|
||||
remaining: GUEST_FREE_REQUESTS,
|
||||
limit: GUEST_FREE_REQUESTS,
|
||||
isGuest: true,
|
||||
isPaid: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Authenticated user - check Flowglad billing
|
||||
const flowglad = getFlowgladServer(request)
|
||||
|
||||
if (!flowglad) {
|
||||
// Flowglad not configured, fall back to daily free limit
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: AUTH_FREE_REQUESTS_DAILY,
|
||||
limit: AUTH_FREE_REQUESTS_DAILY,
|
||||
isGuest: false,
|
||||
isPaid: false,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const billing = await flowglad.getBilling()
|
||||
|
||||
// Check if user has an active subscription
|
||||
const hasActiveSubscription = billing.currentSubscriptions &&
|
||||
billing.currentSubscriptions.length > 0
|
||||
|
||||
if (hasActiveSubscription) {
|
||||
// Check usage balance for paid plan
|
||||
const usage = billing.checkUsageBalance(AI_REQUESTS_METER)
|
||||
|
||||
if (usage) {
|
||||
const remaining = usage.availableBalance
|
||||
return {
|
||||
allowed: remaining > 0,
|
||||
remaining,
|
||||
limit: PAID_PLAN_REQUESTS,
|
||||
reason: remaining <= 0 ? "subscription_limit" : undefined,
|
||||
isGuest: false,
|
||||
isPaid: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Has subscription but no usage meter configured yet
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: PAID_PLAN_REQUESTS,
|
||||
limit: PAID_PLAN_REQUESTS,
|
||||
isGuest: false,
|
||||
isPaid: true,
|
||||
}
|
||||
}
|
||||
|
||||
// No subscription - use daily free limit
|
||||
// For now we allow without tracking (TODO: implement daily limit tracking)
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: AUTH_FREE_REQUESTS_DAILY,
|
||||
limit: AUTH_FREE_REQUESTS_DAILY,
|
||||
isGuest: false,
|
||||
isPaid: false,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[billing] Error checking usage:", error)
|
||||
// On error, allow with daily limit
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: AUTH_FREE_REQUESTS_DAILY,
|
||||
limit: AUTH_FREE_REQUESTS_DAILY,
|
||||
isGuest: false,
|
||||
isPaid: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a usage event after AI request completes.
|
||||
* Only records for paid users with active subscriptions.
|
||||
*/
|
||||
export async function recordUsage(
|
||||
request: Request,
|
||||
amount: number = 1,
|
||||
transactionId?: string
|
||||
): Promise<void> {
|
||||
const auth = getAuth()
|
||||
const session = await auth.api.getSession({ headers: request.headers })
|
||||
|
||||
if (!session?.user) {
|
||||
// Guest users don't record to Flowglad
|
||||
return
|
||||
}
|
||||
|
||||
const flowglad = getFlowgladServer(request)
|
||||
if (!flowglad) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const billing = await flowglad.getBilling()
|
||||
|
||||
const hasActiveSubscription = billing.currentSubscriptions &&
|
||||
billing.currentSubscriptions.length > 0
|
||||
|
||||
if (!hasActiveSubscription) {
|
||||
// Only record usage for paid subscriptions
|
||||
return
|
||||
}
|
||||
|
||||
const subscription = billing.currentSubscriptions![0]
|
||||
|
||||
// Find the usage price for the AI requests meter
|
||||
const usagePrice = billing.pricingModel?.products
|
||||
?.flatMap(p => p.prices || [])
|
||||
?.find((p: { type?: string; usageMeterSlug?: string }) =>
|
||||
p.type === "usage" && p.usageMeterSlug === AI_REQUESTS_METER
|
||||
) as { id: string } | undefined
|
||||
|
||||
if (!usagePrice) {
|
||||
console.warn("[billing] No usage price found for meter:", AI_REQUESTS_METER)
|
||||
return
|
||||
}
|
||||
|
||||
await flowglad.createUsageEvent({
|
||||
subscriptionId: subscription.id,
|
||||
priceId: usagePrice.id,
|
||||
amount,
|
||||
transactionId: transactionId ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("[billing] Error recording usage:", error)
|
||||
// Don't throw - usage recording should not block the request
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get billing summary for display in UI.
|
||||
*/
|
||||
export async function getBillingSummary(request: Request) {
|
||||
const auth = getAuth()
|
||||
const session = await auth.api.getSession({ headers: request.headers })
|
||||
|
||||
if (!session?.user) {
|
||||
return {
|
||||
isGuest: true,
|
||||
isPaid: false,
|
||||
freeLimit: GUEST_FREE_REQUESTS,
|
||||
planName: "Guest",
|
||||
}
|
||||
}
|
||||
|
||||
const flowglad = getFlowgladServer(request)
|
||||
if (!flowglad) {
|
||||
return {
|
||||
isGuest: false,
|
||||
isPaid: false,
|
||||
freeLimit: AUTH_FREE_REQUESTS_DAILY,
|
||||
planName: "Free",
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const billing = await flowglad.getBilling()
|
||||
|
||||
const hasActiveSubscription = billing.currentSubscriptions &&
|
||||
billing.currentSubscriptions.length > 0
|
||||
|
||||
if (hasActiveSubscription) {
|
||||
const usage = billing.checkUsageBalance(AI_REQUESTS_METER)
|
||||
return {
|
||||
isGuest: false,
|
||||
isPaid: true,
|
||||
remaining: usage?.availableBalance ?? PAID_PLAN_REQUESTS,
|
||||
limit: PAID_PLAN_REQUESTS,
|
||||
planName: "Pro",
|
||||
billingPortalUrl: billing.billingPortalUrl ?? undefined,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isGuest: false,
|
||||
isPaid: false,
|
||||
freeLimit: AUTH_FREE_REQUESTS_DAILY,
|
||||
planName: "Free",
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[billing] Error getting summary:", error)
|
||||
return {
|
||||
isGuest: false,
|
||||
isPaid: false,
|
||||
freeLimit: AUTH_FREE_REQUESTS_DAILY,
|
||||
planName: "Free",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import type {
|
||||
SerializedCanvas,
|
||||
SerializedCanvasImage,
|
||||
SerializedCanvasSummary,
|
||||
} from "./types"
|
||||
|
||||
const jsonHeaders = { "content-type": "application/json" }
|
||||
|
||||
const handleJson = async (response: Response) => {
|
||||
if (!response.ok) {
|
||||
const message = await response.text()
|
||||
throw new Error(message || "Canvas request failed")
|
||||
}
|
||||
return (await response.json()) as any
|
||||
}
|
||||
|
||||
export const fetchCanvasSnapshot = async (
|
||||
canvasId: string,
|
||||
): Promise<SerializedCanvas> => {
|
||||
const res = await fetch(`/api/canvas/${canvasId}`, {
|
||||
credentials: "include",
|
||||
})
|
||||
const data = await handleJson(res)
|
||||
return data as SerializedCanvas
|
||||
}
|
||||
|
||||
export const fetchCanvasList = async (): Promise<SerializedCanvasSummary[]> => {
|
||||
const res = await fetch("/api/canvas", { credentials: "include" })
|
||||
const data = await handleJson(res)
|
||||
return data.canvases as SerializedCanvasSummary[]
|
||||
}
|
||||
|
||||
export const createCanvasProject = async (params: {
|
||||
name?: string
|
||||
} = {}): Promise<SerializedCanvas> => {
|
||||
const res = await fetch("/api/canvas", {
|
||||
method: "POST",
|
||||
headers: jsonHeaders,
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ name: params.name }),
|
||||
})
|
||||
const data = await handleJson(res)
|
||||
return data as SerializedCanvas
|
||||
}
|
||||
|
||||
export const createCanvasBox = async (params: {
|
||||
canvasId: string
|
||||
name?: string
|
||||
prompt?: string
|
||||
position?: { x: number; y: number }
|
||||
size?: { width: number; height: number }
|
||||
modelId?: string
|
||||
styleId?: string
|
||||
branchParentId?: string | null
|
||||
}): Promise<SerializedCanvasImage> => {
|
||||
const res = await fetch("/api/canvas/images", {
|
||||
method: "POST",
|
||||
headers: jsonHeaders,
|
||||
credentials: "include",
|
||||
body: JSON.stringify(params),
|
||||
})
|
||||
const data = await handleJson(res)
|
||||
return data.image as SerializedCanvasImage
|
||||
}
|
||||
|
||||
export const updateCanvasBox = async (
|
||||
imageId: string,
|
||||
data: Partial<{
|
||||
name: string
|
||||
prompt: string
|
||||
modelId: string
|
||||
styleId: string
|
||||
position: { x: number; y: number }
|
||||
size: { width: number; height: number }
|
||||
rotation: number
|
||||
}>,
|
||||
): Promise<SerializedCanvasImage> => {
|
||||
const res = await fetch(`/api/canvas/images/${imageId}`, {
|
||||
method: "PATCH",
|
||||
headers: jsonHeaders,
|
||||
credentials: "include",
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
const json = await handleJson(res)
|
||||
return json.image as SerializedCanvasImage
|
||||
}
|
||||
|
||||
export const deleteCanvasBox = async (imageId: string) => {
|
||||
const res = await fetch(`/api/canvas/images/${imageId}`, {
|
||||
method: "DELETE",
|
||||
headers: jsonHeaders,
|
||||
credentials: "include",
|
||||
})
|
||||
await handleJson(res)
|
||||
}
|
||||
|
||||
export const generateCanvasBoxImage = async (params: {
|
||||
imageId: string
|
||||
prompt?: string
|
||||
modelId?: string
|
||||
temperature?: number
|
||||
}): Promise<SerializedCanvasImage> => {
|
||||
const res = await fetch(`/api/canvas/images/${params.imageId}/generate`, {
|
||||
method: "POST",
|
||||
headers: jsonHeaders,
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
prompt: params.prompt,
|
||||
modelId: params.modelId,
|
||||
temperature: params.temperature,
|
||||
}),
|
||||
})
|
||||
const json = await handleJson(res)
|
||||
return json.image as SerializedCanvasImage
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
import { asc, desc, eq, inArray } from "drizzle-orm"
|
||||
import { getDb } from "@/db/connection"
|
||||
import { canvas, canvas_images } from "@/db/schema"
|
||||
import type {
|
||||
CanvasPoint,
|
||||
CanvasSize,
|
||||
SerializedCanvas,
|
||||
SerializedCanvasImage,
|
||||
SerializedCanvasRecord,
|
||||
SerializedCanvasSummary,
|
||||
} from "./types"
|
||||
|
||||
const DEFAULT_POSITION: CanvasPoint = { x: 0, y: 0 }
|
||||
const DEFAULT_IMAGE_SIZE: CanvasSize = { width: 512, height: 512 }
|
||||
const DEFAULT_IMAGE_NAME = "Box 1"
|
||||
const DEFAULT_MODEL = "gemini-2.5-flash-image-preview"
|
||||
|
||||
const resolveDatabaseUrl = () => {
|
||||
try {
|
||||
const { getServerContext } = require("@tanstack/react-start/server") as {
|
||||
getServerContext: () => { cloudflare?: { env?: Record<string, string> } } | null
|
||||
}
|
||||
const ctx = getServerContext()
|
||||
const url = ctx?.cloudflare?.env?.DATABASE_URL
|
||||
if (url) {
|
||||
return url
|
||||
}
|
||||
} catch {
|
||||
// probably not running inside server context
|
||||
}
|
||||
|
||||
if (process.env.DATABASE_URL) {
|
||||
return process.env.DATABASE_URL
|
||||
}
|
||||
|
||||
throw new Error("DATABASE_URL is not configured")
|
||||
}
|
||||
|
||||
const db = () => getDb(resolveDatabaseUrl())
|
||||
|
||||
type DatabaseClient = ReturnType<typeof db>
|
||||
|
||||
const parsePoint = (value: unknown): CanvasPoint => {
|
||||
if (
|
||||
value &&
|
||||
typeof value === "object" &&
|
||||
"x" in value &&
|
||||
"y" in value &&
|
||||
typeof (value as any).x === "number" &&
|
||||
typeof (value as any).y === "number"
|
||||
) {
|
||||
return { x: (value as any).x, y: (value as any).y }
|
||||
}
|
||||
return DEFAULT_POSITION
|
||||
}
|
||||
|
||||
const serializeCanvasRecord = (record: typeof canvas.$inferSelect): SerializedCanvasRecord => ({
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
ownerId: record.owner_id,
|
||||
defaultModel: record.default_model,
|
||||
defaultStyle: record.default_style,
|
||||
backgroundPrompt: record.background_prompt,
|
||||
width: record.width,
|
||||
height: record.height,
|
||||
createdAt: record.created_at.toISOString(),
|
||||
updatedAt: record.updated_at.toISOString(),
|
||||
})
|
||||
|
||||
const serializeImage = (image: typeof canvas_images.$inferSelect): SerializedCanvasImage => ({
|
||||
id: image.id,
|
||||
canvasId: image.canvas_id,
|
||||
name: image.name,
|
||||
prompt: image.prompt,
|
||||
modelId: image.model_id,
|
||||
modelUsed: image.model_used,
|
||||
styleId: image.style_id,
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
rotation: image.rotation,
|
||||
position: parsePoint(image.position),
|
||||
branchParentId: image.branch_parent_id,
|
||||
metadata: (image.metadata as Record<string, unknown> | null) ?? null,
|
||||
imageUrl: image.image_url,
|
||||
imageData: image.content_base64 ?? null,
|
||||
createdAt: image.created_at.toISOString(),
|
||||
updatedAt: image.updated_at.toISOString(),
|
||||
})
|
||||
|
||||
const createCanvasWithDefaults = async (
|
||||
params: {
|
||||
ownerId: string
|
||||
name?: string
|
||||
database?: DatabaseClient
|
||||
},
|
||||
): Promise<SerializedCanvas> => {
|
||||
const database = params.database ?? db()
|
||||
const [createdCanvas] = await database
|
||||
.insert(canvas)
|
||||
.values({
|
||||
owner_id: params.ownerId,
|
||||
name: params.name ?? "Untitled Canvas",
|
||||
})
|
||||
.returning()
|
||||
|
||||
const [createdImage] = await database
|
||||
.insert(canvas_images)
|
||||
.values({
|
||||
canvas_id: createdCanvas.id,
|
||||
name: DEFAULT_IMAGE_NAME,
|
||||
prompt: "",
|
||||
position: DEFAULT_POSITION,
|
||||
width: DEFAULT_IMAGE_SIZE.width,
|
||||
height: DEFAULT_IMAGE_SIZE.height,
|
||||
model_id: DEFAULT_MODEL,
|
||||
style_id: "default",
|
||||
})
|
||||
.returning()
|
||||
|
||||
return {
|
||||
canvas: serializeCanvasRecord(createdCanvas),
|
||||
images: [serializeImage(createdImage)],
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOrCreateCanvasForUser(userId: string): Promise<SerializedCanvas> {
|
||||
const database = db()
|
||||
const existing = await database
|
||||
.select()
|
||||
.from(canvas)
|
||||
.where(eq(canvas.owner_id, userId))
|
||||
.limit(1)
|
||||
|
||||
if (existing.length > 0) {
|
||||
const images = await database
|
||||
.select()
|
||||
.from(canvas_images)
|
||||
.where(eq(canvas_images.canvas_id, existing[0].id))
|
||||
.orderBy(asc(canvas_images.created_at))
|
||||
|
||||
return {
|
||||
canvas: serializeCanvasRecord(existing[0]),
|
||||
images: images.map(serializeImage),
|
||||
}
|
||||
}
|
||||
|
||||
return createCanvasWithDefaults({ ownerId: userId, database })
|
||||
}
|
||||
|
||||
export async function getCanvasSnapshotById(canvasId: string): Promise<SerializedCanvas | null> {
|
||||
const database = db()
|
||||
const records = await database.select().from(canvas).where(eq(canvas.id, canvasId)).limit(1)
|
||||
if (records.length === 0) {
|
||||
return null
|
||||
}
|
||||
const images = await database
|
||||
.select()
|
||||
.from(canvas_images)
|
||||
.where(eq(canvas_images.canvas_id, canvasId))
|
||||
.orderBy(asc(canvas_images.created_at))
|
||||
|
||||
return {
|
||||
canvas: serializeCanvasRecord(records[0]),
|
||||
images: images.map(serializeImage),
|
||||
}
|
||||
}
|
||||
|
||||
export async function createCanvasForUser(params: {
|
||||
userId: string
|
||||
name?: string
|
||||
}): Promise<SerializedCanvas> {
|
||||
return createCanvasWithDefaults({ ownerId: params.userId, name: params.name })
|
||||
}
|
||||
|
||||
export async function listCanvasesForUser(userId: string): Promise<SerializedCanvasSummary[]> {
|
||||
const database = db()
|
||||
const records = await database
|
||||
.select()
|
||||
.from(canvas)
|
||||
.where(eq(canvas.owner_id, userId))
|
||||
.orderBy(desc(canvas.updated_at))
|
||||
|
||||
if (records.length === 0) {
|
||||
const created = await createCanvasWithDefaults({ ownerId: userId, database })
|
||||
return [
|
||||
{
|
||||
canvas: created.canvas,
|
||||
previewImage: created.images[0] ?? null,
|
||||
imageCount: created.images.length,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const canvasIds = records.map((record) => record.id)
|
||||
const previewMap = new Map<string, SerializedCanvasImage>()
|
||||
const countMap = new Map<string, number>()
|
||||
|
||||
if (canvasIds.length > 0) {
|
||||
const images = await database
|
||||
.select()
|
||||
.from(canvas_images)
|
||||
.where(inArray(canvas_images.canvas_id, canvasIds))
|
||||
.orderBy(desc(canvas_images.updated_at))
|
||||
|
||||
for (const image of images) {
|
||||
const serialized = serializeImage(image)
|
||||
const parentCanvasId = serialized.canvasId
|
||||
countMap.set(parentCanvasId, (countMap.get(parentCanvasId) ?? 0) + 1)
|
||||
if (!previewMap.has(parentCanvasId)) {
|
||||
previewMap.set(parentCanvasId, serialized)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return records.map((record) => ({
|
||||
canvas: serializeCanvasRecord(record),
|
||||
previewImage: previewMap.get(record.id) ?? null,
|
||||
imageCount: countMap.get(record.id) ?? 0,
|
||||
}))
|
||||
}
|
||||
|
||||
export async function createCanvasImage(params: {
|
||||
canvasId: string
|
||||
name?: string
|
||||
prompt?: string
|
||||
position?: CanvasPoint
|
||||
size?: CanvasSize
|
||||
modelId?: string
|
||||
styleId?: string
|
||||
branchParentId?: string | null
|
||||
}): Promise<SerializedCanvasImage> {
|
||||
const database = db()
|
||||
const [image] = await database
|
||||
.insert(canvas_images)
|
||||
.values({
|
||||
canvas_id: params.canvasId,
|
||||
name: params.name ?? DEFAULT_IMAGE_NAME,
|
||||
prompt: params.prompt ?? "",
|
||||
position: params.position ?? DEFAULT_POSITION,
|
||||
width: params.size?.width ?? DEFAULT_IMAGE_SIZE.width,
|
||||
height: params.size?.height ?? DEFAULT_IMAGE_SIZE.height,
|
||||
model_id: params.modelId ?? DEFAULT_MODEL,
|
||||
style_id: params.styleId ?? "default",
|
||||
branch_parent_id: params.branchParentId ?? null,
|
||||
})
|
||||
.returning()
|
||||
|
||||
return serializeImage(image)
|
||||
}
|
||||
|
||||
export async function updateCanvasImage(params: {
|
||||
imageId: string
|
||||
data: {
|
||||
name?: string
|
||||
prompt?: string
|
||||
modelId?: string
|
||||
modelUsed?: string | null
|
||||
styleId?: string
|
||||
position?: CanvasPoint
|
||||
size?: CanvasSize
|
||||
rotation?: number
|
||||
metadata?: Record<string, unknown> | null
|
||||
branchParentId?: string | null
|
||||
imageDataBase64?: string | null
|
||||
imageUrl?: string | null
|
||||
}
|
||||
}): Promise<SerializedCanvasImage> {
|
||||
const database = db()
|
||||
const values: Partial<typeof canvas_images.$inferInsert> = {
|
||||
updated_at: new Date(),
|
||||
}
|
||||
|
||||
if (params.data.name !== undefined) values.name = params.data.name
|
||||
if (params.data.prompt !== undefined) values.prompt = params.data.prompt
|
||||
if (params.data.modelId !== undefined) values.model_id = params.data.modelId
|
||||
if (params.data.modelUsed !== undefined) values.model_used = params.data.modelUsed
|
||||
if (params.data.styleId !== undefined) values.style_id = params.data.styleId
|
||||
if (params.data.position) values.position = params.data.position
|
||||
if (params.data.size) {
|
||||
values.width = params.data.size.width
|
||||
values.height = params.data.size.height
|
||||
}
|
||||
if (typeof params.data.rotation === "number") {
|
||||
values.rotation = params.data.rotation
|
||||
}
|
||||
if (params.data.metadata !== undefined) {
|
||||
values.metadata = params.data.metadata ?? null
|
||||
}
|
||||
if (params.data.branchParentId !== undefined) {
|
||||
values.branch_parent_id = params.data.branchParentId
|
||||
}
|
||||
if (params.data.imageDataBase64 !== undefined) {
|
||||
values.content_base64 = params.data.imageDataBase64 ?? null
|
||||
}
|
||||
if (params.data.imageUrl !== undefined) {
|
||||
values.image_url = params.data.imageUrl
|
||||
}
|
||||
|
||||
const [updated] = await database
|
||||
.update(canvas_images)
|
||||
.set(values)
|
||||
.where(eq(canvas_images.id, params.imageId))
|
||||
.returning()
|
||||
|
||||
return serializeImage(updated)
|
||||
}
|
||||
|
||||
export async function deleteCanvasImage(imageId: string) {
|
||||
const database = db()
|
||||
await database.delete(canvas_images).where(eq(canvas_images.id, imageId))
|
||||
}
|
||||
|
||||
export async function updateCanvasRecord(params: {
|
||||
canvasId: string
|
||||
data: {
|
||||
name?: string
|
||||
width?: number
|
||||
height?: number
|
||||
defaultModel?: string
|
||||
defaultStyle?: string
|
||||
backgroundPrompt?: string | null
|
||||
}
|
||||
}): Promise<SerializedCanvasRecord> {
|
||||
const database = db()
|
||||
const values: Partial<typeof canvas.$inferInsert> = {
|
||||
updated_at: new Date(),
|
||||
}
|
||||
|
||||
if (params.data.name !== undefined) values.name = params.data.name
|
||||
if (params.data.width !== undefined) values.width = params.data.width
|
||||
if (params.data.height !== undefined) values.height = params.data.height
|
||||
if (params.data.defaultModel !== undefined) values.default_model = params.data.defaultModel
|
||||
if (params.data.defaultStyle !== undefined) values.default_style = params.data.defaultStyle
|
||||
if (params.data.backgroundPrompt !== undefined)
|
||||
values.background_prompt = params.data.backgroundPrompt
|
||||
|
||||
const [record] = await database
|
||||
.update(canvas)
|
||||
.set(values)
|
||||
.where(eq(canvas.id, params.canvasId))
|
||||
.returning()
|
||||
|
||||
return serializeCanvasRecord(record)
|
||||
}
|
||||
|
||||
export async function getCanvasOwner(canvasId: string) {
|
||||
const database = db()
|
||||
const [record] = await database
|
||||
.select({ ownerId: canvas.owner_id })
|
||||
.from(canvas)
|
||||
.where(eq(canvas.id, canvasId))
|
||||
.limit(1)
|
||||
return record ?? null
|
||||
}
|
||||
|
||||
export async function getCanvasImageRecord(imageId: string) {
|
||||
const database = db()
|
||||
const [record] = await database
|
||||
.select()
|
||||
.from(canvas_images)
|
||||
.where(eq(canvas_images.id, imageId))
|
||||
.limit(1)
|
||||
return record ?? null
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
export type CanvasPoint = {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export type CanvasSize = {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export type SerializedCanvasRecord = {
|
||||
id: string
|
||||
name: string
|
||||
ownerId: string
|
||||
defaultModel: string
|
||||
defaultStyle: string
|
||||
backgroundPrompt: string | null
|
||||
width: number
|
||||
height: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type SerializedCanvasImage = {
|
||||
id: string
|
||||
canvasId: string
|
||||
name: string
|
||||
prompt: string
|
||||
modelId: string
|
||||
modelUsed: string | null
|
||||
styleId: string
|
||||
width: number
|
||||
height: number
|
||||
rotation: number
|
||||
position: CanvasPoint
|
||||
branchParentId: string | null
|
||||
metadata: Record<string, unknown> | null
|
||||
imageUrl: string | null
|
||||
imageData: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type SerializedCanvas = {
|
||||
canvas: SerializedCanvasRecord
|
||||
images: SerializedCanvasImage[]
|
||||
}
|
||||
|
||||
export type SerializedCanvasSummary = {
|
||||
canvas: SerializedCanvasRecord
|
||||
previewImage: SerializedCanvasImage | null
|
||||
imageCount: number
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { eq } from "drizzle-orm"
|
||||
import { getAuth } from "@/lib/auth"
|
||||
import { getAuthDb } from "@/db/connection"
|
||||
import { users } from "@/db/schema"
|
||||
|
||||
const COOKIE_NAME = "canvas_guest_id"
|
||||
const COOKIE_MAX_AGE = 60 * 60 * 24 * 30 // 30 days
|
||||
|
||||
const parseCookies = (header: string | null) => {
|
||||
if (!header) return {}
|
||||
return header.split(";").reduce<Record<string, string>>((acc, part) => {
|
||||
const [key, ...rest] = part.trim().split("=")
|
||||
if (!key) return acc
|
||||
acc[key] = rest.join("=")
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
|
||||
const buildCookie = (id: string) =>
|
||||
`${COOKIE_NAME}=${id}; Path=/; Max-Age=${COOKIE_MAX_AGE}; HttpOnly; SameSite=Lax`
|
||||
|
||||
const resolveDatabaseUrl = () => {
|
||||
try {
|
||||
const { getServerContext } = require("@tanstack/react-start/server") as {
|
||||
getServerContext: () => { cloudflare?: { env?: Record<string, string> } } | null
|
||||
}
|
||||
const ctx = getServerContext()
|
||||
const url = ctx?.cloudflare?.env?.DATABASE_URL
|
||||
if (url) {
|
||||
return url
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
if (process.env.DATABASE_URL) {
|
||||
return process.env.DATABASE_URL
|
||||
}
|
||||
|
||||
throw new Error("DATABASE_URL is not configured")
|
||||
}
|
||||
|
||||
const getAuthDatabase = () => {
|
||||
const url = resolveDatabaseUrl()
|
||||
return getAuthDb(url)
|
||||
}
|
||||
|
||||
async function ensureGuestUser(existingId?: string) {
|
||||
const db = getAuthDatabase()
|
||||
|
||||
if (existingId) {
|
||||
const existing = await db.query.users.findFirst({
|
||||
where(fields, { eq }) {
|
||||
return eq(fields.id, existingId)
|
||||
},
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
return { userId: existingId, setCookie: undefined }
|
||||
}
|
||||
}
|
||||
|
||||
const newId = crypto.randomUUID()
|
||||
const email = `canvas-guest-${newId}@example.local`
|
||||
|
||||
await db.insert(users).values({
|
||||
id: newId,
|
||||
name: "Canvas Guest",
|
||||
email,
|
||||
})
|
||||
|
||||
return { userId: newId, setCookie: buildCookie(newId) }
|
||||
}
|
||||
|
||||
export async function resolveCanvasUser(request: Request) {
|
||||
const session = await getAuth().api.getSession({ headers: request.headers })
|
||||
|
||||
if (session?.user?.id) {
|
||||
return { userId: session.user.id, setCookie: undefined }
|
||||
}
|
||||
|
||||
const cookies = parseCookies(request.headers.get("cookie"))
|
||||
const guestId = cookies[COOKIE_NAME]
|
||||
return ensureGuestUser(guestId)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { createCollection } from "@tanstack/react-db"
|
||||
import { electricCollectionOptions } from "@tanstack/electric-db-collection"
|
||||
import {
|
||||
selectUsersSchema,
|
||||
selectChatThreadSchema,
|
||||
selectChatMessageSchema,
|
||||
} from "@/db/schema"
|
||||
|
||||
export const usersCollection = createCollection(
|
||||
electricCollectionOptions({
|
||||
id: "users",
|
||||
shapeOptions: {
|
||||
url: new URL(
|
||||
"/api/users",
|
||||
typeof window !== "undefined"
|
||||
? window.location.origin
|
||||
: "http://localhost:3000",
|
||||
).toString(),
|
||||
parser: {
|
||||
timestamptz: (date: string) => new Date(date),
|
||||
},
|
||||
},
|
||||
schema: selectUsersSchema,
|
||||
getKey: (item) => item.id,
|
||||
}),
|
||||
)
|
||||
|
||||
const baseUrl =
|
||||
typeof window !== "undefined"
|
||||
? window.location.origin
|
||||
: "http://localhost:3000"
|
||||
|
||||
// Create collections lazily to avoid fetching before authentication
|
||||
// Using a factory pattern so each call gets the same collection instance
|
||||
|
||||
const createChatThreadsCollection = () =>
|
||||
createCollection(
|
||||
electricCollectionOptions({
|
||||
id: "chat_threads",
|
||||
shapeOptions: {
|
||||
url: new URL("/api/chat-threads", baseUrl).toString(),
|
||||
parser: {
|
||||
timestamptz: (date: string) => new Date(date),
|
||||
},
|
||||
fetchClient: (input, init) =>
|
||||
fetch(input, { ...init, credentials: "include" }),
|
||||
onError: () => {
|
||||
// Silently ignore auth errors for guest users
|
||||
},
|
||||
},
|
||||
schema: selectChatThreadSchema,
|
||||
getKey: (item) => item.id,
|
||||
}),
|
||||
)
|
||||
|
||||
const createChatMessagesCollection = () =>
|
||||
createCollection(
|
||||
electricCollectionOptions({
|
||||
id: "chat_messages",
|
||||
shapeOptions: {
|
||||
url: new URL("/api/chat-messages", baseUrl).toString(),
|
||||
parser: {
|
||||
timestamptz: (date: string) => new Date(date),
|
||||
},
|
||||
fetchClient: (input, init) =>
|
||||
fetch(input, { ...init, credentials: "include" }),
|
||||
onError: () => {
|
||||
// Silently ignore auth errors for guest users
|
||||
},
|
||||
},
|
||||
schema: selectChatMessageSchema,
|
||||
getKey: (item) => item.id,
|
||||
}),
|
||||
)
|
||||
|
||||
type ChatThreadsCollection = ReturnType<typeof createChatThreadsCollection>
|
||||
type ChatMessagesCollection = ReturnType<typeof createChatMessagesCollection>
|
||||
|
||||
let _chatThreadsCollection: ChatThreadsCollection | null = null
|
||||
let _chatMessagesCollection: ChatMessagesCollection | null = null
|
||||
|
||||
export function getChatThreadsCollection(): ChatThreadsCollection {
|
||||
if (!_chatThreadsCollection) {
|
||||
_chatThreadsCollection = createChatThreadsCollection()
|
||||
}
|
||||
return _chatThreadsCollection
|
||||
}
|
||||
|
||||
export function getChatMessagesCollection(): ChatMessagesCollection {
|
||||
if (!_chatMessagesCollection) {
|
||||
_chatMessagesCollection = createChatMessagesCollection()
|
||||
}
|
||||
return _chatMessagesCollection
|
||||
}
|
||||
|
||||
// Keep exports for backward compatibility but as getters
|
||||
export const chatThreadsCollection = {
|
||||
get collection() {
|
||||
return getChatThreadsCollection()
|
||||
},
|
||||
}
|
||||
|
||||
export const chatMessagesCollection = {
|
||||
get collection() {
|
||||
return getChatMessagesCollection()
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { ELECTRIC_PROTOCOL_QUERY_PARAMS } from "@electric-sql/client"
|
||||
|
||||
type ElectricEnv = {
|
||||
ELECTRIC_URL?: string
|
||||
ELECTRIC_SOURCE_ID?: string
|
||||
ELECTRIC_SOURCE_SECRET?: string
|
||||
}
|
||||
const DEFAULT_ALLOW_HEADERS =
|
||||
"content-type,authorization,x-requested-with,x-electric-client-id"
|
||||
|
||||
// Get env from Cloudflare context or process.env
|
||||
const getElectricEnv = (): ElectricEnv => {
|
||||
let ELECTRIC_URL: string | undefined
|
||||
let ELECTRIC_SOURCE_ID: string | undefined
|
||||
let ELECTRIC_SOURCE_SECRET: string | undefined
|
||||
|
||||
// Try Cloudflare Workers context first (production)
|
||||
try {
|
||||
const { getServerContext } = require("@tanstack/react-start/server")
|
||||
const ctx = getServerContext()
|
||||
if (ctx?.cloudflare?.env) {
|
||||
const cfEnv = ctx.cloudflare.env as Partial<ElectricEnv>
|
||||
ELECTRIC_URL = cfEnv.ELECTRIC_URL
|
||||
ELECTRIC_SOURCE_ID = cfEnv.ELECTRIC_SOURCE_ID
|
||||
ELECTRIC_SOURCE_SECRET = cfEnv.ELECTRIC_SOURCE_SECRET
|
||||
}
|
||||
} catch {
|
||||
// Not in Cloudflare context
|
||||
}
|
||||
|
||||
// Fall back to process.env (local dev)
|
||||
return {
|
||||
ELECTRIC_URL: ELECTRIC_URL ?? process.env.ELECTRIC_URL,
|
||||
ELECTRIC_SOURCE_ID: ELECTRIC_SOURCE_ID ?? process.env.ELECTRIC_SOURCE_ID,
|
||||
ELECTRIC_SOURCE_SECRET:
|
||||
ELECTRIC_SOURCE_SECRET ?? process.env.ELECTRIC_SOURCE_SECRET,
|
||||
}
|
||||
}
|
||||
|
||||
export function prepareElectricUrl(requestUrl: string): URL {
|
||||
const url = new URL(requestUrl)
|
||||
const env = getElectricEnv()
|
||||
const electricUrl = env.ELECTRIC_URL ?? "http://localhost:3100"
|
||||
const originUrl = new URL(`${electricUrl}/v1/shape`)
|
||||
|
||||
url.searchParams.forEach((value, key) => {
|
||||
if (ELECTRIC_PROTOCOL_QUERY_PARAMS.includes(key)) {
|
||||
originUrl.searchParams.set(key, value)
|
||||
}
|
||||
})
|
||||
|
||||
if (env.ELECTRIC_SOURCE_ID && env.ELECTRIC_SOURCE_SECRET) {
|
||||
originUrl.searchParams.set("source_id", env.ELECTRIC_SOURCE_ID)
|
||||
originUrl.searchParams.set("secret", env.ELECTRIC_SOURCE_SECRET)
|
||||
}
|
||||
|
||||
return originUrl
|
||||
}
|
||||
|
||||
const buildCorsHeaders = (request?: Request) => {
|
||||
const headers = new Headers()
|
||||
const origin = request?.headers.get("origin")
|
||||
|
||||
if (origin) {
|
||||
headers.set("access-control-allow-origin", origin)
|
||||
headers.set("access-control-allow-credentials", "true")
|
||||
} else {
|
||||
headers.set("access-control-allow-origin", "*")
|
||||
}
|
||||
|
||||
const requestedHeaders =
|
||||
request?.headers.get("access-control-request-headers") ?? DEFAULT_ALLOW_HEADERS
|
||||
headers.set("access-control-allow-headers", requestedHeaders)
|
||||
headers.set("access-control-allow-methods", "GET,OPTIONS")
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
export const optionsResponse = (request?: Request) =>
|
||||
new Response(null, {
|
||||
status: 204,
|
||||
headers: buildCorsHeaders(request),
|
||||
})
|
||||
|
||||
export async function proxyElectricRequest(
|
||||
originUrl: URL,
|
||||
request?: Request,
|
||||
): Promise<Response> {
|
||||
const response = await fetch(originUrl)
|
||||
const headers = new Headers(response.headers)
|
||||
const corsHeaders = buildCorsHeaders(request)
|
||||
|
||||
headers.delete("content-encoding")
|
||||
headers.delete("content-length")
|
||||
headers.set("vary", "cookie")
|
||||
corsHeaders.forEach((value, key) => headers.set(key, value))
|
||||
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { FlowgladServer } from "@flowglad/server"
|
||||
import { getAuth } from "./auth"
|
||||
|
||||
type FlowgladEnv = {
|
||||
FLOWGLAD_SECRET_KEY?: string
|
||||
}
|
||||
|
||||
const getEnv = (): FlowgladEnv => {
|
||||
let FLOWGLAD_SECRET_KEY: string | undefined
|
||||
|
||||
try {
|
||||
const { getServerContext } = require("@tanstack/react-start/server") as {
|
||||
getServerContext: () => { cloudflare?: { env?: FlowgladEnv } } | null
|
||||
}
|
||||
const ctx = getServerContext()
|
||||
FLOWGLAD_SECRET_KEY = ctx?.cloudflare?.env?.FLOWGLAD_SECRET_KEY
|
||||
} catch {
|
||||
// Not in server context
|
||||
}
|
||||
|
||||
FLOWGLAD_SECRET_KEY = FLOWGLAD_SECRET_KEY ?? process.env.FLOWGLAD_SECRET_KEY
|
||||
|
||||
return { FLOWGLAD_SECRET_KEY }
|
||||
}
|
||||
|
||||
export const getFlowgladServer = (request?: Request) => {
|
||||
const env = getEnv()
|
||||
|
||||
if (!env.FLOWGLAD_SECRET_KEY) {
|
||||
return null
|
||||
}
|
||||
|
||||
return new FlowgladServer({
|
||||
apiKey: env.FLOWGLAD_SECRET_KEY,
|
||||
getRequestingCustomer: async () => {
|
||||
if (!request) {
|
||||
throw new Error("Request required to get customer")
|
||||
}
|
||||
|
||||
const auth = getAuth()
|
||||
const session = await auth.api.getSession({ headers: request.headers })
|
||||
|
||||
if (!session?.user) {
|
||||
throw new Error("Unauthenticated")
|
||||
}
|
||||
|
||||
return {
|
||||
externalId: session.user.id,
|
||||
email: session.user.email,
|
||||
name: session.user.name ?? undefined,
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a FlowgladServer instance for a specific user ID.
|
||||
* Use this when you already have the user ID and don't need request-based auth.
|
||||
*/
|
||||
export const flowglad = (userId: string) => {
|
||||
const env = getEnv()
|
||||
|
||||
if (!env.FLOWGLAD_SECRET_KEY) {
|
||||
return null
|
||||
}
|
||||
|
||||
return new FlowgladServer({
|
||||
apiKey: env.FLOWGLAD_SECRET_KEY,
|
||||
getRequestingCustomer: async () => ({
|
||||
externalId: userId,
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
export type StreamPageData = {
|
||||
user: {
|
||||
id: string
|
||||
name: string
|
||||
username: string | null
|
||||
image: string | null
|
||||
}
|
||||
stream: {
|
||||
id: string
|
||||
title: string
|
||||
description: string | null
|
||||
is_live: boolean
|
||||
viewer_count: number
|
||||
hls_url: string | null
|
||||
thumbnail_url: string | null
|
||||
started_at: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
export async function getStreamByUsername(
|
||||
username: string,
|
||||
): Promise<StreamPageData | null> {
|
||||
const res = await fetch(`/api/streams/${username}`, {
|
||||
credentials: "include",
|
||||
})
|
||||
|
||||
if (res.status === 404) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error("Failed to fetch stream data")
|
||||
}
|
||||
|
||||
return res.json()
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Example utilities for calling the Worker RPC from the web package
|
||||
*
|
||||
* Usage in server functions or loaders:
|
||||
*
|
||||
* import { getServerContext } from '@tanstack/react-start/server';
|
||||
* import { callWorkerRpc } from '@/lib/worker-rpc';
|
||||
*
|
||||
* export const loader = async () => {
|
||||
* const { WORKER_RPC } = getServerContext().cloudflare.env;
|
||||
* const result = await callWorkerRpc(WORKER_RPC);
|
||||
* return result;
|
||||
* };
|
||||
*/
|
||||
|
||||
import type { WorkerRpc } from "../../../worker/src/rpc"
|
||||
|
||||
/**
|
||||
* Example: Call the sayHello RPC method
|
||||
*/
|
||||
export async function sayHelloRpc(workerRpc: WorkerRpc, name: string) {
|
||||
return await workerRpc.sayHello(name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Example: Call the calculate RPC method
|
||||
*/
|
||||
export async function calculateRpc(
|
||||
workerRpc: WorkerRpc,
|
||||
operation: "add" | "subtract" | "multiply" | "divide",
|
||||
a: number,
|
||||
b: number,
|
||||
) {
|
||||
return await workerRpc.calculate(operation, a, b)
|
||||
}
|
||||
|
||||
/**
|
||||
* Example: Call the processBatch RPC method
|
||||
*/
|
||||
export async function processBatchRpc(workerRpc: WorkerRpc, items: string[]) {
|
||||
return await workerRpc.processBatch(items)
|
||||
}
|
||||
|
||||
/**
|
||||
* Example: Call the getData RPC method
|
||||
*/
|
||||
export async function getDataRpc(workerRpc: WorkerRpc, key: string) {
|
||||
return await workerRpc.getData(key)
|
||||
}
|
||||
Reference in New Issue
Block a user