From 8cc3c993dad928bb12fd5ca5659dabd39297190a Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Tue, 9 Dec 2025 21:34:29 +0900 Subject: [PATCH 01/13] chores --- Taskfile.yaml | 6 ++++++ apps/desktop/src/routes/app/settings/_layout.tsx | 8 ++++++++ 2 files changed, 14 insertions(+) diff --git a/Taskfile.yaml b/Taskfile.yaml index 913a29c5a1..9dda2ca8db 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -91,6 +91,12 @@ tasks: - task: supabase-env - task: supabase-tunnel-start + supabase-no-expose: + platforms: [darwin] + cmds: + - task: supabase-tunnel-stop + - task: supabase-env + supabase-tunnel-stop: platforms: [darwin] cmds: diff --git a/apps/desktop/src/routes/app/settings/_layout.tsx b/apps/desktop/src/routes/app/settings/_layout.tsx index 8b316c8b39..1b67fd7c7a 100644 --- a/apps/desktop/src/routes/app/settings/_layout.tsx +++ b/apps/desktop/src/routes/app/settings/_layout.tsx @@ -4,6 +4,7 @@ import { AudioLines, Bell, CalendarDays, + Import, type LucideIcon, MessageCircleQuestion, Puzzle, @@ -25,6 +26,7 @@ const TAB_KEYS = [ "notifications", "transcription", "intelligence", + "data-import", "integrations", "feedback", "developers", @@ -68,6 +70,12 @@ const TAB_CONFIG: Record< icon: Sparkles, group: 1, }, + "data-import": { + label: "Data Import", + icon: Import, + group: 1, + disabled: true, + }, integrations: { label: "Integrations", icon: Puzzle, From 5bef1eeb623abe30407e9c2af84696a09f4010c0 Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Wed, 10 Dec 2025 09:48:58 +0900 Subject: [PATCH 02/13] add db function --- .../20250101000004_can_start_trial.sql | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 supabase/migrations/20250101000004_can_start_trial.sql diff --git a/supabase/migrations/20250101000004_can_start_trial.sql b/supabase/migrations/20250101000004_can_start_trial.sql new file mode 100644 index 0000000000..02f0d9842a --- /dev/null +++ b/supabase/migrations/20250101000004_can_start_trial.sql @@ -0,0 +1,39 @@ +CREATE OR REPLACE FUNCTION public.can_start_trial() +RETURNS boolean +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_user_id uuid := auth.uid(); + v_customer_id text; +BEGIN + IF v_user_id IS NULL THEN + RETURN false; + END IF; + + SELECT stripe_customer_id INTO v_customer_id + FROM public.profiles + WHERE id = v_user_id; + + IF v_customer_id IS NULL THEN + RETURN true; + END IF; + + RETURN NOT EXISTS ( + SELECT 1 FROM stripe.subscriptions + WHERE customer = v_customer_id + AND ( + status IN ('active', 'trialing') + OR ( + trial_start IS NOT NULL + AND (trial_start #>> '{}')::bigint > extract(epoch from now() - interval '3 months') + ) + ) + ); +END; +$$; + +GRANT EXECUTE ON FUNCTION public.can_start_trial TO authenticated; +REVOKE EXECUTE ON FUNCTION public.can_start_trial FROM anon, public; From 98b943e3957276ea4f5faf0369929411c8d42891 Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Wed, 10 Dec 2025 09:50:00 +0900 Subject: [PATCH 03/13] add start-trial route in apps/api --- apps/api/src/env.ts | 2 + apps/api/src/hono-bindings.ts | 2 + apps/api/src/middleware/supabase.ts | 1 + apps/api/src/routes/billing.ts | 92 +++++++++++++++++++++++++++++ apps/api/src/routes/index.ts | 2 + 5 files changed, 99 insertions(+) create mode 100644 apps/api/src/routes/billing.ts diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index 4e8e19da02..84687a2a1c 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -18,6 +18,8 @@ export const env = createEnv({ SUPABASE_SERVICE_ROLE_KEY: z.string().min(1), STRIPE_SECRET_KEY: z.string().min(1), STRIPE_WEBHOOK_SECRET: z.string().min(1), + STRIPE_MONTHLY_PRICE_ID: z.string().min(1), + STRIPE_YEARLY_PRICE_ID: z.string().min(1), OPENROUTER_API_KEY: z.string().min(1), DEEPGRAM_API_KEY: z.string().min(1), ASSEMBLYAI_API_KEY: z.string().min(1), diff --git a/apps/api/src/hono-bindings.ts b/apps/api/src/hono-bindings.ts index e0d44c6499..97c4dafb80 100644 --- a/apps/api/src/hono-bindings.ts +++ b/apps/api/src/hono-bindings.ts @@ -1,4 +1,5 @@ import type * as Sentry from "@sentry/bun"; +import type { SupabaseClient } from "@supabase/supabase-js"; import type Stripe from "stripe"; import type { Emitter } from "./observability"; @@ -10,6 +11,7 @@ export type AppBindings = { stripeSignature: string; sentrySpan: Sentry.Span; supabaseUserId: string | undefined; + supabaseClient: SupabaseClient | undefined; emit: Emitter; }; }; diff --git a/apps/api/src/middleware/supabase.ts b/apps/api/src/middleware/supabase.ts index 4b21c14f9f..626ae69dc8 100644 --- a/apps/api/src/middleware/supabase.ts +++ b/apps/api/src/middleware/supabase.ts @@ -27,6 +27,7 @@ export const supabaseAuthMiddleware = createMiddleware( } c.set("supabaseUserId", data.user.id); + c.set("supabaseClient", supabaseClient); await next(); }, ); diff --git a/apps/api/src/routes/billing.ts b/apps/api/src/routes/billing.ts new file mode 100644 index 0000000000..baa9c26bfd --- /dev/null +++ b/apps/api/src/routes/billing.ts @@ -0,0 +1,92 @@ +import { Hono } from "hono"; +import { describeRoute } from "hono-openapi"; +import { resolver, validator } from "hono-openapi/zod"; +import { z } from "zod"; + +import { env } from "../env"; +import type { AppBindings } from "../hono-bindings"; +import { stripe } from "../integration/stripe"; +import { supabaseAuthMiddleware } from "../middleware/supabase"; +import { API_TAGS } from "./constants"; + +const StartTrialQuerySchema = z.object({ + interval: z.enum(["monthly", "yearly"]).default("monthly"), +}); + +const StartTrialResponseSchema = z.object({ + started: z.boolean(), +}); + +export const billing = new Hono(); + +billing.post( + "/start-trial", + describeRoute({ + tags: [API_TAGS.INTERNAL], + responses: { + 200: { + description: "result", + content: { + "application/json": { schema: resolver(StartTrialResponseSchema) }, + }, + }, + }, + }), + validator("query", StartTrialQuerySchema), + supabaseAuthMiddleware, + async (c) => { + const { interval } = c.req.valid("query"); + const supabase = c.get("supabaseClient")!; + const userId = c.get("supabaseUserId")!; + + const { data: canTrial, error: trialError } = + await supabase.rpc("can_start_trial"); + + if (trialError || !canTrial) { + return c.json({ started: false }); + } + + const { data: profile } = await supabase + .from("profiles") + .select("stripe_customer_id") + .eq("id", userId) + .single(); + + let stripeCustomerId = profile?.stripe_customer_id as + | string + | null + | undefined; + + if (!stripeCustomerId) { + const { data: user } = await supabase.auth.getUser(); + + const newCustomer = await stripe.customers.create({ + email: user.user?.email, + metadata: { userId }, + }); + + await supabase + .from("profiles") + .update({ stripe_customer_id: newCustomer.id }) + .eq("id", userId); + + stripeCustomerId = newCustomer.id; + } + + const priceId = + interval === "yearly" + ? env.STRIPE_YEARLY_PRICE_ID + : env.STRIPE_MONTHLY_PRICE_ID; + + await stripe.subscriptions.create({ + customer: stripeCustomerId, + items: [{ price: priceId }], + trial_period_days: 14, + trial_settings: { + end_behavior: { missing_payment_method: "cancel" }, + }, + }); + + return c.json({ started: true }); + }, +); diff --git a/apps/api/src/routes/index.ts b/apps/api/src/routes/index.ts index f21102740d..4f5823c452 100644 --- a/apps/api/src/routes/index.ts +++ b/apps/api/src/routes/index.ts @@ -1,6 +1,7 @@ import { Hono } from "hono"; import type { AppBindings } from "../hono-bindings"; +import { billing } from "./billing"; import { health } from "./health"; import { llm } from "./llm"; import { stt } from "./stt"; @@ -11,6 +12,7 @@ export { API_TAGS } from "./constants"; export const routes = new Hono(); routes.route("/health", health); +routes.route("/billing", billing); routes.route("/chat", llm); routes.route("/", stt); routes.route("/webhook", webhook); From 5219c0adc543f8272895dfa37177a4afb162900f Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Wed, 10 Dec 2025 09:50:33 +0900 Subject: [PATCH 04/13] update apps/desktop to support trial --- .../src/components/onboarding/login.tsx | 16 ++- .../src/components/settings/account.tsx | 117 ++++++++++-------- 2 files changed, 75 insertions(+), 58 deletions(-) diff --git a/apps/desktop/src/components/onboarding/login.tsx b/apps/desktop/src/components/onboarding/login.tsx index 4fdb3da0b6..5f85af370e 100644 --- a/apps/desktop/src/components/onboarding/login.tsx +++ b/apps/desktop/src/components/onboarding/login.tsx @@ -1,9 +1,10 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { Button } from "@hypr/ui/components/ui/button"; import { Input } from "@hypr/ui/components/ui/input"; import { useAuth } from "../../auth"; +import { env } from "../../env"; import type { OnboardingNext } from "./shared"; type LoginProps = { @@ -19,9 +20,18 @@ export function Login({ onNext }: LoginProps) { await auth?.signIn(); }, [auth]); + const trialStarted = useRef(false); + useEffect(() => { - if (auth?.session) { - onNext({ local: false }); + if (auth?.session && !trialStarted.current) { + trialStarted.current = true; + + fetch(`${env.VITE_API_URL}/billing/start-trial`, { + method: "POST", + headers: { Authorization: `Bearer ${auth.session.access_token}` }, + }).finally(() => { + onNext({ local: false }); + }); } }, [auth?.session, onNext]); diff --git a/apps/desktop/src/components/settings/account.tsx b/apps/desktop/src/components/settings/account.tsx index 720183e80a..d3fd2b82e7 100644 --- a/apps/desktop/src/components/settings/account.tsx +++ b/apps/desktop/src/components/settings/account.tsx @@ -1,5 +1,6 @@ +import { useQuery } from "@tanstack/react-query"; import { openUrl } from "@tauri-apps/plugin-opener"; -import { ExternalLinkIcon, RefreshCwIcon } from "lucide-react"; +import { ExternalLinkIcon } from "lucide-react"; import { type ReactNode, useCallback, useEffect, useState } from "react"; import { Button } from "@hypr/ui/components/ui/button"; @@ -137,71 +138,77 @@ export function SettingsAccount() { - Manage - - - } + description={`Your current plan is ${isPro ? "PRO" : "FREE"}. `} + action={} > - +

+ Click{" "} + here + to refresh plan status. +

); } -function PlanStatus({ - isPro, - onRefresh, -}: { - isPro: boolean; - onRefresh?: () => Promise; -}) { - const [isRefreshing, setIsRefreshing] = useState(false); +function BillingButton() { + const auth = useAuth(); + const { isPro } = useBillingAccess(); - const handleRefresh = async () => { - if (!onRefresh) { - return; - } - setIsRefreshing(true); - try { - await onRefresh(); - } finally { - setIsRefreshing(false); - } - }; + const canTrialQuery = useQuery({ + enabled: !!auth?.supabase && !isPro, + queryKey: [auth?.session?.user.id ?? "", "canStartTrial"], + queryFn: async () => { + if (!auth?.supabase) { + return false; + } + const { data, error } = await auth.supabase.rpc("can_start_trial"); + if (error) { + throw error; + } + return data as boolean; + }, + }); + + const handleProUpgrade = useCallback(() => { + openUrl(`${WEB_APP_BASE_URL}/app/checkout?period=monthly`); + }, []); - return ( -
- {isPro ? ( - - PRO - - ) : ( - - FREE - - )} + const handleStartTrial = useCallback(() => { + openUrl(`${WEB_APP_BASE_URL}/app/checkout?trial=true`); + }, []); + + const handleOpenAccount = useCallback(() => { + openUrl(`${WEB_APP_BASE_URL}/app/account`); + }, []); + + if (isPro) { + return ( -
+ ); + } + + if (canTrialQuery.data) { + return ( + + ); + } + + return ( + ); } From 461f4019c525c79aa5686229f726c30fcd030351 Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Wed, 10 Dec 2025 09:58:41 +0900 Subject: [PATCH 05/13] openapi stuffs for apps/api --- apps/api/openapi.json | 615 +++++++++++++++++++++++ apps/api/package.json | 1 + apps/api/src/env.ts | 1 + apps/api/src/index.ts | 49 +- apps/api/src/openapi.ts | 45 ++ apps/api/src/scripts/generate-openapi.ts | 16 + 6 files changed, 681 insertions(+), 46 deletions(-) create mode 100644 apps/api/openapi.json create mode 100644 apps/api/src/openapi.ts create mode 100644 apps/api/src/scripts/generate-openapi.ts diff --git a/apps/api/openapi.json b/apps/api/openapi.json new file mode 100644 index 0000000000..c2640f1988 --- /dev/null +++ b/apps/api/openapi.json @@ -0,0 +1,615 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Hyprnote API", + "description": "API for Hyprnote - AI-powered meeting notes application. APIs are categorized by tags: 'internal' for health checks and internal use, 'app' for endpoints used by the Hyprnote application (requires authentication), and 'webhook' for external service callbacks.", + "version": "1.0.0" + }, + "tags": [ + { + "name": "internal", + "description": "Internal endpoints for health checks and monitoring" + }, + { + "name": "app", + "description": "Endpoints used by the Hyprnote application. Requires Supabase authentication." + }, + { + "name": "webhook", + "description": "Webhook endpoints for external service callbacks" + } + ], + "components": { + "securitySchemes": { + "Bearer": { + "type": "http", + "scheme": "bearer", + "description": "Supabase JWT token" + } + }, + "schemas": {} + }, + "servers": [ + { + "url": "https://api.hyprnote.com", + "description": "Production server" + }, + { + "url": "http://localhost:4000", + "description": "Local development server" + } + ], + "paths": { + "/health": { + "get": { + "responses": { + "200": { + "description": "API is healthy", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string" + } + }, + "required": [ + "status" + ], + "additionalProperties": false + } + } + } + } + }, + "operationId": "getHealth", + "tags": [ + "internal" + ], + "parameters": [], + "summary": "Health check", + "description": "Returns the health status of the API server." + } + }, + "/billing/start-trial": { + "post": { + "responses": { + "200": { + "description": "result", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "started": { + "type": "boolean" + } + }, + "required": [ + "started" + ], + "additionalProperties": false + } + } + } + } + }, + "operationId": "postBillingStart-trial", + "tags": [ + "internal" + ], + "parameters": [ + { + "in": "query", + "name": "interval", + "schema": { + "default": "monthly", + "type": "string", + "enum": [ + "monthly", + "yearly" + ] + }, + "required": true + } + ] + } + }, + "/chat/completions": { + "post": { + "responses": { + "200": { + "description": "Chat completion response (streamed or non-streamed)" + }, + "401": { + "description": "Unauthorized - missing or invalid authentication", + "content": { + "text/plain": { + "schema": { + "type": "string", + "example": "unauthorized" + } + } + } + } + }, + "operationId": "postChatCompletions", + "tags": [ + "app" + ], + "parameters": [], + "summary": "Chat completions", + "description": "OpenAI-compatible chat completions endpoint. Proxies requests to OpenRouter with automatic model selection. Requires Supabase authentication.", + "security": [ + { + "Bearer": [] + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "messages": { + "type": "array", + "items": { + "type": "object", + "properties": { + "role": { + "type": "string", + "enum": [ + "system", + "user", + "assistant" + ] + }, + "content": { + "type": "string" + } + }, + "required": [ + "role", + "content" + ], + "additionalProperties": false + } + }, + "tools": { + "type": "array", + "items": {} + }, + "tool_choice": { + "anyOf": [ + { + "type": "string", + "enum": [ + "none", + "auto", + "required" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "function" + }, + "function": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "additionalProperties": false + } + }, + "required": [ + "type", + "function" + ], + "additionalProperties": false + } + ] + }, + "stream": { + "type": "boolean" + }, + "temperature": { + "type": "number" + }, + "max_tokens": { + "type": "number" + } + }, + "required": [ + "messages" + ], + "additionalProperties": {} + } + } + } + } + } + }, + "/listen": { + "get": { + "responses": { + "101": { + "description": "WebSocket upgrade successful" + }, + "400": { + "description": "WebSocket upgrade failed", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "detail": { + "type": "string" + } + }, + "required": [ + "error" + ], + "additionalProperties": false + } + } + } + }, + "401": { + "description": "Unauthorized - missing or invalid authentication", + "content": { + "text/plain": { + "schema": { + "type": "string", + "example": "unauthorized" + } + } + } + }, + "502": { + "description": "Upstream STT service unavailable", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "detail": { + "type": "string" + } + }, + "required": [ + "error" + ], + "additionalProperties": false + } + } + } + }, + "504": { + "description": "Upstream STT service timeout", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "detail": { + "type": "string" + } + }, + "required": [ + "error" + ], + "additionalProperties": false + } + } + } + } + }, + "operationId": "getListen", + "tags": [ + "app" + ], + "parameters": [], + "summary": "Speech-to-text WebSocket", + "description": "WebSocket endpoint for real-time speech-to-text transcription via Deepgram. Requires Supabase authentication in production.", + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/transcribe": { + "post": { + "responses": { + "200": { + "description": "Transcription completed successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "metadata": {}, + "results": { + "type": "object", + "properties": { + "channels": { + "type": "array", + "items": { + "type": "object", + "properties": { + "alternatives": { + "type": "array", + "items": { + "type": "object", + "properties": { + "transcript": { + "type": "string" + }, + "confidence": { + "type": "number" + }, + "words": { + "type": "array", + "items": { + "type": "object", + "properties": { + "word": { + "type": "string" + }, + "start": { + "type": "number" + }, + "end": { + "type": "number" + }, + "confidence": { + "type": "number" + }, + "speaker": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "punctuated_word": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "word", + "start", + "end", + "confidence" + ], + "additionalProperties": false + } + } + }, + "required": [ + "transcript", + "confidence", + "words" + ], + "additionalProperties": false + } + } + }, + "required": [ + "alternatives" + ], + "additionalProperties": false + } + } + }, + "required": [ + "channels" + ], + "additionalProperties": false + } + }, + "required": [ + "metadata", + "results" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "Bad request - missing or invalid audio file", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "detail": { + "type": "string" + } + }, + "required": [ + "error" + ], + "additionalProperties": false + } + } + } + }, + "401": { + "description": "Unauthorized - missing or invalid authentication", + "content": { + "text/plain": { + "schema": { + "type": "string", + "example": "unauthorized" + } + } + } + }, + "500": { + "description": "Internal server error during transcription", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "detail": { + "type": "string" + } + }, + "required": [ + "error" + ], + "additionalProperties": false + } + } + } + }, + "502": { + "description": "Upstream STT service error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "detail": { + "type": "string" + } + }, + "required": [ + "error" + ], + "additionalProperties": false + } + } + } + } + }, + "operationId": "postTranscribe", + "tags": [ + "app" + ], + "parameters": [], + "summary": "Batch speech-to-text transcription", + "description": "HTTP endpoint for batch speech-to-text transcription via file upload. Supports Deepgram, AssemblyAI, and Soniox providers. Use query parameter ?provider=deepgram|assemblyai|soniox to select provider. Requires Supabase authentication.", + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/webhook/stripe": { + "post": { + "responses": { + "200": { + "description": "Webhook processed successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + } + }, + "required": [ + "ok" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "Invalid or missing Stripe signature", + "content": { + "text/plain": { + "schema": { + "type": "string", + "example": "missing_stripe_signature" + } + } + } + }, + "500": { + "description": "Internal server error during billing sync", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ], + "additionalProperties": false + } + } + } + } + }, + "operationId": "postWebhookStripe", + "tags": [ + "webhook" + ], + "parameters": [ + { + "in": "header", + "name": "stripe-signature", + "schema": { + "type": "string" + }, + "required": true + } + ], + "summary": "Stripe webhook", + "description": "Handles Stripe webhook events for billing synchronization. Requires valid Stripe signature." + } + } + } +} \ No newline at end of file diff --git a/apps/api/package.json b/apps/api/package.json index fba8141a19..993eb60410 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -5,6 +5,7 @@ "scripts": { "dev": "dotenvx run --ignore MISSING_ENV_FILE -f ../../.env.supabase -f ../../.env.stripe -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", "test": "bun test" }, diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index 84687a2a1c..6731a650f2 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -29,4 +29,5 @@ export const env = createEnv({ }, runtimeEnv: Bun.env, emptyStringAsUndefined: true, + skipValidation: Bun.env.CI === "true", }); diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 9f6949e6d0..f7bb5e4b2f 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -18,7 +18,8 @@ import { supabaseAuthMiddleware, verifyStripeWebhook, } from "./middleware"; -import { API_TAGS, routes } from "./routes"; +import { openAPIDocumentation } from "./openapi"; +import { routes } from "./routes"; const app = new Hono(); @@ -69,51 +70,7 @@ app.notFound((c) => c.text("not_found", 404)); app.get( "/openapi.json", - openAPISpecs(routes, { - documentation: { - openapi: "3.1.0", - info: { - title: "Hyprnote API", - version: "1.0.0", - description: - "API for Hyprnote - AI-powered meeting notes application. APIs are categorized by tags: 'internal' for health checks and internal use, 'app' for endpoints used by the Hyprnote application (requires authentication), and 'webhook' for external service callbacks.", - }, - tags: [ - { - name: API_TAGS.INTERNAL, - description: "Internal endpoints for health checks and monitoring", - }, - { - name: API_TAGS.APP, - description: - "Endpoints used by the Hyprnote application. Requires Supabase authentication.", - }, - { - name: API_TAGS.WEBHOOK, - description: "Webhook endpoints for external service callbacks", - }, - ], - components: { - securitySchemes: { - Bearer: { - type: "http", - scheme: "bearer", - description: "Supabase JWT token", - }, - }, - }, - servers: [ - { - url: "https://api.hyprnote.com", - description: "Production server", - }, - { - url: "http://localhost:4000", - description: "Local development server", - }, - ], - }, - }), + openAPISpecs(routes, { documentation: openAPIDocumentation }), ); app.get( diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts new file mode 100644 index 0000000000..a772faf5e0 --- /dev/null +++ b/apps/api/src/openapi.ts @@ -0,0 +1,45 @@ +import { API_TAGS } from "./routes"; + +export const openAPIDocumentation = { + openapi: "3.1.0", + info: { + title: "Hyprnote API", + version: "1.0.0", + description: + "API for Hyprnote - AI-powered meeting notes application. APIs are categorized by tags: 'internal' for health checks and internal use, 'app' for endpoints used by the Hyprnote application (requires authentication), and 'webhook' for external service callbacks.", + }, + tags: [ + { + name: API_TAGS.INTERNAL, + description: "Internal endpoints for health checks and monitoring", + }, + { + name: API_TAGS.APP, + description: + "Endpoints used by the Hyprnote application. Requires Supabase authentication.", + }, + { + name: API_TAGS.WEBHOOK, + description: "Webhook endpoints for external service callbacks", + }, + ], + components: { + securitySchemes: { + Bearer: { + type: "http" as const, + scheme: "bearer", + description: "Supabase JWT token", + }, + }, + }, + servers: [ + { + url: "https://api.hyprnote.com", + description: "Production server", + }, + { + url: "http://localhost:4000", + description: "Local development server", + }, + ], +}; diff --git a/apps/api/src/scripts/generate-openapi.ts b/apps/api/src/scripts/generate-openapi.ts new file mode 100644 index 0000000000..c1715e842d --- /dev/null +++ b/apps/api/src/scripts/generate-openapi.ts @@ -0,0 +1,16 @@ +import { generateSpecs } from "hono-openapi"; + +import { openAPIDocumentation } from "../openapi"; +import { routes } from "../routes"; + +async function main() { + const specs = await generateSpecs(routes, { + documentation: openAPIDocumentation, + }); + + const outputPath = new URL("../../openapi.json", import.meta.url); + await Bun.write(outputPath, JSON.stringify(specs, null, 2)); + console.log(`OpenAPI spec written to ${outputPath.pathname}`); +} + +main(); From 6eb5097b2058bd2fd3cffa03cf42e8f27d94e0e9 Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Wed, 10 Dec 2025 10:08:10 +0900 Subject: [PATCH 06/13] openapi client genertion --- apps/api/openapi.config.ts | 13 + apps/api/openapi.json | 2 +- apps/api/package.json | 1 + .../api-client/src/generated/client.gen.ts | 16 + .../src/generated/client/client.gen.ts | 301 ++++++++++++++++ .../api-client/src/generated/client/index.ts | 25 ++ .../src/generated/client/types.gen.ts | 241 +++++++++++++ .../src/generated/client/utils.gen.ts | 332 ++++++++++++++++++ .../api-client/src/generated/core/auth.gen.ts | 42 +++ .../src/generated/core/bodySerializer.gen.ts | 100 ++++++ .../src/generated/core/params.gen.ts | 176 ++++++++++ .../src/generated/core/pathSerializer.gen.ts | 181 ++++++++++ .../generated/core/queryKeySerializer.gen.ts | 136 +++++++ .../generated/core/serverSentEvents.gen.ts | 264 ++++++++++++++ .../src/generated/core/types.gen.ts | 118 +++++++ .../src/generated/core/utils.gen.ts | 143 ++++++++ packages/api-client/src/generated/index.ts | 4 + packages/api-client/src/generated/sdk.gen.ts | 28 ++ .../api-client/src/generated/types.gen.ts | 43 +++ pnpm-lock.yaml | 107 +----- 20 files changed, 2176 insertions(+), 97 deletions(-) create mode 100644 apps/api/openapi.config.ts create mode 100644 packages/api-client/src/generated/client.gen.ts create mode 100644 packages/api-client/src/generated/client/client.gen.ts create mode 100644 packages/api-client/src/generated/client/index.ts create mode 100644 packages/api-client/src/generated/client/types.gen.ts create mode 100644 packages/api-client/src/generated/client/utils.gen.ts create mode 100644 packages/api-client/src/generated/core/auth.gen.ts create mode 100644 packages/api-client/src/generated/core/bodySerializer.gen.ts create mode 100644 packages/api-client/src/generated/core/params.gen.ts create mode 100644 packages/api-client/src/generated/core/pathSerializer.gen.ts create mode 100644 packages/api-client/src/generated/core/queryKeySerializer.gen.ts create mode 100644 packages/api-client/src/generated/core/serverSentEvents.gen.ts create mode 100644 packages/api-client/src/generated/core/types.gen.ts create mode 100644 packages/api-client/src/generated/core/utils.gen.ts create mode 100644 packages/api-client/src/generated/index.ts create mode 100644 packages/api-client/src/generated/sdk.gen.ts create mode 100644 packages/api-client/src/generated/types.gen.ts diff --git a/apps/api/openapi.config.ts b/apps/api/openapi.config.ts new file mode 100644 index 0000000000..234939ff21 --- /dev/null +++ b/apps/api/openapi.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "@hey-api/openapi-ts"; + +export default defineConfig({ + input: "apps/api/openapi.json", + output: "packages/api-client/src/generated", + parser: { + filters: { + tags: { + include: ["internal"], + }, + }, + }, +}); diff --git a/apps/api/openapi.json b/apps/api/openapi.json index c2640f1988..56d9dfd772 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -612,4 +612,4 @@ } } } -} \ No newline at end of file +} diff --git a/apps/api/package.json b/apps/api/package.json index 993eb60410..9b503c5bc5 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -27,6 +27,7 @@ }, "devDependencies": { "@dotenvx/dotenvx": "^1.51.1", + "@hey-api/openapi-ts": "^0.78.3", "@types/bun": "^1.3.4", "typescript": "^5.9.3" } diff --git a/packages/api-client/src/generated/client.gen.ts b/packages/api-client/src/generated/client.gen.ts new file mode 100644 index 0000000000..1366e15165 --- /dev/null +++ b/packages/api-client/src/generated/client.gen.ts @@ -0,0 +1,16 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { type ClientOptions, type Config, createClient, createConfig } from './client'; +import type { ClientOptions as ClientOptions2 } from './types.gen'; + +/** + * The `createClientConfig()` function will be called on client initialization + * and the returned object will become the client's initial configuration. + * + * You may want to initialize your client this way instead of calling + * `setConfig()`. This is useful for example if you're using Next.js + * to ensure your client always has the correct values. + */ +export type CreateClientConfig = (override?: Config) => Config & T>; + +export const client = createClient(createConfig({ baseUrl: 'https://api.hyprnote.com' })); diff --git a/packages/api-client/src/generated/client/client.gen.ts b/packages/api-client/src/generated/client/client.gen.ts new file mode 100644 index 0000000000..c2a5190c22 --- /dev/null +++ b/packages/api-client/src/generated/client/client.gen.ts @@ -0,0 +1,301 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { createSseClient } from '../core/serverSentEvents.gen'; +import type { HttpMethod } from '../core/types.gen'; +import { getValidRequestBody } from '../core/utils.gen'; +import type { + Client, + Config, + RequestOptions, + ResolvedRequestOptions, +} from './types.gen'; +import { + buildUrl, + createConfig, + createInterceptors, + getParseAs, + mergeConfigs, + mergeHeaders, + setAuthParams, +} from './utils.gen'; + +type ReqInit = Omit & { + body?: any; + headers: ReturnType; +}; + +export const createClient = (config: Config = {}): Client => { + let _config = mergeConfigs(createConfig(), config); + + const getConfig = (): Config => ({ ..._config }); + + const setConfig = (config: Config): Config => { + _config = mergeConfigs(_config, config); + return getConfig(); + }; + + const interceptors = createInterceptors< + Request, + Response, + unknown, + ResolvedRequestOptions + >(); + + const beforeRequest = async (options: RequestOptions) => { + const opts = { + ..._config, + ...options, + fetch: options.fetch ?? _config.fetch ?? globalThis.fetch, + headers: mergeHeaders(_config.headers, options.headers), + serializedBody: undefined, + }; + + if (opts.security) { + await setAuthParams({ + ...opts, + security: opts.security, + }); + } + + if (opts.requestValidator) { + await opts.requestValidator(opts); + } + + if (opts.body !== undefined && opts.bodySerializer) { + opts.serializedBody = opts.bodySerializer(opts.body); + } + + // remove Content-Type header if body is empty to avoid sending invalid requests + if (opts.body === undefined || opts.serializedBody === '') { + opts.headers.delete('Content-Type'); + } + + const url = buildUrl(opts); + + return { opts, url }; + }; + + const request: Client['request'] = async (options) => { + // @ts-expect-error + const { opts, url } = await beforeRequest(options); + const requestInit: ReqInit = { + redirect: 'follow', + ...opts, + body: getValidRequestBody(opts), + }; + + let request = new Request(url, requestInit); + + for (const fn of interceptors.request.fns) { + if (fn) { + request = await fn(request, opts); + } + } + + // fetch must be assigned here, otherwise it would throw the error: + // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation + const _fetch = opts.fetch!; + let response: Response; + + try { + response = await _fetch(request); + } catch (error) { + // Handle fetch exceptions (AbortError, network errors, etc.) + let finalError = error; + + for (const fn of interceptors.error.fns) { + if (fn) { + finalError = (await fn( + error, + undefined as any, + request, + opts, + )) as unknown; + } + } + + finalError = finalError || ({} as unknown); + + if (opts.throwOnError) { + throw finalError; + } + + // Return error response + return opts.responseStyle === 'data' + ? undefined + : { + error: finalError, + request, + response: undefined as any, + }; + } + + for (const fn of interceptors.response.fns) { + if (fn) { + response = await fn(response, request, opts); + } + } + + const result = { + request, + response, + }; + + if (response.ok) { + const parseAs = + (opts.parseAs === 'auto' + ? getParseAs(response.headers.get('Content-Type')) + : opts.parseAs) ?? 'json'; + + if ( + response.status === 204 || + response.headers.get('Content-Length') === '0' + ) { + let emptyData: any; + switch (parseAs) { + case 'arrayBuffer': + case 'blob': + case 'text': + emptyData = await response[parseAs](); + break; + case 'formData': + emptyData = new FormData(); + break; + case 'stream': + emptyData = response.body; + break; + case 'json': + default: + emptyData = {}; + break; + } + return opts.responseStyle === 'data' + ? emptyData + : { + data: emptyData, + ...result, + }; + } + + let data: any; + switch (parseAs) { + case 'arrayBuffer': + case 'blob': + case 'formData': + case 'json': + case 'text': + data = await response[parseAs](); + break; + case 'stream': + return opts.responseStyle === 'data' + ? response.body + : { + data: response.body, + ...result, + }; + } + + if (parseAs === 'json') { + if (opts.responseValidator) { + await opts.responseValidator(data); + } + + if (opts.responseTransformer) { + data = await opts.responseTransformer(data); + } + } + + return opts.responseStyle === 'data' + ? data + : { + data, + ...result, + }; + } + + const textError = await response.text(); + let jsonError: unknown; + + try { + jsonError = JSON.parse(textError); + } catch { + // noop + } + + const error = jsonError ?? textError; + let finalError = error; + + for (const fn of interceptors.error.fns) { + if (fn) { + finalError = (await fn(error, response, request, opts)) as string; + } + } + + finalError = finalError || ({} as string); + + if (opts.throwOnError) { + throw finalError; + } + + // TODO: we probably want to return error and improve types + return opts.responseStyle === 'data' + ? undefined + : { + error: finalError, + ...result, + }; + }; + + const makeMethodFn = + (method: Uppercase) => (options: RequestOptions) => + request({ ...options, method }); + + const makeSseFn = + (method: Uppercase) => async (options: RequestOptions) => { + const { opts, url } = await beforeRequest(options); + return createSseClient({ + ...opts, + body: opts.body as BodyInit | null | undefined, + headers: opts.headers as unknown as Record, + method, + onRequest: async (url, init) => { + let request = new Request(url, init); + for (const fn of interceptors.request.fns) { + if (fn) { + request = await fn(request, opts); + } + } + return request; + }, + url, + }); + }; + + return { + buildUrl, + connect: makeMethodFn('CONNECT'), + delete: makeMethodFn('DELETE'), + get: makeMethodFn('GET'), + getConfig, + head: makeMethodFn('HEAD'), + interceptors, + options: makeMethodFn('OPTIONS'), + patch: makeMethodFn('PATCH'), + post: makeMethodFn('POST'), + put: makeMethodFn('PUT'), + request, + setConfig, + sse: { + connect: makeSseFn('CONNECT'), + delete: makeSseFn('DELETE'), + get: makeSseFn('GET'), + head: makeSseFn('HEAD'), + options: makeSseFn('OPTIONS'), + patch: makeSseFn('PATCH'), + post: makeSseFn('POST'), + put: makeSseFn('PUT'), + trace: makeSseFn('TRACE'), + }, + trace: makeMethodFn('TRACE'), + } as Client; +}; diff --git a/packages/api-client/src/generated/client/index.ts b/packages/api-client/src/generated/client/index.ts new file mode 100644 index 0000000000..b295edeca0 --- /dev/null +++ b/packages/api-client/src/generated/client/index.ts @@ -0,0 +1,25 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type { Auth } from '../core/auth.gen'; +export type { QuerySerializerOptions } from '../core/bodySerializer.gen'; +export { + formDataBodySerializer, + jsonBodySerializer, + urlSearchParamsBodySerializer, +} from '../core/bodySerializer.gen'; +export { buildClientParams } from '../core/params.gen'; +export { serializeQueryKeyValue } from '../core/queryKeySerializer.gen'; +export { createClient } from './client.gen'; +export type { + Client, + ClientOptions, + Config, + CreateClientConfig, + Options, + RequestOptions, + RequestResult, + ResolvedRequestOptions, + ResponseStyle, + TDataShape, +} from './types.gen'; +export { createConfig, mergeHeaders } from './utils.gen'; diff --git a/packages/api-client/src/generated/client/types.gen.ts b/packages/api-client/src/generated/client/types.gen.ts new file mode 100644 index 0000000000..b4a499cc03 --- /dev/null +++ b/packages/api-client/src/generated/client/types.gen.ts @@ -0,0 +1,241 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Auth } from '../core/auth.gen'; +import type { + ServerSentEventsOptions, + ServerSentEventsResult, +} from '../core/serverSentEvents.gen'; +import type { + Client as CoreClient, + Config as CoreConfig, +} from '../core/types.gen'; +import type { Middleware } from './utils.gen'; + +export type ResponseStyle = 'data' | 'fields'; + +export interface Config + extends Omit, + CoreConfig { + /** + * Base URL for all requests made by this client. + */ + baseUrl?: T['baseUrl']; + /** + * Fetch API implementation. You can use this option to provide a custom + * fetch instance. + * + * @default globalThis.fetch + */ + fetch?: typeof fetch; + /** + * Please don't use the Fetch client for Next.js applications. The `next` + * options won't have any effect. + * + * Install {@link https://www.npmjs.com/package/@hey-api/client-next `@hey-api/client-next`} instead. + */ + next?: never; + /** + * Return the response data parsed in a specified format. By default, `auto` + * will infer the appropriate method from the `Content-Type` response header. + * You can override this behavior with any of the {@link Body} methods. + * Select `stream` if you don't want to parse response data at all. + * + * @default 'auto' + */ + parseAs?: + | 'arrayBuffer' + | 'auto' + | 'blob' + | 'formData' + | 'json' + | 'stream' + | 'text'; + /** + * Should we return only data or multiple fields (data, error, response, etc.)? + * + * @default 'fields' + */ + responseStyle?: ResponseStyle; + /** + * Throw an error instead of returning it in the response? + * + * @default false + */ + throwOnError?: T['throwOnError']; +} + +export interface RequestOptions< + TData = unknown, + TResponseStyle extends ResponseStyle = 'fields', + ThrowOnError extends boolean = boolean, + Url extends string = string, +> extends Config<{ + responseStyle: TResponseStyle; + throwOnError: ThrowOnError; + }>, + Pick< + ServerSentEventsOptions, + | 'onSseError' + | 'onSseEvent' + | 'sseDefaultRetryDelay' + | 'sseMaxRetryAttempts' + | 'sseMaxRetryDelay' + > { + /** + * Any body that you want to add to your request. + * + * {@link https://developer.mozilla.org/docs/Web/API/fetch#body} + */ + body?: unknown; + path?: Record; + query?: Record; + /** + * Security mechanism(s) to use for the request. + */ + security?: ReadonlyArray; + url: Url; +} + +export interface ResolvedRequestOptions< + TResponseStyle extends ResponseStyle = 'fields', + ThrowOnError extends boolean = boolean, + Url extends string = string, +> extends RequestOptions { + serializedBody?: string; +} + +export type RequestResult< + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = boolean, + TResponseStyle extends ResponseStyle = 'fields', +> = ThrowOnError extends true + ? Promise< + TResponseStyle extends 'data' + ? TData extends Record + ? TData[keyof TData] + : TData + : { + data: TData extends Record + ? TData[keyof TData] + : TData; + request: Request; + response: Response; + } + > + : Promise< + TResponseStyle extends 'data' + ? + | (TData extends Record + ? TData[keyof TData] + : TData) + | undefined + : ( + | { + data: TData extends Record + ? TData[keyof TData] + : TData; + error: undefined; + } + | { + data: undefined; + error: TError extends Record + ? TError[keyof TError] + : TError; + } + ) & { + request: Request; + response: Response; + } + >; + +export interface ClientOptions { + baseUrl?: string; + responseStyle?: ResponseStyle; + throwOnError?: boolean; +} + +type MethodFn = < + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = 'fields', +>( + options: Omit, 'method'>, +) => RequestResult; + +type SseFn = < + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = 'fields', +>( + options: Omit, 'method'>, +) => Promise>; + +type RequestFn = < + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = 'fields', +>( + options: Omit, 'method'> & + Pick< + Required>, + 'method' + >, +) => RequestResult; + +type BuildUrlFn = < + TData extends { + body?: unknown; + path?: Record; + query?: Record; + url: string; + }, +>( + options: TData & Options, +) => string; + +export type Client = CoreClient< + RequestFn, + Config, + MethodFn, + BuildUrlFn, + SseFn +> & { + interceptors: Middleware; +}; + +/** + * The `createClientConfig()` function will be called on client initialization + * and the returned object will become the client's initial configuration. + * + * You may want to initialize your client this way instead of calling + * `setConfig()`. This is useful for example if you're using Next.js + * to ensure your client always has the correct values. + */ +export type CreateClientConfig = ( + override?: Config, +) => Config & T>; + +export interface TDataShape { + body?: unknown; + headers?: unknown; + path?: unknown; + query?: unknown; + url: string; +} + +type OmitKeys = Pick>; + +export type Options< + TData extends TDataShape = TDataShape, + ThrowOnError extends boolean = boolean, + TResponse = unknown, + TResponseStyle extends ResponseStyle = 'fields', +> = OmitKeys< + RequestOptions, + 'body' | 'path' | 'query' | 'url' +> & + ([TData] extends [never] ? unknown : Omit); diff --git a/packages/api-client/src/generated/client/utils.gen.ts b/packages/api-client/src/generated/client/utils.gen.ts new file mode 100644 index 0000000000..4c48a9ee11 --- /dev/null +++ b/packages/api-client/src/generated/client/utils.gen.ts @@ -0,0 +1,332 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { getAuthToken } from '../core/auth.gen'; +import type { QuerySerializerOptions } from '../core/bodySerializer.gen'; +import { jsonBodySerializer } from '../core/bodySerializer.gen'; +import { + serializeArrayParam, + serializeObjectParam, + serializePrimitiveParam, +} from '../core/pathSerializer.gen'; +import { getUrl } from '../core/utils.gen'; +import type { Client, ClientOptions, Config, RequestOptions } from './types.gen'; + +export const createQuerySerializer = ({ + parameters = {}, + ...args +}: QuerySerializerOptions = {}) => { + const querySerializer = (queryParams: T) => { + const search: string[] = []; + if (queryParams && typeof queryParams === 'object') { + for (const name in queryParams) { + const value = queryParams[name]; + + if (value === undefined || value === null) { + continue; + } + + const options = parameters[name] || args; + + if (Array.isArray(value)) { + const serializedArray = serializeArrayParam({ + allowReserved: options.allowReserved, + explode: true, + name, + style: 'form', + value, + ...options.array, + }); + if (serializedArray) search.push(serializedArray); + } else if (typeof value === 'object') { + const serializedObject = serializeObjectParam({ + allowReserved: options.allowReserved, + explode: true, + name, + style: 'deepObject', + value: value as Record, + ...options.object, + }); + if (serializedObject) search.push(serializedObject); + } else { + const serializedPrimitive = serializePrimitiveParam({ + allowReserved: options.allowReserved, + name, + value: value as string, + }); + if (serializedPrimitive) search.push(serializedPrimitive); + } + } + } + return search.join('&'); + }; + return querySerializer; +}; + +/** + * Infers parseAs value from provided Content-Type header. + */ +export const getParseAs = ( + contentType: string | null, +): Exclude => { + if (!contentType) { + // If no Content-Type header is provided, the best we can do is return the raw response body, + // which is effectively the same as the 'stream' option. + return 'stream'; + } + + const cleanContent = contentType.split(';')[0]?.trim(); + + if (!cleanContent) { + return; + } + + if ( + cleanContent.startsWith('application/json') || + cleanContent.endsWith('+json') + ) { + return 'json'; + } + + if (cleanContent === 'multipart/form-data') { + return 'formData'; + } + + if ( + ['application/', 'audio/', 'image/', 'video/'].some((type) => + cleanContent.startsWith(type), + ) + ) { + return 'blob'; + } + + if (cleanContent.startsWith('text/')) { + return 'text'; + } + + return; +}; + +const checkForExistence = ( + options: Pick & { + headers: Headers; + }, + name?: string, +): boolean => { + if (!name) { + return false; + } + if ( + options.headers.has(name) || + options.query?.[name] || + options.headers.get('Cookie')?.includes(`${name}=`) + ) { + return true; + } + return false; +}; + +export const setAuthParams = async ({ + security, + ...options +}: Pick, 'security'> & + Pick & { + headers: Headers; + }) => { + for (const auth of security) { + if (checkForExistence(options, auth.name)) { + continue; + } + + const token = await getAuthToken(auth, options.auth); + + if (!token) { + continue; + } + + const name = auth.name ?? 'Authorization'; + + switch (auth.in) { + case 'query': + if (!options.query) { + options.query = {}; + } + options.query[name] = token; + break; + case 'cookie': + options.headers.append('Cookie', `${name}=${token}`); + break; + case 'header': + default: + options.headers.set(name, token); + break; + } + } +}; + +export const buildUrl: Client['buildUrl'] = (options) => + getUrl({ + baseUrl: options.baseUrl as string, + path: options.path, + query: options.query, + querySerializer: + typeof options.querySerializer === 'function' + ? options.querySerializer + : createQuerySerializer(options.querySerializer), + url: options.url, + }); + +export const mergeConfigs = (a: Config, b: Config): Config => { + const config = { ...a, ...b }; + if (config.baseUrl?.endsWith('/')) { + config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1); + } + config.headers = mergeHeaders(a.headers, b.headers); + return config; +}; + +const headersEntries = (headers: Headers): Array<[string, string]> => { + const entries: Array<[string, string]> = []; + headers.forEach((value, key) => { + entries.push([key, value]); + }); + return entries; +}; + +export const mergeHeaders = ( + ...headers: Array['headers'] | undefined> +): Headers => { + const mergedHeaders = new Headers(); + for (const header of headers) { + if (!header) { + continue; + } + + const iterator = + header instanceof Headers + ? headersEntries(header) + : Object.entries(header); + + for (const [key, value] of iterator) { + if (value === null) { + mergedHeaders.delete(key); + } else if (Array.isArray(value)) { + for (const v of value) { + mergedHeaders.append(key, v as string); + } + } else if (value !== undefined) { + // assume object headers are meant to be JSON stringified, i.e. their + // content value in OpenAPI specification is 'application/json' + mergedHeaders.set( + key, + typeof value === 'object' ? JSON.stringify(value) : (value as string), + ); + } + } + } + return mergedHeaders; +}; + +type ErrInterceptor = ( + error: Err, + response: Res, + request: Req, + options: Options, +) => Err | Promise; + +type ReqInterceptor = ( + request: Req, + options: Options, +) => Req | Promise; + +type ResInterceptor = ( + response: Res, + request: Req, + options: Options, +) => Res | Promise; + +class Interceptors { + fns: Array = []; + + clear(): void { + this.fns = []; + } + + eject(id: number | Interceptor): void { + const index = this.getInterceptorIndex(id); + if (this.fns[index]) { + this.fns[index] = null; + } + } + + exists(id: number | Interceptor): boolean { + const index = this.getInterceptorIndex(id); + return Boolean(this.fns[index]); + } + + getInterceptorIndex(id: number | Interceptor): number { + if (typeof id === 'number') { + return this.fns[id] ? id : -1; + } + return this.fns.indexOf(id); + } + + update( + id: number | Interceptor, + fn: Interceptor, + ): number | Interceptor | false { + const index = this.getInterceptorIndex(id); + if (this.fns[index]) { + this.fns[index] = fn; + return id; + } + return false; + } + + use(fn: Interceptor): number { + this.fns.push(fn); + return this.fns.length - 1; + } +} + +export interface Middleware { + error: Interceptors>; + request: Interceptors>; + response: Interceptors>; +} + +export const createInterceptors = (): Middleware< + Req, + Res, + Err, + Options +> => ({ + error: new Interceptors>(), + request: new Interceptors>(), + response: new Interceptors>(), +}); + +const defaultQuerySerializer = createQuerySerializer({ + allowReserved: false, + array: { + explode: true, + style: 'form', + }, + object: { + explode: true, + style: 'deepObject', + }, +}); + +const defaultHeaders = { + 'Content-Type': 'application/json', +}; + +export const createConfig = ( + override: Config & T> = {}, +): Config & T> => ({ + ...jsonBodySerializer, + headers: defaultHeaders, + parseAs: 'auto', + querySerializer: defaultQuerySerializer, + ...override, +}); diff --git a/packages/api-client/src/generated/core/auth.gen.ts b/packages/api-client/src/generated/core/auth.gen.ts new file mode 100644 index 0000000000..f8a73266f9 --- /dev/null +++ b/packages/api-client/src/generated/core/auth.gen.ts @@ -0,0 +1,42 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type AuthToken = string | undefined; + +export interface Auth { + /** + * Which part of the request do we use to send the auth? + * + * @default 'header' + */ + in?: 'header' | 'query' | 'cookie'; + /** + * Header or query parameter name. + * + * @default 'Authorization' + */ + name?: string; + scheme?: 'basic' | 'bearer'; + type: 'apiKey' | 'http'; +} + +export const getAuthToken = async ( + auth: Auth, + callback: ((auth: Auth) => Promise | AuthToken) | AuthToken, +): Promise => { + const token = + typeof callback === 'function' ? await callback(auth) : callback; + + if (!token) { + return; + } + + if (auth.scheme === 'bearer') { + return `Bearer ${token}`; + } + + if (auth.scheme === 'basic') { + return `Basic ${btoa(token)}`; + } + + return token; +}; diff --git a/packages/api-client/src/generated/core/bodySerializer.gen.ts b/packages/api-client/src/generated/core/bodySerializer.gen.ts new file mode 100644 index 0000000000..552b50f7c8 --- /dev/null +++ b/packages/api-client/src/generated/core/bodySerializer.gen.ts @@ -0,0 +1,100 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { + ArrayStyle, + ObjectStyle, + SerializerOptions, +} from './pathSerializer.gen'; + +export type QuerySerializer = (query: Record) => string; + +export type BodySerializer = (body: any) => any; + +type QuerySerializerOptionsObject = { + allowReserved?: boolean; + array?: Partial>; + object?: Partial>; +}; + +export type QuerySerializerOptions = QuerySerializerOptionsObject & { + /** + * Per-parameter serialization overrides. When provided, these settings + * override the global array/object settings for specific parameter names. + */ + parameters?: Record; +}; + +const serializeFormDataPair = ( + data: FormData, + key: string, + value: unknown, +): void => { + if (typeof value === 'string' || value instanceof Blob) { + data.append(key, value); + } else if (value instanceof Date) { + data.append(key, value.toISOString()); + } else { + data.append(key, JSON.stringify(value)); + } +}; + +const serializeUrlSearchParamsPair = ( + data: URLSearchParams, + key: string, + value: unknown, +): void => { + if (typeof value === 'string') { + data.append(key, value); + } else { + data.append(key, JSON.stringify(value)); + } +}; + +export const formDataBodySerializer = { + bodySerializer: | Array>>( + body: T, + ): FormData => { + const data = new FormData(); + + Object.entries(body).forEach(([key, value]) => { + if (value === undefined || value === null) { + return; + } + if (Array.isArray(value)) { + value.forEach((v) => serializeFormDataPair(data, key, v)); + } else { + serializeFormDataPair(data, key, value); + } + }); + + return data; + }, +}; + +export const jsonBodySerializer = { + bodySerializer: (body: T): string => + JSON.stringify(body, (_key, value) => + typeof value === 'bigint' ? value.toString() : value, + ), +}; + +export const urlSearchParamsBodySerializer = { + bodySerializer: | Array>>( + body: T, + ): string => { + const data = new URLSearchParams(); + + Object.entries(body).forEach(([key, value]) => { + if (value === undefined || value === null) { + return; + } + if (Array.isArray(value)) { + value.forEach((v) => serializeUrlSearchParamsPair(data, key, v)); + } else { + serializeUrlSearchParamsPair(data, key, value); + } + }); + + return data.toString(); + }, +}; diff --git a/packages/api-client/src/generated/core/params.gen.ts b/packages/api-client/src/generated/core/params.gen.ts new file mode 100644 index 0000000000..602715c46c --- /dev/null +++ b/packages/api-client/src/generated/core/params.gen.ts @@ -0,0 +1,176 @@ +// This file is auto-generated by @hey-api/openapi-ts + +type Slot = 'body' | 'headers' | 'path' | 'query'; + +export type Field = + | { + in: Exclude; + /** + * Field name. This is the name we want the user to see and use. + */ + key: string; + /** + * Field mapped name. This is the name we want to use in the request. + * If omitted, we use the same value as `key`. + */ + map?: string; + } + | { + in: Extract; + /** + * Key isn't required for bodies. + */ + key?: string; + map?: string; + } + | { + /** + * Field name. This is the name we want the user to see and use. + */ + key: string; + /** + * Field mapped name. This is the name we want to use in the request. + * If `in` is omitted, `map` aliases `key` to the transport layer. + */ + map: Slot; + }; + +export interface Fields { + allowExtra?: Partial>; + args?: ReadonlyArray; +} + +export type FieldsConfig = ReadonlyArray; + +const extraPrefixesMap: Record = { + $body_: 'body', + $headers_: 'headers', + $path_: 'path', + $query_: 'query', +}; +const extraPrefixes = Object.entries(extraPrefixesMap); + +type KeyMap = Map< + string, + | { + in: Slot; + map?: string; + } + | { + in?: never; + map: Slot; + } +>; + +const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => { + if (!map) { + map = new Map(); + } + + for (const config of fields) { + if ('in' in config) { + if (config.key) { + map.set(config.key, { + in: config.in, + map: config.map, + }); + } + } else if ('key' in config) { + map.set(config.key, { + map: config.map, + }); + } else if (config.args) { + buildKeyMap(config.args, map); + } + } + + return map; +}; + +interface Params { + body: unknown; + headers: Record; + path: Record; + query: Record; +} + +const stripEmptySlots = (params: Params) => { + for (const [slot, value] of Object.entries(params)) { + if (value && typeof value === 'object' && !Object.keys(value).length) { + delete params[slot as Slot]; + } + } +}; + +export const buildClientParams = ( + args: ReadonlyArray, + fields: FieldsConfig, +) => { + const params: Params = { + body: {}, + headers: {}, + path: {}, + query: {}, + }; + + const map = buildKeyMap(fields); + + let config: FieldsConfig[number] | undefined; + + for (const [index, arg] of args.entries()) { + if (fields[index]) { + config = fields[index]; + } + + if (!config) { + continue; + } + + if ('in' in config) { + if (config.key) { + const field = map.get(config.key)!; + const name = field.map || config.key; + if (field.in) { + (params[field.in] as Record)[name] = arg; + } + } else { + params.body = arg; + } + } else { + for (const [key, value] of Object.entries(arg ?? {})) { + const field = map.get(key); + + if (field) { + if (field.in) { + const name = field.map || key; + (params[field.in] as Record)[name] = value; + } else { + params[field.map] = value; + } + } else { + const extra = extraPrefixes.find(([prefix]) => + key.startsWith(prefix), + ); + + if (extra) { + const [prefix, slot] = extra; + (params[slot] as Record)[ + key.slice(prefix.length) + ] = value; + } else if ('allowExtra' in config && config.allowExtra) { + for (const [slot, allowed] of Object.entries(config.allowExtra)) { + if (allowed) { + (params[slot as Slot] as Record)[key] = value; + break; + } + } + } + } + } + } + } + + stripEmptySlots(params); + + return params; +}; diff --git a/packages/api-client/src/generated/core/pathSerializer.gen.ts b/packages/api-client/src/generated/core/pathSerializer.gen.ts new file mode 100644 index 0000000000..8d99931047 --- /dev/null +++ b/packages/api-client/src/generated/core/pathSerializer.gen.ts @@ -0,0 +1,181 @@ +// This file is auto-generated by @hey-api/openapi-ts + +interface SerializeOptions + extends SerializePrimitiveOptions, + SerializerOptions {} + +interface SerializePrimitiveOptions { + allowReserved?: boolean; + name: string; +} + +export interface SerializerOptions { + /** + * @default true + */ + explode: boolean; + style: T; +} + +export type ArrayStyle = 'form' | 'spaceDelimited' | 'pipeDelimited'; +export type ArraySeparatorStyle = ArrayStyle | MatrixStyle; +type MatrixStyle = 'label' | 'matrix' | 'simple'; +export type ObjectStyle = 'form' | 'deepObject'; +type ObjectSeparatorStyle = ObjectStyle | MatrixStyle; + +interface SerializePrimitiveParam extends SerializePrimitiveOptions { + value: string; +} + +export const separatorArrayExplode = (style: ArraySeparatorStyle) => { + switch (style) { + case 'label': + return '.'; + case 'matrix': + return ';'; + case 'simple': + return ','; + default: + return '&'; + } +}; + +export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => { + switch (style) { + case 'form': + return ','; + case 'pipeDelimited': + return '|'; + case 'spaceDelimited': + return '%20'; + default: + return ','; + } +}; + +export const separatorObjectExplode = (style: ObjectSeparatorStyle) => { + switch (style) { + case 'label': + return '.'; + case 'matrix': + return ';'; + case 'simple': + return ','; + default: + return '&'; + } +}; + +export const serializeArrayParam = ({ + allowReserved, + explode, + name, + style, + value, +}: SerializeOptions & { + value: unknown[]; +}) => { + if (!explode) { + const joinedValues = ( + allowReserved ? value : value.map((v) => encodeURIComponent(v as string)) + ).join(separatorArrayNoExplode(style)); + switch (style) { + case 'label': + return `.${joinedValues}`; + case 'matrix': + return `;${name}=${joinedValues}`; + case 'simple': + return joinedValues; + default: + return `${name}=${joinedValues}`; + } + } + + const separator = separatorArrayExplode(style); + const joinedValues = value + .map((v) => { + if (style === 'label' || style === 'simple') { + return allowReserved ? v : encodeURIComponent(v as string); + } + + return serializePrimitiveParam({ + allowReserved, + name, + value: v as string, + }); + }) + .join(separator); + return style === 'label' || style === 'matrix' + ? separator + joinedValues + : joinedValues; +}; + +export const serializePrimitiveParam = ({ + allowReserved, + name, + value, +}: SerializePrimitiveParam) => { + if (value === undefined || value === null) { + return ''; + } + + if (typeof value === 'object') { + throw new Error( + 'Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.', + ); + } + + return `${name}=${allowReserved ? value : encodeURIComponent(value)}`; +}; + +export const serializeObjectParam = ({ + allowReserved, + explode, + name, + style, + value, + valueOnly, +}: SerializeOptions & { + value: Record | Date; + valueOnly?: boolean; +}) => { + if (value instanceof Date) { + return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`; + } + + if (style !== 'deepObject' && !explode) { + let values: string[] = []; + Object.entries(value).forEach(([key, v]) => { + values = [ + ...values, + key, + allowReserved ? (v as string) : encodeURIComponent(v as string), + ]; + }); + const joinedValues = values.join(','); + switch (style) { + case 'form': + return `${name}=${joinedValues}`; + case 'label': + return `.${joinedValues}`; + case 'matrix': + return `;${name}=${joinedValues}`; + default: + return joinedValues; + } + } + + const separator = separatorObjectExplode(style); + const joinedValues = Object.entries(value) + .map(([key, v]) => + serializePrimitiveParam({ + allowReserved, + name: style === 'deepObject' ? `${name}[${key}]` : key, + value: v as string, + }), + ) + .join(separator); + return style === 'label' || style === 'matrix' + ? separator + joinedValues + : joinedValues; +}; diff --git a/packages/api-client/src/generated/core/queryKeySerializer.gen.ts b/packages/api-client/src/generated/core/queryKeySerializer.gen.ts new file mode 100644 index 0000000000..d3bb68396e --- /dev/null +++ b/packages/api-client/src/generated/core/queryKeySerializer.gen.ts @@ -0,0 +1,136 @@ +// This file is auto-generated by @hey-api/openapi-ts + +/** + * JSON-friendly union that mirrors what Pinia Colada can hash. + */ +export type JsonValue = + | null + | string + | number + | boolean + | JsonValue[] + | { [key: string]: JsonValue }; + +/** + * Replacer that converts non-JSON values (bigint, Date, etc.) to safe substitutes. + */ +export const queryKeyJsonReplacer = (_key: string, value: unknown) => { + if ( + value === undefined || + typeof value === 'function' || + typeof value === 'symbol' + ) { + return undefined; + } + if (typeof value === 'bigint') { + return value.toString(); + } + if (value instanceof Date) { + return value.toISOString(); + } + return value; +}; + +/** + * Safely stringifies a value and parses it back into a JsonValue. + */ +export const stringifyToJsonValue = (input: unknown): JsonValue | undefined => { + try { + const json = JSON.stringify(input, queryKeyJsonReplacer); + if (json === undefined) { + return undefined; + } + return JSON.parse(json) as JsonValue; + } catch { + return undefined; + } +}; + +/** + * Detects plain objects (including objects with a null prototype). + */ +const isPlainObject = (value: unknown): value is Record => { + if (value === null || typeof value !== 'object') { + return false; + } + const prototype = Object.getPrototypeOf(value as object); + return prototype === Object.prototype || prototype === null; +}; + +/** + * Turns URLSearchParams into a sorted JSON object for deterministic keys. + */ +const serializeSearchParams = (params: URLSearchParams): JsonValue => { + const entries = Array.from(params.entries()).sort(([a], [b]) => + a.localeCompare(b), + ); + const result: Record = {}; + + for (const [key, value] of entries) { + const existing = result[key]; + if (existing === undefined) { + result[key] = value; + continue; + } + + if (Array.isArray(existing)) { + (existing as string[]).push(value); + } else { + result[key] = [existing, value]; + } + } + + return result; +}; + +/** + * Normalizes any accepted value into a JSON-friendly shape for query keys. + */ +export const serializeQueryKeyValue = ( + value: unknown, +): JsonValue | undefined => { + if (value === null) { + return null; + } + + if ( + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ) { + return value; + } + + if ( + value === undefined || + typeof value === 'function' || + typeof value === 'symbol' + ) { + return undefined; + } + + if (typeof value === 'bigint') { + return value.toString(); + } + + if (value instanceof Date) { + return value.toISOString(); + } + + if (Array.isArray(value)) { + return stringifyToJsonValue(value); + } + + if ( + typeof URLSearchParams !== 'undefined' && + value instanceof URLSearchParams + ) { + return serializeSearchParams(value); + } + + if (isPlainObject(value)) { + return stringifyToJsonValue(value); + } + + return undefined; +}; diff --git a/packages/api-client/src/generated/core/serverSentEvents.gen.ts b/packages/api-client/src/generated/core/serverSentEvents.gen.ts new file mode 100644 index 0000000000..f8fd78e284 --- /dev/null +++ b/packages/api-client/src/generated/core/serverSentEvents.gen.ts @@ -0,0 +1,264 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Config } from './types.gen'; + +export type ServerSentEventsOptions = Omit< + RequestInit, + 'method' +> & + Pick & { + /** + * Fetch API implementation. You can use this option to provide a custom + * fetch instance. + * + * @default globalThis.fetch + */ + fetch?: typeof fetch; + /** + * Implementing clients can call request interceptors inside this hook. + */ + onRequest?: (url: string, init: RequestInit) => Promise; + /** + * Callback invoked when a network or parsing error occurs during streaming. + * + * This option applies only if the endpoint returns a stream of events. + * + * @param error The error that occurred. + */ + onSseError?: (error: unknown) => void; + /** + * Callback invoked when an event is streamed from the server. + * + * This option applies only if the endpoint returns a stream of events. + * + * @param event Event streamed from the server. + * @returns Nothing (void). + */ + onSseEvent?: (event: StreamEvent) => void; + serializedBody?: RequestInit['body']; + /** + * Default retry delay in milliseconds. + * + * This option applies only if the endpoint returns a stream of events. + * + * @default 3000 + */ + sseDefaultRetryDelay?: number; + /** + * Maximum number of retry attempts before giving up. + */ + sseMaxRetryAttempts?: number; + /** + * Maximum retry delay in milliseconds. + * + * Applies only when exponential backoff is used. + * + * This option applies only if the endpoint returns a stream of events. + * + * @default 30000 + */ + sseMaxRetryDelay?: number; + /** + * Optional sleep function for retry backoff. + * + * Defaults to using `setTimeout`. + */ + sseSleepFn?: (ms: number) => Promise; + url: string; + }; + +export interface StreamEvent { + data: TData; + event?: string; + id?: string; + retry?: number; +} + +export type ServerSentEventsResult< + TData = unknown, + TReturn = void, + TNext = unknown, +> = { + stream: AsyncGenerator< + TData extends Record ? TData[keyof TData] : TData, + TReturn, + TNext + >; +}; + +export const createSseClient = ({ + onRequest, + onSseError, + onSseEvent, + responseTransformer, + responseValidator, + sseDefaultRetryDelay, + sseMaxRetryAttempts, + sseMaxRetryDelay, + sseSleepFn, + url, + ...options +}: ServerSentEventsOptions): ServerSentEventsResult => { + let lastEventId: string | undefined; + + const sleep = + sseSleepFn ?? + ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); + + const createStream = async function* () { + let retryDelay: number = sseDefaultRetryDelay ?? 3000; + let attempt = 0; + const signal = options.signal ?? new AbortController().signal; + + while (true) { + if (signal.aborted) break; + + attempt++; + + const headers = + options.headers instanceof Headers + ? options.headers + : new Headers(options.headers as Record | undefined); + + if (lastEventId !== undefined) { + headers.set('Last-Event-ID', lastEventId); + } + + try { + const requestInit: RequestInit = { + redirect: 'follow', + ...options, + body: options.serializedBody, + headers, + signal, + }; + let request = new Request(url, requestInit); + if (onRequest) { + request = await onRequest(url, requestInit); + } + // fetch must be assigned here, otherwise it would throw the error: + // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation + const _fetch = options.fetch ?? globalThis.fetch; + const response = await _fetch(request); + + if (!response.ok) + throw new Error( + `SSE failed: ${response.status} ${response.statusText}`, + ); + + if (!response.body) throw new Error('No body in SSE response'); + + const reader = response.body + .pipeThrough(new TextDecoderStream()) + .getReader(); + + let buffer = ''; + + const abortHandler = () => { + try { + reader.cancel(); + } catch { + // noop + } + }; + + signal.addEventListener('abort', abortHandler); + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += value; + + const chunks = buffer.split('\n\n'); + buffer = chunks.pop() ?? ''; + + for (const chunk of chunks) { + const lines = chunk.split('\n'); + const dataLines: Array = []; + let eventName: string | undefined; + + for (const line of lines) { + if (line.startsWith('data:')) { + dataLines.push(line.replace(/^data:\s*/, '')); + } else if (line.startsWith('event:')) { + eventName = line.replace(/^event:\s*/, ''); + } else if (line.startsWith('id:')) { + lastEventId = line.replace(/^id:\s*/, ''); + } else if (line.startsWith('retry:')) { + const parsed = Number.parseInt( + line.replace(/^retry:\s*/, ''), + 10, + ); + if (!Number.isNaN(parsed)) { + retryDelay = parsed; + } + } + } + + let data: unknown; + let parsedJson = false; + + if (dataLines.length) { + const rawData = dataLines.join('\n'); + try { + data = JSON.parse(rawData); + parsedJson = true; + } catch { + data = rawData; + } + } + + if (parsedJson) { + if (responseValidator) { + await responseValidator(data); + } + + if (responseTransformer) { + data = await responseTransformer(data); + } + } + + onSseEvent?.({ + data, + event: eventName, + id: lastEventId, + retry: retryDelay, + }); + + if (dataLines.length) { + yield data as any; + } + } + } + } finally { + signal.removeEventListener('abort', abortHandler); + reader.releaseLock(); + } + + break; // exit loop on normal completion + } catch (error) { + // connection failed or aborted; retry after delay + onSseError?.(error); + + if ( + sseMaxRetryAttempts !== undefined && + attempt >= sseMaxRetryAttempts + ) { + break; // stop after firing error + } + + // exponential backoff: double retry each attempt, cap at 30s + const backoff = Math.min( + retryDelay * 2 ** (attempt - 1), + sseMaxRetryDelay ?? 30000, + ); + await sleep(backoff); + } + } + }; + + const stream = createStream(); + + return { stream }; +}; diff --git a/packages/api-client/src/generated/core/types.gen.ts b/packages/api-client/src/generated/core/types.gen.ts new file mode 100644 index 0000000000..643c070c9d --- /dev/null +++ b/packages/api-client/src/generated/core/types.gen.ts @@ -0,0 +1,118 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Auth, AuthToken } from './auth.gen'; +import type { + BodySerializer, + QuerySerializer, + QuerySerializerOptions, +} from './bodySerializer.gen'; + +export type HttpMethod = + | 'connect' + | 'delete' + | 'get' + | 'head' + | 'options' + | 'patch' + | 'post' + | 'put' + | 'trace'; + +export type Client< + RequestFn = never, + Config = unknown, + MethodFn = never, + BuildUrlFn = never, + SseFn = never, +> = { + /** + * Returns the final request URL. + */ + buildUrl: BuildUrlFn; + getConfig: () => Config; + request: RequestFn; + setConfig: (config: Config) => Config; +} & { + [K in HttpMethod]: MethodFn; +} & ([SseFn] extends [never] + ? { sse?: never } + : { sse: { [K in HttpMethod]: SseFn } }); + +export interface Config { + /** + * Auth token or a function returning auth token. The resolved value will be + * added to the request payload as defined by its `security` array. + */ + auth?: ((auth: Auth) => Promise | AuthToken) | AuthToken; + /** + * A function for serializing request body parameter. By default, + * {@link JSON.stringify()} will be used. + */ + bodySerializer?: BodySerializer | null; + /** + * An object containing any HTTP headers that you want to pre-populate your + * `Headers` object with. + * + * {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more} + */ + headers?: + | RequestInit['headers'] + | Record< + string, + | string + | number + | boolean + | (string | number | boolean)[] + | null + | undefined + | unknown + >; + /** + * The request method. + * + * {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more} + */ + method?: Uppercase; + /** + * A function for serializing request query parameters. By default, arrays + * will be exploded in form style, objects will be exploded in deepObject + * style, and reserved characters are percent-encoded. + * + * This method will have no effect if the native `paramsSerializer()` Axios + * API function is used. + * + * {@link https://swagger.io/docs/specification/serialization/#query View examples} + */ + querySerializer?: QuerySerializer | QuerySerializerOptions; + /** + * A function validating request data. This is useful if you want to ensure + * the request conforms to the desired shape, so it can be safely sent to + * the server. + */ + requestValidator?: (data: unknown) => Promise; + /** + * A function transforming response data before it's returned. This is useful + * for post-processing data, e.g. converting ISO strings into Date objects. + */ + responseTransformer?: (data: unknown) => Promise; + /** + * A function validating response data. This is useful if you want to ensure + * the response conforms to the desired shape, so it can be safely passed to + * the transformers and returned to the user. + */ + responseValidator?: (data: unknown) => Promise; +} + +type IsExactlyNeverOrNeverUndefined = [T] extends [never] + ? true + : [T] extends [never | undefined] + ? [undefined] extends [T] + ? false + : true + : false; + +export type OmitNever> = { + [K in keyof T as IsExactlyNeverOrNeverUndefined extends true + ? never + : K]: T[K]; +}; diff --git a/packages/api-client/src/generated/core/utils.gen.ts b/packages/api-client/src/generated/core/utils.gen.ts new file mode 100644 index 0000000000..0b5389d089 --- /dev/null +++ b/packages/api-client/src/generated/core/utils.gen.ts @@ -0,0 +1,143 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { BodySerializer, QuerySerializer } from './bodySerializer.gen'; +import { + type ArraySeparatorStyle, + serializeArrayParam, + serializeObjectParam, + serializePrimitiveParam, +} from './pathSerializer.gen'; + +export interface PathSerializer { + path: Record; + url: string; +} + +export const PATH_PARAM_RE = /\{[^{}]+\}/g; + +export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => { + let url = _url; + const matches = _url.match(PATH_PARAM_RE); + if (matches) { + for (const match of matches) { + let explode = false; + let name = match.substring(1, match.length - 1); + let style: ArraySeparatorStyle = 'simple'; + + if (name.endsWith('*')) { + explode = true; + name = name.substring(0, name.length - 1); + } + + if (name.startsWith('.')) { + name = name.substring(1); + style = 'label'; + } else if (name.startsWith(';')) { + name = name.substring(1); + style = 'matrix'; + } + + const value = path[name]; + + if (value === undefined || value === null) { + continue; + } + + if (Array.isArray(value)) { + url = url.replace( + match, + serializeArrayParam({ explode, name, style, value }), + ); + continue; + } + + if (typeof value === 'object') { + url = url.replace( + match, + serializeObjectParam({ + explode, + name, + style, + value: value as Record, + valueOnly: true, + }), + ); + continue; + } + + if (style === 'matrix') { + url = url.replace( + match, + `;${serializePrimitiveParam({ + name, + value: value as string, + })}`, + ); + continue; + } + + const replaceValue = encodeURIComponent( + style === 'label' ? `.${value as string}` : (value as string), + ); + url = url.replace(match, replaceValue); + } + } + return url; +}; + +export const getUrl = ({ + baseUrl, + path, + query, + querySerializer, + url: _url, +}: { + baseUrl?: string; + path?: Record; + query?: Record; + querySerializer: QuerySerializer; + url: string; +}) => { + const pathUrl = _url.startsWith('/') ? _url : `/${_url}`; + let url = (baseUrl ?? '') + pathUrl; + if (path) { + url = defaultPathSerializer({ path, url }); + } + let search = query ? querySerializer(query) : ''; + if (search.startsWith('?')) { + search = search.substring(1); + } + if (search) { + url += `?${search}`; + } + return url; +}; + +export function getValidRequestBody(options: { + body?: unknown; + bodySerializer?: BodySerializer | null; + serializedBody?: unknown; +}) { + const hasBody = options.body !== undefined; + const isSerializedBody = hasBody && options.bodySerializer; + + if (isSerializedBody) { + if ('serializedBody' in options) { + const hasSerializedBody = + options.serializedBody !== undefined && options.serializedBody !== ''; + + return hasSerializedBody ? options.serializedBody : null; + } + + // not all clients implement a serializedBody property (i.e. client-axios) + return options.body !== '' ? options.body : null; + } + + // plain/text body + if (hasBody) { + return options.body; + } + + // no body was provided + return undefined; +} diff --git a/packages/api-client/src/generated/index.ts b/packages/api-client/src/generated/index.ts new file mode 100644 index 0000000000..c352c1047a --- /dev/null +++ b/packages/api-client/src/generated/index.ts @@ -0,0 +1,4 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type * from './types.gen'; +export * from './sdk.gen'; diff --git a/packages/api-client/src/generated/sdk.gen.ts b/packages/api-client/src/generated/sdk.gen.ts new file mode 100644 index 0000000000..a4603d0408 --- /dev/null +++ b/packages/api-client/src/generated/sdk.gen.ts @@ -0,0 +1,28 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Client, Options as Options2, TDataShape } from './client'; +import { client } from './client.gen'; +import type { GetHealthData, GetHealthResponses, PostBillingStartTrialData, PostBillingStartTrialResponses } from './types.gen'; + +export type Options = Options2 & { + /** + * You can provide a client instance returned by `createClient()` instead of + * individual options. This might be also useful if you want to implement a + * custom client. + */ + client?: Client; + /** + * You can pass arbitrary values through the `meta` object. This can be + * used to access values that aren't defined as part of the SDK function. + */ + meta?: Record; +}; + +/** + * Health check + * + * Returns the health status of the API server. + */ +export const getHealth = (options?: Options) => (options?.client ?? client).get({ url: '/health', ...options }); + +export const postBillingStartTrial = (options: Options) => (options.client ?? client).post({ url: '/billing/start-trial', ...options }); diff --git a/packages/api-client/src/generated/types.gen.ts b/packages/api-client/src/generated/types.gen.ts new file mode 100644 index 0000000000..62923c103e --- /dev/null +++ b/packages/api-client/src/generated/types.gen.ts @@ -0,0 +1,43 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type ClientOptions = { + baseUrl: 'https://api.hyprnote.com' | 'http://localhost:4000' | (string & {}); +}; + +export type GetHealthData = { + body?: never; + path?: never; + query?: never; + url: '/health'; +}; + +export type GetHealthResponses = { + /** + * API is healthy + */ + 200: { + status: string; + }; +}; + +export type GetHealthResponse = GetHealthResponses[keyof GetHealthResponses]; + +export type PostBillingStartTrialData = { + body?: never; + path?: never; + query: { + interval: 'monthly' | 'yearly'; + }; + url: '/billing/start-trial'; +}; + +export type PostBillingStartTrialResponses = { + /** + * result + */ + 200: { + started: boolean; + }; +}; + +export type PostBillingStartTrialResponse = PostBillingStartTrialResponses[keyof PostBillingStartTrialResponses]; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f2289dd3a..82bffddc2b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -72,6 +72,9 @@ importers: '@dotenvx/dotenvx': specifier: ^1.51.1 version: 1.51.1 + '@hey-api/openapi-ts': + specifier: ^0.78.3 + version: 0.78.3(typescript@5.9.3) '@types/bun': specifier: ^1.3.4 version: 1.3.4 @@ -415,7 +418,7 @@ importers: version: 1.140.0(@tanstack/react-router@1.140.0(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(@tanstack/router-core@1.140.0)(csstype@3.2.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(solid-js@1.9.10) '@tanstack/router-plugin': specifier: ^1.140.0 - version: 1.140.0(@tanstack/react-router@1.140.0(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(vite@7.2.7(@types/node@24.10.2)(jiti@1.21.7)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)) + version: 1.140.0(@tanstack/react-router@1.140.0(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)) '@tauri-apps/cli': specifier: ^2.9.5 version: 2.9.5 @@ -442,7 +445,7 @@ importers: version: 2.0.3 '@vitejs/plugin-react': specifier: ^4.7.0 - version: 4.7.0(vite@7.2.7(@types/node@24.10.2)(jiti@1.21.7)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)) + version: 4.7.0(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)) autoprefixer: specifier: ^10.4.22 version: 10.4.22(postcss@8.5.6) @@ -463,10 +466,10 @@ importers: version: 5.8.3 vite: specifier: ^7.2.7 - version: 7.2.7(@types/node@24.10.2)(jiti@1.21.7)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + version: 7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) vitest: specifier: ^3.2.4 - version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.2)(jiti@1.21.7)(jsdom@27.3.0(postcss@8.5.6))(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.2)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.6))(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) apps/desktop-e2e: devDependencies: @@ -22200,7 +22203,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.140.0(@tanstack/react-router@1.140.0(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(vite@7.2.7(@types/node@24.10.2)(jiti@1.21.7)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))': + '@tanstack/router-plugin@1.140.0(@tanstack/react-router@1.140.0(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@babel/core': 7.28.5 '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.5) @@ -22218,7 +22221,7 @@ snapshots: zod: 3.25.76 optionalDependencies: '@tanstack/react-router': 1.140.0(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - vite: 7.2.7(@types/node@24.10.2)(jiti@1.21.7)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color @@ -23215,7 +23218,7 @@ snapshots: '@vercel/oidc@3.0.5': {} - '@vitejs/plugin-react@4.7.0(vite@7.2.7(@types/node@24.10.2)(jiti@1.21.7)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))': + '@vitejs/plugin-react@4.7.0(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@babel/core': 7.28.5 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5) @@ -23223,7 +23226,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 7.2.7(@types/node@24.10.2)(jiti@1.21.7)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color @@ -23270,14 +23273,6 @@ snapshots: optionalDependencies: vite: 7.2.7(@types/node@22.19.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) - '@vitest/mocker@3.2.4(vite@7.2.7(@types/node@24.10.2)(jiti@1.21.7)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))': - dependencies: - '@vitest/spy': 3.2.4 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - vite: 7.2.7(@types/node@24.10.2)(jiti@1.21.7)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) - '@vitest/mocker@3.2.4(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 3.2.4 @@ -33082,27 +33077,6 @@ snapshots: - tsx - yaml - vite-node@3.2.4(@types/node@24.10.2)(jiti@1.21.7)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2): - dependencies: - cac: 6.7.14 - debug: 4.4.3(supports-color@10.2.2) - es-module-lexer: 1.7.0 - pathe: 2.0.3 - vite: 7.2.7(@types/node@24.10.2)(jiti@1.21.7)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) - transitivePeerDependencies: - - '@types/node' - - jiti - - less - - lightningcss - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - tsx - - yaml - vite-node@3.2.4(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2): dependencies: cac: 6.7.14 @@ -33177,22 +33151,6 @@ snapshots: tsx: 4.21.0 yaml: 2.8.2 - vite@7.2.7(@types/node@24.10.2)(jiti@1.21.7)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2): - dependencies: - esbuild: 0.25.12 - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - postcss: 8.5.6 - rollup: 4.53.3 - tinyglobby: 0.2.15 - optionalDependencies: - '@types/node': 24.10.2 - fsevents: 2.3.3 - jiti: 1.21.7 - lightningcss: 1.30.2 - tsx: 4.21.0 - yaml: 2.8.2 - vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2): dependencies: esbuild: 0.25.12 @@ -33292,49 +33250,6 @@ snapshots: - tsx - yaml - vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.2)(jiti@1.21.7)(jsdom@27.3.0(postcss@8.5.6))(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2): - dependencies: - '@types/chai': 5.2.3 - '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.2.7(@types/node@24.10.2)(jiti@1.21.7)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)) - '@vitest/pretty-format': 3.2.4 - '@vitest/runner': 3.2.4 - '@vitest/snapshot': 3.2.4 - '@vitest/spy': 3.2.4 - '@vitest/utils': 3.2.4 - chai: 5.3.3 - debug: 4.4.3(supports-color@10.2.2) - expect-type: 1.3.0 - magic-string: 0.30.21 - pathe: 2.0.3 - picomatch: 4.0.3 - std-env: 3.10.0 - tinybench: 2.9.0 - tinyexec: 0.3.2 - tinyglobby: 0.2.15 - tinypool: 1.1.1 - tinyrainbow: 2.0.0 - vite: 7.2.7(@types/node@24.10.2)(jiti@1.21.7)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) - vite-node: 3.2.4(@types/node@24.10.2)(jiti@1.21.7)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) - why-is-node-running: 2.3.0 - optionalDependencies: - '@types/debug': 4.1.12 - '@types/node': 24.10.2 - jsdom: 27.3.0(postcss@8.5.6) - transitivePeerDependencies: - - jiti - - less - - lightningcss - - msw - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - tsx - - yaml - vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.2)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.6))(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@types/chai': 5.2.3 From 49e8aa54a3ea37bfaa3e5c0bd3cb16edbc0d18e2 Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Wed, 10 Dec 2025 10:48:13 +0900 Subject: [PATCH 07/13] use rpc in apps/api --- .vscode/settings.json | 3 +- apps/api/openapi.config.ts | 13 - apps/api/openapi.json | 32 ++- apps/api/package.json | 1 + apps/api/src/routes/index.ts | 2 + apps/api/src/routes/rpc.ts | 41 +++ apps/api/src/scripts/generate-openapi.ts | 12 + apps/desktop/package.json | 1 + .../src/components/onboarding/login.tsx | 15 +- .../src/components/settings/account.tsx | 14 +- apps/web/package.json | 1 + apps/web/src/env.ts | 1 + apps/web/src/functions/billing.ts | 97 +++++++ apps/web/src/middleware/supabase.ts | 26 +- apps/web/src/routes/_view/app/account.tsx | 35 ++- packages/api-client/package.json | 7 + .../api-client/src/generated/client.gen.ts | 10 +- .../client/{client.gen.ts => client.ts} | 162 ++--------- .../api-client/src/generated/client/index.ts | 19 +- .../client/{types.gen.ts => types.ts} | 95 +++---- .../client/{utils.gen.ts => utils.ts} | 259 +++++++++++------ .../generated/core/{auth.gen.ts => auth.ts} | 2 - ...odySerializer.gen.ts => bodySerializer.ts} | 22 +- .../core/{params.gen.ts => params.ts} | 57 +--- ...athSerializer.gen.ts => pathSerializer.ts} | 2 - .../generated/core/queryKeySerializer.gen.ts | 136 --------- .../generated/core/serverSentEvents.gen.ts | 264 ------------------ .../generated/core/{types.gen.ts => types.ts} | 62 ++-- .../src/generated/core/utils.gen.ts | 143 ---------- packages/api-client/src/generated/index.ts | 5 +- packages/api-client/src/generated/sdk.gen.ts | 96 ++++++- .../api-client/src/generated/types.gen.ts | 211 +++++++++++++- packages/api-client/tsconfig.json | 13 + pnpm-lock.yaml | 126 +++++++-- 34 files changed, 973 insertions(+), 1012 deletions(-) delete mode 100644 apps/api/openapi.config.ts create mode 100644 apps/api/src/routes/rpc.ts create mode 100644 packages/api-client/package.json rename packages/api-client/src/generated/client/{client.gen.ts => client.ts} (50%) rename packages/api-client/src/generated/client/{types.gen.ts => types.ts} (75%) rename packages/api-client/src/generated/client/{utils.gen.ts => utils.ts} (58%) rename packages/api-client/src/generated/core/{auth.gen.ts => auth.ts} (93%) rename packages/api-client/src/generated/core/{bodySerializer.gen.ts => bodySerializer.ts} (76%) rename packages/api-client/src/generated/core/{params.gen.ts => params.ts} (69%) rename packages/api-client/src/generated/core/{pathSerializer.gen.ts => pathSerializer.ts} (98%) delete mode 100644 packages/api-client/src/generated/core/queryKeySerializer.gen.ts delete mode 100644 packages/api-client/src/generated/core/serverSentEvents.gen.ts rename packages/api-client/src/generated/core/{types.gen.ts => types.ts} (75%) delete mode 100644 packages/api-client/src/generated/core/utils.gen.ts create mode 100644 packages/api-client/tsconfig.json diff --git a/.vscode/settings.json b/.vscode/settings.json index b74cb8edf1..24397cb05e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,7 +5,8 @@ "**/routeTree.gen.ts": true }, "search.exclude": { - "**/routeTree.gen.ts": true + "**/routeTree.gen.ts": true, + "**/generated/**": true }, "files.readonlyInclude": { "**/routeTree.gen.ts": true, diff --git a/apps/api/openapi.config.ts b/apps/api/openapi.config.ts deleted file mode 100644 index 234939ff21..0000000000 --- a/apps/api/openapi.config.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { defineConfig } from "@hey-api/openapi-ts"; - -export default defineConfig({ - input: "apps/api/openapi.json", - output: "packages/api-client/src/generated", - parser: { - filters: { - tags: { - include: ["internal"], - }, - }, - }, -}); diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 56d9dfd772..78c44fff68 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -241,6 +241,36 @@ } } }, + "/rpc/can-start-trial": { + "get": { + "responses": { + "200": { + "description": "result", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "canStartTrial": { + "type": "boolean" + } + }, + "required": [ + "canStartTrial" + ], + "additionalProperties": false + } + } + } + } + }, + "operationId": "getRpcCan-start-trial", + "tags": [ + "internal" + ], + "parameters": [] + } + }, "/listen": { "get": { "responses": { @@ -612,4 +642,4 @@ } } } -} +} \ No newline at end of file diff --git a/apps/api/package.json b/apps/api/package.json index 9b503c5bc5..a019a7306d 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -10,6 +10,7 @@ "test": "bun test" }, "dependencies": { + "@hypr/api-client": "workspace:*", "@hono/zod-validator": "^0.7.5", "@posthog/ai": "^7.2.1", "@scalar/hono-api-reference": "^0.5.184", diff --git a/apps/api/src/routes/index.ts b/apps/api/src/routes/index.ts index 4f5823c452..56ed8c88e6 100644 --- a/apps/api/src/routes/index.ts +++ b/apps/api/src/routes/index.ts @@ -4,6 +4,7 @@ import type { AppBindings } from "../hono-bindings"; import { billing } from "./billing"; import { health } from "./health"; import { llm } from "./llm"; +import { rpc } from "./rpc"; import { stt } from "./stt"; import { webhook } from "./webhook"; @@ -14,5 +15,6 @@ export const routes = new Hono(); routes.route("/health", health); routes.route("/billing", billing); routes.route("/chat", llm); +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 new file mode 100644 index 0000000000..d4cddf6dc7 --- /dev/null +++ b/apps/api/src/routes/rpc.ts @@ -0,0 +1,41 @@ +import { Hono } from "hono"; +import { describeRoute } from "hono-openapi"; +import { resolver } from "hono-openapi/zod"; +import { z } from "zod"; + +import type { AppBindings } from "../hono-bindings"; +import { supabaseAuthMiddleware } from "../middleware/supabase"; +import { API_TAGS } from "./constants"; + +const CanStartTrialResponseSchema = z.object({ + canStartTrial: z.boolean(), +}); + +export const rpc = new Hono(); + +rpc.get( + "/can-start-trial", + describeRoute({ + tags: [API_TAGS.INTERNAL], + responses: { + 200: { + description: "result", + content: { + "application/json": { schema: resolver(CanStartTrialResponseSchema) }, + }, + }, + }, + }), + supabaseAuthMiddleware, + async (c) => { + const supabase = c.get("supabaseClient")!; + + const { data, error } = await supabase.rpc("can_start_trial"); + + if (error) { + return c.json({ canStartTrial: false }); + } + + return c.json({ canStartTrial: data as boolean }); + }, +); diff --git a/apps/api/src/scripts/generate-openapi.ts b/apps/api/src/scripts/generate-openapi.ts index c1715e842d..9647f25a47 100644 --- a/apps/api/src/scripts/generate-openapi.ts +++ b/apps/api/src/scripts/generate-openapi.ts @@ -1,3 +1,4 @@ +import { createClient } from "@hey-api/openapi-ts"; import { generateSpecs } from "hono-openapi"; import { openAPIDocumentation } from "../openapi"; @@ -11,6 +12,17 @@ async function main() { const outputPath = new URL("../../openapi.json", import.meta.url); await Bun.write(outputPath, JSON.stringify(specs, null, 2)); console.log(`OpenAPI spec written to ${outputPath.pathname}`); + + try { + await createClient({ + input: "./openapi.json", + output: "../../packages/api-client/src/generated", + }); + console.log("OpenAPI client generated successfully"); + } catch (error) { + console.error(error); + process.exit(1); + } } main(); diff --git a/apps/desktop/package.json b/apps/desktop/package.json index a40df7eeb2..33337ee219 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -15,6 +15,7 @@ "gen:schema": "tsx src/components/devtool/seed/script.ts" }, "dependencies": { + "@hypr/api-client": "workspace:*", "@ai-sdk/amazon-bedrock": "^3.0.67", "@ai-sdk/anthropic": "^2.0.53", "@ai-sdk/azure": "^2.0.82", diff --git a/apps/desktop/src/components/onboarding/login.tsx b/apps/desktop/src/components/onboarding/login.tsx index 5f85af370e..d726d87497 100644 --- a/apps/desktop/src/components/onboarding/login.tsx +++ b/apps/desktop/src/components/onboarding/login.tsx @@ -1,5 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; +import { postBillingStartTrial } from "@hypr/api-client"; +import { createClient, createConfig } from "@hypr/api-client/client"; import { Button } from "@hypr/ui/components/ui/button"; import { Input } from "@hypr/ui/components/ui/input"; @@ -26,9 +28,16 @@ export function Login({ onNext }: LoginProps) { if (auth?.session && !trialStarted.current) { trialStarted.current = true; - fetch(`${env.VITE_API_URL}/billing/start-trial`, { - method: "POST", - headers: { Authorization: `Bearer ${auth.session.access_token}` }, + const client = createClient( + createConfig({ + baseUrl: env.VITE_API_URL, + headers: { Authorization: `Bearer ${auth.session.access_token}` }, + }), + ); + + postBillingStartTrial({ + client, + query: { interval: "yearly" }, }).finally(() => { onNext({ local: false }); }); diff --git a/apps/desktop/src/components/settings/account.tsx b/apps/desktop/src/components/settings/account.tsx index d3fd2b82e7..30b9ee5202 100644 --- a/apps/desktop/src/components/settings/account.tsx +++ b/apps/desktop/src/components/settings/account.tsx @@ -3,6 +3,8 @@ import { openUrl } from "@tauri-apps/plugin-opener"; import { ExternalLinkIcon } from "lucide-react"; import { type ReactNode, useCallback, useEffect, useState } from "react"; +import { getRpcCanStartTrial } from "@hypr/api-client"; +import { createClient } from "@hypr/api-client/client"; import { Button } from "@hypr/ui/components/ui/button"; import { Input } from "@hypr/ui/components/ui/input"; @@ -156,17 +158,19 @@ function BillingButton() { const { isPro } = useBillingAccess(); const canTrialQuery = useQuery({ - enabled: !!auth?.supabase && !isPro, + enabled: !!auth?.session && !isPro, queryKey: [auth?.session?.user.id ?? "", "canStartTrial"], queryFn: async () => { - if (!auth?.supabase) { + const headers = auth?.getHeaders(); + if (!headers) { return false; } - const { data, error } = await auth.supabase.rpc("can_start_trial"); + const client = createClient({ baseUrl: env.VITE_API_URL, headers }); + const { data, error } = await getRpcCanStartTrial({ client }); if (error) { - throw error; + return false; } - return data as boolean; + return data?.canStartTrial ?? false; }, }); diff --git a/apps/web/package.json b/apps/web/package.json index ccd49f6429..b9911370eb 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@deepgram/sdk": "^4.11.2", + "@hypr/api-client": "workspace:*", "@hypr/tiptap": "workspace:*", "@hypr/ui": "workspace:*", "@hypr/utils": "workspace:*", diff --git a/apps/web/src/env.ts b/apps/web/src/env.ts index 653e1f7973..11e4e2f12c 100644 --- a/apps/web/src/env.ts +++ b/apps/web/src/env.ts @@ -28,6 +28,7 @@ export const env = createEnv({ clientPrefix: "VITE_", client: { VITE_APP_URL: z.string().min(1), + VITE_API_URL: z.string().default("https://api.hyprnote.com"), VITE_SUPABASE_URL: z.string().min(1), VITE_SUPABASE_ANON_KEY: z.string().min(1), VITE_POSTHOG_API_KEY: isDev ? z.string().optional() : z.string().min(1), diff --git a/apps/web/src/functions/billing.ts b/apps/web/src/functions/billing.ts index 31b0657ca4..68c5806a5d 100644 --- a/apps/web/src/functions/billing.ts +++ b/apps/web/src/functions/billing.ts @@ -1,6 +1,9 @@ import { createServerFn } from "@tanstack/react-start"; import { z } from "zod"; +import { getRpcCanStartTrial } from "@hypr/api-client"; +import { createClient } from "@hypr/api-client/client"; + import { env } from "@/env"; import { getStripeClient } from "@/functions/stripe"; import { getSupabaseServerClient } from "@/functions/supabase"; @@ -189,3 +192,97 @@ export const syncAfterSuccess = createServerFn({ method: "POST" }).handler( }; }, ); + +export const canStartTrial = createServerFn({ method: "POST" }).handler( + async () => { + 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/middleware/supabase.ts b/apps/web/src/middleware/supabase.ts index d1b7fd02ce..80f4b448fa 100644 --- a/apps/web/src/middleware/supabase.ts +++ b/apps/web/src/middleware/supabase.ts @@ -1,17 +1,10 @@ +import { createClient } from "@supabase/supabase-js"; import { createMiddleware } from "@tanstack/react-start"; -import { getSupabaseServerClient } from "@/functions/supabase"; +import { env } from "@/env"; -export const supabaseClientMiddleware = createMiddleware().server( - async ({ next }) => { - const supabase = getSupabaseServerClient(); - return next({ context: { supabase } }); - }, -); - -export const supabaseAuthMiddleware = createMiddleware() - .middleware([supabaseClientMiddleware]) - .server(async ({ next, request, context }) => { +export const supabaseAuthMiddleware = createMiddleware().server( + async ({ next, request }) => { const authHeader = request.headers.get("Authorization"); const token = authHeader?.replace("Bearer ", ""); @@ -25,7 +18,11 @@ export const supabaseAuthMiddleware = createMiddleware() ); } - const { data, error } = await context.supabase.auth.getUser(token); + const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_ANON_KEY, { + global: { headers: { Authorization: `Bearer ${token}` } }, + }); + + const { data, error } = await supabase.auth.getUser(); if (error || !data?.user) { throw new Response( @@ -37,5 +34,6 @@ export const supabaseAuthMiddleware = createMiddleware() ); } - return next({ context: { user: data.user } }); - }); + return next({ context: { supabase, user: data.user } }); + }, +); 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 ( = (override?: Config) => Config & T>; +export type CreateClientConfig = (override?: Config) => Config & T>; -export const client = createClient(createConfig({ baseUrl: 'https://api.hyprnote.com' })); +export const client = createClient(createConfig({ + baseUrl: 'https://api.hyprnote.com' +})); \ No newline at end of file diff --git a/packages/api-client/src/generated/client/client.gen.ts b/packages/api-client/src/generated/client/client.ts similarity index 50% rename from packages/api-client/src/generated/client/client.gen.ts rename to packages/api-client/src/generated/client/client.ts index c2a5190c22..89d1e31582 100644 --- a/packages/api-client/src/generated/client/client.gen.ts +++ b/packages/api-client/src/generated/client/client.ts @@ -1,14 +1,4 @@ -// This file is auto-generated by @hey-api/openapi-ts - -import { createSseClient } from '../core/serverSentEvents.gen'; -import type { HttpMethod } from '../core/types.gen'; -import { getValidRequestBody } from '../core/utils.gen'; -import type { - Client, - Config, - RequestOptions, - ResolvedRequestOptions, -} from './types.gen'; +import type { Client, Config, RequestOptions } from './types'; import { buildUrl, createConfig, @@ -17,7 +7,7 @@ import { mergeConfigs, mergeHeaders, setAuthParams, -} from './utils.gen'; +} from './utils'; type ReqInit = Omit & { body?: any; @@ -38,16 +28,15 @@ export const createClient = (config: Config = {}): Client => { Request, Response, unknown, - ResolvedRequestOptions + RequestOptions >(); - const beforeRequest = async (options: RequestOptions) => { + const request: Client['request'] = async (options) => { const opts = { ..._config, ...options, fetch: options.fetch ?? _config.fetch ?? globalThis.fetch, headers: mergeHeaders(_config.headers, options.headers), - serializedBody: undefined, }; if (opts.security) { @@ -61,32 +50,24 @@ export const createClient = (config: Config = {}): Client => { await opts.requestValidator(opts); } - if (opts.body !== undefined && opts.bodySerializer) { - opts.serializedBody = opts.bodySerializer(opts.body); + if (opts.body && opts.bodySerializer) { + opts.body = opts.bodySerializer(opts.body); } // remove Content-Type header if body is empty to avoid sending invalid requests - if (opts.body === undefined || opts.serializedBody === '') { + if (opts.body === undefined || opts.body === '') { opts.headers.delete('Content-Type'); } const url = buildUrl(opts); - - return { opts, url }; - }; - - const request: Client['request'] = async (options) => { - // @ts-expect-error - const { opts, url } = await beforeRequest(options); const requestInit: ReqInit = { redirect: 'follow', ...opts, - body: getValidRequestBody(opts), }; let request = new Request(url, requestInit); - for (const fn of interceptors.request.fns) { + for (const fn of interceptors.request._fns) { if (fn) { request = await fn(request, opts); } @@ -95,42 +76,9 @@ export const createClient = (config: Config = {}): Client => { // fetch must be assigned here, otherwise it would throw the error: // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation const _fetch = opts.fetch!; - let response: Response; - - try { - response = await _fetch(request); - } catch (error) { - // Handle fetch exceptions (AbortError, network errors, etc.) - let finalError = error; - - for (const fn of interceptors.error.fns) { - if (fn) { - finalError = (await fn( - error, - undefined as any, - request, - opts, - )) as unknown; - } - } - - finalError = finalError || ({} as unknown); - - if (opts.throwOnError) { - throw finalError; - } - - // Return error response - return opts.responseStyle === 'data' - ? undefined - : { - error: finalError, - request, - response: undefined as any, - }; - } + let response = await _fetch(request); - for (const fn of interceptors.response.fns) { + for (const fn of interceptors.response._fns) { if (fn) { response = await fn(response, request, opts); } @@ -142,41 +90,23 @@ export const createClient = (config: Config = {}): Client => { }; if (response.ok) { - const parseAs = - (opts.parseAs === 'auto' - ? getParseAs(response.headers.get('Content-Type')) - : opts.parseAs) ?? 'json'; - if ( response.status === 204 || response.headers.get('Content-Length') === '0' ) { - let emptyData: any; - switch (parseAs) { - case 'arrayBuffer': - case 'blob': - case 'text': - emptyData = await response[parseAs](); - break; - case 'formData': - emptyData = new FormData(); - break; - case 'stream': - emptyData = response.body; - break; - case 'json': - default: - emptyData = {}; - break; - } return opts.responseStyle === 'data' - ? emptyData + ? {} : { - data: emptyData, + data: {}, ...result, }; } + const parseAs = + (opts.parseAs === 'auto' + ? getParseAs(response.headers.get('Content-Type')) + : opts.parseAs) ?? 'json'; + let data: any; switch (parseAs) { case 'arrayBuffer': @@ -225,7 +155,7 @@ export const createClient = (config: Config = {}): Client => { const error = jsonError ?? textError; let finalError = error; - for (const fn of interceptors.error.fns) { + for (const fn of interceptors.error._fns) { if (fn) { finalError = (await fn(error, response, request, opts)) as string; } @@ -246,56 +176,20 @@ export const createClient = (config: Config = {}): Client => { }; }; - const makeMethodFn = - (method: Uppercase) => (options: RequestOptions) => - request({ ...options, method }); - - const makeSseFn = - (method: Uppercase) => async (options: RequestOptions) => { - const { opts, url } = await beforeRequest(options); - return createSseClient({ - ...opts, - body: opts.body as BodyInit | null | undefined, - headers: opts.headers as unknown as Record, - method, - onRequest: async (url, init) => { - let request = new Request(url, init); - for (const fn of interceptors.request.fns) { - if (fn) { - request = await fn(request, opts); - } - } - return request; - }, - url, - }); - }; - return { buildUrl, - connect: makeMethodFn('CONNECT'), - delete: makeMethodFn('DELETE'), - get: makeMethodFn('GET'), + connect: (options) => request({ ...options, method: 'CONNECT' }), + delete: (options) => request({ ...options, method: 'DELETE' }), + get: (options) => request({ ...options, method: 'GET' }), getConfig, - head: makeMethodFn('HEAD'), + head: (options) => request({ ...options, method: 'HEAD' }), interceptors, - options: makeMethodFn('OPTIONS'), - patch: makeMethodFn('PATCH'), - post: makeMethodFn('POST'), - put: makeMethodFn('PUT'), + options: (options) => request({ ...options, method: 'OPTIONS' }), + patch: (options) => request({ ...options, method: 'PATCH' }), + post: (options) => request({ ...options, method: 'POST' }), + put: (options) => request({ ...options, method: 'PUT' }), request, setConfig, - sse: { - connect: makeSseFn('CONNECT'), - delete: makeSseFn('DELETE'), - get: makeSseFn('GET'), - head: makeSseFn('HEAD'), - options: makeSseFn('OPTIONS'), - patch: makeSseFn('PATCH'), - post: makeSseFn('POST'), - put: makeSseFn('PUT'), - trace: makeSseFn('TRACE'), - }, - trace: makeMethodFn('TRACE'), - } as Client; + trace: (options) => request({ ...options, method: 'TRACE' }), + }; }; diff --git a/packages/api-client/src/generated/client/index.ts b/packages/api-client/src/generated/client/index.ts index b295edeca0..5da1f7aee1 100644 --- a/packages/api-client/src/generated/client/index.ts +++ b/packages/api-client/src/generated/client/index.ts @@ -1,25 +1,22 @@ -// This file is auto-generated by @hey-api/openapi-ts - -export type { Auth } from '../core/auth.gen'; -export type { QuerySerializerOptions } from '../core/bodySerializer.gen'; +export type { Auth } from '../core/auth'; +export type { QuerySerializerOptions } from '../core/bodySerializer'; export { formDataBodySerializer, jsonBodySerializer, urlSearchParamsBodySerializer, -} from '../core/bodySerializer.gen'; -export { buildClientParams } from '../core/params.gen'; -export { serializeQueryKeyValue } from '../core/queryKeySerializer.gen'; -export { createClient } from './client.gen'; +} from '../core/bodySerializer'; +export { buildClientParams } from '../core/params'; +export { createClient } from './client'; export type { Client, ClientOptions, Config, CreateClientConfig, Options, + OptionsLegacyParser, RequestOptions, RequestResult, - ResolvedRequestOptions, ResponseStyle, TDataShape, -} from './types.gen'; -export { createConfig, mergeHeaders } from './utils.gen'; +} from './types'; +export { createConfig, mergeHeaders } from './utils'; diff --git a/packages/api-client/src/generated/client/types.gen.ts b/packages/api-client/src/generated/client/types.ts similarity index 75% rename from packages/api-client/src/generated/client/types.gen.ts rename to packages/api-client/src/generated/client/types.ts index b4a499cc03..85295df077 100644 --- a/packages/api-client/src/generated/client/types.gen.ts +++ b/packages/api-client/src/generated/client/types.ts @@ -1,15 +1,9 @@ -// This file is auto-generated by @hey-api/openapi-ts - -import type { Auth } from '../core/auth.gen'; -import type { - ServerSentEventsOptions, - ServerSentEventsResult, -} from '../core/serverSentEvents.gen'; +import type { Auth } from '../core/auth'; import type { Client as CoreClient, Config as CoreConfig, -} from '../core/types.gen'; -import type { Middleware } from './utils.gen'; +} from '../core/types'; +import type { Middleware } from './utils'; export type ResponseStyle = 'data' | 'fields'; @@ -26,7 +20,7 @@ export interface Config * * @default globalThis.fetch */ - fetch?: typeof fetch; + fetch?: (request: Request) => ReturnType; /** * Please don't use the Fetch client for Next.js applications. The `next` * options won't have any effect. @@ -65,22 +59,13 @@ export interface Config } export interface RequestOptions< - TData = unknown, TResponseStyle extends ResponseStyle = 'fields', ThrowOnError extends boolean = boolean, Url extends string = string, > extends Config<{ - responseStyle: TResponseStyle; - throwOnError: ThrowOnError; - }>, - Pick< - ServerSentEventsOptions, - | 'onSseError' - | 'onSseEvent' - | 'sseDefaultRetryDelay' - | 'sseMaxRetryAttempts' - | 'sseMaxRetryDelay' - > { + responseStyle: TResponseStyle; + throwOnError: ThrowOnError; + }> { /** * Any body that you want to add to your request. * @@ -96,14 +81,6 @@ export interface RequestOptions< url: Url; } -export interface ResolvedRequestOptions< - TResponseStyle extends ResponseStyle = 'fields', - ThrowOnError extends boolean = boolean, - Url extends string = string, -> extends RequestOptions { - serializedBody?: string; -} - export type RequestResult< TData = unknown, TError = unknown, @@ -161,29 +138,17 @@ type MethodFn = < ThrowOnError extends boolean = false, TResponseStyle extends ResponseStyle = 'fields', >( - options: Omit, 'method'>, + options: Omit, 'method'>, ) => RequestResult; -type SseFn = < - TData = unknown, - TError = unknown, - ThrowOnError extends boolean = false, - TResponseStyle extends ResponseStyle = 'fields', ->( - options: Omit, 'method'>, -) => Promise>; - type RequestFn = < TData = unknown, TError = unknown, ThrowOnError extends boolean = false, TResponseStyle extends ResponseStyle = 'fields', >( - options: Omit, 'method'> & - Pick< - Required>, - 'method' - >, + options: Omit, 'method'> & + Pick>, 'method'>, ) => RequestResult; type BuildUrlFn = < @@ -194,17 +159,11 @@ type BuildUrlFn = < url: string; }, >( - options: TData & Options, + options: Pick & Options, ) => string; -export type Client = CoreClient< - RequestFn, - Config, - MethodFn, - BuildUrlFn, - SseFn -> & { - interceptors: Middleware; +export type Client = CoreClient & { + interceptors: Middleware; }; /** @@ -232,10 +191,32 @@ type OmitKeys = Pick>; export type Options< TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean, - TResponse = unknown, TResponseStyle extends ResponseStyle = 'fields', > = OmitKeys< - RequestOptions, + RequestOptions, 'body' | 'path' | 'query' | 'url' > & - ([TData] extends [never] ? unknown : Omit); + Omit; + +export type OptionsLegacyParser< + TData = unknown, + ThrowOnError extends boolean = boolean, + TResponseStyle extends ResponseStyle = 'fields', +> = TData extends { body?: any } + ? TData extends { headers?: any } + ? OmitKeys< + RequestOptions, + 'body' | 'headers' | 'url' + > & + TData + : OmitKeys, 'body' | 'url'> & + TData & + Pick, 'headers'> + : TData extends { headers?: any } + ? OmitKeys< + RequestOptions, + 'headers' | 'url' + > & + TData & + Pick, 'body'> + : OmitKeys, 'url'> & TData; diff --git a/packages/api-client/src/generated/client/utils.gen.ts b/packages/api-client/src/generated/client/utils.ts similarity index 58% rename from packages/api-client/src/generated/client/utils.gen.ts rename to packages/api-client/src/generated/client/utils.ts index 4c48a9ee11..a52e672927 100644 --- a/packages/api-client/src/generated/client/utils.gen.ts +++ b/packages/api-client/src/generated/client/utils.ts @@ -1,19 +1,101 @@ -// This file is auto-generated by @hey-api/openapi-ts - -import { getAuthToken } from '../core/auth.gen'; -import type { QuerySerializerOptions } from '../core/bodySerializer.gen'; -import { jsonBodySerializer } from '../core/bodySerializer.gen'; +import { getAuthToken } from '../core/auth'; +import type { + QuerySerializer, + QuerySerializerOptions, +} from '../core/bodySerializer'; +import { jsonBodySerializer } from '../core/bodySerializer'; import { serializeArrayParam, serializeObjectParam, serializePrimitiveParam, -} from '../core/pathSerializer.gen'; -import { getUrl } from '../core/utils.gen'; -import type { Client, ClientOptions, Config, RequestOptions } from './types.gen'; +} from '../core/pathSerializer'; +import type { Client, ClientOptions, Config, RequestOptions } from './types'; + +interface PathSerializer { + path: Record; + url: string; +} + +const PATH_PARAM_RE = /\{[^{}]+\}/g; + +type ArrayStyle = 'form' | 'spaceDelimited' | 'pipeDelimited'; +type MatrixStyle = 'label' | 'matrix' | 'simple'; +type ArraySeparatorStyle = ArrayStyle | MatrixStyle; + +const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => { + let url = _url; + const matches = _url.match(PATH_PARAM_RE); + if (matches) { + for (const match of matches) { + let explode = false; + let name = match.substring(1, match.length - 1); + let style: ArraySeparatorStyle = 'simple'; + + if (name.endsWith('*')) { + explode = true; + name = name.substring(0, name.length - 1); + } + + if (name.startsWith('.')) { + name = name.substring(1); + style = 'label'; + } else if (name.startsWith(';')) { + name = name.substring(1); + style = 'matrix'; + } + + const value = path[name]; + + if (value === undefined || value === null) { + continue; + } + + if (Array.isArray(value)) { + url = url.replace( + match, + serializeArrayParam({ explode, name, style, value }), + ); + continue; + } + + if (typeof value === 'object') { + url = url.replace( + match, + serializeObjectParam({ + explode, + name, + style, + value: value as Record, + valueOnly: true, + }), + ); + continue; + } + + if (style === 'matrix') { + url = url.replace( + match, + `;${serializePrimitiveParam({ + name, + value: value as string, + })}`, + ); + continue; + } + + const replaceValue = encodeURIComponent( + style === 'label' ? `.${value as string}` : (value as string), + ); + url = url.replace(match, replaceValue); + } + } + return url; +}; export const createQuerySerializer = ({ - parameters = {}, - ...args + allowReserved, + array, + object, }: QuerySerializerOptions = {}) => { const querySerializer = (queryParams: T) => { const search: string[] = []; @@ -25,31 +107,29 @@ export const createQuerySerializer = ({ continue; } - const options = parameters[name] || args; - if (Array.isArray(value)) { const serializedArray = serializeArrayParam({ - allowReserved: options.allowReserved, + allowReserved, explode: true, name, style: 'form', value, - ...options.array, + ...array, }); if (serializedArray) search.push(serializedArray); } else if (typeof value === 'object') { const serializedObject = serializeObjectParam({ - allowReserved: options.allowReserved, + allowReserved, explode: true, name, style: 'deepObject', value: value as Record, - ...options.object, + ...object, }); if (serializedObject) search.push(serializedObject); } else { const serializedPrimitive = serializePrimitiveParam({ - allowReserved: options.allowReserved, + allowReserved, name, value: value as string, }); @@ -106,25 +186,6 @@ export const getParseAs = ( return; }; -const checkForExistence = ( - options: Pick & { - headers: Headers; - }, - name?: string, -): boolean => { - if (!name) { - return false; - } - if ( - options.headers.has(name) || - options.query?.[name] || - options.headers.get('Cookie')?.includes(`${name}=`) - ) { - return true; - } - return false; -}; - export const setAuthParams = async ({ security, ...options @@ -133,10 +194,6 @@ export const setAuthParams = async ({ headers: Headers; }) => { for (const auth of security) { - if (checkForExistence(options, auth.name)) { - continue; - } - const token = await getAuthToken(auth, options.auth); if (!token) { @@ -160,11 +217,13 @@ export const setAuthParams = async ({ options.headers.set(name, token); break; } + + return; } }; -export const buildUrl: Client['buildUrl'] = (options) => - getUrl({ +export const buildUrl: Client['buildUrl'] = (options) => { + const url = getUrl({ baseUrl: options.baseUrl as string, path: options.path, query: options.query, @@ -174,6 +233,36 @@ export const buildUrl: Client['buildUrl'] = (options) => : createQuerySerializer(options.querySerializer), url: options.url, }); + return url; +}; + +export const getUrl = ({ + baseUrl, + path, + query, + querySerializer, + url: _url, +}: { + baseUrl?: string; + path?: Record; + query?: Record; + querySerializer: QuerySerializer; + url: string; +}) => { + const pathUrl = _url.startsWith('/') ? _url : `/${_url}`; + let url = (baseUrl ?? '') + pathUrl; + if (path) { + url = defaultPathSerializer({ path, url }); + } + let search = query ? querySerializer(query) : ''; + if (search.startsWith('?')) { + search = search.substring(1); + } + if (search) { + url += `?${search}`; + } + return url; +}; export const mergeConfigs = (a: Config, b: Config): Config => { const config = { ...a, ...b }; @@ -184,27 +273,17 @@ export const mergeConfigs = (a: Config, b: Config): Config => { return config; }; -const headersEntries = (headers: Headers): Array<[string, string]> => { - const entries: Array<[string, string]> = []; - headers.forEach((value, key) => { - entries.push([key, value]); - }); - return entries; -}; - export const mergeHeaders = ( ...headers: Array['headers'] | undefined> ): Headers => { const mergedHeaders = new Headers(); for (const header of headers) { - if (!header) { + if (!header || typeof header !== 'object') { continue; } const iterator = - header instanceof Headers - ? headersEntries(header) - : Object.entries(header); + header instanceof Headers ? header.entries() : Object.entries(header); for (const [key, value] of iterator) { if (value === null) { @@ -245,61 +324,67 @@ type ResInterceptor = ( ) => Res | Promise; class Interceptors { - fns: Array = []; + _fns: (Interceptor | null)[]; - clear(): void { - this.fns = []; + constructor() { + this._fns = []; } - eject(id: number | Interceptor): void { - const index = this.getInterceptorIndex(id); - if (this.fns[index]) { - this.fns[index] = null; - } + clear() { + this._fns = []; } - exists(id: number | Interceptor): boolean { + getInterceptorIndex(id: number | Interceptor): number { + if (typeof id === 'number') { + return this._fns[id] ? id : -1; + } else { + return this._fns.indexOf(id); + } + } + exists(id: number | Interceptor) { const index = this.getInterceptorIndex(id); - return Boolean(this.fns[index]); + return !!this._fns[index]; } - getInterceptorIndex(id: number | Interceptor): number { - if (typeof id === 'number') { - return this.fns[id] ? id : -1; + eject(id: number | Interceptor) { + const index = this.getInterceptorIndex(id); + if (this._fns[index]) { + this._fns[index] = null; } - return this.fns.indexOf(id); } - update( - id: number | Interceptor, - fn: Interceptor, - ): number | Interceptor | false { + update(id: number | Interceptor, fn: Interceptor) { const index = this.getInterceptorIndex(id); - if (this.fns[index]) { - this.fns[index] = fn; + if (this._fns[index]) { + this._fns[index] = fn; return id; + } else { + return false; } - return false; } - use(fn: Interceptor): number { - this.fns.push(fn); - return this.fns.length - 1; + use(fn: Interceptor) { + this._fns = [...this._fns, fn]; + return this._fns.length - 1; } } +// `createInterceptors()` response, meant for external use as it does not +// expose internals export interface Middleware { - error: Interceptors>; - request: Interceptors>; - response: Interceptors>; + error: Pick< + Interceptors>, + 'eject' | 'use' + >; + request: Pick>, 'eject' | 'use'>; + response: Pick< + Interceptors>, + 'eject' | 'use' + >; } -export const createInterceptors = (): Middleware< - Req, - Res, - Err, - Options -> => ({ +// do not add `Middleware` as return type so we can use _fns internally +export const createInterceptors = () => ({ error: new Interceptors>(), request: new Interceptors>(), response: new Interceptors>(), diff --git a/packages/api-client/src/generated/core/auth.gen.ts b/packages/api-client/src/generated/core/auth.ts similarity index 93% rename from packages/api-client/src/generated/core/auth.gen.ts rename to packages/api-client/src/generated/core/auth.ts index f8a73266f9..451c7f30f9 100644 --- a/packages/api-client/src/generated/core/auth.gen.ts +++ b/packages/api-client/src/generated/core/auth.ts @@ -1,5 +1,3 @@ -// This file is auto-generated by @hey-api/openapi-ts - export type AuthToken = string | undefined; export interface Auth { diff --git a/packages/api-client/src/generated/core/bodySerializer.gen.ts b/packages/api-client/src/generated/core/bodySerializer.ts similarity index 76% rename from packages/api-client/src/generated/core/bodySerializer.gen.ts rename to packages/api-client/src/generated/core/bodySerializer.ts index 552b50f7c8..98ce7791f1 100644 --- a/packages/api-client/src/generated/core/bodySerializer.gen.ts +++ b/packages/api-client/src/generated/core/bodySerializer.ts @@ -1,28 +1,18 @@ -// This file is auto-generated by @hey-api/openapi-ts - import type { ArrayStyle, ObjectStyle, SerializerOptions, -} from './pathSerializer.gen'; +} from './pathSerializer'; export type QuerySerializer = (query: Record) => string; export type BodySerializer = (body: any) => any; -type QuerySerializerOptionsObject = { +export interface QuerySerializerOptions { allowReserved?: boolean; - array?: Partial>; - object?: Partial>; -}; - -export type QuerySerializerOptions = QuerySerializerOptionsObject & { - /** - * Per-parameter serialization overrides. When provided, these settings - * override the global array/object settings for specific parameter names. - */ - parameters?: Record; -}; + array?: SerializerOptions; + object?: SerializerOptions; +} const serializeFormDataPair = ( data: FormData, @@ -31,8 +21,6 @@ const serializeFormDataPair = ( ): void => { if (typeof value === 'string' || value instanceof Blob) { data.append(key, value); - } else if (value instanceof Date) { - data.append(key, value.toISOString()); } else { data.append(key, JSON.stringify(value)); } diff --git a/packages/api-client/src/generated/core/params.gen.ts b/packages/api-client/src/generated/core/params.ts similarity index 69% rename from packages/api-client/src/generated/core/params.gen.ts rename to packages/api-client/src/generated/core/params.ts index 602715c46c..7559bbb8c0 100644 --- a/packages/api-client/src/generated/core/params.gen.ts +++ b/packages/api-client/src/generated/core/params.ts @@ -1,38 +1,15 @@ -// This file is auto-generated by @hey-api/openapi-ts - type Slot = 'body' | 'headers' | 'path' | 'query'; export type Field = | { in: Exclude; - /** - * Field name. This is the name we want the user to see and use. - */ key: string; - /** - * Field mapped name. This is the name we want to use in the request. - * If omitted, we use the same value as `key`. - */ map?: string; } | { in: Extract; - /** - * Key isn't required for bodies. - */ key?: string; map?: string; - } - | { - /** - * Field name. This is the name we want the user to see and use. - */ - key: string; - /** - * Field mapped name. This is the name we want to use in the request. - * If `in` is omitted, `map` aliases `key` to the transport layer. - */ - map: Slot; }; export interface Fields { @@ -52,14 +29,10 @@ const extraPrefixes = Object.entries(extraPrefixesMap); type KeyMap = Map< string, - | { - in: Slot; - map?: string; - } - | { - in?: never; - map: Slot; - } + { + in: Slot; + map?: string; + } >; const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => { @@ -75,10 +48,6 @@ const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => { map: config.map, }); } - } else if ('key' in config) { - map.set(config.key, { - map: config.map, - }); } else if (config.args) { buildKeyMap(config.args, map); } @@ -130,9 +99,7 @@ export const buildClientParams = ( if (config.key) { const field = map.get(config.key)!; const name = field.map || config.key; - if (field.in) { - (params[field.in] as Record)[name] = arg; - } + (params[field.in] as Record)[name] = arg; } else { params.body = arg; } @@ -141,12 +108,8 @@ export const buildClientParams = ( const field = map.get(key); if (field) { - if (field.in) { - const name = field.map || key; - (params[field.in] as Record)[name] = value; - } else { - params[field.map] = value; - } + const name = field.map || key; + (params[field.in] as Record)[name] = value; } else { const extra = extraPrefixes.find(([prefix]) => key.startsWith(prefix), @@ -157,8 +120,10 @@ export const buildClientParams = ( (params[slot] as Record)[ key.slice(prefix.length) ] = value; - } else if ('allowExtra' in config && config.allowExtra) { - for (const [slot, allowed] of Object.entries(config.allowExtra)) { + } else { + for (const [slot, allowed] of Object.entries( + config.allowExtra ?? {}, + )) { if (allowed) { (params[slot as Slot] as Record)[key] = value; break; diff --git a/packages/api-client/src/generated/core/pathSerializer.gen.ts b/packages/api-client/src/generated/core/pathSerializer.ts similarity index 98% rename from packages/api-client/src/generated/core/pathSerializer.gen.ts rename to packages/api-client/src/generated/core/pathSerializer.ts index 8d99931047..d692cf0a39 100644 --- a/packages/api-client/src/generated/core/pathSerializer.gen.ts +++ b/packages/api-client/src/generated/core/pathSerializer.ts @@ -1,5 +1,3 @@ -// This file is auto-generated by @hey-api/openapi-ts - interface SerializeOptions extends SerializePrimitiveOptions, SerializerOptions {} diff --git a/packages/api-client/src/generated/core/queryKeySerializer.gen.ts b/packages/api-client/src/generated/core/queryKeySerializer.gen.ts deleted file mode 100644 index d3bb68396e..0000000000 --- a/packages/api-client/src/generated/core/queryKeySerializer.gen.ts +++ /dev/null @@ -1,136 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -/** - * JSON-friendly union that mirrors what Pinia Colada can hash. - */ -export type JsonValue = - | null - | string - | number - | boolean - | JsonValue[] - | { [key: string]: JsonValue }; - -/** - * Replacer that converts non-JSON values (bigint, Date, etc.) to safe substitutes. - */ -export const queryKeyJsonReplacer = (_key: string, value: unknown) => { - if ( - value === undefined || - typeof value === 'function' || - typeof value === 'symbol' - ) { - return undefined; - } - if (typeof value === 'bigint') { - return value.toString(); - } - if (value instanceof Date) { - return value.toISOString(); - } - return value; -}; - -/** - * Safely stringifies a value and parses it back into a JsonValue. - */ -export const stringifyToJsonValue = (input: unknown): JsonValue | undefined => { - try { - const json = JSON.stringify(input, queryKeyJsonReplacer); - if (json === undefined) { - return undefined; - } - return JSON.parse(json) as JsonValue; - } catch { - return undefined; - } -}; - -/** - * Detects plain objects (including objects with a null prototype). - */ -const isPlainObject = (value: unknown): value is Record => { - if (value === null || typeof value !== 'object') { - return false; - } - const prototype = Object.getPrototypeOf(value as object); - return prototype === Object.prototype || prototype === null; -}; - -/** - * Turns URLSearchParams into a sorted JSON object for deterministic keys. - */ -const serializeSearchParams = (params: URLSearchParams): JsonValue => { - const entries = Array.from(params.entries()).sort(([a], [b]) => - a.localeCompare(b), - ); - const result: Record = {}; - - for (const [key, value] of entries) { - const existing = result[key]; - if (existing === undefined) { - result[key] = value; - continue; - } - - if (Array.isArray(existing)) { - (existing as string[]).push(value); - } else { - result[key] = [existing, value]; - } - } - - return result; -}; - -/** - * Normalizes any accepted value into a JSON-friendly shape for query keys. - */ -export const serializeQueryKeyValue = ( - value: unknown, -): JsonValue | undefined => { - if (value === null) { - return null; - } - - if ( - typeof value === 'string' || - typeof value === 'number' || - typeof value === 'boolean' - ) { - return value; - } - - if ( - value === undefined || - typeof value === 'function' || - typeof value === 'symbol' - ) { - return undefined; - } - - if (typeof value === 'bigint') { - return value.toString(); - } - - if (value instanceof Date) { - return value.toISOString(); - } - - if (Array.isArray(value)) { - return stringifyToJsonValue(value); - } - - if ( - typeof URLSearchParams !== 'undefined' && - value instanceof URLSearchParams - ) { - return serializeSearchParams(value); - } - - if (isPlainObject(value)) { - return stringifyToJsonValue(value); - } - - return undefined; -}; diff --git a/packages/api-client/src/generated/core/serverSentEvents.gen.ts b/packages/api-client/src/generated/core/serverSentEvents.gen.ts deleted file mode 100644 index f8fd78e284..0000000000 --- a/packages/api-client/src/generated/core/serverSentEvents.gen.ts +++ /dev/null @@ -1,264 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -import type { Config } from './types.gen'; - -export type ServerSentEventsOptions = Omit< - RequestInit, - 'method' -> & - Pick & { - /** - * Fetch API implementation. You can use this option to provide a custom - * fetch instance. - * - * @default globalThis.fetch - */ - fetch?: typeof fetch; - /** - * Implementing clients can call request interceptors inside this hook. - */ - onRequest?: (url: string, init: RequestInit) => Promise; - /** - * Callback invoked when a network or parsing error occurs during streaming. - * - * This option applies only if the endpoint returns a stream of events. - * - * @param error The error that occurred. - */ - onSseError?: (error: unknown) => void; - /** - * Callback invoked when an event is streamed from the server. - * - * This option applies only if the endpoint returns a stream of events. - * - * @param event Event streamed from the server. - * @returns Nothing (void). - */ - onSseEvent?: (event: StreamEvent) => void; - serializedBody?: RequestInit['body']; - /** - * Default retry delay in milliseconds. - * - * This option applies only if the endpoint returns a stream of events. - * - * @default 3000 - */ - sseDefaultRetryDelay?: number; - /** - * Maximum number of retry attempts before giving up. - */ - sseMaxRetryAttempts?: number; - /** - * Maximum retry delay in milliseconds. - * - * Applies only when exponential backoff is used. - * - * This option applies only if the endpoint returns a stream of events. - * - * @default 30000 - */ - sseMaxRetryDelay?: number; - /** - * Optional sleep function for retry backoff. - * - * Defaults to using `setTimeout`. - */ - sseSleepFn?: (ms: number) => Promise; - url: string; - }; - -export interface StreamEvent { - data: TData; - event?: string; - id?: string; - retry?: number; -} - -export type ServerSentEventsResult< - TData = unknown, - TReturn = void, - TNext = unknown, -> = { - stream: AsyncGenerator< - TData extends Record ? TData[keyof TData] : TData, - TReturn, - TNext - >; -}; - -export const createSseClient = ({ - onRequest, - onSseError, - onSseEvent, - responseTransformer, - responseValidator, - sseDefaultRetryDelay, - sseMaxRetryAttempts, - sseMaxRetryDelay, - sseSleepFn, - url, - ...options -}: ServerSentEventsOptions): ServerSentEventsResult => { - let lastEventId: string | undefined; - - const sleep = - sseSleepFn ?? - ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); - - const createStream = async function* () { - let retryDelay: number = sseDefaultRetryDelay ?? 3000; - let attempt = 0; - const signal = options.signal ?? new AbortController().signal; - - while (true) { - if (signal.aborted) break; - - attempt++; - - const headers = - options.headers instanceof Headers - ? options.headers - : new Headers(options.headers as Record | undefined); - - if (lastEventId !== undefined) { - headers.set('Last-Event-ID', lastEventId); - } - - try { - const requestInit: RequestInit = { - redirect: 'follow', - ...options, - body: options.serializedBody, - headers, - signal, - }; - let request = new Request(url, requestInit); - if (onRequest) { - request = await onRequest(url, requestInit); - } - // fetch must be assigned here, otherwise it would throw the error: - // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation - const _fetch = options.fetch ?? globalThis.fetch; - const response = await _fetch(request); - - if (!response.ok) - throw new Error( - `SSE failed: ${response.status} ${response.statusText}`, - ); - - if (!response.body) throw new Error('No body in SSE response'); - - const reader = response.body - .pipeThrough(new TextDecoderStream()) - .getReader(); - - let buffer = ''; - - const abortHandler = () => { - try { - reader.cancel(); - } catch { - // noop - } - }; - - signal.addEventListener('abort', abortHandler); - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buffer += value; - - const chunks = buffer.split('\n\n'); - buffer = chunks.pop() ?? ''; - - for (const chunk of chunks) { - const lines = chunk.split('\n'); - const dataLines: Array = []; - let eventName: string | undefined; - - for (const line of lines) { - if (line.startsWith('data:')) { - dataLines.push(line.replace(/^data:\s*/, '')); - } else if (line.startsWith('event:')) { - eventName = line.replace(/^event:\s*/, ''); - } else if (line.startsWith('id:')) { - lastEventId = line.replace(/^id:\s*/, ''); - } else if (line.startsWith('retry:')) { - const parsed = Number.parseInt( - line.replace(/^retry:\s*/, ''), - 10, - ); - if (!Number.isNaN(parsed)) { - retryDelay = parsed; - } - } - } - - let data: unknown; - let parsedJson = false; - - if (dataLines.length) { - const rawData = dataLines.join('\n'); - try { - data = JSON.parse(rawData); - parsedJson = true; - } catch { - data = rawData; - } - } - - if (parsedJson) { - if (responseValidator) { - await responseValidator(data); - } - - if (responseTransformer) { - data = await responseTransformer(data); - } - } - - onSseEvent?.({ - data, - event: eventName, - id: lastEventId, - retry: retryDelay, - }); - - if (dataLines.length) { - yield data as any; - } - } - } - } finally { - signal.removeEventListener('abort', abortHandler); - reader.releaseLock(); - } - - break; // exit loop on normal completion - } catch (error) { - // connection failed or aborted; retry after delay - onSseError?.(error); - - if ( - sseMaxRetryAttempts !== undefined && - attempt >= sseMaxRetryAttempts - ) { - break; // stop after firing error - } - - // exponential backoff: double retry each attempt, cap at 30s - const backoff = Math.min( - retryDelay * 2 ** (attempt - 1), - sseMaxRetryDelay ?? 30000, - ); - await sleep(backoff); - } - } - }; - - const stream = createStream(); - - return { stream }; -}; diff --git a/packages/api-client/src/generated/core/types.gen.ts b/packages/api-client/src/generated/core/types.ts similarity index 75% rename from packages/api-client/src/generated/core/types.gen.ts rename to packages/api-client/src/generated/core/types.ts index 643c070c9d..77d8792533 100644 --- a/packages/api-client/src/generated/core/types.gen.ts +++ b/packages/api-client/src/generated/core/types.ts @@ -1,42 +1,33 @@ -// This file is auto-generated by @hey-api/openapi-ts - -import type { Auth, AuthToken } from './auth.gen'; +import type { Auth, AuthToken } from './auth'; import type { BodySerializer, QuerySerializer, QuerySerializerOptions, -} from './bodySerializer.gen'; - -export type HttpMethod = - | 'connect' - | 'delete' - | 'get' - | 'head' - | 'options' - | 'patch' - | 'post' - | 'put' - | 'trace'; +} from './bodySerializer'; -export type Client< +export interface Client< RequestFn = never, Config = unknown, MethodFn = never, BuildUrlFn = never, - SseFn = never, -> = { +> { /** * Returns the final request URL. */ buildUrl: BuildUrlFn; + connect: MethodFn; + delete: MethodFn; + get: MethodFn; getConfig: () => Config; + head: MethodFn; + options: MethodFn; + patch: MethodFn; + post: MethodFn; + put: MethodFn; request: RequestFn; setConfig: (config: Config) => Config; -} & { - [K in HttpMethod]: MethodFn; -} & ([SseFn] extends [never] - ? { sse?: never } - : { sse: { [K in HttpMethod]: SseFn } }); + trace: MethodFn; +} export interface Config { /** @@ -72,7 +63,16 @@ export interface Config { * * {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more} */ - method?: Uppercase; + method?: + | 'CONNECT' + | 'DELETE' + | 'GET' + | 'HEAD' + | 'OPTIONS' + | 'PATCH' + | 'POST' + | 'PUT' + | 'TRACE'; /** * A function for serializing request query parameters. By default, arrays * will be exploded in form style, objects will be exploded in deepObject @@ -102,17 +102,3 @@ export interface Config { */ responseValidator?: (data: unknown) => Promise; } - -type IsExactlyNeverOrNeverUndefined = [T] extends [never] - ? true - : [T] extends [never | undefined] - ? [undefined] extends [T] - ? false - : true - : false; - -export type OmitNever> = { - [K in keyof T as IsExactlyNeverOrNeverUndefined extends true - ? never - : K]: T[K]; -}; diff --git a/packages/api-client/src/generated/core/utils.gen.ts b/packages/api-client/src/generated/core/utils.gen.ts deleted file mode 100644 index 0b5389d089..0000000000 --- a/packages/api-client/src/generated/core/utils.gen.ts +++ /dev/null @@ -1,143 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -import type { BodySerializer, QuerySerializer } from './bodySerializer.gen'; -import { - type ArraySeparatorStyle, - serializeArrayParam, - serializeObjectParam, - serializePrimitiveParam, -} from './pathSerializer.gen'; - -export interface PathSerializer { - path: Record; - url: string; -} - -export const PATH_PARAM_RE = /\{[^{}]+\}/g; - -export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => { - let url = _url; - const matches = _url.match(PATH_PARAM_RE); - if (matches) { - for (const match of matches) { - let explode = false; - let name = match.substring(1, match.length - 1); - let style: ArraySeparatorStyle = 'simple'; - - if (name.endsWith('*')) { - explode = true; - name = name.substring(0, name.length - 1); - } - - if (name.startsWith('.')) { - name = name.substring(1); - style = 'label'; - } else if (name.startsWith(';')) { - name = name.substring(1); - style = 'matrix'; - } - - const value = path[name]; - - if (value === undefined || value === null) { - continue; - } - - if (Array.isArray(value)) { - url = url.replace( - match, - serializeArrayParam({ explode, name, style, value }), - ); - continue; - } - - if (typeof value === 'object') { - url = url.replace( - match, - serializeObjectParam({ - explode, - name, - style, - value: value as Record, - valueOnly: true, - }), - ); - continue; - } - - if (style === 'matrix') { - url = url.replace( - match, - `;${serializePrimitiveParam({ - name, - value: value as string, - })}`, - ); - continue; - } - - const replaceValue = encodeURIComponent( - style === 'label' ? `.${value as string}` : (value as string), - ); - url = url.replace(match, replaceValue); - } - } - return url; -}; - -export const getUrl = ({ - baseUrl, - path, - query, - querySerializer, - url: _url, -}: { - baseUrl?: string; - path?: Record; - query?: Record; - querySerializer: QuerySerializer; - url: string; -}) => { - const pathUrl = _url.startsWith('/') ? _url : `/${_url}`; - let url = (baseUrl ?? '') + pathUrl; - if (path) { - url = defaultPathSerializer({ path, url }); - } - let search = query ? querySerializer(query) : ''; - if (search.startsWith('?')) { - search = search.substring(1); - } - if (search) { - url += `?${search}`; - } - return url; -}; - -export function getValidRequestBody(options: { - body?: unknown; - bodySerializer?: BodySerializer | null; - serializedBody?: unknown; -}) { - const hasBody = options.body !== undefined; - const isSerializedBody = hasBody && options.bodySerializer; - - if (isSerializedBody) { - if ('serializedBody' in options) { - const hasSerializedBody = - options.serializedBody !== undefined && options.serializedBody !== ''; - - return hasSerializedBody ? options.serializedBody : null; - } - - // not all clients implement a serializedBody property (i.e. client-axios) - return options.body !== '' ? options.body : null; - } - - // plain/text body - if (hasBody) { - return options.body; - } - - // no body was provided - return undefined; -} diff --git a/packages/api-client/src/generated/index.ts b/packages/api-client/src/generated/index.ts index c352c1047a..e64537d212 100644 --- a/packages/api-client/src/generated/index.ts +++ b/packages/api-client/src/generated/index.ts @@ -1,4 +1,3 @@ // This file is auto-generated by @hey-api/openapi-ts - -export type * from './types.gen'; -export * from './sdk.gen'; +export * from './types.gen'; +export * from './sdk.gen'; \ No newline at end of file diff --git a/packages/api-client/src/generated/sdk.gen.ts b/packages/api-client/src/generated/sdk.gen.ts index a4603d0408..afbab809f8 100644 --- a/packages/api-client/src/generated/sdk.gen.ts +++ b/packages/api-client/src/generated/sdk.gen.ts @@ -1,10 +1,10 @@ // This file is auto-generated by @hey-api/openapi-ts -import type { Client, Options as Options2, TDataShape } from './client'; -import { client } from './client.gen'; -import type { GetHealthData, GetHealthResponses, PostBillingStartTrialData, PostBillingStartTrialResponses } from './types.gen'; +import type { Options as ClientOptions, TDataShape, Client } from './client'; +import type { GetHealthData, GetHealthResponses, PostBillingStartTrialData, PostBillingStartTrialResponses, PostChatCompletionsData, PostChatCompletionsResponses, PostChatCompletionsErrors, GetRpcCanStartTrialData, GetRpcCanStartTrialResponses, GetListenData, GetListenErrors, PostTranscribeData, PostTranscribeResponses, PostTranscribeErrors, PostWebhookStripeData, PostWebhookStripeResponses, PostWebhookStripeErrors } from './types.gen'; +import { client as _heyApiClient } from './client.gen'; -export type Options = Options2 & { +export type Options = ClientOptions & { /** * You can provide a client instance returned by `createClient()` instead of * individual options. This might be also useful if you want to implement a @@ -20,9 +20,91 @@ export type Options(options?: Options) => (options?.client ?? client).get({ url: '/health', ...options }); +export const getHealth = (options?: Options) => { + return (options?.client ?? _heyApiClient).get({ + url: '/health', + ...options + }); +}; + +export const postBillingStartTrial = (options: Options) => { + return (options.client ?? _heyApiClient).post({ + url: '/billing/start-trial', + ...options + }); +}; + +/** + * Chat completions + * OpenAI-compatible chat completions endpoint. Proxies requests to OpenRouter with automatic model selection. Requires Supabase authentication. + */ +export const postChatCompletions = (options?: Options) => { + return (options?.client ?? _heyApiClient).post({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/chat/completions', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); +}; -export const postBillingStartTrial = (options: Options) => (options.client ?? client).post({ url: '/billing/start-trial', ...options }); +export const getRpcCanStartTrial = (options?: Options) => { + return (options?.client ?? _heyApiClient).get({ + url: '/rpc/can-start-trial', + ...options + }); +}; + +/** + * Speech-to-text WebSocket + * WebSocket endpoint for real-time speech-to-text transcription via Deepgram. Requires Supabase authentication in production. + */ +export const getListen = (options?: Options) => { + return (options?.client ?? _heyApiClient).get({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/listen', + ...options + }); +}; + +/** + * Batch speech-to-text transcription + * HTTP endpoint for batch speech-to-text transcription via file upload. Supports Deepgram, AssemblyAI, and Soniox providers. Use query parameter ?provider=deepgram|assemblyai|soniox to select provider. Requires Supabase authentication. + */ +export const postTranscribe = (options?: Options) => { + return (options?.client ?? _heyApiClient).post({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/transcribe', + ...options + }); +}; + +/** + * Stripe webhook + * Handles Stripe webhook events for billing synchronization. Requires valid Stripe signature. + */ +export const postWebhookStripe = (options: Options) => { + return (options.client ?? _heyApiClient).post({ + url: '/webhook/stripe', + ...options + }); +}; \ No newline at end of file diff --git a/packages/api-client/src/generated/types.gen.ts b/packages/api-client/src/generated/types.gen.ts index 62923c103e..7b1734ad8c 100644 --- a/packages/api-client/src/generated/types.gen.ts +++ b/packages/api-client/src/generated/types.gen.ts @@ -1,9 +1,5 @@ // This file is auto-generated by @hey-api/openapi-ts -export type ClientOptions = { - baseUrl: 'https://api.hyprnote.com' | 'http://localhost:4000' | (string & {}); -}; - export type GetHealthData = { body?: never; path?: never; @@ -41,3 +37,210 @@ export type PostBillingStartTrialResponses = { }; export type PostBillingStartTrialResponse = PostBillingStartTrialResponses[keyof PostBillingStartTrialResponses]; + +export type PostChatCompletionsData = { + body?: { + model?: string; + messages: Array<{ + role: 'system' | 'user' | 'assistant'; + content: string; + }>; + tools?: Array; + tool_choice?: ('none' | 'auto' | 'required') | { + type: 'function'; + function: { + name: string; + }; + }; + stream?: boolean; + temperature?: number; + max_tokens?: number; + [key: string]: unknown | string | Array<{ + role: 'system' | 'user' | 'assistant'; + content: string; + }> | Array | (('none' | 'auto' | 'required') | { + type: 'function'; + function: { + name: string; + }; + }) | boolean | number | undefined; + }; + path?: never; + query?: never; + url: '/chat/completions'; +}; + +export type PostChatCompletionsErrors = { + /** + * Unauthorized - missing or invalid authentication + */ + 401: string; +}; + +export type PostChatCompletionsError = PostChatCompletionsErrors[keyof PostChatCompletionsErrors]; + +export type PostChatCompletionsResponses = { + /** + * Chat completion response (streamed or non-streamed) + */ + 200: unknown; +}; + +export type GetRpcCanStartTrialData = { + body?: never; + path?: never; + query?: never; + url: '/rpc/can-start-trial'; +}; + +export type GetRpcCanStartTrialResponses = { + /** + * result + */ + 200: { + canStartTrial: boolean; + }; +}; + +export type GetRpcCanStartTrialResponse = GetRpcCanStartTrialResponses[keyof GetRpcCanStartTrialResponses]; + +export type GetListenData = { + body?: never; + path?: never; + query?: never; + url: '/listen'; +}; + +export type GetListenErrors = { + /** + * WebSocket upgrade failed + */ + 400: { + error: string; + detail?: string; + }; + /** + * Unauthorized - missing or invalid authentication + */ + 401: string; + /** + * Upstream STT service unavailable + */ + 502: { + error: string; + detail?: string; + }; + /** + * Upstream STT service timeout + */ + 504: { + error: string; + detail?: string; + }; +}; + +export type GetListenError = GetListenErrors[keyof GetListenErrors]; + +export type PostTranscribeData = { + body?: never; + path?: never; + query?: never; + url: '/transcribe'; +}; + +export type PostTranscribeErrors = { + /** + * Bad request - missing or invalid audio file + */ + 400: { + error: string; + detail?: string; + }; + /** + * Unauthorized - missing or invalid authentication + */ + 401: string; + /** + * Internal server error during transcription + */ + 500: { + error: string; + detail?: string; + }; + /** + * Upstream STT service error + */ + 502: { + error: string; + detail?: string; + }; +}; + +export type PostTranscribeError = PostTranscribeErrors[keyof PostTranscribeErrors]; + +export type PostTranscribeResponses = { + /** + * Transcription completed successfully + */ + 200: { + metadata: unknown; + results: { + channels: Array<{ + alternatives: Array<{ + transcript: string; + confidence: number; + words: Array<{ + word: string; + start: number; + end: number; + confidence: number; + speaker?: number | null; + punctuated_word?: string | null; + }>; + }>; + }>; + }; + }; +}; + +export type PostTranscribeResponse = PostTranscribeResponses[keyof PostTranscribeResponses]; + +export type PostWebhookStripeData = { + body?: never; + headers: { + 'stripe-signature': string; + }; + path?: never; + query?: never; + url: '/webhook/stripe'; +}; + +export type PostWebhookStripeErrors = { + /** + * Invalid or missing Stripe signature + */ + 400: string; + /** + * Internal server error during billing sync + */ + 500: { + error: string; + }; +}; + +export type PostWebhookStripeError = PostWebhookStripeErrors[keyof PostWebhookStripeErrors]; + +export type PostWebhookStripeResponses = { + /** + * Webhook processed successfully + */ + 200: { + ok: boolean; + }; +}; + +export type PostWebhookStripeResponse = PostWebhookStripeResponses[keyof PostWebhookStripeResponses]; + +export type ClientOptions = { + baseUrl: 'https://api.hyprnote.com' | 'http://localhost:4000' | (string & {}); +}; \ No newline at end of file diff --git a/packages/api-client/tsconfig.json b/packages/api-client/tsconfig.json new file mode 100644 index 0000000000..5c9aec8950 --- /dev/null +++ b/packages/api-client/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "esModuleInterop": true + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 82bffddc2b..f3424dbb32 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: '@hono/zod-validator': specifier: ^0.7.5 version: 0.7.5(hono@4.10.7)(zod@4.1.13) + '@hypr/api-client': + specifier: workspace:* + version: link:../../packages/api-client '@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)) @@ -151,6 +154,9 @@ importers: '@huggingface/languages': specifier: ^1.0.0 version: 1.0.0 + '@hypr/api-client': + specifier: workspace:* + version: link:../../packages/api-client '@hypr/codemirror': specifier: workspace:^ version: link:../../packages/codemirror @@ -418,7 +424,7 @@ importers: version: 1.140.0(@tanstack/react-router@1.140.0(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(@tanstack/router-core@1.140.0)(csstype@3.2.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(solid-js@1.9.10) '@tanstack/router-plugin': specifier: ^1.140.0 - version: 1.140.0(@tanstack/react-router@1.140.0(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)) + version: 1.140.0(@tanstack/react-router@1.140.0(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(vite@7.2.7(@types/node@24.10.2)(jiti@1.21.7)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)) '@tauri-apps/cli': specifier: ^2.9.5 version: 2.9.5 @@ -445,7 +451,7 @@ importers: version: 2.0.3 '@vitejs/plugin-react': specifier: ^4.7.0 - version: 4.7.0(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)) + version: 4.7.0(vite@7.2.7(@types/node@24.10.2)(jiti@1.21.7)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)) autoprefixer: specifier: ^10.4.22 version: 10.4.22(postcss@8.5.6) @@ -466,10 +472,10 @@ importers: version: 5.8.3 vite: specifier: ^7.2.7 - version: 7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + version: 7.2.7(@types/node@24.10.2)(jiti@1.21.7)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) vitest: specifier: ^3.2.4 - version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.2)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.6))(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.2)(jiti@1.21.7)(jsdom@27.3.0(postcss@8.5.6))(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) apps/desktop-e2e: devDependencies: @@ -619,6 +625,9 @@ importers: '@deepgram/sdk': specifier: ^4.11.2 version: 4.11.2 + '@hypr/api-client': + specifier: workspace:* + version: link:../../packages/api-client '@hypr/tiptap': specifier: workspace:* version: link:../../packages/tiptap @@ -705,7 +714,7 @@ importers: version: 1.11.19 drizzle-orm: specifier: ^0.44.7 - version: 0.44.7(@cloudflare/workers-types@4.20251209.0)(@opentelemetry/api@1.8.0)(@types/pg@8.15.6)(bun-types@1.3.4)(pg@8.16.3)(postgres@3.4.7) + version: 0.44.7(@cloudflare/workers-types@4.20251209.0)(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(bun-types@1.3.4)(pg@8.16.3)(postgres@3.4.7) exa-js: specifier: ^1.10.2 version: 1.10.2(ws@8.18.3) @@ -847,6 +856,8 @@ importers: specifier: ^3.4.18 version: 3.4.18(tsx@4.21.0)(yaml@2.8.2) + packages/api-client: {} + packages/codemirror: dependencies: '@codemirror/lang-jinja': @@ -22203,7 +22214,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.140.0(@tanstack/react-router@1.140.0(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))': + '@tanstack/router-plugin@1.140.0(@tanstack/react-router@1.140.0(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(vite@7.2.7(@types/node@24.10.2)(jiti@1.21.7)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@babel/core': 7.28.5 '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.5) @@ -22221,7 +22232,7 @@ snapshots: zod: 3.25.76 optionalDependencies: '@tanstack/react-router': 1.140.0(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - vite: 7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.2.7(@types/node@24.10.2)(jiti@1.21.7)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color @@ -23218,7 +23229,7 @@ snapshots: '@vercel/oidc@3.0.5': {} - '@vitejs/plugin-react@4.7.0(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))': + '@vitejs/plugin-react@4.7.0(vite@7.2.7(@types/node@24.10.2)(jiti@1.21.7)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@babel/core': 7.28.5 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5) @@ -23226,7 +23237,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.2.7(@types/node@24.10.2)(jiti@1.21.7)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color @@ -23273,6 +23284,14 @@ snapshots: optionalDependencies: vite: 7.2.7(@types/node@22.19.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + '@vitest/mocker@3.2.4(vite@7.2.7(@types/node@24.10.2)(jiti@1.21.7)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.2.7(@types/node@24.10.2)(jiti@1.21.7)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + '@vitest/mocker@3.2.4(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 3.2.4 @@ -25290,15 +25309,6 @@ snapshots: transitivePeerDependencies: - supports-color - drizzle-orm@0.44.7(@cloudflare/workers-types@4.20251209.0)(@opentelemetry/api@1.8.0)(@types/pg@8.15.6)(bun-types@1.3.4)(pg@8.16.3)(postgres@3.4.7): - optionalDependencies: - '@cloudflare/workers-types': 4.20251209.0 - '@opentelemetry/api': 1.8.0 - '@types/pg': 8.15.6 - bun-types: 1.3.4 - pg: 8.16.3 - postgres: 3.4.7 - drizzle-orm@0.44.7(@cloudflare/workers-types@4.20251209.0)(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(bun-types@1.3.4)(pg@8.16.3)(postgres@3.4.7): optionalDependencies: '@cloudflare/workers-types': 4.20251209.0 @@ -33077,6 +33087,27 @@ snapshots: - tsx - yaml + vite-node@3.2.4(@types/node@24.10.2)(jiti@1.21.7)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + cac: 6.7.14 + debug: 4.4.3(supports-color@10.2.2) + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.2.7(@types/node@24.10.2)(jiti@1.21.7)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vite-node@3.2.4(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2): dependencies: cac: 6.7.14 @@ -33151,6 +33182,22 @@ snapshots: tsx: 4.21.0 yaml: 2.8.2 + vite@7.2.7(@types/node@24.10.2)(jiti@1.21.7)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.53.3 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 24.10.2 + fsevents: 2.3.3 + jiti: 1.21.7 + lightningcss: 1.30.2 + tsx: 4.21.0 + yaml: 2.8.2 + vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2): dependencies: esbuild: 0.25.12 @@ -33250,6 +33297,49 @@ snapshots: - tsx - yaml + vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.2)(jiti@1.21.7)(jsdom@27.3.0(postcss@8.5.6))(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@7.2.7(@types/node@24.10.2)(jiti@1.21.7)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3(supports-color@10.2.2) + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.2.7(@types/node@24.10.2)(jiti@1.21.7)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + vite-node: 3.2.4(@types/node@24.10.2)(jiti@1.21.7)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/debug': 4.1.12 + '@types/node': 24.10.2 + jsdom: 27.3.0(postcss@8.5.6) + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.2)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.6))(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@types/chai': 5.2.3 From 893463bfb4738cc23adf964e5b37bd75ab8e2e71 Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Wed, 10 Dec 2025 11:04:11 +0900 Subject: [PATCH 08/13] rename stuff --- apps/api/src/index.ts | 4 ++-- apps/api/src/scripts/generate-openapi.ts | 4 ++-- apps/web/content/docs/developers/8.api.mdx | 2 +- apps/web/src/components/openapi-docs.tsx | 4 ++-- plugins/webhook/src/lib.rs | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index f7bb5e4b2f..4fa6680ba7 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -69,7 +69,7 @@ app.onError((err, c) => { app.notFound((c) => c.text("not_found", 404)); app.get( - "/openapi.json", + "/openapi.gen.json", openAPISpecs(routes, { documentation: openAPIDocumentation }), ); @@ -78,7 +78,7 @@ app.get( apiReference({ theme: "saturn", spec: { - url: "/openapi.json", + url: "/openapi.gen.json", }, }), ); diff --git a/apps/api/src/scripts/generate-openapi.ts b/apps/api/src/scripts/generate-openapi.ts index 9647f25a47..607860236e 100644 --- a/apps/api/src/scripts/generate-openapi.ts +++ b/apps/api/src/scripts/generate-openapi.ts @@ -9,13 +9,13 @@ async function main() { documentation: openAPIDocumentation, }); - const outputPath = new URL("../../openapi.json", import.meta.url); + const outputPath = new URL("../../openapi.gen.json", import.meta.url); await Bun.write(outputPath, JSON.stringify(specs, null, 2)); console.log(`OpenAPI spec written to ${outputPath.pathname}`); try { await createClient({ - input: "./openapi.json", + input: "./openapi.gen.json", output: "../../packages/api-client/src/generated", }); console.log("OpenAPI client generated successfully"); diff --git a/apps/web/content/docs/developers/8.api.mdx b/apps/web/content/docs/developers/8.api.mdx index 1c133fd610..b9122ba9c8 100644 --- a/apps/web/content/docs/developers/8.api.mdx +++ b/apps/web/content/docs/developers/8.api.mdx @@ -29,7 +29,7 @@ Authorization: Bearer When running the API locally, the OpenAPI documentation is available at: ``` -http://localhost:8787/openapi.json +http://localhost:8787/openapi.gen.json ``` You can use tools like [Swagger UI](https://swagger.io/tools/swagger-ui/) or [Redoc](https://redocly.com/redoc) to visualize the OpenAPI spec. diff --git a/apps/web/src/components/openapi-docs.tsx b/apps/web/src/components/openapi-docs.tsx index d26ae53d86..0957d7b949 100644 --- a/apps/web/src/components/openapi-docs.tsx +++ b/apps/web/src/components/openapi-docs.tsx @@ -69,7 +69,7 @@ export function OpenAPIDocs({ apiUrl }: { apiUrl: string }) { const [loading, setLoading] = useState(true); useEffect(() => { - fetch(`${apiUrl}/openapi.json`) + fetch(`${apiUrl}/openapi.gen.json`) .then((res) => { if (!res.ok) throw new Error(`Failed to fetch: ${res.status}`); return res.json(); @@ -131,7 +131,7 @@ export function OpenAPIDocs({ apiUrl }: { apiUrl: string }) {

Date: Wed, 10 Dec 2025 12:29:38 +0900 Subject: [PATCH 09/13] openapi stuff --- apps/api/{openapi.json => openapi.gen.json} | 237 +++--------------- apps/api/src/openapi.ts | 19 +- apps/api/src/routes/billing.ts | 2 +- apps/api/src/routes/constants.ts | 5 +- apps/api/src/routes/health.ts | 6 +- apps/api/src/routes/llm.ts | 18 +- apps/api/src/routes/rpc.ts | 2 +- apps/api/src/routes/stt.ts | 98 +------- apps/api/src/routes/webhook.ts | 29 +-- apps/api/src/scripts/generate-openapi.ts | 8 + apps/desktop/src/auth.tsx | 23 +- .../src/components/onboarding/login.tsx | 33 ++- apps/desktop/src/utils/index.ts | 13 + apps/web/content/docs/developers/8.api.mdx | 28 +-- packages/api-client/src/generated/sdk.gen.ts | 79 +----- .../api-client/src/generated/types.gen.ts | 203 --------------- .../{openapi.json => openapi.gen.json} | 0 17 files changed, 110 insertions(+), 693 deletions(-) rename apps/api/{openapi.json => openapi.gen.json} (62%) rename plugins/webhook/{openapi.json => openapi.gen.json} (100%) diff --git a/apps/api/openapi.json b/apps/api/openapi.gen.json similarity index 62% rename from apps/api/openapi.json rename to apps/api/openapi.gen.json index 78c44fff68..35051800c6 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.gen.json @@ -2,29 +2,22 @@ "openapi": "3.1.0", "info": { "title": "Hyprnote API", - "description": "API for Hyprnote - AI-powered meeting notes application. APIs are categorized by tags: 'internal' for health checks and internal use, 'app' for endpoints used by the Hyprnote application (requires authentication), and 'webhook' for external service callbacks.", + "description": "Development documentation", "version": "1.0.0" }, "tags": [ { - "name": "internal", - "description": "Internal endpoints for health checks and monitoring" + "name": "private" }, { - "name": "app", - "description": "Endpoints used by the Hyprnote application. Requires Supabase authentication." - }, - { - "name": "webhook", - "description": "Webhook endpoints for external service callbacks" + "name": "public" } ], "components": { "securitySchemes": { "Bearer": { "type": "http", - "scheme": "bearer", - "description": "Supabase JWT token" + "scheme": "bearer" } }, "schemas": {} @@ -44,7 +37,7 @@ "get": { "responses": { "200": { - "description": "API is healthy", + "description": "-", "content": { "application/json": { "schema": { @@ -65,11 +58,9 @@ }, "operationId": "getHealth", "tags": [ - "internal" + "private-skip-openapi" ], - "parameters": [], - "summary": "Health check", - "description": "Returns the health status of the API server." + "parameters": [] } }, "/billing/start-trial": { @@ -97,7 +88,7 @@ }, "operationId": "postBillingStart-trial", "tags": [ - "internal" + "private" ], "parameters": [ { @@ -120,27 +111,17 @@ "post": { "responses": { "200": { - "description": "Chat completion response (streamed or non-streamed)" + "description": "-" }, "401": { - "description": "Unauthorized - missing or invalid authentication", - "content": { - "text/plain": { - "schema": { - "type": "string", - "example": "unauthorized" - } - } - } + "description": "-" } }, "operationId": "postChatCompletions", "tags": [ - "app" + "private-skip-openapi" ], "parameters": [], - "summary": "Chat completions", - "description": "OpenAI-compatible chat completions endpoint. Proxies requests to OpenRouter with automatic model selection. Requires Supabase authentication.", "security": [ { "Bearer": [] @@ -266,7 +247,7 @@ }, "operationId": "getRpcCan-start-trial", "tags": [ - "internal" + "private" ], "parameters": [] } @@ -275,93 +256,26 @@ "get": { "responses": { "101": { - "description": "WebSocket upgrade successful" + "description": "-" }, "400": { - "description": "WebSocket upgrade failed", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - }, - "detail": { - "type": "string" - } - }, - "required": [ - "error" - ], - "additionalProperties": false - } - } - } + "description": "-" }, "401": { - "description": "Unauthorized - missing or invalid authentication", - "content": { - "text/plain": { - "schema": { - "type": "string", - "example": "unauthorized" - } - } - } + "description": "-" }, "502": { - "description": "Upstream STT service unavailable", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - }, - "detail": { - "type": "string" - } - }, - "required": [ - "error" - ], - "additionalProperties": false - } - } - } + "description": "-" }, "504": { - "description": "Upstream STT service timeout", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - }, - "detail": { - "type": "string" - } - }, - "required": [ - "error" - ], - "additionalProperties": false - } - } - } + "description": "-" } }, "operationId": "getListen", "tags": [ - "app" + "private-skip-openapi" ], "parameters": [], - "summary": "Speech-to-text WebSocket", - "description": "WebSocket endpoint for real-time speech-to-text transcription via Deepgram. Requires Supabase authentication in production.", "security": [ { "Bearer": [] @@ -373,7 +287,7 @@ "post": { "responses": { "200": { - "description": "Transcription completed successfully", + "description": "-", "content": { "application/json": { "schema": { @@ -479,90 +393,23 @@ } }, "400": { - "description": "Bad request - missing or invalid audio file", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - }, - "detail": { - "type": "string" - } - }, - "required": [ - "error" - ], - "additionalProperties": false - } - } - } + "description": "-" }, "401": { - "description": "Unauthorized - missing or invalid authentication", - "content": { - "text/plain": { - "schema": { - "type": "string", - "example": "unauthorized" - } - } - } + "description": "-" }, "500": { - "description": "Internal server error during transcription", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - }, - "detail": { - "type": "string" - } - }, - "required": [ - "error" - ], - "additionalProperties": false - } - } - } + "description": "-" }, "502": { - "description": "Upstream STT service error", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - }, - "detail": { - "type": "string" - } - }, - "required": [ - "error" - ], - "additionalProperties": false - } - } - } + "description": "-" } }, "operationId": "postTranscribe", "tags": [ - "app" + "private-skip-openapi" ], "parameters": [], - "summary": "Batch speech-to-text transcription", - "description": "HTTP endpoint for batch speech-to-text transcription via file upload. Supports Deepgram, AssemblyAI, and Soniox providers. Use query parameter ?provider=deepgram|assemblyai|soniox to select provider. Requires Supabase authentication.", "security": [ { "Bearer": [] @@ -574,7 +421,7 @@ "post": { "responses": { "200": { - "description": "Webhook processed successfully", + "description": "-", "content": { "application/json": { "schema": { @@ -593,39 +440,15 @@ } }, "400": { - "description": "Invalid or missing Stripe signature", - "content": { - "text/plain": { - "schema": { - "type": "string", - "example": "missing_stripe_signature" - } - } - } + "description": "-" }, "500": { - "description": "Internal server error during billing sync", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": [ - "error" - ], - "additionalProperties": false - } - } - } + "description": "-" } }, "operationId": "postWebhookStripe", "tags": [ - "webhook" + "private-skip-openapi" ], "parameters": [ { @@ -636,9 +459,7 @@ }, "required": true } - ], - "summary": "Stripe webhook", - "description": "Handles Stripe webhook events for billing synchronization. Requires valid Stripe signature." + ] } } } diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index a772faf5e0..b7f8f78146 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -5,30 +5,13 @@ export const openAPIDocumentation = { info: { title: "Hyprnote API", version: "1.0.0", - description: - "API for Hyprnote - AI-powered meeting notes application. APIs are categorized by tags: 'internal' for health checks and internal use, 'app' for endpoints used by the Hyprnote application (requires authentication), and 'webhook' for external service callbacks.", }, - tags: [ - { - name: API_TAGS.INTERNAL, - description: "Internal endpoints for health checks and monitoring", - }, - { - name: API_TAGS.APP, - description: - "Endpoints used by the Hyprnote application. Requires Supabase authentication.", - }, - { - name: API_TAGS.WEBHOOK, - description: "Webhook endpoints for external service callbacks", - }, - ], + tags: [{ name: API_TAGS.PRIVATE }, { name: API_TAGS.PUBLIC }], components: { securitySchemes: { Bearer: { type: "http" as const, scheme: "bearer", - description: "Supabase JWT token", }, }, }, diff --git a/apps/api/src/routes/billing.ts b/apps/api/src/routes/billing.ts index baa9c26bfd..44d7a2b008 100644 --- a/apps/api/src/routes/billing.ts +++ b/apps/api/src/routes/billing.ts @@ -22,7 +22,7 @@ export const billing = new Hono(); billing.post( "/start-trial", describeRoute({ - tags: [API_TAGS.INTERNAL], + tags: [API_TAGS.PRIVATE], responses: { 200: { description: "result", diff --git a/apps/api/src/routes/constants.ts b/apps/api/src/routes/constants.ts index 2675a14cd6..0a4ea2b316 100644 --- a/apps/api/src/routes/constants.ts +++ b/apps/api/src/routes/constants.ts @@ -1,6 +1,5 @@ export const API_TAGS = { - INTERNAL: "internal", - APP: "app", - WEBHOOK: "webhook", + PRIVATE_SKIP_OPENAPI: "private-skip-openapi", + PRIVATE: "private", PUBLIC: "public", } as const; diff --git a/apps/api/src/routes/health.ts b/apps/api/src/routes/health.ts index a67fea16b3..55bf2d2b62 100644 --- a/apps/api/src/routes/health.ts +++ b/apps/api/src/routes/health.ts @@ -15,12 +15,10 @@ export const health = new Hono(); health.get( "/", describeRoute({ - tags: [API_TAGS.INTERNAL], - summary: "Health check", - description: "Returns the health status of the API server.", + tags: [API_TAGS.PRIVATE_SKIP_OPENAPI], responses: { 200: { - description: "API is healthy", + description: "result", content: { "application/json": { schema: resolver(HealthResponseSchema), diff --git a/apps/api/src/routes/llm.ts b/apps/api/src/routes/llm.ts index 3391bbd810..c59f62c64c 100644 --- a/apps/api/src/routes/llm.ts +++ b/apps/api/src/routes/llm.ts @@ -38,23 +38,11 @@ export const llm = new Hono(); llm.post( "/completions", describeRoute({ - tags: [API_TAGS.APP], - summary: "Chat completions", - description: - "OpenAI-compatible chat completions endpoint. Proxies requests to OpenRouter with automatic model selection. Requires Supabase authentication.", + tags: [API_TAGS.PRIVATE_SKIP_OPENAPI], security: [{ Bearer: [] }], responses: { - 200: { - description: "Chat completion response (streamed or non-streamed)", - }, - 401: { - description: "Unauthorized - missing or invalid authentication", - content: { - "text/plain": { - schema: { type: "string", example: "unauthorized" }, - }, - }, - }, + 200: { description: "-" }, + 401: { description: "-" }, }, }), validator("json", ChatCompletionRequestSchema), diff --git a/apps/api/src/routes/rpc.ts b/apps/api/src/routes/rpc.ts index d4cddf6dc7..7e18ffaff5 100644 --- a/apps/api/src/routes/rpc.ts +++ b/apps/api/src/routes/rpc.ts @@ -16,7 +16,7 @@ export const rpc = new Hono(); rpc.get( "/can-start-trial", describeRoute({ - tags: [API_TAGS.INTERNAL], + tags: [API_TAGS.PRIVATE], responses: { 200: { description: "result", diff --git a/apps/api/src/routes/stt.ts b/apps/api/src/routes/stt.ts index 94a668dcb0..5d9de474af 100644 --- a/apps/api/src/routes/stt.ts +++ b/apps/api/src/routes/stt.ts @@ -8,11 +8,6 @@ import { listenSocketHandler } from "../listen"; import { transcribeBatch } from "../stt"; import { API_TAGS } from "./constants"; -const WebSocketErrorSchema = z.object({ - error: z.string(), - detail: z.string().optional(), -}); - const BatchWordSchema = z.object({ word: z.string(), start: z.number(), @@ -41,57 +36,19 @@ const BatchResponseSchema = z.object({ results: BatchResultsSchema, }); -const BatchErrorSchema = z.object({ - error: z.string(), - detail: z.string().optional(), -}); - export const stt = new Hono(); stt.get( "/listen", describeRoute({ - tags: [API_TAGS.APP], - summary: "Speech-to-text WebSocket", - description: - "WebSocket endpoint for real-time speech-to-text transcription via Deepgram. Requires Supabase authentication in production.", + tags: [API_TAGS.PRIVATE_SKIP_OPENAPI], security: [{ Bearer: [] }], responses: { - 101: { - description: "WebSocket upgrade successful", - }, - 400: { - description: "WebSocket upgrade failed", - content: { - "application/json": { - schema: resolver(WebSocketErrorSchema), - }, - }, - }, - 401: { - description: "Unauthorized - missing or invalid authentication", - content: { - "text/plain": { - schema: { type: "string", example: "unauthorized" }, - }, - }, - }, - 502: { - description: "Upstream STT service unavailable", - content: { - "application/json": { - schema: resolver(WebSocketErrorSchema), - }, - }, - }, - 504: { - description: "Upstream STT service timeout", - content: { - "application/json": { - schema: resolver(WebSocketErrorSchema), - }, - }, - }, + 101: { description: "-" }, + 400: { description: "-" }, + 401: { description: "-" }, + 502: { description: "-" }, + 504: { description: "-" }, }, }), (c, next) => { @@ -102,52 +59,21 @@ stt.get( stt.post( "/transcribe", describeRoute({ - tags: [API_TAGS.APP], - summary: "Batch speech-to-text transcription", - description: - "HTTP endpoint for batch speech-to-text transcription via file upload. Supports Deepgram, AssemblyAI, and Soniox providers. Use query parameter ?provider=deepgram|assemblyai|soniox to select provider. Requires Supabase authentication.", + tags: [API_TAGS.PRIVATE_SKIP_OPENAPI], security: [{ Bearer: [] }], responses: { 200: { - description: "Transcription completed successfully", + description: "result", content: { "application/json": { schema: resolver(BatchResponseSchema), }, }, }, - 400: { - description: "Bad request - missing or invalid audio file", - content: { - "application/json": { - schema: resolver(BatchErrorSchema), - }, - }, - }, - 401: { - description: "Unauthorized - missing or invalid authentication", - content: { - "text/plain": { - schema: { type: "string", example: "unauthorized" }, - }, - }, - }, - 500: { - description: "Internal server error during transcription", - content: { - "application/json": { - schema: resolver(BatchErrorSchema), - }, - }, - }, - 502: { - description: "Upstream STT service error", - content: { - "application/json": { - schema: resolver(BatchErrorSchema), - }, - }, - }, + 400: { description: "-" }, + 401: { description: "-" }, + 500: { description: "-" }, + 502: { description: "-" }, }, }), async (c) => { diff --git a/apps/api/src/routes/webhook.ts b/apps/api/src/routes/webhook.ts index a8f21b3879..1a98acea53 100644 --- a/apps/api/src/routes/webhook.ts +++ b/apps/api/src/routes/webhook.ts @@ -14,44 +14,23 @@ const WebhookSuccessSchema = z.object({ ok: z.boolean(), }); -const WebhookErrorSchema = z.object({ - error: z.string(), -}); - export const webhook = new Hono(); webhook.post( "/stripe", describeRoute({ - tags: [API_TAGS.WEBHOOK], - summary: "Stripe webhook", - description: - "Handles Stripe webhook events for billing synchronization. Requires valid Stripe signature.", + tags: [API_TAGS.PRIVATE_SKIP_OPENAPI], responses: { 200: { - description: "Webhook processed successfully", + description: "result", content: { "application/json": { schema: resolver(WebhookSuccessSchema), }, }, }, - 400: { - description: "Invalid or missing Stripe signature", - content: { - "text/plain": { - schema: { type: "string", example: "missing_stripe_signature" }, - }, - }, - }, - 500: { - description: "Internal server error during billing sync", - content: { - "application/json": { - schema: resolver(WebhookErrorSchema), - }, - }, - }, + 400: { description: "-" }, + 500: { description: "-" }, }, }), validator( diff --git a/apps/api/src/scripts/generate-openapi.ts b/apps/api/src/scripts/generate-openapi.ts index 607860236e..ff79030151 100644 --- a/apps/api/src/scripts/generate-openapi.ts +++ b/apps/api/src/scripts/generate-openapi.ts @@ -3,6 +3,7 @@ import { generateSpecs } from "hono-openapi"; import { openAPIDocumentation } from "../openapi"; import { routes } from "../routes"; +import { API_TAGS } from "../routes/constants"; async function main() { const specs = await generateSpecs(routes, { @@ -17,6 +18,13 @@ async function main() { await createClient({ input: "./openapi.gen.json", output: "../../packages/api-client/src/generated", + parser: { + filters: { + tags: { + include: [API_TAGS.PRIVATE], + }, + }, + }, }); console.log("OpenAPI client generated successfully"); } catch (error) { diff --git a/apps/desktop/src/auth.tsx b/apps/desktop/src/auth.tsx index 6a7b2fec96..81d6d47ca5 100644 --- a/apps/desktop/src/auth.tsx +++ b/apps/desktop/src/auth.tsx @@ -6,7 +6,6 @@ import { SupabaseClient, type SupportedStorage, } from "@supabase/supabase-js"; -import { getIdentifier } from "@tauri-apps/api/app"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { onOpenUrl } from "@tauri-apps/plugin-deep-link"; import { fetch as tauriFetch } from "@tauri-apps/plugin-http"; @@ -21,17 +20,7 @@ import { } from "react"; import { env } from "./env"; - -const getScheme = async (): Promise => { - const id = await getIdentifier(); - const schemes: Record = { - "com.hyprnote.stable": "hyprnote", - "com.hyprnote.nightly": "hyprnote-nightly", - "com.hyprnote.staging": "hyprnote-staging", - "com.hyprnote.dev": "hypr", - }; - return schemes[id] ?? "hypr"; -}; +import { getScheme } from "./utils"; const isLocalAuthServer = (url: string | undefined): boolean => { if (!url) return false; @@ -103,7 +92,7 @@ const AuthContext = createContext<{ session: Session | null; signIn: () => Promise; signOut: () => Promise; - refreshSession: () => Promise; + refreshSession: () => Promise; handleAuthCallback: (url: string) => Promise; getHeaders: () => Record | null; getAvatarUrl: () => Promise; @@ -251,18 +240,20 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { } }; - const refreshSession = async () => { + const refreshSession = async (): Promise => { if (!supabase) { - return; + return null; } const { data, error } = await supabase.auth.refreshSession(); if (error) { - return; + return null; } if (data.session) { setSession(data.session); + return data.session; } + return null; }; const getHeaders = useCallback(() => { diff --git a/apps/desktop/src/components/onboarding/login.tsx b/apps/desktop/src/components/onboarding/login.tsx index d726d87497..5044abaec9 100644 --- a/apps/desktop/src/components/onboarding/login.tsx +++ b/apps/desktop/src/components/onboarding/login.tsx @@ -1,6 +1,7 @@ +import { jwtDecode } from "jwt-decode"; import { useCallback, useEffect, useRef, useState } from "react"; -import { postBillingStartTrial } from "@hypr/api-client"; +import { getRpcCanStartTrial, postBillingStartTrial } from "@hypr/api-client"; import { createClient, createConfig } from "@hypr/api-client/client"; import { Button } from "@hypr/ui/components/ui/button"; import { Input } from "@hypr/ui/components/ui/input"; @@ -9,6 +10,15 @@ import { useAuth } from "../../auth"; import { env } from "../../env"; import type { OnboardingNext } from "./shared"; +function checkIsPro(accessToken: string): boolean { + try { + const decoded = jwtDecode<{ entitlements?: string[] }>(accessToken); + return decoded.entitlements?.includes("hyprnote_pro") ?? false; + } catch { + return false; + } +} + type LoginProps = { onNext: OnboardingNext; }; @@ -35,14 +45,21 @@ export function Login({ onNext }: LoginProps) { }), ); - postBillingStartTrial({ - client, - query: { interval: "yearly" }, - }).finally(() => { - onNext({ local: false }); - }); + (async () => { + const { data } = await getRpcCanStartTrial({ client }); + if (data?.canStartTrial) { + await postBillingStartTrial({ + client, + query: { interval: "yearly" }, + }); + } + + const newSession = await auth.refreshSession(); + const isPro = newSession ? checkIsPro(newSession.access_token) : false; + onNext({ local: !isPro }); + })(); } - }, [auth?.session, onNext]); + }, [auth?.session, auth?.refreshSession, onNext]); useEffect(() => { handleSignIn(); diff --git a/apps/desktop/src/utils/index.ts b/apps/desktop/src/utils/index.ts index 97b950c4aa..c8f7075825 100644 --- a/apps/desktop/src/utils/index.ts +++ b/apps/desktop/src/utils/index.ts @@ -1,7 +1,20 @@ +import { getIdentifier } from "@tauri-apps/api/app"; + export * from "./timeline"; export * from "./segment"; export const id = () => crypto.randomUUID() as string; +export const getScheme = async (): Promise => { + const id = await getIdentifier(); + const schemes: Record = { + "com.hyprnote.stable": "hyprnote", + "com.hyprnote.nightly": "hyprnote-nightly", + "com.hyprnote.staging": "hyprnote-staging", + "com.hyprnote.dev": "hypr", + }; + return schemes[id] ?? "hypr"; +}; + // https://www.rfc-editor.org/rfc/rfc4122#section-4.1.7 export const DEFAULT_USER_ID = "00000000-0000-0000-0000-000000000000"; diff --git a/apps/web/content/docs/developers/8.api.mdx b/apps/web/content/docs/developers/8.api.mdx index b9122ba9c8..cf46eee53a 100644 --- a/apps/web/content/docs/developers/8.api.mdx +++ b/apps/web/content/docs/developers/8.api.mdx @@ -6,30 +6,4 @@ summary: "OpenAPI documentation for the Hyprnote API" ## API Reference -The Hyprnote API provides endpoints for various functionalities used by the application. The API is categorized into three types: - -- **Internal**: Endpoints for health checks and monitoring -- **App**: Endpoints used by the Hyprnote application (requires authentication) -- **Webhook**: Endpoints for external service callbacks (e.g., Stripe) - -## Authentication - -Most API endpoints require authentication using a Supabase JWT token. Include the token in the `Authorization` header: - -``` -Authorization: Bearer -``` - -## API Documentation - - - -## Local Development - -When running the API locally, the OpenAPI documentation is available at: - -``` -http://localhost:8787/openapi.gen.json -``` - -You can use tools like [Swagger UI](https://swagger.io/tools/swagger-ui/) or [Redoc](https://redocly.com/redoc) to visualize the OpenAPI spec. +Public API is coming soon. diff --git a/packages/api-client/src/generated/sdk.gen.ts b/packages/api-client/src/generated/sdk.gen.ts index afbab809f8..845da86c1b 100644 --- a/packages/api-client/src/generated/sdk.gen.ts +++ b/packages/api-client/src/generated/sdk.gen.ts @@ -1,7 +1,7 @@ // This file is auto-generated by @hey-api/openapi-ts import type { Options as ClientOptions, TDataShape, Client } from './client'; -import type { GetHealthData, GetHealthResponses, PostBillingStartTrialData, PostBillingStartTrialResponses, PostChatCompletionsData, PostChatCompletionsResponses, PostChatCompletionsErrors, GetRpcCanStartTrialData, GetRpcCanStartTrialResponses, GetListenData, GetListenErrors, PostTranscribeData, PostTranscribeResponses, PostTranscribeErrors, PostWebhookStripeData, PostWebhookStripeResponses, PostWebhookStripeErrors } from './types.gen'; +import type { PostBillingStartTrialData, PostBillingStartTrialResponses, GetRpcCanStartTrialData, GetRpcCanStartTrialResponses } from './types.gen'; import { client as _heyApiClient } from './client.gen'; export type Options = ClientOptions & { @@ -18,17 +18,6 @@ export type Options; }; -/** - * Health check - * Returns the health status of the API server. - */ -export const getHealth = (options?: Options) => { - return (options?.client ?? _heyApiClient).get({ - url: '/health', - ...options - }); -}; - export const postBillingStartTrial = (options: Options) => { return (options.client ?? _heyApiClient).post({ url: '/billing/start-trial', @@ -36,75 +25,9 @@ export const postBillingStartTrial = (opti }); }; -/** - * Chat completions - * OpenAI-compatible chat completions endpoint. Proxies requests to OpenRouter with automatic model selection. Requires Supabase authentication. - */ -export const postChatCompletions = (options?: Options) => { - return (options?.client ?? _heyApiClient).post({ - security: [ - { - scheme: 'bearer', - type: 'http' - } - ], - url: '/chat/completions', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options?.headers - } - }); -}; - export const getRpcCanStartTrial = (options?: Options) => { return (options?.client ?? _heyApiClient).get({ url: '/rpc/can-start-trial', ...options }); -}; - -/** - * Speech-to-text WebSocket - * WebSocket endpoint for real-time speech-to-text transcription via Deepgram. Requires Supabase authentication in production. - */ -export const getListen = (options?: Options) => { - return (options?.client ?? _heyApiClient).get({ - security: [ - { - scheme: 'bearer', - type: 'http' - } - ], - url: '/listen', - ...options - }); -}; - -/** - * Batch speech-to-text transcription - * HTTP endpoint for batch speech-to-text transcription via file upload. Supports Deepgram, AssemblyAI, and Soniox providers. Use query parameter ?provider=deepgram|assemblyai|soniox to select provider. Requires Supabase authentication. - */ -export const postTranscribe = (options?: Options) => { - return (options?.client ?? _heyApiClient).post({ - security: [ - { - scheme: 'bearer', - type: 'http' - } - ], - url: '/transcribe', - ...options - }); -}; - -/** - * Stripe webhook - * Handles Stripe webhook events for billing synchronization. Requires valid Stripe signature. - */ -export const postWebhookStripe = (options: Options) => { - return (options.client ?? _heyApiClient).post({ - url: '/webhook/stripe', - ...options - }); }; \ No newline at end of file diff --git a/packages/api-client/src/generated/types.gen.ts b/packages/api-client/src/generated/types.gen.ts index 7b1734ad8c..5a9ca908f8 100644 --- a/packages/api-client/src/generated/types.gen.ts +++ b/packages/api-client/src/generated/types.gen.ts @@ -1,23 +1,5 @@ // This file is auto-generated by @hey-api/openapi-ts -export type GetHealthData = { - body?: never; - path?: never; - query?: never; - url: '/health'; -}; - -export type GetHealthResponses = { - /** - * API is healthy - */ - 200: { - status: string; - }; -}; - -export type GetHealthResponse = GetHealthResponses[keyof GetHealthResponses]; - export type PostBillingStartTrialData = { body?: never; path?: never; @@ -38,54 +20,6 @@ export type PostBillingStartTrialResponses = { export type PostBillingStartTrialResponse = PostBillingStartTrialResponses[keyof PostBillingStartTrialResponses]; -export type PostChatCompletionsData = { - body?: { - model?: string; - messages: Array<{ - role: 'system' | 'user' | 'assistant'; - content: string; - }>; - tools?: Array; - tool_choice?: ('none' | 'auto' | 'required') | { - type: 'function'; - function: { - name: string; - }; - }; - stream?: boolean; - temperature?: number; - max_tokens?: number; - [key: string]: unknown | string | Array<{ - role: 'system' | 'user' | 'assistant'; - content: string; - }> | Array | (('none' | 'auto' | 'required') | { - type: 'function'; - function: { - name: string; - }; - }) | boolean | number | undefined; - }; - path?: never; - query?: never; - url: '/chat/completions'; -}; - -export type PostChatCompletionsErrors = { - /** - * Unauthorized - missing or invalid authentication - */ - 401: string; -}; - -export type PostChatCompletionsError = PostChatCompletionsErrors[keyof PostChatCompletionsErrors]; - -export type PostChatCompletionsResponses = { - /** - * Chat completion response (streamed or non-streamed) - */ - 200: unknown; -}; - export type GetRpcCanStartTrialData = { body?: never; path?: never; @@ -104,143 +38,6 @@ export type GetRpcCanStartTrialResponses = { export type GetRpcCanStartTrialResponse = GetRpcCanStartTrialResponses[keyof GetRpcCanStartTrialResponses]; -export type GetListenData = { - body?: never; - path?: never; - query?: never; - url: '/listen'; -}; - -export type GetListenErrors = { - /** - * WebSocket upgrade failed - */ - 400: { - error: string; - detail?: string; - }; - /** - * Unauthorized - missing or invalid authentication - */ - 401: string; - /** - * Upstream STT service unavailable - */ - 502: { - error: string; - detail?: string; - }; - /** - * Upstream STT service timeout - */ - 504: { - error: string; - detail?: string; - }; -}; - -export type GetListenError = GetListenErrors[keyof GetListenErrors]; - -export type PostTranscribeData = { - body?: never; - path?: never; - query?: never; - url: '/transcribe'; -}; - -export type PostTranscribeErrors = { - /** - * Bad request - missing or invalid audio file - */ - 400: { - error: string; - detail?: string; - }; - /** - * Unauthorized - missing or invalid authentication - */ - 401: string; - /** - * Internal server error during transcription - */ - 500: { - error: string; - detail?: string; - }; - /** - * Upstream STT service error - */ - 502: { - error: string; - detail?: string; - }; -}; - -export type PostTranscribeError = PostTranscribeErrors[keyof PostTranscribeErrors]; - -export type PostTranscribeResponses = { - /** - * Transcription completed successfully - */ - 200: { - metadata: unknown; - results: { - channels: Array<{ - alternatives: Array<{ - transcript: string; - confidence: number; - words: Array<{ - word: string; - start: number; - end: number; - confidence: number; - speaker?: number | null; - punctuated_word?: string | null; - }>; - }>; - }>; - }; - }; -}; - -export type PostTranscribeResponse = PostTranscribeResponses[keyof PostTranscribeResponses]; - -export type PostWebhookStripeData = { - body?: never; - headers: { - 'stripe-signature': string; - }; - path?: never; - query?: never; - url: '/webhook/stripe'; -}; - -export type PostWebhookStripeErrors = { - /** - * Invalid or missing Stripe signature - */ - 400: string; - /** - * Internal server error during billing sync - */ - 500: { - error: string; - }; -}; - -export type PostWebhookStripeError = PostWebhookStripeErrors[keyof PostWebhookStripeErrors]; - -export type PostWebhookStripeResponses = { - /** - * Webhook processed successfully - */ - 200: { - ok: boolean; - }; -}; - -export type PostWebhookStripeResponse = PostWebhookStripeResponses[keyof PostWebhookStripeResponses]; - export type ClientOptions = { baseUrl: 'https://api.hyprnote.com' | 'http://localhost:4000' | (string & {}); }; \ No newline at end of file diff --git a/plugins/webhook/openapi.json b/plugins/webhook/openapi.gen.json similarity index 100% rename from plugins/webhook/openapi.json rename to plugins/webhook/openapi.gen.json From a705d7fdaf6366701f9fefde5ab756df1ff06a5f Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Wed, 10 Dec 2025 13:50:30 +0900 Subject: [PATCH 10/13] onboarding wip --- apps/desktop/src/auth.tsx | 1 + apps/desktop/src/billing.tsx | 21 +- .../src/components/onboarding/config.tsx | 18 +- .../onboarding/configure-notice.tsx | 60 +++++ .../src/components/onboarding/login.tsx | 146 +++++------ .../src/components/onboarding/machine.ts | 50 ++++ .../src/components/onboarding/model.tsx | 242 ------------------ .../src/components/onboarding/shared.tsx | 5 +- .../src/routes/app/onboarding/index.tsx | 125 ++++++--- apps/web/src/routes/_view/callback/auth.tsx | 2 +- 10 files changed, 285 insertions(+), 385 deletions(-) create mode 100644 apps/desktop/src/components/onboarding/configure-notice.tsx create mode 100644 apps/desktop/src/components/onboarding/machine.ts delete mode 100644 apps/desktop/src/components/onboarding/model.tsx diff --git a/apps/desktop/src/auth.tsx b/apps/desktop/src/auth.tsx index 81d6d47ca5..fa28e7c37f 100644 --- a/apps/desktop/src/auth.tsx +++ b/apps/desktop/src/auth.tsx @@ -125,6 +125,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { if (res.error) { console.error(res.error); } else { + setSession(res.data.session); setServerReachable(true); supabase.auth.startAutoRefresh(); } diff --git a/apps/desktop/src/billing.tsx b/apps/desktop/src/billing.tsx index f6189fc93b..0d0e7f40d4 100644 --- a/apps/desktop/src/billing.tsx +++ b/apps/desktop/src/billing.tsx @@ -11,6 +11,15 @@ import { import { useAuth } from "./auth"; import { env } from "./env"; +export function getEntitlementsFromToken(accessToken: string): string[] { + try { + const decoded = jwtDecode<{ entitlements?: string[] }>(accessToken); + return decoded.entitlements ?? []; + } catch { + return []; + } +} + type BillingContextValue = { entitlements: string[]; isPro: boolean; @@ -28,17 +37,7 @@ export function BillingProvider({ children }: { children: ReactNode }) { if (!auth?.session?.access_token) { return []; } - - try { - const decoded = jwtDecode<{ entitlements?: string[] }>( - auth.session.access_token, - ); - const result = decoded.entitlements ?? []; - return result; - } catch (e) { - console.error(e); - return []; - } + return getEntitlementsFromToken(auth.session.access_token); }, [auth?.session?.access_token]); const isPro = useMemo( diff --git a/apps/desktop/src/components/onboarding/config.tsx b/apps/desktop/src/components/onboarding/config.tsx index 78bc000c5e..23b90e2922 100644 --- a/apps/desktop/src/components/onboarding/config.tsx +++ b/apps/desktop/src/components/onboarding/config.tsx @@ -4,13 +4,17 @@ import type { ComponentType } from "react"; import { useAuth } from "../../auth"; import { usePlatform } from "../../hooks/usePlatform"; +import { ConfigureNotice } from "./configure-notice"; import { Login } from "./login"; -import { ModelDownload } from "./model"; import { Permissions } from "./permissions"; import type { OnboardingNext } from "./shared"; import { Welcome } from "./welcome"; -export type OnboardingStepId = "welcome" | "login" | "permissions" | "model"; +export type OnboardingStepId = + | "welcome" + | "login" + | "configure-notice" + | "permissions"; export type OnboardingContext = { platform: Platform; @@ -39,16 +43,16 @@ export const STEP_CONFIGS: OnboardingStepConfig[] = [ shouldShow: (ctx) => !ctx.local, component: Login, }, + { + id: "configure-notice", + shouldShow: (ctx) => ctx.local, + component: ConfigureNotice, + }, { id: "permissions", shouldShow: (ctx) => ctx.platform === "macos", component: Permissions, }, - { - id: "model", - shouldShow: (ctx) => ctx.local && ctx.isAppleSilicon, - component: ModelDownload, - }, ]; export function useOnboardingContext( diff --git a/apps/desktop/src/components/onboarding/configure-notice.tsx b/apps/desktop/src/components/onboarding/configure-notice.tsx new file mode 100644 index 0000000000..cb6eb8cb8a --- /dev/null +++ b/apps/desktop/src/components/onboarding/configure-notice.tsx @@ -0,0 +1,60 @@ +import { Button } from "@hypr/ui/components/ui/button"; + +import { OnboardingContainer, type OnboardingNext } from "./shared"; + +type ConfigureNoticeProps = { + onNext: OnboardingNext; +}; + +export function ConfigureNotice({ onNext }: ConfigureNoticeProps) { + return ( + +

+ +
+ + +
+ + ); +} + +export function Requirment({ + title, + description, +}: { + title: string; + description: string; +}) { + return ( +
+

{title}

+

{description}

+
+ ); +} diff --git a/apps/desktop/src/components/onboarding/login.tsx b/apps/desktop/src/components/onboarding/login.tsx index 5044abaec9..c7e846fbe0 100644 --- a/apps/desktop/src/components/onboarding/login.tsx +++ b/apps/desktop/src/components/onboarding/login.tsx @@ -1,5 +1,4 @@ -import { jwtDecode } from "jwt-decode"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { getRpcCanStartTrial, postBillingStartTrial } from "@hypr/api-client"; import { createClient, createConfig } from "@hypr/api-client/client"; @@ -7,31 +6,16 @@ import { Button } from "@hypr/ui/components/ui/button"; import { Input } from "@hypr/ui/components/ui/input"; import { useAuth } from "../../auth"; +import { getEntitlementsFromToken } from "../../billing"; import { env } from "../../env"; -import type { OnboardingNext } from "./shared"; +import { OnboardingContainer, type OnboardingNext } from "./shared"; -function checkIsPro(accessToken: string): boolean { - try { - const decoded = jwtDecode<{ entitlements?: string[] }>(accessToken); - return decoded.entitlements?.includes("hyprnote_pro") ?? false; - } catch { - return false; - } -} - -type LoginProps = { - onNext: OnboardingNext; -}; - -export function Login({ onNext }: LoginProps) { +export function Login({ onNext }: { onNext: OnboardingNext }) { const auth = useAuth(); const [showManualInput, setShowManualInput] = useState(false); const [callbackUrl, setCallbackUrl] = useState(""); - const handleSignIn = useCallback(async () => { - await auth?.signIn(); - }, [auth]); - + const signInStarted = useRef(false); const trialStarted = useRef(false); useEffect(() => { @@ -46,52 +30,51 @@ export function Login({ onNext }: LoginProps) { ); (async () => { - const { data } = await getRpcCanStartTrial({ client }); - if (data?.canStartTrial) { - await postBillingStartTrial({ - client, - query: { interval: "yearly" }, - }); + try { + const { data } = await getRpcCanStartTrial({ client }); + if (data?.canStartTrial) { + await postBillingStartTrial({ + client, + query: { interval: "monthly" }, + }); + } + + const newSession = await auth.refreshSession(); + const isPro = newSession + ? getEntitlementsFromToken(newSession.access_token).includes( + "hyprnote_pro", + ) + : false; + onNext({ local: !isPro }); + } catch (e) { + console.error("Failed to process login:", e); + onNext({ local: true }); } - - const newSession = await auth.refreshSession(); - const isPro = newSession ? checkIsPro(newSession.access_token) : false; - onNext({ local: !isPro }); })(); } }, [auth?.session, auth?.refreshSession, onNext]); useEffect(() => { - handleSignIn(); - }, [handleSignIn]); + if (!signInStarted.current) { + signInStarted.current = true; + auth?.signIn(); + } + }, [auth]); if (showManualInput) { return ( - <> - HYPRNOTE + setCallbackUrl(e.target.value)} /> - -
-

- Manual callback -

-

- Paste the callback URL from your browser -

-
- -
- setCallbackUrl(e.target.value)} - /> +
- + ); } return ( - <> - HYPRNOTE - -
-

- Waiting for sign-in... -

-

- Complete the sign-in process in your browser -

-
- -
- +
- + ); } diff --git a/apps/desktop/src/components/onboarding/machine.ts b/apps/desktop/src/components/onboarding/machine.ts new file mode 100644 index 0000000000..5c56a9e65f --- /dev/null +++ b/apps/desktop/src/components/onboarding/machine.ts @@ -0,0 +1,50 @@ +import { fromTransition } from "xstate"; + +import { + type OnboardingContext, + type OnboardingStepId, + STEP_CONFIGS, +} from "./config"; + +export type OnboardingState = { + step: OnboardingStepId; + local: boolean; + done: boolean; +}; + +export type OnboardingEvent = + | { type: "NEXT"; local?: boolean } + | { type: "GO_TO"; step: OnboardingStepId; local?: boolean }; + +function getNextStep( + ctx: OnboardingContext, + currentStep: OnboardingStepId, +): OnboardingStepId | null { + const visibleSteps = STEP_CONFIGS.filter((s) => s.shouldShow(ctx)); + const currentIndex = visibleSteps.findIndex((s) => s.id === currentStep); + return visibleSteps[currentIndex + 1]?.id ?? null; +} + +export function createOnboardingLogic( + ctx: OnboardingContext, + initialStep: OnboardingStepId, + initialLocal: boolean, +) { + return fromTransition( + (state: OnboardingState, event: OnboardingEvent): OnboardingState => { + const nextLocal = event.local ?? state.local; + const mergedCtx = { ...ctx, local: nextLocal }; + + if (event.type === "GO_TO") { + return { step: event.step, local: nextLocal, done: false }; + } + + const nextStep = getNextStep(mergedCtx, state.step); + if (nextStep) { + return { step: nextStep, local: nextLocal, done: false }; + } + return { ...state, local: nextLocal, done: true }; + }, + { step: initialStep, local: initialLocal, done: false }, + ); +} diff --git a/apps/desktop/src/components/onboarding/model.tsx b/apps/desktop/src/components/onboarding/model.tsx deleted file mode 100644 index 9384116a86..0000000000 --- a/apps/desktop/src/components/onboarding/model.tsx +++ /dev/null @@ -1,242 +0,0 @@ -import { Icon } from "@iconify-icon/react"; -import { useQuery } from "@tanstack/react-query"; -import { useCallback, useEffect, useState } from "react"; - -import { - commands as localSttCommands, - events as localSttEvents, - type SupportedSttModel, -} from "@hypr/plugin-local-stt"; -import { Button } from "@hypr/ui/components/ui/button"; -import { cn } from "@hypr/utils"; - -import * as main from "../../store/tinybase/main"; -import { OnboardingContainer, type OnboardingNext } from "./shared"; - -type ModelDownloadProps = { - onNext: OnboardingNext; -}; - -const DEFAULT_MODEL: SupportedSttModel = "am-parakeet-v2"; - -export function ModelDownload({ onNext }: ModelDownloadProps) { - const { - progress, - hasError, - isDownloaded, - showProgress, - handleDownload, - handleCancel, - } = useModelDownload(DEFAULT_MODEL); - - return ( - - onNext()} - /> - - ); -} - -type ModelCardProps = { - name: string; - description: string; - size: string; - isDownloaded: boolean; - showProgress: boolean; - progress: number; - hasError: boolean; - onDownload: () => void; - onCancel: () => void; - onContinue: () => void; -}; - -function ModelCard({ - name, - description, - size, - isDownloaded, - showProgress, - progress, - hasError, - onDownload, - onCancel, - onContinue, -}: ModelCardProps) { - return ( -
-
-
- {name} - {size} -
- {description} -
- - {isDownloaded ? ( - - ) : ( - - )} -
- ); -} - -function useModelDownload(model: SupportedSttModel) { - const [progress, setProgress] = useState(0); - const [isStarting, setIsStarting] = useState(false); - const [hasError, setHasError] = useState(false); - - const isDownloaded = useQuery({ - queryKey: ["local-stt", "is-downloaded", model], - queryFn: async () => { - const result = await localSttCommands.isModelDownloaded(model); - return result.status === "ok" ? result.data : false; - }, - }); - - const isDownloading = useQuery({ - queryKey: ["local-stt", "is-downloading", model], - queryFn: async () => { - const result = await localSttCommands.isModelDownloading(model); - return result.status === "ok" ? result.data : false; - }, - refetchInterval: 1000, - }); - - const showProgress = - !isDownloaded.data && (isStarting || (isDownloading.data ?? false)); - - const handleSelectModel = main.UI.useSetValueCallback( - "current_stt_model", - (m: SupportedSttModel) => m, - [], - main.STORE_ID, - ); - - const handleSelectProvider = main.UI.useSetValueCallback( - "current_stt_provider", - (p: string) => p, - [], - main.STORE_ID, - ); - - useEffect(() => { - if (isDownloading.data) { - setIsStarting(false); - } - }, [isDownloading.data]); - - useEffect(() => { - const unlisten = localSttEvents.downloadProgressPayload.listen((event) => { - if (event.payload.model === model) { - if (event.payload.progress < 0) { - setHasError(true); - setIsStarting(false); - setProgress(0); - } else { - setHasError(false); - setProgress(Math.max(0, Math.min(100, event.payload.progress))); - } - } - }); - - return () => { - unlisten.then((fn) => fn()); - }; - }, [model]); - - useEffect(() => { - if (isDownloaded.data) { - setProgress(0); - handleSelectProvider("hyprnote"); - handleSelectModel(model); - } - }, [isDownloaded.data, model, handleSelectModel, handleSelectProvider]); - - const handleDownload = useCallback(() => { - if (isDownloaded.data || isDownloading.data || isStarting) { - return; - } - setHasError(false); - setIsStarting(true); - setProgress(0); - localSttCommands.downloadModel(model).then((result) => { - if (result.status === "error") { - setHasError(true); - setIsStarting(false); - } - }); - }, [isDownloaded.data, isDownloading.data, isStarting, model]); - - const handleCancel = useCallback(() => { - localSttCommands.cancelDownload(model); - setIsStarting(false); - setProgress(0); - }, [model]); - - return { - progress, - hasError, - isDownloaded: isDownloaded.data ?? false, - showProgress, - handleDownload, - handleCancel, - }; -} diff --git a/apps/desktop/src/components/onboarding/shared.tsx b/apps/desktop/src/components/onboarding/shared.tsx index 24effef85a..e1a086b949 100644 --- a/apps/desktop/src/components/onboarding/shared.tsx +++ b/apps/desktop/src/components/onboarding/shared.tsx @@ -3,7 +3,10 @@ import type { ReactNode } from "react"; import { Button } from "@hypr/ui/components/ui/button"; -export type OnboardingNext = (params?: { local?: boolean }) => void; +export type OnboardingNext = (params?: { + local?: boolean; + step?: string; +}) => void; type OnboardingAction = { kind: "skip" | "next"; diff --git a/apps/desktop/src/routes/app/onboarding/index.tsx b/apps/desktop/src/routes/app/onboarding/index.tsx index 2715b3a1fc..389f2afb9c 100644 --- a/apps/desktop/src/routes/app/onboarding/index.tsx +++ b/apps/desktop/src/routes/app/onboarding/index.tsx @@ -1,14 +1,20 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router"; -import { useCallback, useMemo } from "react"; +import { useActorRef, useSelector } from "@xstate/react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; import { z } from "zod"; import { commands as windowsCommands } from "@hypr/plugin-windows"; import { + type OnboardingContext, type OnboardingStepId, STEP_CONFIGS, useOnboardingContext, } from "../../../components/onboarding/config"; +import { + createOnboardingLogic, + type OnboardingState, +} from "../../../components/onboarding/machine"; import type { OnboardingNext } from "../../../components/onboarding/shared"; import { commands } from "../../../types/tauri.gen"; @@ -27,60 +33,97 @@ export const Route = createFileRoute("/app/onboarding/")({ component: Component, }); +function finishOnboarding() { + commands.setOnboardingNeeded(false).catch((e) => console.error(e)); + windowsCommands.windowShow({ type: "main" }).then(() => { + windowsCommands.windowDestroy({ type: "onboarding" }); + }); +} + +function LoadingState() { + return ( +
+
+
+ ); +} + function Component() { - const navigate = useNavigate(); const { step, local } = Route.useSearch(); const ctx = useOnboardingContext(local); - const visibleSteps = useMemo(() => { - if (!ctx) { - return []; + if (!ctx) { + return ; + } + + return ; +} + +function OnboardingFlow({ + ctx, + initialStep, + initialLocal, +}: { + ctx: OnboardingContext; + initialStep: OnboardingStepId; + initialLocal: boolean; +}) { + const navigate = useNavigate(); + + const logic = useMemo( + () => createOnboardingLogic(ctx, initialStep, initialLocal), + [ctx, initialStep, initialLocal], + ); + + const actorRef = useActorRef(logic); + const state = useSelector(actorRef, (s) => s.context as OnboardingState); + const prevStateRef = useRef(state); + + useEffect(() => { + const prev = prevStateRef.current; + prevStateRef.current = state; + + if (state.done) { + finishOnboarding(); + return; } - return STEP_CONFIGS.filter((s) => s.shouldShow(ctx)); - }, [ctx]); - const currentIndex = visibleSteps.findIndex((s) => s.id === step); - const currentConfig = visibleSteps[currentIndex]; + if (state.step !== prev.step || state.local !== prev.local) { + navigate({ + to: "/app/onboarding", + search: { step: state.step, local: state.local }, + }); + } + }, [state, navigate]); const goNext = useCallback( (params) => { - if (!ctx) { - return; - } - - const nextLocal = params?.local ?? local; - const nextCtx = { ...ctx, local: nextLocal }; - const nextVisibleSteps = STEP_CONFIGS.filter((s) => - s.shouldShow(nextCtx), - ); - const nextCurrentIndex = nextVisibleSteps.findIndex((s) => s.id === step); - const nextStep = nextVisibleSteps[nextCurrentIndex + 1]; - - if (nextStep) { - navigate({ - to: "/app/onboarding", - search: { step: nextStep.id, local: nextLocal }, + if (params?.step) { + actorRef.send({ + type: "GO_TO", + step: params.step as OnboardingStepId, + local: params.local, }); - return; + } else { + actorRef.send({ type: "NEXT", local: params?.local }); } - - commands.setOnboardingNeeded(false).catch((e) => console.error(e)); - windowsCommands.windowShow({ type: "main" }).then(() => { - windowsCommands.windowDestroy({ type: "onboarding" }); - }); }, - [ctx, navigate, step, local], + [actorRef], + ); + + const visibleSteps = useMemo( + () => + STEP_CONFIGS.filter((s) => s.shouldShow({ ...ctx, local: state.local })), + [ctx, state.local], ); - if (!ctx || !currentConfig) { - return ( -
-
-
- ); + const currentConfig = visibleSteps.find((s) => s.id === state.step); + + if (!currentConfig) { + return ; } const StepComponent = currentConfig.component; diff --git a/apps/web/src/routes/_view/callback/auth.tsx b/apps/web/src/routes/_view/callback/auth.tsx index f4842e5b5c..4064f93cea 100644 --- a/apps/web/src/routes/_view/callback/auth.tsx +++ b/apps/web/src/routes/_view/callback/auth.tsx @@ -96,7 +96,7 @@ function Component() { ) { setTimeout(() => { handleDeeplink(); - }, 2000); + }, 200); } }, [search]); From 444c8887627736fdd93a5c9c56c763653ba53dff Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Wed, 10 Dec 2025 13:58:31 +0900 Subject: [PATCH 11/13] fix bearer case sensitive --- apps/api/src/middleware/load-test-auth.ts | 2 +- apps/api/src/middleware/supabase.ts | 2 +- apps/desktop/src/components/settings/account.tsx | 3 ++- apps/web/src/middleware/supabase.ts | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/api/src/middleware/load-test-auth.ts b/apps/api/src/middleware/load-test-auth.ts index 64d8a9fc2c..003e732ef9 100644 --- a/apps/api/src/middleware/load-test-auth.ts +++ b/apps/api/src/middleware/load-test-auth.ts @@ -6,7 +6,7 @@ export const loadTestOverride = createMiddleware<{ Variables: { supabaseUserId: string }; }>(async (c, next) => { if (env.OVERRIDE_AUTH) { - const token = c.req.header("Authorization")?.replace("Bearer ", ""); + const token = c.req.header("Authorization")?.replace(/^bearer /i, ""); if (token === env.OVERRIDE_AUTH) { c.set("supabaseUserId", "load-test-user"); return next(); diff --git a/apps/api/src/middleware/supabase.ts b/apps/api/src/middleware/supabase.ts index 626ae69dc8..b27f7a9d5c 100644 --- a/apps/api/src/middleware/supabase.ts +++ b/apps/api/src/middleware/supabase.ts @@ -11,7 +11,7 @@ export const supabaseAuthMiddleware = createMiddleware( return c.text("unauthorized", 401); } - const token = authHeader.replace("Bearer ", ""); + const token = authHeader.replace(/^bearer /i, ""); const supabaseClient = createClient( env.SUPABASE_URL, env.SUPABASE_ANON_KEY, diff --git a/apps/desktop/src/components/settings/account.tsx b/apps/desktop/src/components/settings/account.tsx index 30b9ee5202..c503dfd117 100644 --- a/apps/desktop/src/components/settings/account.tsx +++ b/apps/desktop/src/components/settings/account.tsx @@ -168,8 +168,9 @@ function BillingButton() { const client = createClient({ baseUrl: env.VITE_API_URL, headers }); const { data, error } = await getRpcCanStartTrial({ client }); if (error) { - return false; + throw error; } + return data?.canStartTrial ?? false; }, }); diff --git a/apps/web/src/middleware/supabase.ts b/apps/web/src/middleware/supabase.ts index 80f4b448fa..f72a6b96ef 100644 --- a/apps/web/src/middleware/supabase.ts +++ b/apps/web/src/middleware/supabase.ts @@ -6,7 +6,7 @@ import { env } from "@/env"; export const supabaseAuthMiddleware = createMiddleware().server( async ({ next, request }) => { const authHeader = request.headers.get("Authorization"); - const token = authHeader?.replace("Bearer ", ""); + const token = authHeader?.replace(/^bearer /i, ""); if (!token) { throw new Response( From 9a5dc5e1fbae7000f8558b94eefd97efab5479d3 Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Wed, 10 Dec 2025 14:09:05 +0900 Subject: [PATCH 12/13] trial flow got it working in desktop --- .../src/components/settings/account.tsx | 46 +++++++++++++++---- 1 file changed, 36 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/components/settings/account.tsx b/apps/desktop/src/components/settings/account.tsx index c503dfd117..bfb26f966f 100644 --- a/apps/desktop/src/components/settings/account.tsx +++ b/apps/desktop/src/components/settings/account.tsx @@ -1,9 +1,9 @@ -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { openUrl } from "@tauri-apps/plugin-opener"; import { ExternalLinkIcon } from "lucide-react"; import { type ReactNode, useCallback, useEffect, useState } from "react"; -import { getRpcCanStartTrial } from "@hypr/api-client"; +import { getRpcCanStartTrial, postBillingStartTrial } from "@hypr/api-client"; import { createClient } from "@hypr/api-client/client"; import { Button } from "@hypr/ui/components/ui/button"; import { Input } from "@hypr/ui/components/ui/input"; @@ -145,7 +145,12 @@ export function SettingsAccount() { >

Click{" "} - here + auth?.refreshSession()} + className="text-primary underline cursor-pointer" + > + here + to refresh plan status.

@@ -175,14 +180,32 @@ function BillingButton() { }, }); + const startTrialMutation = useMutation({ + mutationFn: async () => { + const headers = auth?.getHeaders(); + if (!headers) { + throw new Error("Not authenticated"); + } + const client = createClient({ baseUrl: env.VITE_API_URL, headers }); + const { error } = await postBillingStartTrial({ + client, + query: { interval: "monthly" }, + }); + if (error) { + throw error; + } + + await new Promise((resolve) => setTimeout(resolve, 3000)); + }, + onSuccess: async () => { + await auth?.refreshSession(); + }, + }); + const handleProUpgrade = useCallback(() => { openUrl(`${WEB_APP_BASE_URL}/app/checkout?period=monthly`); }, []); - const handleStartTrial = useCallback(() => { - openUrl(`${WEB_APP_BASE_URL}/app/checkout?trial=true`); - }, []); - const handleOpenAccount = useCallback(() => { openUrl(`${WEB_APP_BASE_URL}/app/account`); }, []); @@ -202,9 +225,12 @@ function BillingButton() { if (canTrialQuery.data) { return ( - ); } From 37298be531d89776e8e66c0687d3cfbc1eaf181b Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Wed, 10 Dec 2025 14:42:53 +0900 Subject: [PATCH 13/13] handle review --- apps/api/src/routes/billing.ts | 73 +++++++++++--- apps/api/src/routes/rpc.ts | 22 ++++- .../onboarding/configure-notice.tsx | 6 +- apps/web/src/functions/billing.ts | 97 ------------------- apps/web/src/routes/_view/app/account.tsx | 35 +------ 5 files changed, 81 insertions(+), 152 deletions(-) diff --git a/apps/api/src/routes/billing.ts b/apps/api/src/routes/billing.ts index 44d7a2b008..fbd92acf3a 100644 --- a/apps/api/src/routes/billing.ts +++ b/apps/api/src/routes/billing.ts @@ -1,3 +1,4 @@ +import * as Sentry from "@sentry/bun"; import { Hono } from "hono"; import { describeRoute } from "hono-openapi"; import { resolver, validator } from "hono-openapi/zod"; @@ -36,8 +37,14 @@ billing.post( supabaseAuthMiddleware, async (c) => { const { interval } = c.req.valid("query"); - const supabase = c.get("supabaseClient")!; - const userId = c.get("supabaseUserId")!; + const supabase = c.get("supabaseClient"); + if (!supabase) { + return c.json({ error: "Supabase client missing" }, 500); + } + const userId = c.get("supabaseUserId"); + if (!userId) { + return c.json({ error: "User ID missing" }, 500); + } const { data: canTrial, error: trialError } = await supabase.rpc("can_start_trial"); @@ -60,17 +67,28 @@ billing.post( if (!stripeCustomerId) { const { data: user } = await supabase.auth.getUser(); - const newCustomer = await stripe.customers.create({ - email: user.user?.email, - metadata: { userId }, - }); + const newCustomer = await stripe.customers.create( + { email: user.user?.email, metadata: { userId } }, + { idempotencyKey: `create-customer-${userId}` }, + ); await supabase .from("profiles") .update({ stripe_customer_id: newCustomer.id }) - .eq("id", userId); + .eq("id", userId) + .is("stripe_customer_id", null); + + const { data: updated } = await supabase + .from("profiles") + .select("stripe_customer_id") + .eq("id", userId) + .single(); - stripeCustomerId = newCustomer.id; + stripeCustomerId = updated?.stripe_customer_id; + } + + if (!stripeCustomerId) { + return c.json({ error: "stripe_customer_id_missing" }, 500); } const priceId = @@ -78,14 +96,37 @@ billing.post( ? env.STRIPE_YEARLY_PRICE_ID : env.STRIPE_MONTHLY_PRICE_ID; - await stripe.subscriptions.create({ - customer: stripeCustomerId, - items: [{ price: priceId }], - trial_period_days: 14, - trial_settings: { - end_behavior: { missing_payment_method: "cancel" }, - }, - }); + try { + await stripe.subscriptions.create( + { + customer: stripeCustomerId, + items: [{ price: priceId }], + trial_period_days: 14, + trial_settings: { + end_behavior: { missing_payment_method: "cancel" }, + }, + }, + { + idempotencyKey: `trial-${userId}-${new Date().toISOString().slice(0, 10)}`, + }, + ); + } catch (error) { + const errorMessage = + error instanceof Error + ? `Failed to create Stripe subscription: ${error.message}` + : "Failed to create Stripe subscription: unknown error"; + const errorDetails = error instanceof Error ? error.stack : String(error); + + if (env.NODE_ENV !== "production") { + console.error(errorMessage, errorDetails); + } else { + Sentry.captureException(error, { + tags: { billing: "start_trial", operation: "create_subscription" }, + extra: { userId, stripeCustomerId, priceId, errorDetails }, + }); + } + return c.json({ error: "failed_to_create_subscription" }, 500); + } return c.json({ started: true }); }, diff --git a/apps/api/src/routes/rpc.ts b/apps/api/src/routes/rpc.ts index 7e18ffaff5..4bb959a843 100644 --- a/apps/api/src/routes/rpc.ts +++ b/apps/api/src/routes/rpc.ts @@ -28,14 +28,30 @@ rpc.get( }), supabaseAuthMiddleware, async (c) => { - const supabase = c.get("supabaseClient")!; + const supabase = c.get("supabaseClient"); + if (!supabase) { + return c.json({ error: "Supabase client missing" }, 500); + } const { data, error } = await supabase.rpc("can_start_trial"); if (error) { - return c.json({ canStartTrial: false }); + 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: data as boolean }); + return c.json({ canStartTrial: data }); }, ); diff --git a/apps/desktop/src/components/onboarding/configure-notice.tsx b/apps/desktop/src/components/onboarding/configure-notice.tsx index cb6eb8cb8a..3af5779c15 100644 --- a/apps/desktop/src/components/onboarding/configure-notice.tsx +++ b/apps/desktop/src/components/onboarding/configure-notice.tsx @@ -13,11 +13,11 @@ export function ConfigureNotice({ onNext }: ConfigureNoticeProps) { description="You need at least these to get started. With a free trial, no need to worry about configuration." >
- - @@ -44,7 +44,7 @@ export function ConfigureNotice({ onNext }: ConfigureNoticeProps) { ); } -export function Requirment({ +export function Requirement({ title, description, }: { diff --git a/apps/web/src/functions/billing.ts b/apps/web/src/functions/billing.ts index 68c5806a5d..31b0657ca4 100644 --- a/apps/web/src/functions/billing.ts +++ b/apps/web/src/functions/billing.ts @@ -1,9 +1,6 @@ import { createServerFn } from "@tanstack/react-start"; import { z } from "zod"; -import { getRpcCanStartTrial } from "@hypr/api-client"; -import { createClient } from "@hypr/api-client/client"; - import { env } from "@/env"; import { getStripeClient } from "@/functions/stripe"; import { getSupabaseServerClient } from "@/functions/supabase"; @@ -192,97 +189,3 @@ export const syncAfterSuccess = createServerFn({ method: "POST" }).handler( }; }, ); - -export const canStartTrial = createServerFn({ method: "POST" }).handler( - async () => { - 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/routes/_view/app/account.tsx b/apps/web/src/routes/_view/app/account.tsx index 62e7bd4ee5..31df845c74 100644 --- a/apps/web/src/routes/_view/app/account.tsx +++ b/apps/web/src/routes/_view/app/account.tsx @@ -3,12 +3,7 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; import { signOutFn } from "@/functions/auth"; -import { - canStartTrial, - createPortalSession, - createTrialCheckoutSession, - syncAfterSuccess, -} from "@/functions/billing"; +import { createPortalSession, syncAfterSuccess } from "@/functions/billing"; import { addContact } from "@/functions/loops"; import { useAnalytics } from "@/hooks/use-posthog"; @@ -63,11 +58,6 @@ function AccountSettingsCard() { queryFn: () => syncAfterSuccess(), }); - const canTrialQuery = useQuery({ - queryKey: ["canStartTrial"], - queryFn: () => canStartTrial(), - }); - const manageBillingMutation = useMutation({ mutationFn: async () => { const { url } = await createPortalSession(); @@ -77,15 +67,6 @@ 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"; @@ -97,7 +78,7 @@ function AccountSettingsCard() { })(); const renderPlanButton = () => { - if (billingQuery.isLoading || canTrialQuery.isLoading) { + if (billingQuery.isLoading) { return (
Loading... @@ -106,18 +87,6 @@ function AccountSettingsCard() { } if (currentPlan === "free") { - if (canTrialQuery.data) { - return ( - - ); - } - return (