diff --git a/.env.example b/.env.example index 6175669..0eb4f0b 100644 --- a/.env.example +++ b/.env.example @@ -6,4 +6,6 @@ GITHUB_CLIENT_ID= GITHUB_CLIENT_SECRET= BETTER_AUTH_SECRET= -SCRAWN_KEY= +BETTER_AUTH_BASE_URL= + +MASTER_API_KEY= diff --git a/drizzle.config.ts b/drizzle.config.ts index abc17ff..b13b621 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from "drizzle-kit" import dotenv from "dotenv" -dotenv.config({ path: ".env.local" }) +dotenv.config() export default defineConfig({ schema: "./src/db/schema.ts", diff --git a/src/db/schema.ts b/src/db/schema.ts index 0462b0e..74b8fab 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -49,3 +49,22 @@ export const verification = pgTable("verification", { createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(), }) + +export const org = pgTable("org", { + orgId: text("org_id").primaryKey().notNull(), + userId: text("user_id") + .notNull() + .unique() + .references(() => user.id, { + onDelete: "cascade", + }), + createdAt: timestamp("created_at").notNull().defaultNow(), +}) + +export const project = pgTable("project", { + projectId: text("project_id").notNull().primaryKey(), + orgId: text("org_id") + .notNull() + .references(() => org.orgId, { onDelete: "cascade" }), + createdAt: timestamp("created_at").notNull().defaultNow(), +}) diff --git a/src/lib/auth.ts b/src/lib/auth.ts index eab912c..232d6c1 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -9,4 +9,5 @@ export const auth = betterAuth({ enabled: true, }, plugins: [tanstackStartCookies()], + baseURL: "BETTER_AUTH_BASE_URL", }) diff --git a/src/lib/scrawn-server.ts b/src/lib/scrawn-server.ts index 1c301af..2aebacb 100644 --- a/src/lib/scrawn-server.ts +++ b/src/lib/scrawn-server.ts @@ -1,503 +1,26 @@ -import { and, count as analyticsCount, desc, eq, sum } from "@scrawn/analytics" -import { createServerFn } from "@tanstack/react-start" -import { createAnalytics } from "./scrawn" -import { db } from "./db" -import { user } from "@/db/schema" -import { count } from "drizzle-orm" - -const SCRAWN_HTTP_URL = process.env.SCRAWN_HTTP_URL || "http://localhost:8070" -const SCRAWN_KEY = process.env.SCRAWN_KEY as string - -function validator(): { (): T; (value: unknown): T } { - return ((input: unknown) => input as T) as { (): T; (value: unknown): T } -} - -export const checkUsersExist = createServerFn({ method: "GET" }).handler( - async () => { - const [result] = await db.select({ count: count() }).from(user) - return { exists: (result?.count ?? 0) > 0 } - } -) - -export const createAdminUser = createServerFn({ method: "POST" }) - .inputValidator( - validator<{ name: string; email: string; password: string }>() - ) - .handler(async (ctx) => { - const existing = await db.select({ count: count() }).from(user) - if ((existing[0]?.count ?? 0) > 0) { - return { error: "An admin user already exists" } - } - const { auth } = await import("./auth") - await auth.api.signUpEmail({ - body: { - name: ctx.data.name, - email: ctx.data.email, - password: ctx.data.password, - }, - }) - return { success: true } - }) - -export const getBackendConfig = createServerFn({ method: "GET" }).handler( - async () => { - const res = await fetch(`${SCRAWN_HTTP_URL}/api/v1/internals/config`, { - headers: { Authorization: `Bearer ${SCRAWN_KEY}` }, - }) - if (!res.ok) return { configured: false } - return res.json() as Promise<{ - configured: boolean - dodo_live_product_id?: string - dodo_test_product_id?: string - }> - } -) - -export const submitOnboarding = createServerFn({ method: "POST" }) - .inputValidator( - validator<{ - dodoLiveApiKey: string - dodoTestApiKey: string - dodoLiveProductId: string - dodoTestProductId: string - currency: string - redirectUrl: string - }>() - ) - .handler(async (ctx) => { - const res = await fetch(`${SCRAWN_HTTP_URL}/api/v1/internals/onboarding`, { - method: "POST", - headers: { - Authorization: `Bearer ${SCRAWN_KEY}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(ctx.data), - }) - if (!res.ok) { - const body = await res.json().catch(() => ({})) - return { error: body.error || "Failed to save configuration" } - } - return { success: true } - }) - -export const getUsageOverTime = createServerFn({ method: "GET" }) - .inputValidator(validator<{ mode?: string }>()) - .handler(async (ctx) => { - const analytics = createAnalytics() - const f = analytics.query.basicUsage.fields - let q = analytics.query.basicUsage - .aggregate(sum(f.debitAmount)) - .groupBy(f.ingestedTimestamp) - .orderBy(desc(f.ingestedTimestamp)) - .limit(30) - if (ctx.data.mode) { - q = q.where(and(eq(f.mode, ctx.data.mode))) - } - const result = await q.execute() - return result.rows - .reverse() - .filter((r) => r.groupValue != null) - .map((r) => ({ groupValue: r.groupValue!, aggValue: r.aggValue })) - } -) - -export const getTopUsers = createServerFn({ method: "GET" }).handler( - async () => { - const analytics = createAnalytics() - const f = analytics.query.basicUsage.fields - const result = await analytics.query.basicUsage - .aggregate(sum(f.debitAmount)) - .groupBy(f.userId) - .orderBy(desc(f.debitAmount)) - .limit(10) - .execute() - return result.rows.map((r) => ({ - groupValue: r.groupValue, - aggValue: r.aggValue, - })) - } -) - -export const getEventTypeDistribution = createServerFn({ - method: "GET", -}).handler(async () => { - const analytics = createAnalytics() - const f = analytics.query.basicUsage.fields - const result = await analytics.query.basicUsage - .aggregate(analyticsCount()) - .groupBy(f.eventType) - .execute() - return result.rows.map((r) => ({ - groupValue: r.groupValue, - aggValue: r.aggValue, - })) -}) - -export const getAiTokenUsage = createServerFn({ method: "GET" }) - .inputValidator(validator<{ mode?: string }>()) - .handler(async (ctx) => { - const analytics = createAnalytics() - const f = analytics.query.aiToken.fields - const mode = ctx.data.mode - - const mkQ = () => { - let q = analytics.query.aiToken.aggregate(sum(f.inputTokens)).groupBy(f.model) - if (mode) q = q.where(and(eq(f.mode, mode))) - return q.execute() - } - const mkQ2 = () => { - let q = analytics.query.aiToken.aggregate(sum(f.outputTokens)).groupBy(f.model) - if (mode) q = q.where(and(eq(f.mode, mode))) - return q.execute() - } - - const [input, output] = await Promise.all([mkQ(), mkQ2()]) - return { - input: input.rows.map((r) => ({ - groupValue: r.groupValue, - aggValue: r.aggValue, - })), - output: output.rows.map((r) => ({ - groupValue: r.groupValue, - aggValue: r.aggValue, - })), - } - }) - -export const getAiTokenUsageOverTime = createServerFn({ method: "GET" }).handler( - async () => { - const analytics = createAnalytics() - const f = analytics.query.aiToken.fields - const result = await analytics.query.aiToken - .orderBy(desc(f.ingestedTimestamp)) - .limit(500) - .execute() - - const groups = new Map>() - for (const row of result.rows) { - const date = (row.ingestedTimestamp ?? "").slice(0, 10) - const model = row.model ?? "unknown" - if (!groups.has(date)) groups.set(date, new Map()) - const modelMap = groups.get(date)! - if (!modelMap.has(model)) modelMap.set(model, { input: 0, output: 0 }) - const acc = modelMap.get(model)! - acc.input += row.inputTokens ?? 0 - acc.output += row.outputTokens ?? 0 - } - - const flat: Array<{ date: string; model: string; inputTokens: number; outputTokens: number }> = [] - for (const [date, modelMap] of groups) { - for (const [model, counts] of modelMap) { - flat.push({ date, model, inputTokens: counts.input, outputTokens: counts.output }) - } - } - - return flat.sort((a, b) => a.date.localeCompare(b.date)) - } -) - -export const getPaymentHistory = createServerFn({ method: "GET" }) - .inputValidator(validator<{ mode?: string }>()) - .handler(async (ctx) => { - const analytics = createAnalytics() - const f = analytics.query.payment.fields - const mode = ctx.data.mode - let q = analytics.query.payment - .aggregate(sum(f.creditAmount)) - .groupBy(f.ingestedTimestamp) - .orderBy(desc(f.ingestedTimestamp)) - .limit(30) - if (mode) q = q.where(and(eq(f.mode, mode))) - const result = await q.execute() - return result.rows - .reverse() - .filter((r) => r.groupValue != null) - .map((r) => ({ groupValue: r.groupValue!, aggValue: r.aggValue })) - } -) - -export const getRecentEvents = createServerFn({ method: "GET" }).handler( - async () => { - const analytics = createAnalytics() - const f = analytics.query.basicUsage.fields - const result = await analytics.query.basicUsage - .orderBy(desc(f.ingestedTimestamp)) - .limit(10) - .execute() - return result.rows - } -) - -export const getFilteredEvents = createServerFn({ method: "GET" }) - .inputValidator( - validator<{ - apiKeyId?: string - userId?: string - eventType?: string - mode?: string - model?: string - limit?: number - offset?: number - }>() - ) - .handler(async (ctx) => { - const analytics = createAnalytics() - const bf = analytics.query.basicUsage.fields - const af = analytics.query.aiToken.fields - const limit = ctx.data.limit ?? 10 - const offset = ctx.data.offset ?? 0 - const fetchLimit = 500 - - function mkBasicQuery() { - const conds: import("@scrawn/analytics").FilterCondition[] = [] - if (ctx.data.apiKeyId) conds.push(eq(bf.apiKeyId, ctx.data.apiKeyId)) - if (ctx.data.userId) conds.push(eq(bf.userId, ctx.data.userId)) - if (ctx.data.mode) conds.push(eq(bf.mode, ctx.data.mode)) - let q = analytics.query.basicUsage.orderBy(desc(bf.ingestedTimestamp)).limit(fetchLimit) - if (conds.length > 0) q = q.where(and(...conds)) - return q.execute() - } - - function mkAiQuery() { - const conds: import("@scrawn/analytics").FilterCondition[] = [] - if (ctx.data.apiKeyId) conds.push(eq(af.apiKeyId, ctx.data.apiKeyId)) - if (ctx.data.userId) conds.push(eq(af.userId, ctx.data.userId)) - if (ctx.data.mode) conds.push(eq(af.mode, ctx.data.mode)) - if (ctx.data.model) conds.push(eq(af.model, ctx.data.model)) - let q = analytics.query.aiToken.orderBy(desc(af.ingestedTimestamp)).limit(fetchLimit) - if (conds.length > 0) q = q.where(and(...conds)) - return q.execute() - } - - const [basicResult, aiResult] = await Promise.all([mkBasicQuery(), mkAiQuery()]) - - const basicRows = basicResult.rows.map((r) => ({ - eventId: (r as { eventId?: string }).eventId ?? "", - eventType: (r as { eventType?: string }).eventType ?? "", - userId: (r as { userId?: string }).userId ?? "", - reportedTimestamp: (r as { reportedTimestamp?: string }).reportedTimestamp ?? "", - ingestedTimestamp: (r as { ingestedTimestamp?: string }).ingestedTimestamp ?? "", - basicUsageType: (r as { basicUsageType?: string }).basicUsageType ?? "", - debitAmount: Number((r as { debitAmount?: number }).debitAmount ?? 0), - })) - - const aiRows = aiResult.rows.map((r) => ({ - eventId: (r as { eventId?: string }).eventId ?? "", - eventType: (r as { eventType?: string }).eventType ?? "", - userId: (r as { userId?: string }).userId ?? "", - reportedTimestamp: (r as { reportedTimestamp?: string }).reportedTimestamp ?? "", - ingestedTimestamp: (r as { ingestedTimestamp?: string }).ingestedTimestamp ?? "", - basicUsageType: "", - debitAmount: Number((r as { debitAmount?: number }).debitAmount ?? 0), - })) - - const all = [...basicRows, ...aiRows].sort((a, b) => - b.ingestedTimestamp.localeCompare(a.ingestedTimestamp) - ) - - const total = (basicResult.total ?? 0) + (aiResult.total ?? 0) - - return { - rows: all.slice(offset, offset + limit), - total, - } - }) - -export const getApiKeySummary = createServerFn({ method: "GET" }) - .inputValidator(validator<{ apiKeyId: string }>()) - .handler(async (ctx) => { - const analytics = createAnalytics() - const sf = analytics.query.basicUsage.fields - const af = analytics.query.aiToken.fields - const pf = analytics.query.payment.fields - const filter = and(eq(sf.apiKeyId, ctx.data.apiKeyId)) - - const [basicDebit, basicCount, aiInput, aiOutput, aiCache, aiCount, creditSum] = await Promise.all([ - analytics.query.basicUsage.where(filter).aggregate(sum(sf.debitAmount)).execute(), - analytics.query.basicUsage.where(filter).aggregate(analyticsCount()).execute(), - analytics.query.aiToken.where(and(eq(af.apiKeyId, ctx.data.apiKeyId))).aggregate(sum(af.inputDebitAmount)).execute(), - analytics.query.aiToken.where(and(eq(af.apiKeyId, ctx.data.apiKeyId))).aggregate(sum(af.outputDebitAmount)).execute(), - analytics.query.aiToken.where(and(eq(af.apiKeyId, ctx.data.apiKeyId))).aggregate(sum(af.inputCacheDebitAmount)).execute(), - analytics.query.aiToken.where(and(eq(af.apiKeyId, ctx.data.apiKeyId))).aggregate(analyticsCount()).execute(), - analytics.query.payment.where(and(eq(pf.apiKeyId, ctx.data.apiKeyId))).aggregate(sum(pf.creditAmount)).execute(), - ]) - - const aiDebit = - Number(aiInput.rows[0]?.aggValue ?? 0) + - Number(aiOutput.rows[0]?.aggValue ?? 0) + - Number(aiCache.rows[0]?.aggValue ?? 0) - const totalRevenue = - (Number(basicDebit.rows[0]?.aggValue ?? 0) + aiDebit).toString() - const totalEvents = - (Number(basicCount.rows[0]?.aggValue ?? 0) + Number(aiCount.rows[0]?.aggValue ?? 0)).toString() - - return { - totalRevenue, - totalEvents, - totalCredits: creditSum.rows[0]?.aggValue ?? "0", - } - }) - -export const getDashboardSummary = createServerFn({ method: "GET" }) - .inputValidator(validator<{ mode?: string }>()) - .handler(async (ctx) => { - const analytics = createAnalytics() - const sf = analytics.query.basicUsage.fields - const af = analytics.query.aiToken.fields - const pf = analytics.query.payment.fields - const mode = ctx.data.mode - - const [basicDebit, aiInput, aiOutput, aiCache, basicCount, aiCount, creditResult] = await Promise.all([ - mode - ? await analytics.query.basicUsage.where(and(eq(sf.mode, mode))).aggregate(sum(sf.debitAmount)).execute() - : await analytics.query.basicUsage.aggregate(sum(sf.debitAmount)).execute(), - mode - ? await analytics.query.aiToken.where(and(eq(af.mode, mode))).aggregate(sum(af.inputDebitAmount)).execute() - : await analytics.query.aiToken.aggregate(sum(af.inputDebitAmount)).execute(), - mode - ? await analytics.query.aiToken.where(and(eq(af.mode, mode))).aggregate(sum(af.outputDebitAmount)).execute() - : await analytics.query.aiToken.aggregate(sum(af.outputDebitAmount)).execute(), - mode - ? await analytics.query.aiToken.where(and(eq(af.mode, mode))).aggregate(sum(af.inputCacheDebitAmount)).execute() - : await analytics.query.aiToken.aggregate(sum(af.inputCacheDebitAmount)).execute(), - mode - ? await analytics.query.basicUsage.where(and(eq(sf.mode, mode))).aggregate(analyticsCount()).execute() - : await analytics.query.basicUsage.aggregate(analyticsCount()).execute(), - mode - ? await analytics.query.aiToken.where(and(eq(af.mode, mode))).aggregate(analyticsCount()).execute() - : await analytics.query.aiToken.aggregate(analyticsCount()).execute(), - mode - ? await analytics.query.payment.where(and(eq(pf.mode, mode))).aggregate(sum(pf.creditAmount)).execute() - : await analytics.query.payment.aggregate(sum(pf.creditAmount)).execute(), - ]) - - const aiDebit = - Number(aiInput.rows[0]?.aggValue ?? 0) + - Number(aiOutput.rows[0]?.aggValue ?? 0) + - Number(aiCache.rows[0]?.aggValue ?? 0) - const totalRevenue = - (Number(basicDebit.rows[0]?.aggValue ?? 0) + aiDebit).toString() - const totalEvents = - (Number(basicCount.rows[0]?.aggValue ?? 0) + Number(aiCount.rows[0]?.aggValue ?? 0)).toString() - - return { - totalRevenue, - totalEvents, - totalCredits: creditResult.rows[0]?.aggValue ?? "0", - } - }) - -async function apiGet(path: string) { - const res = await fetch(`${SCRAWN_HTTP_URL}${path}`, { - headers: { Authorization: `Bearer ${SCRAWN_KEY}` }, - }) - if (!res.ok) { - const body = await res.json().catch(() => ({})) - throw new Error(body.error || `Request failed: ${res.status}`) - } - return res.json() -} - -async function apiPost(path: string, body: unknown) { - const res = await fetch(`${SCRAWN_HTTP_URL}${path}`, { - method: "POST", - headers: { - Authorization: `Bearer ${SCRAWN_KEY}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(body), - }) - if (!res.ok) { - const body = await res.json().catch(() => ({})) - throw new Error(body.error || `Request failed: ${res.status}`) - } - return res.json() -} - -async function apiDelete(path: string) { - const res = await fetch(`${SCRAWN_HTTP_URL}${path}`, { - method: "DELETE", - headers: { Authorization: `Bearer ${SCRAWN_KEY}` }, - }) - if (!res.ok) { - const body = await res.json().catch(() => ({})) - throw new Error(body.error || `Request failed: ${res.status}`) - } - return res.json() -} - -// API Keys -export const listApiKeys = createServerFn({ method: "GET" }).handler(async () => - apiGet("/api/v1/api-keys") -) - -export const createApiKey = createServerFn({ method: "POST" }) - .inputValidator( - validator<{ - name: string - role: "test" | "production" - expiresIn: number - webhookUrl: string - }>() - ) - .handler(async (ctx) => apiPost("/api/v1/api-keys", ctx.data)) - -export const revokeApiKey = createServerFn({ method: "POST" }) - .inputValidator(validator<{ id: string }>()) - .handler(async (ctx) => apiDelete(`/api/v1/api-keys/${ctx.data.id}`)) - -// Tags -export const listTags = createServerFn({ method: "GET" }).handler(async () => - apiGet("/api/v1/tags") -) - -export const createTag = createServerFn({ method: "POST" }) - .inputValidator(validator<{ key: string; amount: number }>()) - .handler(async (ctx) => apiPost("/api/v1/tags", ctx.data)) - -export const deleteTag = createServerFn({ method: "POST" }) - .inputValidator(validator<{ key: string }>()) - .handler(async (ctx) => apiDelete(`/api/v1/tags/${ctx.data.key}`)) - -// Expressions -export const listExpressions = createServerFn({ method: "GET" }).handler( - async () => apiGet("/api/v1/expressions") -) - -export const createExpression = createServerFn({ method: "POST" }) - .inputValidator(validator<{ key: string; expr: string }>()) - .handler(async (ctx) => apiPost("/api/v1/expressions", ctx.data)) - -export const deleteExpression = createServerFn({ method: "POST" }) - .inputValidator(validator<{ key: string }>()) - .handler(async (ctx) => apiDelete(`/api/v1/expressions/${ctx.data.key}`)) - -// Webhook deliveries -export const listDeliveries = createServerFn({ method: "GET" }) - .inputValidator( - validator<{ apiKeyId?: string; eventType?: string; status?: string; role?: string; limit?: number; offset?: number }>() - ) - .handler(async (ctx) => { - const params = new URLSearchParams() - if (ctx.data.apiKeyId) params.set("apiKeyId", ctx.data.apiKeyId) - if (ctx.data.eventType) params.set("eventType", ctx.data.eventType) - if (ctx.data.status) params.set("status", ctx.data.status) - if (ctx.data.role) params.set("role", ctx.data.role) - if (ctx.data.limit) params.set("limit", String(ctx.data.limit)) - if (ctx.data.offset) params.set("offset", String(ctx.data.offset)) - return apiGet(`/api/v1/internals/webhook-deliveries?${params}`) - }) - -// Send test webhook -export const sendTestWebhook = createServerFn({ method: "POST" }) - .inputValidator(validator<{ apiKeyId: string }>()) - .handler(async (ctx) => - apiPost("/api/v1/internals/webhook-endpoint/send-test", ctx.data) - ) - -// Set webhook URL for an API key (dashboard key can set for any key) -export const setWebhookUrl = createServerFn({ method: "POST" }) - .inputValidator(validator<{ apiKeyId: string; url: string }>()) - .handler(async (ctx) => - apiPost("/api/v1/internals/webhook-endpoint", ctx.data) - ) +export { checkUsersExist, createAdminUser } from "./server/auth" +export { getBackendConfig, submitOnboarding } from "./server/onboarding" +export { + getUsageOverTime, + getTopUsers, + getEventTypeDistribution, + getAiTokenUsage, + getAiTokenUsageOverTime, + getPaymentHistory, + getRecentEvents, + getFilteredEvents, + getApiKeySummary, + getDashboardSummary, +} from "./server/analytics" +export { listApiKeys, createApiKey, revokeApiKey } from "./server/apiKeys" +export { listTags, createTag, deleteTag } from "./server/tags" +export { + listExpressions, + createExpression, + deleteExpression, +} from "./server/expressions" +export { + listDeliveries, + sendTestWebhook, + setWebhookUrl, +} from "./server/webhooks" diff --git a/src/lib/server/analytics.ts b/src/lib/server/analytics.ts new file mode 100644 index 0000000..1ffd771 --- /dev/null +++ b/src/lib/server/analytics.ts @@ -0,0 +1,311 @@ +import { and, count as analyticsCount, desc, eq, sum } from "@scrawn/analytics" +import { createServerFn } from "@tanstack/react-start" +import { createAnalytics } from "../scrawn" +import { validator } from "./core" + +export const getUsageOverTime = createServerFn({ method: "GET" }) + .inputValidator(validator<{ mode?: string }>()) + .handler(async (ctx) => { + const analytics = createAnalytics() + const f = analytics.query.basicUsage.fields + let q = analytics.query.basicUsage + .aggregate(sum(f.debitAmount)) + .groupBy(f.ingestedTimestamp) + .orderBy(desc(f.ingestedTimestamp)) + .limit(30) + if (ctx.data.mode) { + q = q.where(and(eq(f.mode, ctx.data.mode))) + } + const result = await q.execute() + return result.rows + .reverse() + .filter((r) => r.groupValue != null) + .map((r) => ({ groupValue: r.groupValue!, aggValue: r.aggValue })) + } +) + +export const getTopUsers = createServerFn({ method: "GET" }).handler( + async () => { + const analytics = createAnalytics() + const f = analytics.query.basicUsage.fields + const result = await analytics.query.basicUsage + .aggregate(sum(f.debitAmount)) + .groupBy(f.userId) + .orderBy(desc(f.debitAmount)) + .limit(10) + .execute() + return result.rows.map((r) => ({ + groupValue: r.groupValue, + aggValue: r.aggValue, + })) + } +) + +export const getEventTypeDistribution = createServerFn({ + method: "GET", +}).handler(async () => { + const analytics = createAnalytics() + const f = analytics.query.basicUsage.fields + const result = await analytics.query.basicUsage + .aggregate(analyticsCount()) + .groupBy(f.eventType) + .execute() + return result.rows.map((r) => ({ + groupValue: r.groupValue, + aggValue: r.aggValue, + })) +}) + +export const getAiTokenUsage = createServerFn({ method: "GET" }) + .inputValidator(validator<{ mode?: string }>()) + .handler(async (ctx) => { + const analytics = createAnalytics() + const f = analytics.query.aiToken.fields + const mode = ctx.data.mode + + const mkQ = () => { + let q = analytics.query.aiToken.aggregate(sum(f.inputTokens)).groupBy(f.model) + if (mode) q = q.where(and(eq(f.mode, mode))) + return q.execute() + } + const mkQ2 = () => { + let q = analytics.query.aiToken.aggregate(sum(f.outputTokens)).groupBy(f.model) + if (mode) q = q.where(and(eq(f.mode, mode))) + return q.execute() + } + + const [input, output] = await Promise.all([mkQ(), mkQ2()]) + return { + input: input.rows.map((r) => ({ + groupValue: r.groupValue, + aggValue: r.aggValue, + })), + output: output.rows.map((r) => ({ + groupValue: r.groupValue, + aggValue: r.aggValue, + })), + } + }) + +export const getAiTokenUsageOverTime = createServerFn({ method: "GET" }).handler( + async () => { + const analytics = createAnalytics() + const f = analytics.query.aiToken.fields + const result = await analytics.query.aiToken + .orderBy(desc(f.ingestedTimestamp)) + .limit(500) + .execute() + + const groups = new Map>() + for (const row of result.rows) { + const date = (row.ingestedTimestamp ?? "").slice(0, 10) + const model = row.model ?? "unknown" + if (!groups.has(date)) groups.set(date, new Map()) + const modelMap = groups.get(date)! + if (!modelMap.has(model)) modelMap.set(model, { input: 0, output: 0 }) + const acc = modelMap.get(model)! + acc.input += row.inputTokens ?? 0 + acc.output += row.outputTokens ?? 0 + } + + const flat: Array<{ date: string; model: string; inputTokens: number; outputTokens: number }> = [] + for (const [date, modelMap] of groups) { + for (const [model, counts] of modelMap) { + flat.push({ date, model, inputTokens: counts.input, outputTokens: counts.output }) + } + } + + return flat.sort((a, b) => a.date.localeCompare(b.date)) + } +) + +export const getPaymentHistory = createServerFn({ method: "GET" }) + .inputValidator(validator<{ mode?: string }>()) + .handler(async (ctx) => { + const analytics = createAnalytics() + const f = analytics.query.payment.fields + const mode = ctx.data.mode + let q = analytics.query.payment + .aggregate(sum(f.creditAmount)) + .groupBy(f.ingestedTimestamp) + .orderBy(desc(f.ingestedTimestamp)) + .limit(30) + if (mode) q = q.where(and(eq(f.mode, mode))) + const result = await q.execute() + return result.rows + .reverse() + .filter((r) => r.groupValue != null) + .map((r) => ({ groupValue: r.groupValue!, aggValue: r.aggValue })) + } +) + +export const getRecentEvents = createServerFn({ method: "GET" }).handler( + async () => { + const analytics = createAnalytics() + const f = analytics.query.basicUsage.fields + const result = await analytics.query.basicUsage + .orderBy(desc(f.ingestedTimestamp)) + .limit(10) + .execute() + return result.rows + } +) + +export const getFilteredEvents = createServerFn({ method: "GET" }) + .inputValidator( + validator<{ + apiKeyId?: string + userId?: string + eventType?: string + mode?: string + model?: string + limit?: number + offset?: number + }>() + ) + .handler(async (ctx) => { + const analytics = createAnalytics() + const bf = analytics.query.basicUsage.fields + const af = analytics.query.aiToken.fields + const limit = ctx.data.limit ?? 10 + const offset = ctx.data.offset ?? 0 + const fetchLimit = 500 + + function mkBasicQuery() { + const conds: import("@scrawn/analytics").FilterCondition[] = [] + if (ctx.data.apiKeyId) conds.push(eq(bf.apiKeyId, ctx.data.apiKeyId)) + if (ctx.data.userId) conds.push(eq(bf.userId, ctx.data.userId)) + if (ctx.data.mode) conds.push(eq(bf.mode, ctx.data.mode)) + let q = analytics.query.basicUsage.orderBy(desc(bf.ingestedTimestamp)).limit(fetchLimit) + if (conds.length > 0) q = q.where(and(...conds)) + return q.execute() + } + + function mkAiQuery() { + const conds: import("@scrawn/analytics").FilterCondition[] = [] + if (ctx.data.apiKeyId) conds.push(eq(af.apiKeyId, ctx.data.apiKeyId)) + if (ctx.data.userId) conds.push(eq(af.userId, ctx.data.userId)) + if (ctx.data.mode) conds.push(eq(af.mode, ctx.data.mode)) + if (ctx.data.model) conds.push(eq(af.model, ctx.data.model)) + let q = analytics.query.aiToken.orderBy(desc(af.ingestedTimestamp)).limit(fetchLimit) + if (conds.length > 0) q = q.where(and(...conds)) + return q.execute() + } + + const [basicResult, aiResult] = await Promise.all([mkBasicQuery(), mkAiQuery()]) + + const basicRows = basicResult.rows.map((r) => ({ + eventId: (r as { eventId?: string }).eventId ?? "", + eventType: (r as { eventType?: string }).eventType ?? "", + userId: (r as { userId?: string }).userId ?? "", + reportedTimestamp: (r as { reportedTimestamp?: string }).reportedTimestamp ?? "", + ingestedTimestamp: (r as { ingestedTimestamp?: string }).ingestedTimestamp ?? "", + basicUsageType: (r as { basicUsageType?: string }).basicUsageType ?? "", + debitAmount: Number((r as { debitAmount?: number }).debitAmount ?? 0), + })) + + const aiRows = aiResult.rows.map((r) => ({ + eventId: (r as { eventId?: string }).eventId ?? "", + eventType: (r as { eventType?: string }).eventType ?? "", + userId: (r as { userId?: string }).userId ?? "", + reportedTimestamp: (r as { reportedTimestamp?: string }).reportedTimestamp ?? "", + ingestedTimestamp: (r as { ingestedTimestamp?: string }).ingestedTimestamp ?? "", + basicUsageType: "", + debitAmount: Number((r as { debitAmount?: number }).debitAmount ?? 0), + })) + + const all = [...basicRows, ...aiRows].sort((a, b) => + b.ingestedTimestamp.localeCompare(a.ingestedTimestamp) + ) + + const total = (basicResult.total ?? 0) + (aiResult.total ?? 0) + + return { + rows: all.slice(offset, offset + limit), + total, + } + }) + +export const getApiKeySummary = createServerFn({ method: "GET" }) + .inputValidator(validator<{ apiKeyId: string }>()) + .handler(async (ctx) => { + const analytics = createAnalytics() + const sf = analytics.query.basicUsage.fields + const af = analytics.query.aiToken.fields + const pf = analytics.query.payment.fields + const filter = and(eq(sf.apiKeyId, ctx.data.apiKeyId)) + + const [basicDebit, basicCount, aiInput, aiOutput, aiCache, aiCount, creditSum] = await Promise.all([ + analytics.query.basicUsage.where(filter).aggregate(sum(sf.debitAmount)).execute(), + analytics.query.basicUsage.where(filter).aggregate(analyticsCount()).execute(), + analytics.query.aiToken.where(and(eq(af.apiKeyId, ctx.data.apiKeyId))).aggregate(sum(af.inputDebitAmount)).execute(), + analytics.query.aiToken.where(and(eq(af.apiKeyId, ctx.data.apiKeyId))).aggregate(sum(af.outputDebitAmount)).execute(), + analytics.query.aiToken.where(and(eq(af.apiKeyId, ctx.data.apiKeyId))).aggregate(sum(af.inputCacheDebitAmount)).execute(), + analytics.query.aiToken.where(and(eq(af.apiKeyId, ctx.data.apiKeyId))).aggregate(analyticsCount()).execute(), + analytics.query.payment.where(and(eq(pf.apiKeyId, ctx.data.apiKeyId))).aggregate(sum(pf.creditAmount)).execute(), + ]) + + const aiDebit = + Number(aiInput.rows[0]?.aggValue ?? 0) + + Number(aiOutput.rows[0]?.aggValue ?? 0) + + Number(aiCache.rows[0]?.aggValue ?? 0) + const totalRevenue = + (Number(basicDebit.rows[0]?.aggValue ?? 0) + aiDebit).toString() + const totalEvents = + (Number(basicCount.rows[0]?.aggValue ?? 0) + Number(aiCount.rows[0]?.aggValue ?? 0)).toString() + + return { + totalRevenue, + totalEvents, + totalCredits: creditSum.rows[0]?.aggValue ?? "0", + } + }) + +export const getDashboardSummary = createServerFn({ method: "GET" }) + .inputValidator(validator<{ mode?: string }>()) + .handler(async (ctx) => { + const analytics = createAnalytics() + const sf = analytics.query.basicUsage.fields + const af = analytics.query.aiToken.fields + const pf = analytics.query.payment.fields + const mode = ctx.data.mode + + const [basicDebit, aiInput, aiOutput, aiCache, basicCount, aiCount, creditResult] = await Promise.all([ + mode + ? await analytics.query.basicUsage.where(and(eq(sf.mode, mode))).aggregate(sum(sf.debitAmount)).execute() + : await analytics.query.basicUsage.aggregate(sum(sf.debitAmount)).execute(), + mode + ? await analytics.query.aiToken.where(and(eq(af.mode, mode))).aggregate(sum(af.inputDebitAmount)).execute() + : await analytics.query.aiToken.aggregate(sum(af.inputDebitAmount)).execute(), + mode + ? await analytics.query.aiToken.where(and(eq(af.mode, mode))).aggregate(sum(af.outputDebitAmount)).execute() + : await analytics.query.aiToken.aggregate(sum(af.outputDebitAmount)).execute(), + mode + ? await analytics.query.aiToken.where(and(eq(af.mode, mode))).aggregate(sum(af.inputCacheDebitAmount)).execute() + : await analytics.query.aiToken.aggregate(sum(af.inputCacheDebitAmount)).execute(), + mode + ? await analytics.query.basicUsage.where(and(eq(sf.mode, mode))).aggregate(analyticsCount()).execute() + : await analytics.query.basicUsage.aggregate(analyticsCount()).execute(), + mode + ? await analytics.query.aiToken.where(and(eq(af.mode, mode))).aggregate(analyticsCount()).execute() + : await analytics.query.aiToken.aggregate(analyticsCount()).execute(), + mode + ? await analytics.query.payment.where(and(eq(pf.mode, mode))).aggregate(sum(pf.creditAmount)).execute() + : await analytics.query.payment.aggregate(sum(pf.creditAmount)).execute(), + ]) + + const aiDebit = + Number(aiInput.rows[0]?.aggValue ?? 0) + + Number(aiOutput.rows[0]?.aggValue ?? 0) + + Number(aiCache.rows[0]?.aggValue ?? 0) + const totalRevenue = + (Number(basicDebit.rows[0]?.aggValue ?? 0) + aiDebit).toString() + const totalEvents = + (Number(basicCount.rows[0]?.aggValue ?? 0) + Number(aiCount.rows[0]?.aggValue ?? 0)).toString() + + return { + totalRevenue, + totalEvents, + totalCredits: creditResult.rows[0]?.aggValue ?? "0", + } + }) diff --git a/src/lib/server/apiKeys.ts b/src/lib/server/apiKeys.ts new file mode 100644 index 0000000..adbeed9 --- /dev/null +++ b/src/lib/server/apiKeys.ts @@ -0,0 +1,21 @@ +import { createServerFn } from "@tanstack/react-start" +import { apiGet, apiPost, apiDelete, validator } from "./core" + +export const listApiKeys = createServerFn({ method: "GET" }).handler(async () => + apiGet("/api/v1/api-keys") +) + +export const createApiKey = createServerFn({ method: "POST" }) + .inputValidator( + validator<{ + name: string + role: "test" | "production" + expiresIn: number + webhookUrl: string + }>() + ) + .handler(async (ctx) => apiPost("/api/v1/api-keys", ctx.data)) + +export const revokeApiKey = createServerFn({ method: "POST" }) + .inputValidator(validator<{ id: string }>()) + .handler(async (ctx) => apiDelete(`/api/v1/api-keys/${ctx.data.id}`)) diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts new file mode 100644 index 0000000..1955286 --- /dev/null +++ b/src/lib/server/auth.ts @@ -0,0 +1,39 @@ +import { createServerFn } from "@tanstack/react-start" +import { db } from "@/lib/db" +import { user } from "@/db/schema" +import { count } from "drizzle-orm" +import { validator } from "./core" + +export const checkUsersExist = createServerFn({ method: "GET" }).handler( + async () => { + const [result] = await db.select({ count: count() }).from(user) + return { exists: (result?.count ?? 0) > 0 } + } +) + +export const createAdminUser = createServerFn({ method: "POST" }) + .inputValidator( + validator<{ + name: string + email: string + password: string + }>() + ) + .handler(async (ctx) => { + const [existing] = await db.select({ count: count() }).from(user) + if ((existing?.count ?? 0) > 0) { + return { error: "An admin user already exists" } + } + + const { auth } = await import("@/lib/auth") + + await auth.api.signUpEmail({ + body: { + name: ctx.data.name, + email: ctx.data.email, + password: ctx.data.password, + }, + }) + + return { success: true } + }) diff --git a/src/lib/server/core.ts b/src/lib/server/core.ts new file mode 100644 index 0000000..c7ff6f3 --- /dev/null +++ b/src/lib/server/core.ts @@ -0,0 +1,45 @@ +const SCRAWN_HTTP_URL = process.env.SCRAWN_HTTP_URL || "http://localhost:8070" +const SCRAWN_KEY = process.env.SCRAWN_KEY as string + +export function validator(): { (): T; (value: unknown): T } { + return ((input: unknown) => input as T) as { (): T; (value: unknown): T } +} + +export async function apiGet(path: string) { + const res = await fetch(`${SCRAWN_HTTP_URL}${path}`, { + headers: { Authorization: `Bearer ${SCRAWN_KEY}` }, + }) + if (!res.ok) { + const body = await res.json().catch(() => ({})) + throw new Error(body.error || `Request failed: ${res.status}`) + } + return res.json() +} + +export async function apiPost(path: string, body: unknown) { + const res = await fetch(`${SCRAWN_HTTP_URL}${path}`, { + method: "POST", + headers: { + Authorization: `Bearer ${SCRAWN_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }) + if (!res.ok) { + const body = await res.json().catch(() => ({})) + throw new Error(body.error || `Request failed: ${res.status}`) + } + return res.json() +} + +export async function apiDelete(path: string) { + const res = await fetch(`${SCRAWN_HTTP_URL}${path}`, { + method: "DELETE", + headers: { Authorization: `Bearer ${SCRAWN_KEY}` }, + }) + if (!res.ok) { + const body = await res.json().catch(() => ({})) + throw new Error(body.error || `Request failed: ${res.status}`) + } + return res.json() +} diff --git a/src/lib/server/expressions.ts b/src/lib/server/expressions.ts new file mode 100644 index 0000000..8843a02 --- /dev/null +++ b/src/lib/server/expressions.ts @@ -0,0 +1,14 @@ +import { createServerFn } from "@tanstack/react-start" +import { apiGet, apiPost, apiDelete, validator } from "./core" + +export const listExpressions = createServerFn({ method: "GET" }).handler( + async () => apiGet("/api/v1/expressions") +) + +export const createExpression = createServerFn({ method: "POST" }) + .inputValidator(validator<{ key: string; expr: string }>()) + .handler(async (ctx) => apiPost("/api/v1/expressions", ctx.data)) + +export const deleteExpression = createServerFn({ method: "POST" }) + .inputValidator(validator<{ key: string }>()) + .handler(async (ctx) => apiDelete(`/api/v1/expressions/${ctx.data.key}`)) diff --git a/src/lib/server/onboarding.ts b/src/lib/server/onboarding.ts new file mode 100644 index 0000000..13caeb3 --- /dev/null +++ b/src/lib/server/onboarding.ts @@ -0,0 +1,94 @@ +import { createServerFn } from "@tanstack/react-start" +import { validator } from "./core" +import { db } from "../db" +import { eq } from "drizzle-orm" +import { org, project } from "@/db/schema" +import { randomUUID } from "crypto" +import { getRequest } from "@tanstack/react-start/server" +import { auth } from "../auth" + +const SCRAWN_HTTP_URL = process.env.SCRAWN_HTTP_URL || "http://localhost:8070" +const MASTER_API_KEY = process.env.MASTER_API_KEY as string + +export const getBackendConfig = createServerFn({ method: "GET" }).handler( + async () => { + const res = await fetch(`${SCRAWN_HTTP_URL}/api/v1/internals/config`, { + headers: { Authorization: `Bearer ${MASTER_API_KEY}` }, + }) + if (!res.ok) return { configured: false } + return res.json() as Promise<{ + configured: boolean + dodo_live_product_id?: string + dodo_test_product_id?: string + }> + } +) + +export const submitOnboarding = createServerFn({ method: "POST" }) + .inputValidator( + validator<{ + name: string + dodoLiveApiKey: string + dodoTestApiKey: string + dodoLiveProductId: string + dodoTestProductId: string + currency: string + redirectUrl: string + }>() + ) + .handler(async (ctx) => { + const request = getRequest() + const session = await auth.api.getSession({ + headers: request?.headers, + }) + + if (!session) { + return { error: "Unauthorized" } + } + + const userId = session.user.id + + let userOrg = await db.query.org.findFirst({ + where: eq(org.userId, userId), + }) + + if (!userOrg) { + const newOrgId = randomUUID() + const [newOrg] = await db + .insert(org) + .values({ + orgId: newOrgId, + userId: userId, + }) + .returning() + userOrg = newOrg + } + + const res = await fetch(`${SCRAWN_HTTP_URL}/api/v1/internals/onboarding`, { + method: "POST", + headers: { + Authorization: `Bearer ${MASTER_API_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ ...ctx.data }), + }) + + if (!res.ok) { + const body = await res.json().catch(() => ({})) + return { error: body.error || "Failed to save configuration" } + } + + const data = await res.json().catch(() => ({})) + + const returnedProjectId = data.projectId + + if (!returnedProjectId) { + return { error: "The project id is undefined" } + } + + await db.insert(project).values({ + projectId: returnedProjectId, + orgId: userOrg.orgId, + }) + return { success: true } + }) diff --git a/src/lib/server/tags.ts b/src/lib/server/tags.ts new file mode 100644 index 0000000..f8c7849 --- /dev/null +++ b/src/lib/server/tags.ts @@ -0,0 +1,14 @@ +import { createServerFn } from "@tanstack/react-start" +import { apiGet, apiPost, apiDelete, validator } from "./core" + +export const listTags = createServerFn({ method: "GET" }).handler(async () => + apiGet("/api/v1/tags") +) + +export const createTag = createServerFn({ method: "POST" }) + .inputValidator(validator<{ key: string; amount: number }>()) + .handler(async (ctx) => apiPost("/api/v1/tags", ctx.data)) + +export const deleteTag = createServerFn({ method: "POST" }) + .inputValidator(validator<{ key: string }>()) + .handler(async (ctx) => apiDelete(`/api/v1/tags/${ctx.data.key}`)) diff --git a/src/lib/server/webhooks.ts b/src/lib/server/webhooks.ts new file mode 100644 index 0000000..1352d8b --- /dev/null +++ b/src/lib/server/webhooks.ts @@ -0,0 +1,29 @@ +import { createServerFn } from "@tanstack/react-start" +import { apiGet, apiPost, validator } from "./core" + +export const listDeliveries = createServerFn({ method: "GET" }) + .inputValidator( + validator<{ apiKeyId?: string; eventType?: string; status?: string; role?: string; limit?: number; offset?: number }>() + ) + .handler(async (ctx) => { + const params = new URLSearchParams() + if (ctx.data.apiKeyId) params.set("apiKeyId", ctx.data.apiKeyId) + if (ctx.data.eventType) params.set("eventType", ctx.data.eventType) + if (ctx.data.status) params.set("status", ctx.data.status) + if (ctx.data.role) params.set("role", ctx.data.role) + if (ctx.data.limit) params.set("limit", String(ctx.data.limit)) + if (ctx.data.offset) params.set("offset", String(ctx.data.offset)) + return apiGet(`/api/v1/internals/webhook-deliveries?${params}`) + }) + +export const sendTestWebhook = createServerFn({ method: "POST" }) + .inputValidator(validator<{ apiKeyId: string }>()) + .handler(async (ctx) => + apiPost("/api/v1/internals/webhook-endpoint/send-test", ctx.data) + ) + +export const setWebhookUrl = createServerFn({ method: "POST" }) + .inputValidator(validator<{ apiKeyId: string; url: string }>()) + .handler(async (ctx) => + apiPost("/api/v1/internals/webhook-endpoint", ctx.data) + ) diff --git a/src/routes/api/auth/$.ts b/src/routes/api/auth/$.ts index ab5ee6a..dfebd29 100644 --- a/src/routes/api/auth/$.ts +++ b/src/routes/api/auth/$.ts @@ -4,8 +4,8 @@ import { auth } from "@/lib/auth" export const Route = createFileRoute("/api/auth/$")({ server: { handlers: { - GET: async ({ request }: { request: Request }) => auth.handler(request), - POST: async ({ request }: { request: Request }) => auth.handler(request), + GET: async ({ request }) => auth.handler(request), + POST: async ({ request }) => auth.handler(request), }, }, }) diff --git a/src/routes/onboarding.tsx b/src/routes/onboarding.tsx index e8c1cb8..32a3498 100644 --- a/src/routes/onboarding.tsx +++ b/src/routes/onboarding.tsx @@ -1,20 +1,22 @@ -import { createFileRoute, useNavigate } from "@tanstack/react-router" -import { useState, useEffect } from "react" -import { authClient } from "@/lib/auth-client" -import { getBackendConfig, submitOnboarding } from "@/lib/scrawn-server" -import { Button } from "@/components/ui/button" -import { motion, AnimatePresence } from "framer-motion" import { + ArrowLeft, + ArrowRight, + Coins, Eye, EyeOff, - Lock, - Key, - Coins, + Folder, Globe, + Key, + Lock, ShieldAlert, - ArrowRight, - ArrowLeft, } from "lucide-react" +import { AnimatePresence, motion } from "framer-motion" +import { createFileRoute, useNavigate } from "@tanstack/react-router" +import { useEffect, useState } from "react" +import { getBackendConfig, submitOnboarding } from "@/lib/scrawn-server" +import { Button } from "@/components/ui/button" + +import { authClient } from "@/lib/auth-client" export const Route = createFileRoute("/onboarding")({ head: () => ({ @@ -24,7 +26,8 @@ export const Route = createFileRoute("/onboarding")({ }, { name: "description", - content: "Configure your Scrawn billing gateway. Connect DodoPayments API credentials, product IDs, currency models, and redirection configurations.", + content: + "Configure your Scrawn billing gateway. Connect DodoPayments API credentials, product IDs, currency models, and redirection configurations.", }, { name: "og:title", @@ -32,7 +35,8 @@ export const Route = createFileRoute("/onboarding")({ }, { name: "og:description", - content: "Configure your Scrawn billing gateway. Connect DodoPayments API credentials, product IDs, currency models, and redirection configurations.", + content: + "Configure your Scrawn billing gateway. Connect DodoPayments API credentials, product IDs, currency models, and redirection configurations.", }, { name: "og:image", @@ -44,7 +48,8 @@ export const Route = createFileRoute("/onboarding")({ }, { name: "twitter:description", - content: "Configure your Scrawn billing gateway. Connect DodoPayments API credentials, webhook secrets, currency models, and redirection configurations.", + content: + "Configure your Scrawn billing gateway. Connect DodoPayments API credentials, webhook secrets, currency models, and redirection configurations.", }, { name: "twitter:image", @@ -57,32 +62,37 @@ export const Route = createFileRoute("/onboarding")({ const stepDetails = [ { - tag: "// STEP 01 - PRODUCTION AUTH", + tag: "// STEP 01 - PROJECT SETUP", + title: "PROJECT NAME", + desc: "Choose a name for your Scrawn project. This is used to identify your project across the dashboard and API.", + }, + { + tag: "// STEP 02 - PRODUCTION AUTH", title: "LIVE API KEY", desc: "Your DodoPayments Live API Key connects Scrawn to the production environment to authenticate secure billing and transaction operations.", }, { - tag: "// STEP 02 - SANDBOX ENVIRONMENT", + tag: "// STEP 03 - SANDBOX ENVIRONMENT", title: "TEST API KEY", desc: "Your DodoPayments Test API Key is used to mock checkout states, run sandbox webhooks, and simulate user pricing upgrades during local development.", }, { - tag: "// STEP 03 - SANDBOX PRODUCT", + tag: "// STEP 04 - SANDBOX PRODUCT", title: "TEST PRODUCT ID", desc: "The test-mode product identifier used for sandbox checkout sessions and development billing simulations.", }, { - tag: "// STEP 04 - PRODUCTION PRODUCT", + tag: "// STEP 05 - PRODUCTION PRODUCT", title: "LIVE PRODUCT ID", desc: "The live-mode product identifier used for production checkout sessions and real billing operations.", }, { - tag: "// STEP 05 - SETTLEMENT CONFIG", + tag: "// STEP 06 - SETTLEMENT CONFIG", title: "BASE CURRENCY", desc: "Select the default base currency. All system revenue analytics, metered logs, and usage graphs will process and display values in this currency.", }, { - tag: "// STEP 06 - REDIRECT GATEWAY", + tag: "// STEP 07 - REDIRECT GATEWAY", title: "REDIRECT URL", desc: "The default endpoint URL where customers will be redirected back to after completing checkout or managing their subscriptions.", }, @@ -93,6 +103,7 @@ function Onboarding() { const { data: session, isPending } = authClient.useSession() const [step, setStep] = useState(0) + const [name, setName] = useState("") const [dodoLiveApiKey, setDodoLiveApiKey] = useState("") const [dodoTestApiKey, setDodoTestApiKey] = useState("") const [dodoLiveProductId, setDodoLiveProductId] = useState("") @@ -123,18 +134,22 @@ function Onboarding() { function handleNext(e: React.FormEvent) { e.preventDefault() - if (step < 5) { + if (step < 6) { setStep(step + 1) } else { handleFinalSubmit() } } + const userId = session.user.id + async function handleFinalSubmit() { setLoading(true) setError("") const res = await submitOnboarding({ data: { + userId, + name, dodoLiveApiKey, dodoTestApiKey, dodoLiveProductId, @@ -149,8 +164,6 @@ function Onboarding() { return } - // Wait for the backend to confirm config before navigating - // Prevents a race where the dashboard mounts before the DB write propagates for (let i = 0; i < 10; i++) { const config = await getBackendConfig() if (config.configured) { @@ -178,9 +191,8 @@ function Onboarding() { {/* Main split-screen container */}
- {/* Left Column: Visual schematic & Step information */} -
+
-
+
{currentDetails.tag}
-

- {currentDetails.title.split(" ").slice(0, -1).join(" ")}{" "} -
+

+ {currentDetails.title.split(" ").slice(0, -1).join(" ")}
{currentDetails.title.split(" ").slice(-1)[0]}

-

+

{currentDetails.desc}

@@ -210,14 +221,13 @@ function Onboarding() {
{/* Right Column: Setup Form Card */} -
+
{/* Stacked background offset card */} -
+
{/* Foreground Form Card */}
- -
+
Need help setting up your Dodo Payments keys?{" "} -
-
- Configuration Progress - Step {step + 1} of 6 -
-
- -
-
+
+
+ Configuration Progress + Step {step + 1} of 7 +
+
+ +
+
- {/* Conditional Active Step Inputs */} -
- - {step === 0 && ( - - -
- - setDodoLiveApiKey(e.target.value)} - placeholder="XCmeKyWvG1-TTc3w.UZwYsW1fCkxnnMc5N-3GT70L-qBXZ26BwdnmBp3T9Hf3GLz5" - required - className="w-full border-2 border-black bg-white dark:bg-black dark:border-white pl-10 pr-10 py-2.5 text-sm font-mono text-black dark:text-white transition-all outline-none focus:bg-yellow-50/10 dark:focus:bg-zinc-950 focus:translate-x-[1px] focus:translate-y-[1px]" - /> - -
-
- )} + {/* Conditional Active Step Inputs */} +
+ + {step === 0 && ( + + +
+ + setName(e.target.value)} + placeholder="My Scrawn Project" + required + className="w-full border-2 border-black bg-white py-2.5 pr-4 pl-10 font-mono text-sm text-black transition-all outline-none focus:translate-x-[1px] focus:translate-y-[1px] focus:bg-yellow-50/10 dark:border-white dark:bg-black dark:text-white dark:focus:bg-zinc-950" + /> +
+
+ )} - {step === 1 && ( - + +
+ + setDodoLiveApiKey(e.target.value)} + placeholder="XCmeKyWvG1-TTc3w.UZwYsW1fCkxnnMc5N-3GT70L-qBXZ26BwdnmBp3T9Hf3GLz5" + required + className="w-full border-2 border-black bg-white py-2.5 pr-10 pl-10 font-mono text-sm text-black transition-all outline-none focus:translate-x-[1px] focus:translate-y-[1px] focus:bg-yellow-50/10 dark:border-white dark:bg-black dark:text-white dark:focus:bg-zinc-950" + /> + -
-
- )} + {showLiveApiKey ? ( + + ) : ( + + )} + +
+ + )} - {step === 2 && ( - + +
+ + setDodoTestApiKey(e.target.value)} + placeholder="XCmeKyWvG1-TTc3w.UZwYsW1fCkxnnMc5N-3GT70L-qBXZ26BwdnmBp3T9Hf3GLz5" + required + className="w-full border-2 border-black bg-white py-2.5 pr-10 pl-10 font-mono text-sm text-black transition-all outline-none focus:translate-x-[1px] focus:translate-y-[1px] focus:bg-yellow-50/10 dark:border-white dark:bg-black dark:text-white dark:focus:bg-zinc-950" + /> + +
+
+ )} - {step === 3 && ( - - -
- - setDodoLiveProductId(e.target.value)} - placeholder="pdt_..." - required - className="w-full border-2 border-black bg-white dark:bg-black dark:border-white pl-10 pr-4 py-2.5 text-sm font-mono text-black dark:text-white transition-all outline-none focus:bg-yellow-50/10 dark:focus:bg-zinc-950 focus:translate-x-[1px] focus:translate-y-[1px]" - /> -
-
- )} + {step === 3 && ( + + +
+ + setDodoTestProductId(e.target.value)} + placeholder="pdt_..." + required + className="w-full border-2 border-black bg-white py-2.5 pr-4 pl-10 font-mono text-sm text-black transition-all outline-none focus:translate-x-[1px] focus:translate-y-[1px] focus:bg-yellow-50/10 dark:border-white dark:bg-black dark:text-white dark:focus:bg-zinc-950" + /> +
+
+ )} - {step === 4 && ( - - -
- - -
-
- )} + {step === 4 && ( + + +
+ + setDodoLiveProductId(e.target.value)} + placeholder="pdt_..." + required + className="w-full border-2 border-black bg-white py-2.5 pr-4 pl-10 font-mono text-sm text-black transition-all outline-none focus:translate-x-[1px] focus:translate-y-[1px] focus:bg-yellow-50/10 dark:border-white dark:bg-black dark:text-white dark:focus:bg-zinc-950" + /> +
+
+ )} - {step === 5 && ( - + +
+ + setRedirectUrl(e.target.value)} - placeholder="https://app.scrawn.dev" - required - className="w-full border-2 border-black bg-white dark:bg-black dark:border-white pl-10 pr-4 py-2.5 text-sm font-mono text-black dark:text-white transition-all outline-none focus:bg-yellow-50/10 dark:focus:bg-zinc-950 focus:translate-x-[1px] focus:translate-y-[1px]" - /> -
-
- )} -
-
- - {error && ( -
- - - Error: {error} - -
+ + + + + + +
+ )} - {/* Action Buttons */} -
- {step > 0 && ( - - )} - -
- + +
+ + setRedirectUrl(e.target.value)} + placeholder="https://app.scrawn.dev" + required + className="w-full border-2 border-black bg-white py-2.5 pr-4 pl-10 font-mono text-sm text-black transition-all outline-none focus:translate-x-[1px] focus:translate-y-[1px] focus:bg-yellow-50/10 dark:border-white dark:bg-black dark:text-white dark:focus:bg-zinc-950" + /> +
+ + )} + +
+ + {error && ( +
+ + + Error: {error} + +
+ )} + + {/* Action Buttons */} +
+ {step > 0 && ( + + )} + +
+
{/* Empty space matching height at bottom */} -
+
) } diff --git a/src/routes/sign-in.tsx b/src/routes/sign-in.tsx index 4420be2..c75fa8f 100644 --- a/src/routes/sign-in.tsx +++ b/src/routes/sign-in.tsx @@ -57,9 +57,15 @@ function SignIn() { const [showPassword, setShowPassword] = useState(false) useEffect(() => { - checkUsersExist().then((res) => { - setMode(res.exists ? "sign-in" : "setup") - }) + checkUsersExist() + .then((res) => { + setMode(res.exists ? "sign-in" : "setup") + }) + .catch((err) => { + console.error("checkUsersExist failed:", err) + setError(err instanceof Error ? err.message : String(err)) + setMode("sign-in") + }) }, []) useEffect(() => {