diff --git a/apps/api/openapi.gen.json b/apps/api/openapi.gen.json index 35051800c6..77ec7ba87b 100644 --- a/apps/api/openapi.gen.json +++ b/apps/api/openapi.gen.json @@ -37,7 +37,7 @@ "get": { "responses": { "200": { - "description": "-", + "description": "result", "content": { "application/json": { "schema": { @@ -222,6 +222,209 @@ } } }, + "/file-transcription/start": { + "post": { + "responses": { + "200": { + "description": "Pipeline started", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "pipelineId": { + "type": "string" + }, + "invocationId": { + "type": "string" + } + }, + "required": [ + "pipelineId", + "invocationId" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "Invalid fileId" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal error" + } + }, + "operationId": "postFile-transcriptionStart", + "tags": [ + "private" + ], + "parameters": [], + "security": [ + { + "Bearer": [] + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "fileId": { + "type": "string" + }, + "pipelineId": { + "type": "string" + } + }, + "required": [ + "fileId" + ], + "additionalProperties": false + } + } + } + } + } + }, + "/file-transcription/status/{pipelineId}": { + "get": { + "responses": { + "200": { + "description": "Pipeline status", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "QUEUED", + "TRANSCRIBING", + "TRANSCRIBED", + "LLM_RUNNING", + "DONE", + "ERROR" + ] + }, + "transcript": { + "type": "string" + }, + "llmResult": { + "type": "string" + }, + "error": { + "type": "string" + } + }, + "required": [ + "status" + ], + "additionalProperties": false + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal error" + } + }, + "operationId": "getFile-transcriptionStatusByPipelineId", + "tags": [ + "private" + ], + "parameters": [ + { + "in": "path", + "name": "pipelineId", + "schema": { + "type": "string" + }, + "required": true + } + ], + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/file-transcription/result/{pipelineId}": { + "get": { + "responses": { + "200": { + "description": "Pipeline result", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "QUEUED", + "TRANSCRIBING", + "TRANSCRIBED", + "LLM_RUNNING", + "DONE", + "ERROR" + ] + }, + "transcript": { + "type": "string" + }, + "llmResult": { + "type": "string" + }, + "error": { + "type": "string" + } + }, + "required": [ + "status" + ], + "additionalProperties": false + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal error" + } + }, + "operationId": "getFile-transcriptionResultByPipelineId", + "tags": [ + "private" + ], + "parameters": [ + { + "in": "path", + "name": "pipelineId", + "schema": { + "type": "string" + }, + "required": true + } + ], + "security": [ + { + "Bearer": [] + } + ] + } + }, "/rpc/can-start-trial": { "get": { "responses": { @@ -287,7 +490,7 @@ "post": { "responses": { "200": { - "description": "-", + "description": "result", "content": { "application/json": { "schema": { @@ -421,7 +624,7 @@ "post": { "responses": { "200": { - "description": "-", + "description": "result", "content": { "application/json": { "schema": { diff --git a/apps/api/package.json b/apps/api/package.json index a019a7306d..09b912f80f 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "scripts": { - "dev": "dotenvx run --ignore MISSING_ENV_FILE -f ../../.env.supabase -f ../../.env.stripe -f .env -- bun --hot src/index.ts", + "dev": "dotenvx run --ignore MISSING_ENV_FILE -f ../../.env.supabase -f ../../.env.stripe -f ../../.env.restate -f .env -- bun --hot src/index.ts", "stripe:migrate": "dotenvx run --ignore MISSING_ENV_FILE -f ../../.env.supabase -f ../../.env.stripe -f .env -- bun src/scripts/run-stripe-migrations.ts", "openapi": "CI=true bun src/scripts/generate-openapi.ts", "typecheck": "tsc --noEmit", @@ -11,6 +11,7 @@ }, "dependencies": { "@hypr/api-client": "workspace:*", + "@restatedev/restate-sdk-clients": "^1.9.1", "@hono/zod-validator": "^0.7.5", "@posthog/ai": "^7.2.1", "@scalar/hono-api-reference": "^0.5.184", diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index 6731a650f2..60ad1e536e 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -25,6 +25,7 @@ export const env = createEnv({ ASSEMBLYAI_API_KEY: z.string().min(1), SONIOX_API_KEY: z.string().min(1), POSTHOG_API_KEY: z.string().min(1), + RESTATE_INGRESS_URL: z.url(), OVERRIDE_AUTH: z.string().optional(), }, runtimeEnv: Bun.env, diff --git a/apps/api/src/integration/index.ts b/apps/api/src/integration/index.ts index f22e73bee8..b65028d35c 100644 --- a/apps/api/src/integration/index.ts +++ b/apps/api/src/integration/index.ts @@ -2,3 +2,4 @@ export * from "./supabase"; export * from "./stripe"; export * from "./openrouter"; export * from "./posthog"; +export * from "./restate"; diff --git a/apps/api/src/integration/restate.ts b/apps/api/src/integration/restate.ts new file mode 100644 index 0000000000..d20f21d0f3 --- /dev/null +++ b/apps/api/src/integration/restate.ts @@ -0,0 +1,12 @@ +import * as clients from "@restatedev/restate-sdk-clients"; + +import { env } from "../env"; + +let restateClientInstance: ReturnType | null = null; + +export function getRestateClient() { + if (!restateClientInstance) { + restateClientInstance = clients.connect({ url: env.RESTATE_INGRESS_URL }); + } + return restateClientInstance; +} diff --git a/apps/api/src/routes/file-transcription.ts b/apps/api/src/routes/file-transcription.ts new file mode 100644 index 0000000000..4cb84e398e --- /dev/null +++ b/apps/api/src/routes/file-transcription.ts @@ -0,0 +1,208 @@ +import type { IngressWorkflowClient } from "@restatedev/restate-sdk-clients"; +import { Hono } from "hono"; +import { describeRoute } from "hono-openapi"; +import { resolver, validator } from "hono-openapi/zod"; +import { z } from "zod"; + +import type { AppBindings } from "../hono-bindings"; +import { getRestateClient } from "../integration/restate"; +import { supabaseAuthMiddleware } from "../middleware/supabase"; +import { API_TAGS } from "./constants"; + +const PipelineStatus = z.enum([ + "QUEUED", + "TRANSCRIBING", + "TRANSCRIBED", + "LLM_RUNNING", + "DONE", + "ERROR", +]); + +const StatusResponseSchema = z.object({ + status: PipelineStatus, + transcript: z.string().optional(), + llmResult: z.string().optional(), + error: z.string().optional(), +}); + +type StatusStateType = z.infer; + +type SttFileInput = { + userId: string; + fileId: string; +}; + +type SttFileDefinition = { + run: (ctx: unknown, input: SttFileInput) => Promise; + getStatus: (ctx: unknown) => Promise; +}; + +type SttFileClient = IngressWorkflowClient; + +const StartInputSchema = z.object({ + fileId: z.string(), + pipelineId: z.string().optional(), +}); + +const StartResponseSchema = z.object({ + pipelineId: z.string(), + invocationId: z.string(), +}); + +const StatusInputSchema = z.object({ + pipelineId: z.string(), +}); + +export const fileTranscription = new Hono(); + +fileTranscription.post( + "/start", + describeRoute({ + tags: [API_TAGS.PRIVATE], + security: [{ Bearer: [] }], + responses: { + 200: { + description: "Pipeline started", + content: { + "application/json": { schema: resolver(StartResponseSchema) }, + }, + }, + 400: { description: "Invalid fileId" }, + 401: { description: "Unauthorized" }, + 500: { description: "Internal error" }, + }, + }), + supabaseAuthMiddleware, + validator("json", StartInputSchema), + async (c) => { + const userId = c.get("supabaseUserId")!; + const data = c.req.valid("json"); + + const segments = data.fileId.split("/").filter(Boolean); + const [ownerId, ...rest] = segments; + + if ( + !ownerId || + ownerId !== userId || + rest.length === 0 || + rest.some((s) => s === "." || s === "..") + ) { + return c.json({ error: "Invalid fileId" }, 400); + } + + const safeFileId = `${userId}/${rest.join("/")}`; + const rawId = data.pipelineId ?? crypto.randomUUID(); + const pipelineId = `${userId}:${rawId}`; + + try { + const restateClient = getRestateClient(); + const workflowClient: SttFileClient = + restateClient.workflowClient( + { name: "SttFile" }, + pipelineId, + ); + const handle = await workflowClient.workflowSubmit({ + userId, + fileId: safeFileId, + }); + + return c.json({ + pipelineId, + invocationId: handle.invocationId, + }); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : "Unknown error"; + return c.json({ error: errorMessage }, 500); + } + }, +); + +fileTranscription.get( + "/status/:pipelineId", + describeRoute({ + tags: [API_TAGS.PRIVATE], + security: [{ Bearer: [] }], + responses: { + 200: { + description: "Pipeline status", + content: { + "application/json": { schema: resolver(StatusResponseSchema) }, + }, + }, + 401: { description: "Unauthorized" }, + 403: { description: "Forbidden" }, + 500: { description: "Internal error" }, + }, + }), + supabaseAuthMiddleware, + validator("param", StatusInputSchema), + async (c) => { + const userId = c.get("supabaseUserId")!; + const { pipelineId } = c.req.valid("param"); + + const [ownerId] = pipelineId.split(":"); + if (ownerId !== userId) { + return c.json({ error: "Forbidden" }, 403); + } + + try { + const restateClient = getRestateClient(); + const workflowClient: SttFileClient = + restateClient.workflowClient( + { name: "SttFile" }, + pipelineId, + ); + const status = await workflowClient.getStatus(); + + return c.json(status); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : "Unknown error"; + return c.json({ error: errorMessage }, 500); + } + }, +); + +fileTranscription.get( + "/result/:pipelineId", + describeRoute({ + tags: [API_TAGS.PRIVATE], + security: [{ Bearer: [] }], + responses: { + 200: { + description: "Pipeline result", + content: { + "application/json": { schema: resolver(StatusResponseSchema) }, + }, + }, + 401: { description: "Unauthorized" }, + 403: { description: "Forbidden" }, + 500: { description: "Internal error" }, + }, + }), + supabaseAuthMiddleware, + validator("param", StatusInputSchema), + async (c) => { + const userId = c.get("supabaseUserId")!; + const { pipelineId } = c.req.valid("param"); + + const [ownerId] = pipelineId.split(":"); + if (ownerId !== userId) { + return c.json({ error: "Forbidden" }, 403); + } + + try { + const restateClient = getRestateClient(); + const workflowClient: SttFileClient = + restateClient.workflowClient( + { name: "SttFile" }, + pipelineId, + ); + const result = await workflowClient.workflowAttach(); + + return c.json(result); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : "Unknown error"; + return c.json({ error: errorMessage }, 500); + } + }, +); diff --git a/apps/api/src/routes/index.ts b/apps/api/src/routes/index.ts index 56ed8c88e6..95a5e40989 100644 --- a/apps/api/src/routes/index.ts +++ b/apps/api/src/routes/index.ts @@ -2,6 +2,7 @@ import { Hono } from "hono"; import type { AppBindings } from "../hono-bindings"; import { billing } from "./billing"; +import { fileTranscription } from "./file-transcription"; import { health } from "./health"; import { llm } from "./llm"; import { rpc } from "./rpc"; @@ -15,6 +16,7 @@ export const routes = new Hono(); routes.route("/health", health); routes.route("/billing", billing); routes.route("/chat", llm); +routes.route("/file-transcription", fileTranscription); routes.route("/rpc", rpc); routes.route("/", stt); routes.route("/webhook", webhook); diff --git a/apps/api/src/routes/rpc.ts b/apps/api/src/routes/rpc.ts index 4bb959a843..5be375494a 100644 --- a/apps/api/src/routes/rpc.ts +++ b/apps/api/src/routes/rpc.ts @@ -29,29 +29,18 @@ rpc.get( supabaseAuthMiddleware, async (c) => { const supabase = c.get("supabaseClient"); + if (!supabase) { - return c.json({ error: "Supabase client missing" }, 500); + console.error("supabaseClient not attached by middleware"); + return c.json({ error: "Internal server error" }, 500); } const { data, error } = await supabase.rpc("can_start_trial"); if (error) { - console.error("Supabase RPC error (can_start_trial):", { - message: error.message, - details: error.details, - }); - return c.json({ error: "Internal server error" }, 500); - } - - if (typeof data !== "boolean") { - console.error("Unexpected RPC response type (can_start_trial):", { - expectedType: "boolean", - actualType: typeof data, - payload: data, - }); - return c.json({ error: "Unexpected response from database" }, 502); + return c.json({ canStartTrial: false }); } - return c.json({ canStartTrial: data }); + return c.json({ canStartTrial: data as boolean }); }, ); diff --git a/apps/web/package.json b/apps/web/package.json index b9911370eb..9959febc27 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "scripts": { - "dev": "VITE_APP_URL=\"http://localhost:3000\" dotenvx run --ignore MISSING_ENV_FILE -f ../../.env.supabase -f ../../.env.restate -f .env -- vite dev --port 3000", + "dev": "VITE_APP_URL=\"http://localhost:3000\" dotenvx run --ignore MISSING_ENV_FILE -f ../../.env.supabase -f .env -- vite dev --port 3000", "build": "vite build && cp public/sitemap.xml dist/client/sitemap.xml && pagefind --site ./dist/client", "serve": "vite preview", "test": "playwright test", @@ -23,7 +23,6 @@ "@netlify/cache": "^3.3.4", "@netlify/vite-plugin-tanstack-start": "^1.2.3", "@posthog/react": "^1.5.2", - "@restatedev/restate-sdk-clients": "^1.9.1", "@sentry/tanstackstart-react": "^10.29.0", "@stripe/stripe-js": "^8.5.3", "@supabase/ssr": "^0.7.0", diff --git a/apps/web/src/components/search.tsx b/apps/web/src/components/search.tsx index ba7c77b01d..2e81354e7c 100644 --- a/apps/web/src/components/search.tsx +++ b/apps/web/src/components/search.tsx @@ -346,7 +346,7 @@ function SearchCommandPalette({ {!isLoading && results.length > 0 && ( {results.map((result, index) => ( { + const supabase = getSupabaseServerClient(); + const { data: sessionData } = await supabase.auth.getSession(); + + if (!sessionData.session) { + return false; + } + + const client = createClient({ + baseUrl: env.VITE_API_URL, + headers: { + Authorization: `Bearer ${sessionData.session.access_token}`, + }, + }); + + const { data, error } = await getRpcCanStartTrial({ client }); + + if (error) { + console.error("can_start_trial error:", error); + return false; + } + + return data?.canStartTrial ?? false; + }, +); + +export const createTrialCheckoutSession = createServerFn({ + method: "POST", +}).handler(async () => { + const supabase = getSupabaseServerClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user?.id) { + throw new Error("Unauthorized"); + } + + const stripe = getStripeClient(); + + let stripeCustomerId = await getStripeCustomerIdForUser(supabase, { + id: user.id, + user_metadata: user.user_metadata, + }); + + if (!stripeCustomerId) { + const newCustomer = await stripe.customers.create({ + email: user.email, + metadata: { + userId: user.id, + }, + }); + + await Promise.all([ + supabase.auth.updateUser({ + data: { + stripe_customer_id: newCustomer.id, + }, + }), + supabase + .from("profiles") + .update({ stripe_customer_id: newCustomer.id }) + .eq("id", user.id), + ]); + + stripeCustomerId = newCustomer.id; + } + + const checkout = await stripe.checkout.sessions.create({ + customer: stripeCustomerId, + mode: "subscription", + payment_method_collection: "if_required", + line_items: [ + { + price: env.STRIPE_MONTHLY_PRICE_ID, + quantity: 1, + }, + ], + subscription_data: { + trial_period_days: 14, + trial_settings: { + end_behavior: { + missing_payment_method: "cancel", + }, + }, + }, + success_url: `${env.VITE_APP_URL}/app/account?trial=started`, + cancel_url: `${env.VITE_APP_URL}/app/account`, + }); + + return { url: checkout.url }; +}); diff --git a/apps/web/src/functions/transcription.ts b/apps/web/src/functions/transcription.ts index 0cfcf18285..f16f235501 100644 --- a/apps/web/src/functions/transcription.ts +++ b/apps/web/src/functions/transcription.ts @@ -1,51 +1,32 @@ -import { createClient } from "@deepgram/sdk"; -import type { IngressWorkflowClient } from "@restatedev/restate-sdk-clients"; -import * as clients from "@restatedev/restate-sdk-clients"; +import { createClient as createDeepgramClient } from "@deepgram/sdk"; import { createServerFn } from "@tanstack/react-start"; import { z } from "zod"; +import { + getFileTranscriptionResultByPipelineId, + getFileTranscriptionStatusByPipelineId, + postFileTranscriptionStart, +} from "@hypr/api-client"; +import { createClient } from "@hypr/api-client/client"; + import { env } from "@/env"; import { getSupabaseServerClient } from "@/functions/supabase"; -const PipelineStatus = z.enum([ - "QUEUED", - "TRANSCRIBING", - "TRANSCRIBED", - "LLM_RUNNING", - "DONE", - "ERROR", -]); - -export type PipelineStatusType = z.infer; - -const StatusState = z.object({ - status: PipelineStatus, - transcript: z.string().optional(), - llmResult: z.string().optional(), - error: z.string().optional(), -}); - -export type StatusStateType = z.infer; - -type SttFileInput = { - userId: string; - fileId: string; -}; - -// Workflow definition type matching the server-side handler signatures. -// The first parameter (ctx) is the Restate context, which is stripped by IngressWorkflowClient. -type SttFileDefinition = { - run: (ctx: unknown, input: SttFileInput) => Promise; - getStatus: (ctx: unknown) => Promise; +export type PipelineStatusType = + | "QUEUED" + | "TRANSCRIBING" + | "TRANSCRIBED" + | "LLM_RUNNING" + | "DONE" + | "ERROR"; + +export type StatusStateType = { + status: PipelineStatusType; + transcript?: string; + llmResult?: string; + error?: string; }; -// Client type with workflowSubmit, workflowAttach, and other client methods -type SttFileClient = IngressWorkflowClient; - -function getRestateClient() { - return clients.connect({ url: env.RESTATE_INGRESS_URL }); -} - export const startAudioPipeline = createServerFn({ method: "POST" }) .inputValidator( z.object({ @@ -55,52 +36,36 @@ export const startAudioPipeline = createServerFn({ method: "POST" }) ) .handler(async ({ data }) => { const supabase = getSupabaseServerClient(); - const { data: userData } = await supabase.auth.getUser(); + const { data: sessionData } = await supabase.auth.getSession(); - if (!userData.user) { + if (!sessionData.session) { return { error: true, message: "Unauthorized" }; } - const userId = userData.user.id; - - // Validate fileId belongs to the authenticated user - // fileId format: {userId}/{timestamp}-{fileName} - const segments = data.fileId.split("/").filter(Boolean); - const [ownerId, ...rest] = segments; - - if ( - !ownerId || - ownerId !== userId || - rest.length === 0 || - rest.some((s) => s === "." || s === "..") - ) { - return { error: true, message: "Invalid fileId" }; + const client = createClient({ + baseUrl: env.VITE_API_URL, + headers: { + Authorization: `Bearer ${sessionData.session.access_token}`, + }, + }); + + const { data: result, error } = await postFileTranscriptionStart({ + client, + body: { + fileId: data.fileId, + pipelineId: data.pipelineId, + }, + }); + + if (error || !result) { + return { error: true, message: "Failed to start pipeline" }; } - const safeFileId = `${userId}/${rest.join("/")}`; - const pipelineId = data.pipelineId ?? crypto.randomUUID(); - - try { - const restateClient = getRestateClient(); - const workflowClient: SttFileClient = - restateClient.workflowClient( - { name: "SttFile" }, - pipelineId, - ); - const handle = await workflowClient.workflowSubmit({ - userId, - fileId: safeFileId, - }); - - return { - success: true, - pipelineId, - invocationId: handle.invocationId, - }; - } catch (err) { - const errorMessage = err instanceof Error ? err.message : "Unknown error"; - return { error: true, message: errorMessage }; - } + return { + success: true, + pipelineId: result.pipelineId, + invocationId: result.invocationId, + }; }); export const getAudioPipelineStatus = createServerFn({ method: "GET" }) @@ -111,29 +76,33 @@ export const getAudioPipelineStatus = createServerFn({ method: "GET" }) ) .handler(async ({ data }) => { const supabase = getSupabaseServerClient(); - const { data: userData } = await supabase.auth.getUser(); + const { data: sessionData } = await supabase.auth.getSession(); - if (!userData.user) { + if (!sessionData.session) { return { error: true, message: "Unauthorized" }; } - try { - const restateClient = getRestateClient(); - const workflowClient: SttFileClient = - restateClient.workflowClient( - { name: "SttFile" }, - data.pipelineId, - ); - const status = await workflowClient.getStatus(); + const client = createClient({ + baseUrl: env.VITE_API_URL, + headers: { + Authorization: `Bearer ${sessionData.session.access_token}`, + }, + }); + + const { data: status, error } = + await getFileTranscriptionStatusByPipelineId({ + client, + path: { pipelineId: data.pipelineId }, + }); - return { - success: true, - status, - }; - } catch (err) { - const errorMessage = err instanceof Error ? err.message : "Unknown error"; - return { error: true, message: errorMessage }; + if (error || !status) { + return { error: true, message: "Failed to get pipeline status" }; } + + return { + success: true, + status, + }; }); export const getAudioPipelineResult = createServerFn({ method: "GET" }) @@ -144,30 +113,33 @@ export const getAudioPipelineResult = createServerFn({ method: "GET" }) ) .handler(async ({ data }) => { const supabase = getSupabaseServerClient(); - const { data: userData } = await supabase.auth.getUser(); + const { data: sessionData } = await supabase.auth.getSession(); - if (!userData.user) { + if (!sessionData.session) { return { error: true, message: "Unauthorized" }; } - try { - const restateClient = getRestateClient(); - const workflowClient: SttFileClient = - restateClient.workflowClient( - { name: "SttFile" }, - data.pipelineId, - ); - - const result = await workflowClient.workflowAttach(); + const client = createClient({ + baseUrl: env.VITE_API_URL, + headers: { + Authorization: `Bearer ${sessionData.session.access_token}`, + }, + }); + + const { data: result, error } = + await getFileTranscriptionResultByPipelineId({ + client, + path: { pipelineId: data.pipelineId }, + }); - return { - success: true, - result, - }; - } catch (err) { - const errorMessage = err instanceof Error ? err.message : "Unknown error"; - return { error: true, message: errorMessage }; + if (error || !result) { + return { error: true, message: "Failed to get pipeline result" }; } + + return { + success: true, + result, + }; }); export const transcribeAudio = createServerFn({ method: "POST" }) @@ -186,7 +158,7 @@ export const transcribeAudio = createServerFn({ method: "POST" }) return { error: true, message: "Unauthorized" }; } - const deepgram = createClient(env.DEEPGRAM_API_KEY); + const deepgram = createDeepgramClient(env.DEEPGRAM_API_KEY); const transcriptionRecord = await supabase .from("transcriptions") diff --git a/apps/web/src/routes/_view/app/account.tsx b/apps/web/src/routes/_view/app/account.tsx index 31df845c74..62e7bd4ee5 100644 --- a/apps/web/src/routes/_view/app/account.tsx +++ b/apps/web/src/routes/_view/app/account.tsx @@ -3,7 +3,12 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; import { signOutFn } from "@/functions/auth"; -import { createPortalSession, syncAfterSuccess } from "@/functions/billing"; +import { + canStartTrial, + createPortalSession, + createTrialCheckoutSession, + syncAfterSuccess, +} from "@/functions/billing"; import { addContact } from "@/functions/loops"; import { useAnalytics } from "@/hooks/use-posthog"; @@ -58,6 +63,11 @@ function AccountSettingsCard() { queryFn: () => syncAfterSuccess(), }); + const canTrialQuery = useQuery({ + queryKey: ["canStartTrial"], + queryFn: () => canStartTrial(), + }); + const manageBillingMutation = useMutation({ mutationFn: async () => { const { url } = await createPortalSession(); @@ -67,6 +77,15 @@ function AccountSettingsCard() { }, }); + const startTrialMutation = useMutation({ + mutationFn: async () => { + const { url } = await createTrialCheckoutSession(); + if (url) { + window.location.href = url; + } + }, + }); + const currentPlan = (() => { if (!billingQuery.data || billingQuery.data.status === "none") { return "free"; @@ -78,7 +97,7 @@ function AccountSettingsCard() { })(); const renderPlanButton = () => { - if (billingQuery.isLoading) { + if (billingQuery.isLoading || canTrialQuery.isLoading) { return (
Loading... @@ -87,6 +106,18 @@ function AccountSettingsCard() { } if (currentPlan === "free") { + if (canTrialQuery.data) { + return ( + + ); + } + return ( = ClientOptions & { @@ -25,6 +25,49 @@ export const postBillingStartTrial = (opti }); }; +export const postFileTranscriptionStart = (options?: Options) => { + return (options?.client ?? _heyApiClient).post({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/file-transcription/start', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); +}; + +export const getFileTranscriptionStatusByPipelineId = (options: Options) => { + return (options.client ?? _heyApiClient).get({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/file-transcription/status/{pipelineId}', + ...options + }); +}; + +export const getFileTranscriptionResultByPipelineId = (options: Options) => { + return (options.client ?? _heyApiClient).get({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/file-transcription/result/{pipelineId}', + ...options + }); +}; + export const getRpcCanStartTrial = (options?: Options) => { return (options?.client ?? _heyApiClient).get({ url: '/rpc/can-start-trial', diff --git a/packages/api-client/src/generated/types.gen.ts b/packages/api-client/src/generated/types.gen.ts index 5a9ca908f8..c33eb911eb 100644 --- a/packages/api-client/src/generated/types.gen.ts +++ b/packages/api-client/src/generated/types.gen.ts @@ -20,6 +20,111 @@ export type PostBillingStartTrialResponses = { export type PostBillingStartTrialResponse = PostBillingStartTrialResponses[keyof PostBillingStartTrialResponses]; +export type PostFileTranscriptionStartData = { + body?: { + fileId: string; + pipelineId?: string; + }; + path?: never; + query?: never; + url: '/file-transcription/start'; +}; + +export type PostFileTranscriptionStartErrors = { + /** + * Invalid fileId + */ + 400: unknown; + /** + * Unauthorized + */ + 401: unknown; + /** + * Internal error + */ + 500: unknown; +}; + +export type PostFileTranscriptionStartResponses = { + /** + * Pipeline started + */ + 200: { + pipelineId: string; + invocationId: string; + }; +}; + +export type PostFileTranscriptionStartResponse = PostFileTranscriptionStartResponses[keyof PostFileTranscriptionStartResponses]; + +export type GetFileTranscriptionStatusByPipelineIdData = { + body?: never; + path: { + pipelineId: string; + }; + query?: never; + url: '/file-transcription/status/{pipelineId}'; +}; + +export type GetFileTranscriptionStatusByPipelineIdErrors = { + /** + * Unauthorized + */ + 401: unknown; + /** + * Internal error + */ + 500: unknown; +}; + +export type GetFileTranscriptionStatusByPipelineIdResponses = { + /** + * Pipeline status + */ + 200: { + status: 'QUEUED' | 'TRANSCRIBING' | 'TRANSCRIBED' | 'LLM_RUNNING' | 'DONE' | 'ERROR'; + transcript?: string; + llmResult?: string; + error?: string; + }; +}; + +export type GetFileTranscriptionStatusByPipelineIdResponse = GetFileTranscriptionStatusByPipelineIdResponses[keyof GetFileTranscriptionStatusByPipelineIdResponses]; + +export type GetFileTranscriptionResultByPipelineIdData = { + body?: never; + path: { + pipelineId: string; + }; + query?: never; + url: '/file-transcription/result/{pipelineId}'; +}; + +export type GetFileTranscriptionResultByPipelineIdErrors = { + /** + * Unauthorized + */ + 401: unknown; + /** + * Internal error + */ + 500: unknown; +}; + +export type GetFileTranscriptionResultByPipelineIdResponses = { + /** + * Pipeline result + */ + 200: { + status: 'QUEUED' | 'TRANSCRIBING' | 'TRANSCRIBED' | 'LLM_RUNNING' | 'DONE' | 'ERROR'; + transcript?: string; + llmResult?: string; + error?: string; + }; +}; + +export type GetFileTranscriptionResultByPipelineIdResponse = GetFileTranscriptionResultByPipelineIdResponses[keyof GetFileTranscriptionResultByPipelineIdResponses]; + export type GetRpcCanStartTrialData = { body?: never; path?: never; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f3424dbb32..b95263f313 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: '@posthog/ai': specifier: ^7.2.1 version: 7.2.1(@modelcontextprotocol/sdk@1.24.3(@cfworker/json-schema@4.1.1)(zod@4.1.13))(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(posthog-node@5.17.2)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(ws@8.18.3)(zod-to-json-schema@3.25.0(zod@4.1.13)) + '@restatedev/restate-sdk-clients': + specifier: ^1.9.1 + version: 1.9.1 '@scalar/hono-api-reference': specifier: ^0.5.184 version: 0.5.184(hono@4.10.7) @@ -658,9 +661,6 @@ importers: '@posthog/react': specifier: ^1.5.2 version: 1.5.2(@types/react@19.2.7)(posthog-js@1.302.2)(react@19.2.1) - '@restatedev/restate-sdk-clients': - specifier: ^1.9.1 - version: 1.9.1 '@sentry/tanstackstart-react': specifier: ^10.29.0 version: 10.29.0(react@19.2.1)