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/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/api/openapi.gen.json b/apps/api/openapi.gen.json new file mode 100644 index 0000000000..35051800c6 --- /dev/null +++ b/apps/api/openapi.gen.json @@ -0,0 +1,466 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Hyprnote API", + "description": "Development documentation", + "version": "1.0.0" + }, + "tags": [ + { + "name": "private" + }, + { + "name": "public" + } + ], + "components": { + "securitySchemes": { + "Bearer": { + "type": "http", + "scheme": "bearer" + } + }, + "schemas": {} + }, + "servers": [ + { + "url": "https://api.hyprnote.com", + "description": "Production server" + }, + { + "url": "http://localhost:4000", + "description": "Local development server" + } + ], + "paths": { + "/health": { + "get": { + "responses": { + "200": { + "description": "-", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string" + } + }, + "required": [ + "status" + ], + "additionalProperties": false + } + } + } + } + }, + "operationId": "getHealth", + "tags": [ + "private-skip-openapi" + ], + "parameters": [] + } + }, + "/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": [ + "private" + ], + "parameters": [ + { + "in": "query", + "name": "interval", + "schema": { + "default": "monthly", + "type": "string", + "enum": [ + "monthly", + "yearly" + ] + }, + "required": true + } + ] + } + }, + "/chat/completions": { + "post": { + "responses": { + "200": { + "description": "-" + }, + "401": { + "description": "-" + } + }, + "operationId": "postChatCompletions", + "tags": [ + "private-skip-openapi" + ], + "parameters": [], + "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": {} + } + } + } + } + } + }, + "/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": [ + "private" + ], + "parameters": [] + } + }, + "/listen": { + "get": { + "responses": { + "101": { + "description": "-" + }, + "400": { + "description": "-" + }, + "401": { + "description": "-" + }, + "502": { + "description": "-" + }, + "504": { + "description": "-" + } + }, + "operationId": "getListen", + "tags": [ + "private-skip-openapi" + ], + "parameters": [], + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/transcribe": { + "post": { + "responses": { + "200": { + "description": "-", + "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": "-" + }, + "401": { + "description": "-" + }, + "500": { + "description": "-" + }, + "502": { + "description": "-" + } + }, + "operationId": "postTranscribe", + "tags": [ + "private-skip-openapi" + ], + "parameters": [], + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/webhook/stripe": { + "post": { + "responses": { + "200": { + "description": "-", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + } + }, + "required": [ + "ok" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "-" + }, + "500": { + "description": "-" + } + }, + "operationId": "postWebhookStripe", + "tags": [ + "private-skip-openapi" + ], + "parameters": [ + { + "in": "header", + "name": "stripe-signature", + "schema": { + "type": "string" + }, + "required": true + } + ] + } + } + } +} \ No newline at end of file diff --git a/apps/api/package.json b/apps/api/package.json index fba8141a19..a019a7306d 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -5,10 +5,12 @@ "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" }, "dependencies": { + "@hypr/api-client": "workspace:*", "@hono/zod-validator": "^0.7.5", "@posthog/ai": "^7.2.1", "@scalar/hono-api-reference": "^0.5.184", @@ -26,6 +28,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/apps/api/src/env.ts b/apps/api/src/env.ts index 4e8e19da02..6731a650f2 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), @@ -27,4 +29,5 @@ export const env = createEnv({ }, runtimeEnv: Bun.env, emptyStringAsUndefined: true, + skipValidation: Bun.env.CI === "true", }); 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/index.ts b/apps/api/src/index.ts index 9f6949e6d0..4fa6680ba7 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(); @@ -68,52 +69,8 @@ app.onError((err, c) => { 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", - }, - ], - }, - }), + "/openapi.gen.json", + openAPISpecs(routes, { documentation: openAPIDocumentation }), ); app.get( @@ -121,7 +78,7 @@ app.get( apiReference({ theme: "saturn", spec: { - url: "/openapi.json", + url: "/openapi.gen.json", }, }), ); 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 4b21c14f9f..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, @@ -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/openapi.ts b/apps/api/src/openapi.ts new file mode 100644 index 0000000000..b7f8f78146 --- /dev/null +++ b/apps/api/src/openapi.ts @@ -0,0 +1,28 @@ +import { API_TAGS } from "./routes"; + +export const openAPIDocumentation = { + openapi: "3.1.0", + info: { + title: "Hyprnote API", + version: "1.0.0", + }, + tags: [{ name: API_TAGS.PRIVATE }, { name: API_TAGS.PUBLIC }], + components: { + securitySchemes: { + Bearer: { + type: "http" as const, + scheme: "bearer", + }, + }, + }, + servers: [ + { + url: "https://api.hyprnote.com", + description: "Production server", + }, + { + url: "http://localhost:4000", + description: "Local development server", + }, + ], +}; diff --git a/apps/api/src/routes/billing.ts b/apps/api/src/routes/billing.ts new file mode 100644 index 0000000000..fbd92acf3a --- /dev/null +++ b/apps/api/src/routes/billing.ts @@ -0,0 +1,133 @@ +import * as Sentry from "@sentry/bun"; +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.PRIVATE], + 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"); + 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"); + + 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 } }, + { idempotencyKey: `create-customer-${userId}` }, + ); + + await supabase + .from("profiles") + .update({ stripe_customer_id: newCustomer.id }) + .eq("id", userId) + .is("stripe_customer_id", null); + + const { data: updated } = await supabase + .from("profiles") + .select("stripe_customer_id") + .eq("id", userId) + .single(); + + stripeCustomerId = updated?.stripe_customer_id; + } + + if (!stripeCustomerId) { + return c.json({ error: "stripe_customer_id_missing" }, 500); + } + + const priceId = + interval === "yearly" + ? env.STRIPE_YEARLY_PRICE_ID + : env.STRIPE_MONTHLY_PRICE_ID; + + 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/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/index.ts b/apps/api/src/routes/index.ts index f21102740d..56ed8c88e6 100644 --- a/apps/api/src/routes/index.ts +++ b/apps/api/src/routes/index.ts @@ -1,8 +1,10 @@ import { Hono } from "hono"; 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"; @@ -11,6 +13,8 @@ export { API_TAGS } from "./constants"; 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/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 new file mode 100644 index 0000000000..4bb959a843 --- /dev/null +++ b/apps/api/src/routes/rpc.ts @@ -0,0 +1,57 @@ +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.PRIVATE], + responses: { + 200: { + description: "result", + content: { + "application/json": { schema: resolver(CanStartTrialResponseSchema) }, + }, + }, + }, + }), + supabaseAuthMiddleware, + async (c) => { + 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) { + 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 }); + }, +); 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 new file mode 100644 index 0000000000..ff79030151 --- /dev/null +++ b/apps/api/src/scripts/generate-openapi.ts @@ -0,0 +1,36 @@ +import { createClient } from "@hey-api/openapi-ts"; +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, { + documentation: openAPIDocumentation, + }); + + 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.gen.json", + output: "../../packages/api-client/src/generated", + parser: { + filters: { + tags: { + include: [API_TAGS.PRIVATE], + }, + }, + }, + }); + 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/auth.tsx b/apps/desktop/src/auth.tsx index 6a7b2fec96..fa28e7c37f 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; @@ -136,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(); } @@ -251,18 +241,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/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..3af5779c15 --- /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 Requirement({ + 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 4fdb3da0b6..c7e846fbe0 100644 --- a/apps/desktop/src/components/onboarding/login.tsx +++ b/apps/desktop/src/components/onboarding/login.tsx @@ -1,61 +1,80 @@ -import { useCallback, useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; +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"; import { useAuth } from "../../auth"; -import type { OnboardingNext } from "./shared"; +import { getEntitlementsFromToken } from "../../billing"; +import { env } from "../../env"; +import { OnboardingContainer, type OnboardingNext } from "./shared"; -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(() => { - if (auth?.session) { - onNext({ local: false }); + if (auth?.session && !trialStarted.current) { + trialStarted.current = true; + + const client = createClient( + createConfig({ + baseUrl: env.VITE_API_URL, + headers: { Authorization: `Bearer ${auth.session.access_token}` }, + }), + ); + + (async () => { + 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 }); + } + })(); } - }, [auth?.session, onNext]); + }, [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/components/settings/account.tsx b/apps/desktop/src/components/settings/account.tsx index 720183e80a..bfb26f966f 100644 --- a/apps/desktop/src/components/settings/account.tsx +++ b/apps/desktop/src/components/settings/account.tsx @@ -1,7 +1,10 @@ +import { useMutation, 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 { 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"; @@ -137,71 +140,106 @@ export function SettingsAccount() { - Manage - - - } + description={`Your current plan is ${isPro ? "PRO" : "FREE"}. `} + action={} > - +

+ Click{" "} + auth?.refreshSession()} + className="text-primary underline cursor-pointer" + > + 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?.session && !isPro, + queryKey: [auth?.session?.user.id ?? "", "canStartTrial"], + queryFn: async () => { + const headers = auth?.getHeaders(); + if (!headers) { + return false; + } + const client = createClient({ baseUrl: env.VITE_API_URL, headers }); + const { data, error } = await getRpcCanStartTrial({ client }); + if (error) { + throw error; + } - return ( -
- {isPro ? ( - - PRO - - ) : ( - - FREE - - )} + return data?.canStartTrial ?? false; + }, + }); + + 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 handleOpenAccount = useCallback(() => { + openUrl(`${WEB_APP_BASE_URL}/app/account`); + }, []); + + if (isPro) { + return ( -
+ ); + } + + if (canTrialQuery.data) { + return ( + + ); + } + + return ( + ); } 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/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, 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 1c133fd610..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.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/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/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 }) {

{ - 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 ", ""); + const token = authHeader?.replace(/^bearer /i, ""); if (!token) { throw new Response( @@ -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/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]); diff --git a/packages/api-client/package.json b/packages/api-client/package.json new file mode 100644 index 0000000000..b798ef298d --- /dev/null +++ b/packages/api-client/package.json @@ -0,0 +1,7 @@ +{ + "name": "@hypr/api-client", + "exports": { + ".": "./src/generated/index.ts", + "./client": "./src/generated/client/index.ts" + } +} 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..47727b3a45 --- /dev/null +++ b/packages/api-client/src/generated/client.gen.ts @@ -0,0 +1,18 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { ClientOptions } from './types.gen'; +import { type Config, type ClientOptions as DefaultClientOptions, createClient, createConfig } from './client'; + +/** + * 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' +})); \ No newline at end of file diff --git a/packages/api-client/src/generated/client/client.ts b/packages/api-client/src/generated/client/client.ts new file mode 100644 index 0000000000..89d1e31582 --- /dev/null +++ b/packages/api-client/src/generated/client/client.ts @@ -0,0 +1,195 @@ +import type { Client, Config, RequestOptions } from './types'; +import { + buildUrl, + createConfig, + createInterceptors, + getParseAs, + mergeConfigs, + mergeHeaders, + setAuthParams, +} from './utils'; + +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, + RequestOptions + >(); + + const request: Client['request'] = async (options) => { + const opts = { + ..._config, + ...options, + fetch: options.fetch ?? _config.fetch ?? globalThis.fetch, + headers: mergeHeaders(_config.headers, options.headers), + }; + + if (opts.security) { + await setAuthParams({ + ...opts, + security: opts.security, + }); + } + + if (opts.requestValidator) { + await opts.requestValidator(opts); + } + + 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.body === '') { + opts.headers.delete('Content-Type'); + } + + const url = buildUrl(opts); + const requestInit: ReqInit = { + redirect: 'follow', + ...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 = await _fetch(request); + + for (const fn of interceptors.response._fns) { + if (fn) { + response = await fn(response, request, opts); + } + } + + const result = { + request, + response, + }; + + if (response.ok) { + if ( + response.status === 204 || + response.headers.get('Content-Length') === '0' + ) { + return opts.responseStyle === 'data' + ? {} + : { + data: {}, + ...result, + }; + } + + const parseAs = + (opts.parseAs === 'auto' + ? getParseAs(response.headers.get('Content-Type')) + : opts.parseAs) ?? 'json'; + + 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, + }; + }; + + return { + buildUrl, + connect: (options) => request({ ...options, method: 'CONNECT' }), + delete: (options) => request({ ...options, method: 'DELETE' }), + get: (options) => request({ ...options, method: 'GET' }), + getConfig, + head: (options) => request({ ...options, method: 'HEAD' }), + interceptors, + 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, + 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 new file mode 100644 index 0000000000..5da1f7aee1 --- /dev/null +++ b/packages/api-client/src/generated/client/index.ts @@ -0,0 +1,22 @@ +export type { Auth } from '../core/auth'; +export type { QuerySerializerOptions } from '../core/bodySerializer'; +export { + formDataBodySerializer, + jsonBodySerializer, + urlSearchParamsBodySerializer, +} from '../core/bodySerializer'; +export { buildClientParams } from '../core/params'; +export { createClient } from './client'; +export type { + Client, + ClientOptions, + Config, + CreateClientConfig, + Options, + OptionsLegacyParser, + RequestOptions, + RequestResult, + ResponseStyle, + TDataShape, +} from './types'; +export { createConfig, mergeHeaders } from './utils'; diff --git a/packages/api-client/src/generated/client/types.ts b/packages/api-client/src/generated/client/types.ts new file mode 100644 index 0000000000..85295df077 --- /dev/null +++ b/packages/api-client/src/generated/client/types.ts @@ -0,0 +1,222 @@ +import type { Auth } from '../core/auth'; +import type { + Client as CoreClient, + Config as CoreConfig, +} from '../core/types'; +import type { Middleware } from './utils'; + +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?: (request: Request) => ReturnType; + /** + * 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< + TResponseStyle extends ResponseStyle = 'fields', + ThrowOnError extends boolean = boolean, + Url extends string = string, +> extends Config<{ + responseStyle: TResponseStyle; + throwOnError: ThrowOnError; + }> { + /** + * 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 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 RequestFn = < + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = 'fields', +>( + options: Omit, 'method'> & + Pick>, 'method'>, +) => RequestResult; + +type BuildUrlFn = < + TData extends { + body?: unknown; + path?: Record; + query?: Record; + url: string; + }, +>( + options: Pick & Options, +) => string; + +export type Client = CoreClient & { + 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, + TResponseStyle extends ResponseStyle = 'fields', +> = OmitKeys< + RequestOptions, + 'body' | 'path' | 'query' | 'url' +> & + 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.ts b/packages/api-client/src/generated/client/utils.ts new file mode 100644 index 0000000000..a52e672927 --- /dev/null +++ b/packages/api-client/src/generated/client/utils.ts @@ -0,0 +1,417 @@ +import { getAuthToken } from '../core/auth'; +import type { + QuerySerializer, + QuerySerializerOptions, +} from '../core/bodySerializer'; +import { jsonBodySerializer } from '../core/bodySerializer'; +import { + serializeArrayParam, + serializeObjectParam, + serializePrimitiveParam, +} 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 = ({ + allowReserved, + array, + object, +}: 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; + } + + if (Array.isArray(value)) { + const serializedArray = serializeArrayParam({ + allowReserved, + explode: true, + name, + style: 'form', + value, + ...array, + }); + if (serializedArray) search.push(serializedArray); + } else if (typeof value === 'object') { + const serializedObject = serializeObjectParam({ + allowReserved, + explode: true, + name, + style: 'deepObject', + value: value as Record, + ...object, + }); + if (serializedObject) search.push(serializedObject); + } else { + const serializedPrimitive = serializePrimitiveParam({ + 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; +}; + +export const setAuthParams = async ({ + security, + ...options +}: Pick, 'security'> & + Pick & { + headers: Headers; + }) => { + for (const auth of security) { + 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; + } + + return; + } +}; + +export const buildUrl: Client['buildUrl'] = (options) => { + const url = 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, + }); + 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 }; + if (config.baseUrl?.endsWith('/')) { + config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1); + } + config.headers = mergeHeaders(a.headers, b.headers); + return config; +}; + +export const mergeHeaders = ( + ...headers: Array['headers'] | undefined> +): Headers => { + const mergedHeaders = new Headers(); + for (const header of headers) { + if (!header || typeof header !== 'object') { + continue; + } + + const iterator = + header instanceof Headers ? header.entries() : 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: (Interceptor | null)[]; + + constructor() { + this._fns = []; + } + + clear() { + this._fns = []; + } + + 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 !!this._fns[index]; + } + + eject(id: number | Interceptor) { + const index = this.getInterceptorIndex(id); + if (this._fns[index]) { + this._fns[index] = null; + } + } + + update(id: number | Interceptor, fn: Interceptor) { + const index = this.getInterceptorIndex(id); + if (this._fns[index]) { + this._fns[index] = fn; + return id; + } else { + return false; + } + } + + 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: Pick< + Interceptors>, + 'eject' | 'use' + >; + request: Pick>, 'eject' | 'use'>; + response: Pick< + Interceptors>, + 'eject' | 'use' + >; +} + +// 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>(), +}); + +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.ts b/packages/api-client/src/generated/core/auth.ts new file mode 100644 index 0000000000..451c7f30f9 --- /dev/null +++ b/packages/api-client/src/generated/core/auth.ts @@ -0,0 +1,40 @@ +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.ts b/packages/api-client/src/generated/core/bodySerializer.ts new file mode 100644 index 0000000000..98ce7791f1 --- /dev/null +++ b/packages/api-client/src/generated/core/bodySerializer.ts @@ -0,0 +1,88 @@ +import type { + ArrayStyle, + ObjectStyle, + SerializerOptions, +} from './pathSerializer'; + +export type QuerySerializer = (query: Record) => string; + +export type BodySerializer = (body: any) => any; + +export interface QuerySerializerOptions { + allowReserved?: boolean; + array?: SerializerOptions; + object?: SerializerOptions; +} + +const serializeFormDataPair = ( + data: FormData, + key: string, + value: unknown, +): void => { + if (typeof value === 'string' || value instanceof Blob) { + data.append(key, value); + } 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.ts b/packages/api-client/src/generated/core/params.ts new file mode 100644 index 0000000000..7559bbb8c0 --- /dev/null +++ b/packages/api-client/src/generated/core/params.ts @@ -0,0 +1,141 @@ +type Slot = 'body' | 'headers' | 'path' | 'query'; + +export type Field = + | { + in: Exclude; + key: string; + map?: string; + } + | { + in: Extract; + key?: string; + map?: string; + }; + +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; + } +>; + +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 (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; + (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) { + const name = field.map || key; + (params[field.in] as Record)[name] = 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 { + 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.ts b/packages/api-client/src/generated/core/pathSerializer.ts new file mode 100644 index 0000000000..d692cf0a39 --- /dev/null +++ b/packages/api-client/src/generated/core/pathSerializer.ts @@ -0,0 +1,179 @@ +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/types.ts b/packages/api-client/src/generated/core/types.ts new file mode 100644 index 0000000000..77d8792533 --- /dev/null +++ b/packages/api-client/src/generated/core/types.ts @@ -0,0 +1,104 @@ +import type { Auth, AuthToken } from './auth'; +import type { + BodySerializer, + QuerySerializer, + QuerySerializerOptions, +} from './bodySerializer'; + +export interface Client< + RequestFn = never, + Config = unknown, + MethodFn = never, + BuildUrlFn = 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; + trace: MethodFn; +} + +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?: + | '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 + * 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; +} diff --git a/packages/api-client/src/generated/index.ts b/packages/api-client/src/generated/index.ts new file mode 100644 index 0000000000..e64537d212 --- /dev/null +++ b/packages/api-client/src/generated/index.ts @@ -0,0 +1,3 @@ +// This file is auto-generated by @hey-api/openapi-ts +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 new file mode 100644 index 0000000000..845da86c1b --- /dev/null +++ b/packages/api-client/src/generated/sdk.gen.ts @@ -0,0 +1,33 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Options as ClientOptions, TDataShape, Client } from './client'; +import type { PostBillingStartTrialData, PostBillingStartTrialResponses, GetRpcCanStartTrialData, GetRpcCanStartTrialResponses } from './types.gen'; +import { client as _heyApiClient } from './client.gen'; + +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 + * 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; +}; + +export const postBillingStartTrial = (options: Options) => { + return (options.client ?? _heyApiClient).post({ + url: '/billing/start-trial', + ...options + }); +}; + +export const getRpcCanStartTrial = (options?: Options) => { + return (options?.client ?? _heyApiClient).get({ + url: '/rpc/can-start-trial', + ...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 new file mode 100644 index 0000000000..5a9ca908f8 --- /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 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]; + +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 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/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 diff --git a/plugins/webhook/src/lib.rs b/plugins/webhook/src/lib.rs index 0225b0ee41..d7cff47cbd 100644 --- a/plugins/webhook/src/lib.rs +++ b/plugins/webhook/src/lib.rs @@ -61,7 +61,7 @@ mod test { #[test] fn export_openapi() { let openapi_json = generate_openapi_json(); - let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("openapi.json"); + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("openapi.gen.json"); std::fs::write(&path, openapi_json).unwrap(); } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f2289dd3a..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)) @@ -72,6 +75,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 @@ -148,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 @@ -616,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 @@ -702,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) @@ -844,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': @@ -25295,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 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;