From 403d735d74e102d14f898598fd8776edc0cfbb9f Mon Sep 17 00:00:00 2001 From: Thibault Le Ouay Date: Thu, 24 Sep 2026 09:11:32 +0200 Subject: [PATCH] dashboard: free trial by default (#2760) * dashboard: free trial by default * chore: minor frontend fixes * refactor: files * wip: * fix: review * ci: apply automated fixes * fix: review * fix: --------- Co-authored-by: Maximilian Kaske Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../(dashboard)/settings/billing/client.tsx | 74 +- .../settings/billing/search-params.ts | 1 + .../src/app/onboarding/_steps/step-3.tsx | 2 +- .../src/components/content/billing-addons.tsx | 33 +- .../data-table/billing/data-table.tsx | 13 +- .../src/components/nav/nav-banner-trial.tsx | 74 + .../src/components/nav/nav-banner.tsx | 31 +- .../dashboard/src/components/nav/nav-user.tsx | 32 +- .../src/components/nav/workspace-switcher.tsx | 22 +- apps/dashboard/src/lib/auth/index.ts | 91 +- apps/dashboard/src/lib/trial.ts | 2 + packages/analytics/src/events.ts | 12 + packages/api/package.json | 1 + packages/api/src/env.ts | 2 + packages/api/src/router/stripe/index.ts | 340 +- packages/api/src/router/stripe/shared.ts | 34 +- .../src/router/stripe/trial-lifecycle.test.ts | 579 ++ packages/api/src/router/stripe/trial.test.ts | 276 + packages/api/src/router/stripe/trial.ts | 175 + packages/api/src/router/stripe/webhook.ts | 107 +- packages/api/src/router/user.ts | 33 + packages/api/src/router/workspace.ts | 7 +- .../drizzle/0087_narrow_black_tarantula.sql | 1 + packages/db/drizzle/meta/0087_snapshot.json | 6060 +++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + .../db/src/schema/workspaces/workspace.ts | 1 + packages/db/src/test/factories.ts | 4 +- packages/emails/emails/welcome.tsx | 17 +- packages/emails/src/templates.test.tsx | 15 + .../invitation/__tests__/invitation.test.ts | 63 +- packages/services/src/invitation/index.ts | 1 + packages/services/src/invitation/pending.ts | 25 + .../src/workspace/__tests__/billing.test.ts | 368 + .../src/workspace/__tests__/downgrade.test.ts | 39 + .../src/workspace/__tests__/trial.test.ts | 120 + .../src/workspace/__tests__/workspace.test.ts | 29 + packages/services/src/workspace/downgrade.ts | 16 +- packages/services/src/workspace/index.ts | 21 +- packages/services/src/workspace/list.ts | 85 + packages/services/src/workspace/schemas.ts | 59 +- packages/services/src/workspace/trial.ts | 85 + packages/services/src/workspace/update.ts | 114 +- packages/ui/src/hooks/use-cookie-state.ts | 9 +- pnpm-lock.yaml | 118 +- pnpm-workspace.yaml | 1 + 45 files changed, 8870 insertions(+), 329 deletions(-) create mode 100644 apps/dashboard/src/components/nav/nav-banner-trial.tsx create mode 100644 apps/dashboard/src/lib/trial.ts create mode 100644 packages/api/src/router/stripe/trial-lifecycle.test.ts create mode 100644 packages/api/src/router/stripe/trial.test.ts create mode 100644 packages/api/src/router/stripe/trial.ts create mode 100644 packages/db/drizzle/0087_narrow_black_tarantula.sql create mode 100644 packages/db/drizzle/meta/0087_snapshot.json create mode 100644 packages/services/src/invitation/pending.ts create mode 100644 packages/services/src/workspace/__tests__/billing.test.ts create mode 100644 packages/services/src/workspace/__tests__/trial.test.ts create mode 100644 packages/services/src/workspace/trial.ts diff --git a/apps/dashboard/src/app/(dashboard)/settings/billing/client.tsx b/apps/dashboard/src/app/(dashboard)/settings/billing/client.tsx index 8c83b4c7..e93b194f 100644 --- a/apps/dashboard/src/app/(dashboard)/settings/billing/client.tsx +++ b/apps/dashboard/src/app/(dashboard)/settings/billing/client.tsx @@ -3,7 +3,7 @@ import { allPlans } from "@openstatus/db/src/schema/plan/config"; import type { Limits } from "@openstatus/db/src/schema/plan/schema"; import { Button } from "@openstatus/ui/components/ui/button"; -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; import { useQueryStates } from "nuqs"; import { useEffect, useMemo, useTransition } from "react"; @@ -34,15 +34,11 @@ import { FormCardSeparator, FormCardTitle, } from "@/components/forms/form-card"; +import { formatDate } from "@/lib/formatter"; import { useTRPC } from "@/lib/trpc/client"; import { searchParamsParsers } from "./search-params"; -const BASE_URL = - process.env.NODE_ENV === "production" - ? "https://app.openstatus.dev" - : "http://localhost:3000"; - function calculateTotalRequests(limits: Limits) { const monitors = limits.monitors; const maxRegions = limits["max-regions"]; @@ -79,9 +75,19 @@ export function Client() { const trpc = useTRPC(); const router = useRouter(); const [isPending, startTransition] = useTransition(); - const [{ success }, setSearchParams] = useQueryStates(searchParamsParsers); + const queryClient = useQueryClient(); + const [{ success, setup }, setSearchParams] = + useQueryStates(searchParamsParsers); const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); const { data: usage } = useQuery(trpc.workspace.usage.queryOptions()); + const paymentMethodSetupMutation = useMutation( + trpc.stripeRouter.getPaymentMethodSetupSession.mutationOptions({ + onSuccess: (url) => { + if (url) window.location.assign(url); + }, + onError: (error) => toast.error(error.message), + }), + ); const customerPortalMutation = useMutation( trpc.stripeRouter.getUserCustomerPortal.mutationOptions({ onSuccess: (url) => { @@ -116,6 +122,22 @@ export function Client() { } }, [success, setSearchParams]); + useEffect(() => { + if (setup) { + queryClient.invalidateQueries({ + queryKey: trpc.workspace.get.queryKey(), + }); + setTimeout(() => { + toast.success("Payment method added", { + description: "Your plan continues after the trial.", + duration: 5_000, + onAutoClose: () => setSearchParams({ setup: null }), + onDismiss: () => setSearchParams({ setup: null }), + }); + }, 500); + } + }, [setup, setSearchParams, queryClient, trpc]); + const totalRequests = useMemo(() => { const httpRequests = httpWorkspace30d?.data?.reduce( (acc, curr) => acc + curr.count, @@ -131,6 +153,7 @@ export function Client() { if (!workspace) return null; const planAddons = allPlans[workspace.plan].addons; + const trialDaysLeft = workspace.trialDaysLeft; return ( @@ -142,6 +165,41 @@ export function Client() { + {workspace.trialEndsAt && trialDaysLeft ? ( + + + Starter trial + + + {trialDaysLeft} + {" "} + {trialDaysLeft === 1 ? "day" : "days"} left. Your trial ends + on {formatDate(workspace.trialEndsAt)}. + + + + + Add a payment method to keep Starter, or move to the free plan + after the trial. + + + + + ) : null} Usage @@ -265,7 +323,7 @@ export function Client() { startTransition(async () => { await customerPortalMutation.mutateAsync({ workspaceSlug: workspace.slug, - returnUrl: `${BASE_URL}/settings/billing`, + returnUrl: `${window.location.origin}/settings/billing`, }); }); }} diff --git a/apps/dashboard/src/app/(dashboard)/settings/billing/search-params.ts b/apps/dashboard/src/app/(dashboard)/settings/billing/search-params.ts index d96c8317..0e6b7e14 100644 --- a/apps/dashboard/src/app/(dashboard)/settings/billing/search-params.ts +++ b/apps/dashboard/src/app/(dashboard)/settings/billing/search-params.ts @@ -2,6 +2,7 @@ import { createSearchParamsCache, parseAsBoolean } from "nuqs/server"; export const searchParamsParsers = { success: parseAsBoolean, + setup: parseAsBoolean, }; export const searchParamsCache = createSearchParamsCache(searchParamsParsers); diff --git a/apps/dashboard/src/app/onboarding/_steps/step-3.tsx b/apps/dashboard/src/app/onboarding/_steps/step-3.tsx index b6c43637..752ea53f 100644 --- a/apps/dashboard/src/app/onboarding/_steps/step-3.tsx +++ b/apps/dashboard/src/app/onboarding/_steps/step-3.tsx @@ -129,7 +129,7 @@ export function Step3({

- SOC2 audit incoming? Ping us for a 14-day free trial. + SOC2 audit incoming? Ping us to try the Team plan.

diff --git a/apps/dashboard/src/components/content/billing-addons.tsx b/apps/dashboard/src/components/content/billing-addons.tsx index 6cac3aeb..aa0f966b 100644 --- a/apps/dashboard/src/components/content/billing-addons.tsx +++ b/apps/dashboard/src/components/content/billing-addons.tsx @@ -70,6 +70,15 @@ export function BillingAddons({ }, }), ); + const paymentMethodSetupMutation = useMutation( + trpc.stripeRouter.getPaymentMethodSetupSession.mutationOptions({ + onSuccess: (url) => { + if (url) window.location.assign(url); + }, + onError: (error) => toast.error(error.message), + }), + ); + const isTrialing = workspace.trialDaysLeft !== null; const plan = workspace.plan; const packSize = getAddonPackSize(addon); const maxPacks = getAddonMaxQuantity(addon); @@ -109,10 +118,23 @@ export function BillingAddons({ return "Billing information updated"; }, error: (error) => { - if (isTRPCClientError(error)) { - return error.message; + if (!isTRPCClientError(error)) + return { message: "Failed to update" }; + if (error.data?.code === "PRECONDITION_FAILED") { + return { + message: error.message, + action: { + label: "Add payment method", + onClick: () => + paymentMethodSetupMutation.mutate({ + workspaceSlug: workspace.slug, + successUrl: `${window.location.origin}/settings/billing?setup=true`, + cancelUrl: `${window.location.origin}/settings/billing`, + }), + }, + }; } - return "Failed to update"; + return { message: error.message }; }, }); await promise; @@ -126,6 +148,8 @@ export function BillingAddons({ ? defaultValue > 0 : defaultValue !== defaultLimit; const isQuantity = typeof value === "number"; + // Mirrors the server: a boolean submit toggles, so `true` means removing. + const isRemoval = typeof value === "boolean" ? value : value === 0; return ( @@ -170,6 +194,9 @@ export function BillingAddons({ packSize, unitLabel, )} + {isTrialing && !isRemoval + ? " Adding it ends your Starter trial and charges your card today." + : null} {isQuantity && typeof value === "number" ? ( diff --git a/apps/dashboard/src/components/data-table/billing/data-table.tsx b/apps/dashboard/src/components/data-table/billing/data-table.tsx index b33042ca..0c89c99f 100644 --- a/apps/dashboard/src/components/data-table/billing/data-table.tsx +++ b/apps/dashboard/src/components/data-table/billing/data-table.tsx @@ -85,6 +85,7 @@ export function DataTable({ restrictTo }: { restrictTo?: WorkspacePlan[] }) { if (!workspace) return null; + const isTrialing = workspace.trialDaysLeft !== null; const filteredPlans = Object.values(plans).filter((plan) => restrictTo ? restrictTo.includes(plan.id) : true, ); @@ -104,7 +105,9 @@ export function DataTable({ restrictTo }: { restrictTo?: WorkspacePlan[] }) { - A list to compare the different features by plan. + {isTrialing + ? "Upgrading ends your trial and charges your card today." + : "A list to compare the different features by plan."} @@ -181,10 +184,14 @@ export function DataTable({ restrictTo }: { restrictTo?: WorkspacePlan[] }) { disabled={isPending || isCurrentPlan} > {isCurrentPlan - ? "Current Plan" + ? isTrialing + ? "On Trial" + : "Current Plan" : isPending ? "Choosing..." - : "Choose"} + : isTrialing && !isFreePlan + ? "Upgrade now" + : "Choose"} diff --git a/apps/dashboard/src/components/nav/nav-banner-trial.tsx b/apps/dashboard/src/components/nav/nav-banner-trial.tsx new file mode 100644 index 00000000..9c3202ba --- /dev/null +++ b/apps/dashboard/src/components/nav/nav-banner-trial.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { Close } from "@openstatus/icons"; +import { + SidebarGroup, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuAction, + SidebarMenuButton, + SidebarMenuItem, +} from "@openstatus/ui/components/ui/sidebar"; +import { useMutation } from "@tanstack/react-query"; +import { toast } from "sonner"; + +import { useTRPC } from "@/lib/trpc/client"; + +export function NavBannerTrial({ + workspaceSlug, + daysLeft, + handleClose, +}: { + workspaceSlug: string; + daysLeft: number; + handleClose: () => void; +}) { + const trpc = useTRPC(); + const paymentMethodSetupMutation = useMutation( + trpc.stripeRouter.getPaymentMethodSetupSession.mutationOptions({ + onSuccess: (url) => { + if (url) window.location.assign(url); + }, + onError: (error) => toast.error(error.message), + }), + ); + + return ( + + + + Trial ends in {daysLeft === 1 ? "1 day" : `${daysLeft} days`} + + + + + + + + Add a payment method to keep Starter. Without one, the workspace moves + to the free plan. + + + + paymentMethodSetupMutation.mutate({ + workspaceSlug, + successUrl: `${window.location.origin}/settings/billing?setup=true`, + cancelUrl: `${window.location.origin}/settings/billing`, + }) + } + > + Add payment method + + + + + ); +} diff --git a/apps/dashboard/src/components/nav/nav-banner.tsx b/apps/dashboard/src/components/nav/nav-banner.tsx index 4522d28d..aa26db22 100644 --- a/apps/dashboard/src/components/nav/nav-banner.tsx +++ b/apps/dashboard/src/components/nav/nav-banner.tsx @@ -3,16 +3,25 @@ import { useCookieState } from "@openstatus/ui/hooks/use-cookie-state"; import { useQuery } from "@tanstack/react-query"; +import { TRIAL_BANNER_DAYS } from "@/lib/trial"; import { useTRPC } from "@/lib/trpc/client"; import { NavBannerChecklist } from "./nav-banner-checklist"; +import { NavBannerTrial } from "./nav-banner-trial"; import { NavBannerUpgrade } from "./nav-banner-upgrade"; const EXPIRES_IN = 7 * 24 * 60 * 60 * 1000; // in 7 days +const TRIAL_REFETCH_MS = 60 * 60 * 1000; export function NavBanner() { const trpc = useTRPC(); - const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); + const { data: workspace } = useQuery({ + ...trpc.workspace.get.queryOptions(), + // `trialDaysLeft` is computed server-side, so a sidebar left open would + // otherwise keep yesterday's count and miss the banner threshold. + refetchInterval: (query) => + query.state.data?.trialDaysLeft != null ? TRIAL_REFETCH_MS : false, + }); const [openChecklist, setOpenChecklist] = useCookieState<"true" | "false">( "sidebar_banner_checklist", "true", @@ -23,9 +32,29 @@ export function NavBanner() { "true", { expires: EXPIRES_IN }, ); + const [openTrial, setOpenTrial] = useCookieState<"true" | "false">( + "sidebar_banner_trial", + "true", + { expires: EXPIRES_IN }, + ); if (!workspace) return null; + const trialDaysLeft = workspace.trialDaysLeft; + if ( + openTrial === "true" && + trialDaysLeft !== null && + trialDaysLeft <= TRIAL_BANNER_DAYS + ) { + return ( + setOpenTrial("false")} + /> + ); + } + if (openChecklist === "true") { return setOpenChecklist("false")} />; } diff --git a/apps/dashboard/src/components/nav/nav-user.tsx b/apps/dashboard/src/components/nav/nav-user.tsx index 3e49bca9..16e4ccfa 100644 --- a/apps/dashboard/src/components/nav/nav-user.tsx +++ b/apps/dashboard/src/components/nav/nav-user.tsx @@ -34,10 +34,11 @@ import { SidebarMenuItem, useSidebar, } from "@openstatus/ui/components/ui/sidebar"; -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { signOut } from "next-auth/react"; import { useTheme } from "next-themes"; import Link from "next/link"; +import { toast } from "sonner"; import { useTRPC } from "@/lib/trpc/client"; @@ -47,10 +48,19 @@ export function NavUser() { const trpc = useTRPC(); const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); const { data: user } = useQuery(trpc.user.get.queryOptions()); + const paymentMethodSetupMutation = useMutation( + trpc.stripeRouter.getPaymentMethodSetupSession.mutationOptions({ + onSuccess: (url) => { + if (url) window.location.assign(url); + }, + onError: (error) => toast.error(error.message), + }), + ); if (!user || !workspace) return null; const userName = user?.name ?? `${user?.firstName} ${user?.lastName}`.trim(); + const isTrialing = workspace.trialDaysLeft !== null; return ( @@ -107,7 +117,25 @@ export function NavUser() { - {workspace.plan === "free" ? ( + {isTrialing ? ( + <> + + paymentMethodSetupMutation.mutate({ + workspaceSlug: workspace.slug, + successUrl: `${window.location.origin}/settings/billing?setup=true`, + cancelUrl: `${window.location.origin}/settings/billing`, + }) + } + disabled={paymentMethodSetupMutation.isPending} + className="font-commit-mono tracking-tight" + > + + Add payment method + + + + ) : workspace.plan === "free" ? ( <> @@ -63,9 +66,22 @@ export function WorkspaceSwitcher({ className, side }: WorkspaceSwitcherProps) { {workspace.slug} {" "} - - {workspace.plan === "team" ? "pro" : workspace.plan} - + {trialDaysLeft ? ( + + trial · {trialDaysLeft}d + + ) : ( + + {workspace.plan === "team" ? "pro" : workspace.plan} + + )} diff --git a/apps/dashboard/src/lib/auth/index.ts b/apps/dashboard/src/lib/auth/index.ts index b987e844..2d04fc55 100644 --- a/apps/dashboard/src/lib/auth/index.ts +++ b/apps/dashboard/src/lib/auth/index.ts @@ -1,6 +1,7 @@ import { Events, setupAnalytics } from "@openstatus/analytics"; import { db, eq } from "@openstatus/db"; -import { user } from "@openstatus/db/src/schema"; +import { type User, user } from "@openstatus/db/src/schema"; +import { getCurrency } from "@openstatus/db/src/schema/plan/utils"; import { WelcomeEmail, sendEmail } from "@openstatus/emails"; import type { DefaultSession } from "next-auth"; import NextAuth from "next-auth"; @@ -44,6 +45,60 @@ async function syncUser( .run(); } +// Runs from the `signIn` event, not `createUser`: only `signIn` carries the +// account, and the trial must know the provider to skip SSO signups. It also +// fires after the account row is linked, so nothing races the adapter. +async function onNewUser(newUser: Partial, provider?: string) { + if (!newUser.id || !newUser.email) { + throw new Error("User id & email is required"); + } + + // this means the user has already been created with clerk + if (newUser.tenantId) return; + + const requestHeaders = await headers(); + const currency = getCurrency({ + continent: requestHeaders.get("x-vercel-ip-continent") || "NA", + country: requestHeaders.get("x-vercel-ip-country") || "US", + }); + + // Imported lazily to keep Stripe out of the proxy bundle, which also loads this module. + const { maybeStartSignupTrial } = + await import("@openstatus/api/src/router/stripe/trial"); + const trial = await maybeStartSignupTrial({ + userId: newUser.id, + email: newUser.email, + provider, + currency, + }).catch((error: Error) => { + console.error("signup trial failed", { userId: newUser.id, error }); + return { started: false, reason: "stripe_error" } as const; + }); + + await sendEmail({ + from: "Thibault from openstatus ", + subject: "Welcome to openstatus.", + to: [newUser.email], + react: WelcomeEmail({ + trialEndsAt: trial.started ? trial.trialEndsAt : undefined, + }), + }); + + const analytics = await setupAnalytics({ + userId: `usr_${newUser.id}`, + email: newUser.email, + location: requestHeaders.get("x-forwarded-for") ?? undefined, + userAgent: requestHeaders.get("user-agent") ?? undefined, + }); + + await analytics.track(Events.CreateUser); + if (trial.started) { + await analytics.track({ ...Events.StartTrial, currency }); + } else if (trial.reason !== "disabled") { + await analytics.track({ ...Events.SkipTrial, reason: trial.reason }); + } +} + const { handlers, signIn, @@ -147,32 +202,6 @@ const { }, }, events: { - // That should probably done in the callback method instead - async createUser(params) { - if (!params.user.id || !params.user.email) { - throw new Error("User id & email is required"); - } - - // this means the user has already been created with clerk - if (params.user.tenantId) return; - - await sendEmail({ - from: "Thibault from openstatus ", - subject: "Welcome to openstatus.", - to: [params.user.email], - react: WelcomeEmail(), - }); - - const analytics = await setupAnalytics({ - userId: `usr_${params.user.id}`, - email: params.user.email, - location: (await headers()).get("x-forwarded-for") ?? undefined, - userAgent: (await headers()).get("user-agent") ?? undefined, - }); - - await analytics.track(Events.CreateUser); - }, - async signIn(params) { if (params.account?.provider === "workos") { const { organization_id: organizationId } = readWorkOSProfile( @@ -184,7 +213,13 @@ const { } } - if (params.isNewUser) return; + if (params.isNewUser) { + await onNewUser( + { ...params.user, id: Number(params.user.id) }, + params.account?.provider, + ); + return; + } if (!params.user.id || !params.user.email) return; const analytics = await setupAnalytics({ diff --git a/apps/dashboard/src/lib/trial.ts b/apps/dashboard/src/lib/trial.ts new file mode 100644 index 00000000..3becacd5 --- /dev/null +++ b/apps/dashboard/src/lib/trial.ts @@ -0,0 +1,2 @@ +// days-left at which the sidebar banner and switcher turn warning +export const TRIAL_BANNER_DAYS = 3; diff --git a/packages/analytics/src/events.ts b/packages/analytics/src/events.ts index d511cd40..020d6e82 100644 --- a/packages/analytics/src/events.ts +++ b/packages/analytics/src/events.ts @@ -208,6 +208,18 @@ export const Events = { name: "workspace_downgraded", channel: "billing", }, + StartTrial: { + name: "trial_started", + channel: "billing", + }, + SkipTrial: { + name: "trial_skipped", + channel: "billing", + }, + ConvertTrial: { + name: "trial_converted", + channel: "billing", + }, GlobalSpeedChecker: { name: "global_speed_checker", channel: "checker", diff --git a/packages/api/package.json b/packages/api/package.json index 7b3178db..d8edbe8d 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -52,6 +52,7 @@ "date-fns": "catalog:", "drizzle-orm": "catalog:", "isomorphic-dompurify": "catalog:", + "mailchecker": "catalog:", "nanoid": "catalog:", "nanoid-dictionary": "catalog:", "next": "catalog:", diff --git a/packages/api/src/env.ts b/packages/api/src/env.ts index 9082ec7b..7ff4f6f8 100644 --- a/packages/api/src/env.ts +++ b/packages/api/src/env.ts @@ -21,6 +21,7 @@ export const env = createEnv({ SLACK_FEEDBACK_WEBHOOK_URL: z.string().optional(), EXTERNAL_REPORT_SALT: z.string().optional(), SELF_HOST: z.stringbool().prefault("false"), + NODE_ENV: z.enum(["development", "test", "production"]).optional(), }, runtimeEnv: { @@ -39,6 +40,7 @@ export const env = createEnv({ SLACK_FEEDBACK_WEBHOOK_URL: process.env.SLACK_FEEDBACK_WEBHOOK_URL, EXTERNAL_REPORT_SALT: process.env.EXTERNAL_REPORT_SALT, SELF_HOST: process.env.SELF_HOST, + NODE_ENV: process.env.NODE_ENV, }, skipValidation: process.env.NODE_ENV === "test", }); diff --git a/packages/api/src/router/stripe/index.ts b/packages/api/src/router/stripe/index.ts index 35c9e443..b1d7f9e0 100644 --- a/packages/api/src/router/stripe/index.ts +++ b/packages/api/src/router/stripe/index.ts @@ -1,23 +1,23 @@ import { Events } from "@openstatus/analytics"; -import { eq } from "@openstatus/db"; -import { - selectWorkspaceSchema, - user, - usersToWorkspaces, - workspace, - workspacePlans, -} from "@openstatus/db/src/schema"; +import { workspacePlans } from "@openstatus/db/src/schema"; import type { AddonQuantityKey } from "@openstatus/db/src/schema/plan/schema"; import { addons, billingIntervals, } from "@openstatus/db/src/schema/plan/schema"; +import { isAddonQuantityKey } from "@openstatus/db/src/schema/plan/utils"; +import { + ConflictError, + type ServiceContext, + countWorkspaceUsage, +} from "@openstatus/services"; import { - isAddonQuantityKey, - updateAddonInLimits, -} from "@openstatus/db/src/schema/plan/utils"; -import { countWorkspaceUsage } from "@openstatus/services"; -import { updateWorkspacePlan } from "@openstatus/services/workspace"; + getWorkspace, + getWorkspaceForMember, + updateWorkspaceLimits, + updateWorkspacePlan, + updateWorkspaceStripeId, +} from "@openstatus/services/workspace"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; @@ -26,7 +26,9 @@ import { buildFromSubscriptionOrThrow, getCurrentPeriodEnd, getCurrentSubscription, + hasPaymentMethod, stripe, + trialEndsAtOf, } from "./shared"; import { getPlanFromPriceId, @@ -46,54 +48,72 @@ const url = ? "https://www.openstatus.dev" : "http://localhost:3000"; +// The slug is input, so the target may differ from the active `ctx.workspace`. +async function resolveWorkspaceCtx(opts: { + ctx: { db: ServiceContext["db"]; user: { id: number } }; + input: { workspaceSlug: string }; +}) { + const access = await getWorkspaceForMember({ + input: { slug: opts.input.workspaceSlug, userId: opts.ctx.user.id }, + db: opts.ctx.db, + }); + if (!access) return; + const ctx: ServiceContext = { + workspace: access.workspace, + actor: { type: "user", userId: opts.ctx.user.id }, + db: opts.ctx.db, + }; + return { ctx, email: access.email }; +} + +async function ensureStripeCustomer(ctx: ServiceContext, email: string | null) { + if (ctx.workspace.stripeId) return ctx.workspace.stripeId; + + const customer = await stripe.customers.create({ + metadata: { workspaceId: String(ctx.workspace.id) }, + email: email || "", + }); + + try { + await updateWorkspaceStripeId({ ctx, input: { stripeId: customer.id } }); + } catch (err) { + if (!(err instanceof ConflictError)) throw err; + // A concurrent request linked its customer first; ours would orphan. + await stripe.customers.del(customer.id).catch(() => undefined); + const linked = await getWorkspace({ ctx }); + if (!linked.stripeId) throw err; + return linked.stripeId; + } + + return customer.id; +} + +async function createPaymentMethodSetupSession(args: { + customer: string; + subscriptionId: string; + successUrl: string; + cancelUrl: string; +}) { + return stripe.checkout.sessions.create({ + mode: "setup", + payment_method_types: ["card"], + customer: args.customer, + setup_intent_data: { metadata: { subscriptionId: args.subscriptionId } }, + success_url: args.successUrl, + cancel_url: args.cancelUrl, + }); +} + export const stripeRouter = createTRPCRouter({ getUserCustomerPortal: protectedProcedure .input( z.object({ workspaceSlug: z.string(), returnUrl: z.string().optional() }), ) .mutation(async (opts) => { - const result = await opts.ctx.db - .select() - .from(workspace) - .where(eq(workspace.slug, opts.input.workspaceSlug)) - .get(); - - if (!result) return; - - const currentUser = opts.ctx.db - .select() - .from(user) - .where(eq(user.id, opts.ctx.user.id)) - .as("currentUser"); - const userHasAccess = await opts.ctx.db - .select() - .from(usersToWorkspaces) - .where(eq(usersToWorkspaces.workspaceId, result.id)) - .innerJoin(currentUser, eq(usersToWorkspaces.userId, currentUser.id)) - .get(); - - if (!userHasAccess || !userHasAccess.users_to_workspaces) return; - let stripeId = result.stripeId; - if (!stripeId) { - const customerData: { - metadata: { workspaceId: string }; - email?: string; - } = { - metadata: { - workspaceId: String(result.id), - }, - email: userHasAccess.currentUser.email || "", - }; - - const stripeUser = await stripe.customers.create(customerData); - - stripeId = stripeUser.id; - await opts.ctx.db - .update(workspace) - .set({ stripeId }) - .where(eq(workspace.id, result.id)) - .run(); - } + const resolved = await resolveWorkspaceCtx(opts); + if (!resolved) return; + const result = resolved.ctx.workspace; + const stripeId = await ensureStripeCustomer(resolved.ctx, resolved.email); const session = await stripe.billingPortal.sessions.create({ customer: stripeId, @@ -116,53 +136,10 @@ export const stripeRouter = createTRPCRouter({ }), ) .mutation(async (opts) => { - // The following code is duplicated we should extract it - const result = await opts.ctx.db - .select() - .from(workspace) - .where(eq(workspace.slug, opts.input.workspaceSlug)) - .get(); - - if (!result) return; - - const currentUser = opts.ctx.db - .select() - .from(user) - .where(eq(user.id, opts.ctx.user.id)) - .as("currentUser"); - const userHasAccess = await opts.ctx.db - .select() - .from(usersToWorkspaces) - .where(eq(usersToWorkspaces.workspaceId, result.id)) - .innerJoin(currentUser, eq(usersToWorkspaces.userId, currentUser.id)) - .get(); - - if (!userHasAccess || !userHasAccess.users_to_workspaces) return; - let stripeId = result.stripeId; - if (!stripeId) { - const currentUser = await opts.ctx.db - .select() - .from(user) - .where(eq(user.id, opts.ctx.user.id)) - .get(); - const customerData: { - metadata: { workspaceId: string }; - email?: string; - } = { - metadata: { - workspaceId: String(result.id), - }, - email: currentUser?.email || "", - }; - const stripeUser = await stripe.customers.create(customerData); - - stripeId = stripeUser.id; - await opts.ctx.db - .update(workspace) - .set({ stripeId }) - .where(eq(workspace.id, result.id)) - .run(); - } + const resolved = await resolveWorkspaceCtx(opts); + if (!resolved) return; + const result = resolved.ctx.workspace; + const stripeId = await ensureStripeCustomer(resolved.ctx, resolved.email); const priceId = getPriceIdForPlan(opts.input.plan, opts.input.interval); if (!priceId) { @@ -180,7 +157,30 @@ export const stripeRouter = createTRPCRouter({ // dropping every addon they bought. const { current } = await getCurrentSubscription(stripeId); - if (current) { + const billingUrl = `${url}/app/${result.slug}/settings/billing`; + const isTrialing = current?.status === "trialing"; + const trialWithoutCard = + current !== undefined && + isTrialing && + !(await hasPaymentMethod(current)); + + if ( + trialWithoutCard && + opts.input.plan === "starter" && + opts.input.interval === "monthly" + ) { + const session = await createPaymentMethodSetupSession({ + customer: stripeId, + subscriptionId: current.id, + successUrl: opts.input.successUrl || `${billingUrl}?setup=true`, + cancelUrl: opts.input.cancelUrl || billingUrl, + }); + return { type: "setup" as const, session }; + } + + // A trial without a card cannot be charged in place, so it goes through + // a regular checkout; `sessionCompleted` retires the trial subscription. + if (current && !trialWithoutCard) { const planItem = current.items.data.find((item) => getPlanFromPriceId(item.price.id), ); @@ -223,6 +223,10 @@ export const stripeRouter = createTRPCRouter({ items: [{ id: planItem.id, price: priceId }], proration_behavior: "create_prorations", cancel_at_period_end: false, + ...(isTrialing && { + trial_end: "now", + payment_behavior: "error_if_incomplete", + }), }); const built = buildFromSubscriptionOrThrow(updated); @@ -239,16 +243,13 @@ export const stripeRouter = createTRPCRouter({ // rebuilds the same state from the same subscription, so applying it // again is a no-op. await updateWorkspacePlan({ - ctx: { - workspace: selectWorkspaceSchema.parse(result), - actor: { type: "user", userId: opts.ctx.user.id }, - db: opts.ctx.db, - }, + ctx: resolved.ctx, input: { plan: built.plan, subscriptionId: updated.id, endsAt: getCurrentPeriodEnd(updated), paidUntil: getCurrentPeriodEnd(updated), + trialEndsAt: trialEndsAtOf(updated), limits: built.limits, reason: "plan_changed", }, @@ -275,51 +276,63 @@ export const stripeRouter = createTRPCRouter({ enabled: true, }, mode: "subscription", - success_url: - opts.input.successUrl || - `${url}/app/${result.slug}/settings/billing?success=true`, - cancel_url: - opts.input.cancelUrl || `${url}/app/${result.slug}/settings/billing`, + success_url: opts.input.successUrl || `${billingUrl}?success=true`, + cancel_url: opts.input.cancelUrl || billingUrl, }); return { type: "checkout" as const, session }; }), - addAddon: protectedProcedure - .meta({ track: Events.AddFeature, trackProps: ["feature"] }) + getPaymentMethodSetupSession: protectedProcedure .input( z.object({ workspaceSlug: z.string(), - feature: z.enum(addons), - value: z.union([z.boolean(), z.number()]), + successUrl: z.string().optional(), + cancelUrl: z.string().optional(), }), ) .mutation(async (opts) => { - // The following code is duplicated we should extract it - const result = await opts.ctx.db - .select() - .from(workspace) - .where(eq(workspace.slug, opts.input.workspaceSlug)) - .get(); - - if (!result) return; - - const ws = selectWorkspaceSchema.parse(result); - - const currentUser = opts.ctx.db - .select() - .from(user) - .where(eq(user.id, opts.ctx.user.id)) - .as("currentUser"); - const userHasAccess = await opts.ctx.db - .select() - .from(usersToWorkspaces) - .where(eq(usersToWorkspaces.workspaceId, result.id)) - .innerJoin(currentUser, eq(usersToWorkspaces.userId, currentUser.id)) - .get(); - - if (!userHasAccess || !userHasAccess.users_to_workspaces) return; - const stripeId = result.stripeId; + const resolved = await resolveWorkspaceCtx(opts); + const ws = resolved?.ctx.workspace; + if (!ws?.stripeId) return; + + const { current } = await getCurrentSubscription(ws.stripeId); + if (!current) return; + + const billingUrl = `${url}/app/${ws.slug}/settings/billing`; + const session = await createPaymentMethodSetupSession({ + customer: ws.stripeId, + subscriptionId: current.id, + successUrl: opts.input.successUrl || `${billingUrl}?setup=true`, + cancelUrl: opts.input.cancelUrl || billingUrl, + }); + + return session.url; + }), + + addAddon: protectedProcedure + .meta({ track: Events.AddFeature, trackProps: ["feature"] }) + .input( + z + .object({ + workspaceSlug: z.string(), + feature: z.enum(addons), + value: z.union([z.boolean(), z.number()]), + }) + // A boolean on a quantity addon (or a number on a toggle) would change + // the Stripe item but leave the limit untouched, so reject it up front. + .refine( + (i) => + (typeof i.value === "number") === isAddonQuantityKey(i.feature), + { message: "Value does not match the addon type", path: ["value"] }, + ), + ) + .mutation(async (opts) => { + const resolved = await resolveWorkspaceCtx(opts); + if (!resolved) return; + const ws = resolved.ctx.workspace; + + const stripeId = ws.stripeId; if (!stripeId) { throw new TRPCError({ code: "BAD_REQUEST", @@ -339,6 +352,16 @@ export const stripeRouter = createTRPCRouter({ return; } + const isTrialing = current.status === "trialing"; + const isRemoval = opts.input.value === false || opts.input.value === 0; + // Removing an addon never ends the trial, so it needs no card. + if (isTrialing && !isRemoval && !(await hasPaymentMethod(current))) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "Add a payment method first.", + }); + } + const priceId = getPriceIdForFeature(opts.input.feature); if (!priceId) { @@ -396,7 +419,7 @@ export const stripeRouter = createTRPCRouter({ const current = await countWorkspaceUsage( opts.ctx.db, - result.id, + ws.id, opts.input.feature, ); if (current > resolved.newLimit) { @@ -408,7 +431,16 @@ export const stripeRouter = createTRPCRouter({ } const item = items.data.find((item) => item.price.id === priceId); - const isRemoval = opts.input.value === false || quantity === 0; + + // Charge the plan before granting the addon, so a declined card leaves + // the trial and the limits untouched. + const endsTrial = isTrialing && !isRemoval; + if (endsTrial) { + await stripe.subscriptions.update(current.id, { + trial_end: "now", + payment_behavior: "error_if_incomplete", + }); + } if (isRemoval) { if (item) { @@ -426,17 +458,15 @@ export const stripeRouter = createTRPCRouter({ }); } - const newLimits = updateAddonInLimits( - ws.limits, - opts.input.feature, - newValue, - ); - - await opts.ctx.db - .update(workspace) - .set({ limits: JSON.stringify(newLimits) }) - .where(eq(workspace.id, result.id)) - .run(); + await updateWorkspaceLimits({ + ctx: resolved.ctx, + input: { + addon: opts.input.feature, + value: newValue, + ...(endsTrial && { trialEndsAt: null }), + reason: endsTrial ? "trial_converted" : "addon_changed", + }, + }); // TODO: send email to user notifying about the change if not already from stripe diff --git a/packages/api/src/router/stripe/shared.ts b/packages/api/src/router/stripe/shared.ts index 49ef0183..6c3a9d55 100644 --- a/packages/api/src/router/stripe/shared.ts +++ b/packages/api/src/router/stripe/shared.ts @@ -146,15 +146,14 @@ export async function cancelSubscription(customer?: string) { if (!customer) return; try { - const subscriptionId = await stripe.subscriptions - .list({ - customer, - }) - .then((res) => res.data[0]?.id); + const { current } = await getCurrentSubscription(customer); + if (!current) return; - if (!subscriptionId) return; + if (current.status === "trialing") { + return await stripe.subscriptions.cancel(current.id); + } - return await stripe.subscriptions.update(subscriptionId, { + return await stripe.subscriptions.update(current.id, { cancel_at_period_end: true, cancellation_details: { comment: "Customer deleted their OpenStatus project.", @@ -165,3 +164,24 @@ export async function cancelSubscription(customer?: string) { return; } } + +export function customerIdOf(subscription: Stripe.Subscription) { + return typeof subscription.customer === "string" + ? subscription.customer + : subscription.customer.id; +} + +export async function hasPaymentMethod(subscription: Stripe.Subscription) { + if (subscription.default_payment_method) return true; + const customer = await stripe.customers.retrieve(customerIdOf(subscription)); + return ( + !customer.deleted && + Boolean(customer.invoice_settings.default_payment_method) + ); +} + +export function trialEndsAtOf(subscription: Stripe.Subscription) { + return subscription.status === "trialing" && subscription.trial_end + ? new Date(subscription.trial_end * 1000) + : null; +} diff --git a/packages/api/src/router/stripe/trial-lifecycle.test.ts b/packages/api/src/router/stripe/trial-lifecycle.test.ts new file mode 100644 index 00000000..e919d2fb --- /dev/null +++ b/packages/api/src/router/stripe/trial-lifecycle.test.ts @@ -0,0 +1,579 @@ +import { and, db, desc, eq } from "@openstatus/db"; +import { + auditLog, + selectUserSchema, + selectWorkspaceSchema, + user, + workspace, +} from "@openstatus/db/src/schema"; +import { + addUserToWorkspace, + createTestWorkspace, + createWorkspace, +} from "@openstatus/db/src/test/factories"; +import { delivery, resend } from "@openstatus/emails/src/send"; +import { expect } from "@std/expect"; +import { afterEach, beforeEach, describe, test } from "@std/testing/bdd"; +import { assertSpyCalls, type Stub, stub } from "@std/testing/mock"; +import type Stripe from "stripe"; + +import { edgeRouter } from "../../edge"; +import { createInnerTRPCContext } from "../../trpc"; +import { stripeRouter } from "./index"; +import { cancelSubscription, stripe } from "./shared"; +import { FEATURES, PLANS } from "./utils"; +import { webhookRouter } from "./webhook"; + +const DAY = 86_400; +const now = () => Math.floor(Date.now() / 1000); +const STARTER_PRICE = + PLANS.find((p) => p.plan === "starter")?.price.monthly.priceIds.test ?? ""; +const TEAM_PRICE = + PLANS.find((p) => p.plan === "team")?.price.monthly.priceIds.test ?? ""; +const WHITE_LABEL_PRICE = + FEATURES.find((f) => f.feature === "white-label")?.price.monthly.priceIds + .test ?? ""; + +function subscription( + customer: string, + overrides: Partial = {}, + price = STARTER_PRICE, +) { + const periodEnd = now() + 14 * DAY; + return { + id: `sub_${crypto.randomUUID()}`, + customer, + status: "trialing", + created: now() - DAY, + trial_end: periodEnd, + cancel_at_period_end: false, + default_payment_method: null, + metadata: {}, + currency: "usd", + items: { + data: [ + { + id: "si_plan", + price: { id: price, recurring: { interval: "month" } }, + quantity: 1, + current_period_end: periodEnd, + }, + ], + }, + ...overrides, + } as Stripe.Subscription; +} + +async function seedTrial() { + const stripeId = `cus_${crypto.randomUUID()}`; + const trialEndsAt = new Date((now() + 14 * DAY) * 1000); + const fixture = await createTestWorkspace({ + plan: "starter", + stripeId, + limits: "{}", + trialEndsAt, + }); + const ws = selectWorkspaceSchema.parse(fixture.workspace); + return { ...fixture, ws, stripeId, trialEndsAt }; +} + +function customerWith(defaultPaymentMethod: string | null) { + const invoiceSettings: Partial = { + default_payment_method: defaultPaymentMethod, + }; + const partial: Partial = { + invoice_settings: invoiceSettings as Stripe.Customer.InvoiceSettings, + }; + return partial as Stripe.Customer; +} + +async function readWorkspace(id: number) { + return db.select().from(workspace).where(eq(workspace.id, id)).get(); +} + +async function lastPlanAuditReason(workspaceId: number) { + const row = await db + .select({ metadata: auditLog.metadata }) + .from(auditLog) + .where( + and( + eq(auditLog.workspaceId, workspaceId), + eq(auditLog.entityType, "workspace"), + eq(auditLog.action, "workspace.update"), + ), + ) + .orderBy(desc(auditLog.id)) + .get(); + return (row?.metadata as { reason?: string } | null)?.reason; +} + +function asCaller(fixture: Awaited>) { + return createInnerTRPCContext({ + session: { user: { id: String(fixture.user.id) } }, + workspace: fixture.ws, + user: selectUserSchema.parse(fixture.user), + }); +} + +describe("trial lifecycle", () => { + let live: Stripe.Subscription[]; + let customer: Stripe.Customer; + let stubs: Stub[]; + let updateSubscription: Stub; + let cancelStripeSubscription: Stub; + let createSession: Stub; + + beforeEach(() => { + live = []; + customer = customerWith(null); + updateSubscription = stub( + stripe.subscriptions, + "update", + (id: string, params?: Stripe.SubscriptionUpdateParams) => { + const base = live.find((s) => s.id === id) ?? subscription("cus_x"); + const items = params?.items?.[0]?.price + ? { + ...base.items, + data: [ + { + ...base.items.data[0], + price: { id: params.items[0].price }, + } as Stripe.SubscriptionItem, + ], + } + : base.items; + return Promise.resolve({ + ...base, + items, + status: params?.trial_end === "now" ? "active" : base.status, + trial_end: params?.trial_end === "now" ? null : base.trial_end, + } as Stripe.Response); + }, + ); + cancelStripeSubscription = stub(stripe.subscriptions, "cancel", () => + Promise.resolve({} as Stripe.Response), + ); + createSession = stub(stripe.checkout.sessions, "create", () => + Promise.resolve({ + url: "https://checkout.stripe.test/session", + } as Stripe.Response), + ); + stubs = [ + updateSubscription, + cancelStripeSubscription, + createSession, + stub( + stripe.subscriptions, + "list", + () => + ({ + autoPagingToArray: (_opts: { limit: number }) => + Promise.resolve(live), + }) as Stripe.ApiListPromise, + ), + stub(stripe.customers, "retrieve", () => + Promise.resolve(customer as Stripe.Response), + ), + ]; + }); + + afterEach(() => { + for (const s of stubs) s.restore(); + }); + + function withCard() { + customer = customerWith("pm_card"); + } + + describe("getCheckoutSession", () => { + test("trialing without a card → regular checkout, no in-place swap", async () => { + const s = await seedTrial(); + live = [subscription(s.stripeId)]; + + const result = await stripeRouter + .createCaller(asCaller(s)) + .getCheckoutSession({ + currency: "USD", + workspaceSlug: s.ws.slug, + plan: "team", + }); + + expect(result?.type).toBe("checkout"); + assertSpyCalls(updateSubscription, 0); + expect(createSession.calls[0]?.args[0]).toMatchObject({ + mode: "subscription", + customer: s.stripeId, + line_items: [{ price: TEAM_PRICE, quantity: 1 }], + }); + }); + + test("trialing without a card on Starter monthly → setup session", async () => { + const s = await seedTrial(); + const sub = subscription(s.stripeId); + live = [sub]; + + const result = await stripeRouter + .createCaller(asCaller(s)) + .getCheckoutSession({ + currency: "USD", + workspaceSlug: s.ws.slug, + plan: "starter", + }); + + expect(result?.type).toBe("setup"); + expect(createSession.calls[0]?.args[0]).toMatchObject({ + mode: "setup", + customer: s.stripeId, + setup_intent_data: { metadata: { subscriptionId: sub.id } }, + }); + }); + + test("trialing with a card → plan swapped and the trial ended now", async () => { + const s = await seedTrial(); + withCard(); + const sub = subscription(s.stripeId); + live = [sub]; + + const result = await stripeRouter + .createCaller(asCaller(s)) + .getCheckoutSession({ + currency: "USD", + workspaceSlug: s.ws.slug, + plan: "team", + }); + + expect(result?.type).toBe("updated"); + expect(updateSubscription.calls[0]?.args).toEqual([ + sub.id, + { + items: [{ id: "si_plan", price: TEAM_PRICE }], + proration_behavior: "create_prorations", + cancel_at_period_end: false, + trial_end: "now", + payment_behavior: "error_if_incomplete", + }, + ]); + const after = await readWorkspace(s.ws.id); + expect(after?.plan).toBe("team"); + expect(after?.trialEndsAt).toBeNull(); + }); + }); + + describe("addAddon", () => { + test("trialing without a card → precondition failed", async () => { + const s = await seedTrial(); + live = [subscription(s.stripeId)]; + + await expect( + stripeRouter.createCaller(asCaller(s)).addAddon({ + workspaceSlug: s.ws.slug, + feature: "white-label", + value: true, + }), + ).rejects.toMatchObject({ code: "PRECONDITION_FAILED" }); + assertSpyCalls(updateSubscription, 0); + }); + + test("trialing with a card → trial ended before the addon is added", async () => { + const s = await seedTrial(); + withCard(); + const sub = subscription(s.stripeId); + live = [sub]; + const planItem = sub.items.data[0]; + const listItems = stub( + stripe.subscriptionItems, + "list", + () => + Promise.resolve({ + data: planItem ? [planItem] : [], + } as Stripe.Response< + Stripe.ApiList + >) as Stripe.ApiListPromise, + ); + const createItem = stub(stripe.subscriptionItems, "create", () => + Promise.resolve({} as Stripe.Response), + ); + stubs.push(listItems, createItem); + + await stripeRouter.createCaller(asCaller(s)).addAddon({ + workspaceSlug: s.ws.slug, + feature: "white-label", + value: true, + }); + + expect(updateSubscription.calls[0]?.args).toEqual([ + sub.id, + { trial_end: "now", payment_behavior: "error_if_incomplete" }, + ]); + expect(createItem.calls[0]?.args[0]).toMatchObject({ + price: WHITE_LABEL_PRICE, + subscription: sub.id, + }); + const after = await readWorkspace(s.ws.id); + expect(after?.trialEndsAt).toBeNull(); + expect(JSON.parse(after?.limits ?? "{}")["white-label"]).toBe(true); + }); + + test("a boolean on a quantity addon is rejected before Stripe is touched", async () => { + const s = await seedTrial(); + withCard(); + live = [subscription(s.stripeId)]; + const delItem = stub(stripe.subscriptionItems, "del", () => + Promise.resolve({} as Stripe.Response), + ); + stubs.push(delItem); + + await expect( + stripeRouter.createCaller(asCaller(s)).addAddon({ + workspaceSlug: s.ws.slug, + feature: "monitors", + value: false, + }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + assertSpyCalls(delItem, 0); + assertSpyCalls(updateSubscription, 0); + }); + }); + + describe("cancelSubscription", () => { + test("trialing → cancelled immediately", async () => { + const sub = subscription("cus_cancel_trial"); + live = [sub]; + + await cancelSubscription("cus_cancel_trial"); + + expect(cancelStripeSubscription.calls[0]?.args[0]).toBe(sub.id); + assertSpyCalls(updateSubscription, 0); + }); + + test("active → cancelled at period end", async () => { + const sub = subscription("cus_cancel_active", { status: "active" }); + live = [sub]; + + await cancelSubscription("cus_cancel_active"); + + assertSpyCalls(cancelStripeSubscription, 0); + expect(updateSubscription.calls[0]?.args[1]).toMatchObject({ + cancel_at_period_end: true, + }); + }); + }); + + describe("deleteAccount", () => { + test("a trial is cancelled and the account deleted", async () => { + const s = await seedTrial(); + const sub = subscription(s.stripeId); + live = [sub]; + + await edgeRouter.createCaller(asCaller(s)).user.deleteAccount(); + + expect(cancelStripeSubscription.calls[0]?.args[0]).toBe(sub.id); + const after = await readWorkspace(s.ws.id); + expect(after?.plan).toBe("free"); + expect(after?.trialEndsAt).toBeNull(); + expect(await lastPlanAuditReason(s.ws.id)).toBe("account_deleted"); + const deleted = await db + .select() + .from(user) + .where(eq(user.id, s.user.id)) + .get(); + expect(deleted?.deletedAt).not.toBeNull(); + }); + + test("a paid workspace elsewhere rejects the delete and keeps the trial", async () => { + const s = await seedTrial(); + live = [subscription(s.stripeId)]; + const paid = await createWorkspace({ plan: "team", trialEndsAt: null }); + await addUserToWorkspace(s.user.id, paid.id, "owner"); + + await expect( + edgeRouter.createCaller(asCaller(s)).user.deleteAccount(), + ).rejects.toMatchObject({ code: "PRECONDITION_FAILED" }); + + assertSpyCalls(cancelStripeSubscription, 0); + const after = await readWorkspace(s.ws.id); + expect(after?.plan).toBe("starter"); + expect(after?.trialEndsAt).toEqual(s.trialEndsAt); + }); + }); + + describe("webhook", () => { + const caller = () => + webhookRouter.createCaller(createInnerTRPCContext({ session: null })); + + function event( + type: string, + object: Stripe.Subscription | Stripe.Checkout.Session, + previous_attributes?: Partial, + ) { + return { + event: { + id: `evt_${crypto.randomUUID()}`, + created: now(), + type, + data: { object: { ...object }, previous_attributes }, + }, + }; + } + + test("setup checkout completed → card set as the default everywhere", async () => { + const intent: Partial = { + payment_method: "pm_new", + metadata: { subscriptionId: "sub_setup" }, + }; + const retrieveIntent = stub(stripe.setupIntents, "retrieve", () => + Promise.resolve(intent as Stripe.Response), + ); + const retrieveSub = stub(stripe.subscriptions, "retrieve", () => + Promise.resolve( + subscription("cus_setup", { + id: "sub_setup", + }) as Stripe.Response, + ), + ); + const updateCustomer = stub(stripe.customers, "update", () => + Promise.resolve({} as Stripe.Response), + ); + stubs.push(retrieveIntent, retrieveSub, updateCustomer); + + await caller().sessionCompleted( + event("checkout.session.completed", { + mode: "setup", + customer: "cus_setup", + setup_intent: "seti_1", + } as Stripe.Checkout.Session), + ); + + expect(updateCustomer.calls[0]?.args).toEqual([ + "cus_setup", + { invoice_settings: { default_payment_method: "pm_new" } }, + ]); + expect(updateSubscription.calls[0]?.args).toEqual([ + "sub_setup", + { default_payment_method: "pm_new" }, + ]); + }); + + test("trialing → active clears trialEndsAt", async () => { + const s = await seedTrial(); + const sub = subscription(s.stripeId, { status: "active" }); + live = [sub]; + + await caller().customerSubscriptionUpdated( + event("customer.subscription.updated", sub, { status: "trialing" }), + ); + + const after = await readWorkspace(s.ws.id); + expect(after?.plan).toBe("starter"); + expect(after?.trialEndsAt).toBeNull(); + }); + + test("a live trial keeps trialEndsAt in sync", async () => { + const s = await seedTrial(); + const sub = subscription(s.stripeId, { trial_end: now() + 20 * DAY }); + live = [sub]; + + await caller().customerSubscriptionUpdated( + event("customer.subscription.updated", sub), + ); + + const after = await readWorkspace(s.ws.id); + expect(after?.trialEndsAt).toEqual(new Date((sub.trial_end ?? 0) * 1000)); + }); + + test("a trial that ran out is audited as trial_ended, not churn", async () => { + const send = stub(resend.emails, "send"); + const enabled = stub(delivery, "enabled", () => true); + stubs.push(send, enabled); + const s = await seedTrial(); + live = []; + + await caller().customerSubscriptionDeleted( + event( + "customer.subscription.deleted", + subscription(s.stripeId, { status: "canceled" }), + ), + ); + + const after = await readWorkspace(s.ws.id); + expect(after?.plan).toBe("free"); + expect(after?.trialEndsAt).toBeNull(); + expect(await lastPlanAuditReason(s.ws.id)).toBe("trial_ended"); + }); + + test("a trial the customer cancelled is audited as trial_cancelled", async () => { + const send = stub(resend.emails, "send"); + const enabled = stub(delivery, "enabled", () => true); + stubs.push(send, enabled); + const s = await seedTrial(); + live = []; + + await caller().customerSubscriptionDeleted( + event( + "customer.subscription.deleted", + subscription(s.stripeId, { + status: "canceled", + cancellation_details: { + reason: "cancellation_requested", + } as Stripe.Subscription.CancellationDetails, + }), + ), + ); + + const after = await readWorkspace(s.ws.id); + expect(after?.plan).toBe("free"); + expect(after?.trialEndsAt).toBeNull(); + expect(await lastPlanAuditReason(s.ws.id)).toBe("trial_cancelled"); + }); + + test("a converted trial cancelled later is churn even if the marker is stale", async () => { + const send = stub(resend.emails, "send"); + const enabled = stub(delivery, "enabled", () => true); + stubs.push(send, enabled); + // The trial-to-active webhook never landed, so `trialEndsAt` is set. + const s = await seedTrial(); + live = []; + const trialEnd = now() - 30 * DAY; + + await caller().customerSubscriptionDeleted( + event( + "customer.subscription.deleted", + subscription(s.stripeId, { + status: "canceled", + trial_end: trialEnd, + ended_at: now(), + cancellation_details: { + reason: "cancellation_requested", + } as Stripe.Subscription.CancellationDetails, + }), + ), + ); + + const after = await readWorkspace(s.ws.id); + expect(after?.plan).toBe("free"); + expect(after?.trialEndsAt).toBeNull(); + expect(await lastPlanAuditReason(s.ws.id)).toBe("subscription_deleted"); + }); + + test("deleted on an already-free workspace → nothing happens", async () => { + const send = stub(resend.emails, "send"); + const enabled = stub(delivery, "enabled", () => true); + stubs.push(send, enabled); + const stripeId = `cus_${crypto.randomUUID()}`; + await createTestWorkspace({ + plan: "free", + stripeId, + subscriptionId: null, + }); + + await caller().customerSubscriptionDeleted( + event( + "customer.subscription.deleted", + subscription(stripeId, { status: "canceled" }), + ), + ); + + assertSpyCalls(send, 0); + }); + }); +}); diff --git a/packages/api/src/router/stripe/trial.test.ts b/packages/api/src/router/stripe/trial.test.ts new file mode 100644 index 00000000..4bb08c9e --- /dev/null +++ b/packages/api/src/router/stripe/trial.test.ts @@ -0,0 +1,276 @@ +import { db, eq } from "@openstatus/db"; +import { invitation, workspace } from "@openstatus/db/src/schema"; +import { getLimits } from "@openstatus/db/src/schema/plan/utils"; +import { createTestWorkspace } from "@openstatus/db/src/test/factories"; +import { expect } from "@std/expect"; +import { afterEach, beforeEach, describe, test } from "@std/testing/bdd"; +import { assertSpyCalls, type Stub, stub } from "@std/testing/mock"; +import type Stripe from "stripe"; + +import { stripe } from "./shared"; +import { TRIAL_DAYS, maybeStartSignupTrial } from "./trial"; +import { PLANS } from "./utils"; + +const STARTER_PRICE = PLANS.find((p) => p.plan === "starter")?.price.monthly + .priceIds.test; +const trialEnd = Math.floor(Date.now() / 1000) + TRIAL_DAYS * 86_400; + +async function freeWorkspace() { + return createTestWorkspace({ plan: "free", stripeId: null }); +} + +function customer(id: string, metadata: Record = {}) { + return { id, metadata } as unknown as Stripe.Customer; +} + +describe("maybeStartSignupTrial", () => { + let customerId: string; + let customers: Stripe.Customer[]; + let price: Partial; + let stubs: Stub[]; + let list: Stub; + let createCustomer: Stub; + let createSubscription: Stub; + // Runs inside `customers.create`, to interleave work with the Stripe call. + let onCreateCustomer: (() => Promise) | undefined; + + beforeEach(() => { + customerId = `cus_${crypto.randomUUID()}`; + customers = []; + onCreateCustomer = undefined; + price = { currency: "usd", currency_options: {} }; + list = stub( + stripe.customers, + "list", + () => + Promise.resolve({ data: customers } as Stripe.Response< + Stripe.ApiList + >) as Stripe.ApiListPromise, + ); + createCustomer = stub(stripe.customers, "create", async () => { + await onCreateCustomer?.(); + return { id: customerId } as Stripe.Response; + }); + createSubscription = stub(stripe.subscriptions, "create", () => + Promise.resolve({ + id: "sub_trial", + status: "trialing", + trial_end: trialEnd, + items: { + data: [ + { + price: { id: STARTER_PRICE }, + quantity: 1, + current_period_end: trialEnd, + }, + ], + }, + } as Stripe.Response), + ); + stubs = [ + list, + createCustomer, + createSubscription, + stub(stripe.prices, "retrieve", () => + Promise.resolve(price as Stripe.Response), + ), + ]; + }); + + afterEach(() => { + for (const s of stubs) s.restore(); + }); + + test("starts a Starter trial on the owner's workspace", async () => { + const { workspace: ws, user } = await freeWorkspace(); + price = { + currency: "usd", + currency_options: { + eur: {} as Stripe.Price.CurrencyOptions, + }, + }; + + const result = await maybeStartSignupTrial({ + userId: user.id, + email: user.email ?? "", + provider: "github", + currency: "EUR", + }); + + const trialEndsAt = new Date(trialEnd * 1000); + expect(result).toEqual({ started: true, trialEndsAt }); + + assertSpyCalls(createCustomer, 1); + expect(createCustomer.calls[0]?.args).toEqual([ + { + email: user.email, + metadata: { workspaceId: String(ws.id), trialed: "true" }, + }, + { idempotencyKey: `trial-customer:ws_${ws.id}` }, + ]); + + assertSpyCalls(createSubscription, 1); + const [params, options] = createSubscription.calls[0]?.args ?? []; + expect(params).toMatchObject({ + customer: customerId, + currency: "eur", + items: [{ price: STARTER_PRICE }], + trial_period_days: TRIAL_DAYS, + trial_settings: { end_behavior: { missing_payment_method: "cancel" } }, + }); + expect(options).toEqual({ idempotencyKey: `trial-sub:ws_${ws.id}` }); + + const after = await db + .select() + .from(workspace) + .where(eq(workspace.id, ws.id)) + .get(); + expect(after?.plan).toBe("starter"); + expect(after?.stripeId).toBe(customerId); + expect(after?.subscriptionId).toBe("sub_trial"); + expect(after?.trialEndsAt).toEqual(trialEndsAt); + expect(after?.endsAt).toEqual(trialEndsAt); + expect(JSON.parse(after?.limits ?? "{}")).toEqual(getLimits("starter")); + }); + + test("falls back to the price currency when the requested one is missing", async () => { + const { user } = await freeWorkspace(); + price = { currency: "eur", currency_options: {} }; + + await maybeStartSignupTrial({ + userId: user.id, + email: user.email ?? "", + currency: "USD", + }); + + expect(createSubscription.calls[0]?.args[0]).toMatchObject({ + currency: "eur", + }); + }); + + test("skips SSO sign-ins", async () => { + const { user } = await freeWorkspace(); + + const result = await maybeStartSignupTrial({ + userId: user.id, + email: user.email ?? "", + provider: "workos", + currency: "USD", + }); + + expect(result).toEqual({ started: false, reason: "sso" }); + assertSpyCalls(createCustomer, 0); + }); + + test("skips users with a pending invitation", async () => { + const { workspace: ws, user } = await freeWorkspace(); + await db.insert(invitation).values({ + email: (user.email ?? "").toUpperCase(), + workspaceId: ws.id, + token: crypto.randomUUID(), + expiresAt: new Date(Date.now() + 86_400_000), + }); + + const result = await maybeStartSignupTrial({ + userId: user.id, + email: user.email ?? "", + currency: "USD", + }); + + expect(result).toEqual({ started: false, reason: "invited" }); + assertSpyCalls(createCustomer, 0); + }); + + test("skips disposable email domains", async () => { + const { user } = await freeWorkspace(); + + const result = await maybeStartSignupTrial({ + userId: user.id, + email: "someone@mailinator.com", + currency: "USD", + }); + + expect(result).toEqual({ started: false, reason: "disposable" }); + assertSpyCalls(list, 0); + }); + + test("skips emails that already had a trial", async () => { + const { user } = await freeWorkspace(); + customers = [ + customer("cus_paid"), + customer("cus_old", { trialed: "true" }), + ]; + + const result = await maybeStartSignupTrial({ + userId: user.id, + email: "O'Brien@example.com", + currency: "USD", + }); + + expect(result).toEqual({ started: false, reason: "already_trialed" }); + expect(list.calls[0]?.args[0]).toEqual({ + email: "o'brien@example.com", + limit: 100, + }); + assertSpyCalls(createCustomer, 0); + }); + + test("ignores customers for the email that never trialed", async () => { + const { user } = await freeWorkspace(); + customers = [customer("cus_paid")]; + + const result = await maybeStartSignupTrial({ + userId: user.id, + email: user.email ?? "", + currency: "USD", + }); + + expect(result).toMatchObject({ started: true }); + assertSpyCalls(createCustomer, 1); + }); + + test("drops its customer when another request linked one first", async () => { + const { workspace: ws, user } = await freeWorkspace(); + const linked = `cus_${crypto.randomUUID()}`; + // Simulate the race: a checkout links its customer while ours is created. + onCreateCustomer = async () => { + await db + .update(workspace) + .set({ stripeId: linked }) + .where(eq(workspace.id, ws.id)); + }; + const delCustomer = stub(stripe.customers, "del", () => + Promise.resolve({} as Stripe.Response), + ); + stubs.push(delCustomer); + + const result = await maybeStartSignupTrial({ + userId: user.id, + email: user.email ?? "", + currency: "USD", + }); + + expect(result).toEqual({ started: false, reason: "not_eligible" }); + expect(delCustomer.calls[0]?.args[0]).toBe(customerId); + assertSpyCalls(createSubscription, 0); + const after = await db + .select({ stripeId: workspace.stripeId }) + .from(workspace) + .where(eq(workspace.id, ws.id)) + .get(); + expect(after?.stripeId).toBe(linked); + }); + + test("leaves an already-billed workspace alone", async () => { + const { user } = await createTestWorkspace({ plan: "team" }); + + const result = await maybeStartSignupTrial({ + userId: user.id, + email: user.email ?? "", + currency: "USD", + }); + + expect(result).toEqual({ started: false, reason: "not_eligible" }); + assertSpyCalls(createCustomer, 0); + }); +}); diff --git a/packages/api/src/router/stripe/trial.ts b/packages/api/src/router/stripe/trial.ts new file mode 100644 index 00000000..427044e3 --- /dev/null +++ b/packages/api/src/router/stripe/trial.ts @@ -0,0 +1,175 @@ +import { + ConflictError, + type DB, + type ServiceContext, +} from "@openstatus/services"; +import { hasPendingInvitation } from "@openstatus/services/invitation"; +import { + downgradeWorkspaceToFree, + findTrialEligibleWorkspace, + listOwnedTrialWorkspaces, + updateWorkspacePlan, + updateWorkspaceStripeId, +} from "@openstatus/services/workspace"; +import MailChecker from "mailchecker"; + +import { env } from "../../env"; +import { + buildFromSubscriptionOrThrow, + cancelSubscription, + stripe, +} from "./shared"; +import { getPriceIdForPlan } from "./utils"; + +export const TRIAL_DAYS = 14; + +export type TrialSkipReason = + | "disabled" + | "sso" + | "invited" + | "disposable" + | "already_trialed" + | "not_eligible" + | "stripe_error"; + +export type SignupTrialResult = + | { started: true; trialEndsAt: Date } + | { started: false; reason: TrialSkipReason }; + +// `customers.list` is read-after-write consistent; the Search API is not, so +// a re-signup right after a trial could slip past it. +async function hasTrialedBefore(email: string) { + const result = await stripe.customers.list({ email, limit: 100 }); + return result.data.some((c) => c.metadata.trialed === "true"); +} + +async function resolveCurrency(priceId: string, requested: "USD" | "EUR") { + const price = await stripe.prices.retrieve(priceId, { + expand: ["currency_options"], + }); + const wanted = requested.toLowerCase(); + return wanted === price.currency || price.currency_options?.[wanted] + ? wanted + : price.currency; +} + +export async function maybeStartSignupTrial(args: { + userId: number; + email: string; + provider?: string; + currency: "USD" | "EUR"; + db?: DB; +}): Promise { + const { db } = args; + const email = args.email.trim().toLowerCase(); + + if ( + env.SELF_HOST || + env.NODE_ENV === "development" || + !env.STRIPE_SECRET_KEY + ) { + return { started: false, reason: "disabled" }; + } + if (args.provider === "workos") return { started: false, reason: "sso" }; + if (await hasPendingInvitation({ email, db })) { + return { started: false, reason: "invited" }; + } + if (!MailChecker.isValid(email)) { + return { started: false, reason: "disposable" }; + } + if (await hasTrialedBefore(email)) { + return { started: false, reason: "already_trialed" }; + } + + const ws = await findTrialEligibleWorkspace({ + input: { userId: args.userId }, + db, + }); + if (!ws) return { started: false, reason: "not_eligible" }; + + const ctx: ServiceContext = { + workspace: ws, + actor: { type: "system", job: "signup-trial" }, + db, + }; + + const priceId = getPriceIdForPlan("starter", "monthly"); + if (!priceId) throw new Error("Missing Starter monthly price"); + const currency = await resolveCurrency(priceId, args.currency); + + const customer = await stripe.customers.create( + { email, metadata: { workspaceId: String(ws.id), trialed: "true" } }, + { idempotencyKey: `trial-customer:ws_${ws.id}` }, + ); + + try { + await updateWorkspaceStripeId({ ctx, input: { stripeId: customer.id } }); + } catch (err) { + if (!(err instanceof ConflictError)) throw err; + // Another request (a checkout, say) linked a customer since the + // eligibility read. Drop ours so its `trialed` flag doesn't block a + // later trial for this email. + await stripe.customers.del(customer.id).catch(() => undefined); + return { started: false, reason: "not_eligible" }; + } + + const subscription = await stripe.subscriptions.create( + { + customer: customer.id, + currency, + items: [{ price: priceId }], + trial_period_days: TRIAL_DAYS, + trial_settings: { + end_behavior: { missing_payment_method: "cancel" }, + }, + payment_settings: { save_default_payment_method: "on_subscription" }, + metadata: { source: "signup_trial", workspaceId: String(ws.id) }, + }, + { idempotencyKey: `trial-sub:ws_${ws.id}` }, + ); + + const built = buildFromSubscriptionOrThrow(subscription); + if (!built || !subscription.trial_end) { + throw new Error(`Trial subscription ${subscription.id} is not a trial`); + } + + const trialEndsAt = new Date(subscription.trial_end * 1000); + + await updateWorkspacePlan({ + ctx, + input: { + plan: built.plan, + subscriptionId: subscription.id, + endsAt: trialEndsAt, + paidUntil: trialEndsAt, + trialEndsAt, + limits: built.limits, + reason: "trial_started", + }, + }); + + return { started: true, trialEndsAt }; +} + +export async function cancelOwnedTrials(args: { + userId: number; + db?: DB; +}): Promise { + const { db } = args; + const trials = await listOwnedTrialWorkspaces({ + input: { userId: args.userId }, + db, + }); + + const customDomains: string[] = []; + for (const ws of trials) { + await cancelSubscription(ws.stripeId ?? undefined); + const result = await downgradeWorkspaceToFree({ + ctx: { workspace: ws, actor: { type: "user", userId: args.userId }, db }, + input: { reason: "account_deleted" }, + }); + customDomains.push(...result.customDomains); + } + + return customDomains; +} diff --git a/packages/api/src/router/stripe/webhook.ts b/packages/api/src/router/stripe/webhook.ts index 66b6bdad..c884894f 100644 --- a/packages/api/src/router/stripe/webhook.ts +++ b/packages/api/src/router/stripe/webhook.ts @@ -1,6 +1,6 @@ import { Events, setupAnalytics } from "@openstatus/analytics"; -import { and, eq } from "@openstatus/db"; -import { user, usersToWorkspaces } from "@openstatus/db/src/schema"; +import { eq } from "@openstatus/db"; +import { user } from "@openstatus/db/src/schema"; import { billingRecipients, cancelScheduledEmail, @@ -16,6 +16,7 @@ import { type DowngradeTrim, downgradeWorkspaceToFree, getWorkspaceByStripeId, + listWorkspaceOwners, previewWorkspaceDowngrade, updateWorkspacePlan, } from "@openstatus/services/workspace"; @@ -28,11 +29,13 @@ import { createTRPCRouter, publicProcedure } from "../../trpc"; import { buildFromSubscriptionOrThrow, cancelSupersededSubscriptions, + customerIdOf, getCurrentPeriodEnd, getCurrentSubscription, isNewerSubscription, listLiveSubscriptions, stripe, + trialEndsAtOf, } from "./shared"; const webhookProcedure = publicProcedure.input( @@ -58,18 +61,22 @@ const REMINDER_METADATA_KEY = "reminder_email_id"; type Db = Parameters[0]["db"]; +// Stripe cancels a trial without a card at `trial_end`, but its cycle +// processing can lag behind that timestamp by a little. +const TRIAL_END_SLACK_S = 60 * 60; + +function endedDuringTrial( + subscription: Stripe.Subscription, + eventCreated: number, +) { + if (!subscription.trial_end) return false; + const endedAt = + subscription.ended_at ?? subscription.canceled_at ?? eventCreated; + return endedAt <= subscription.trial_end + TRIAL_END_SLACK_S; +} + async function getOwnerEmails(db: NonNullable, workspaceId: number) { - const owners = await db - .select({ email: user.email }) - .from(usersToWorkspaces) - .innerJoin(user, eq(user.id, usersToWorkspaces.userId)) - .where( - and( - eq(usersToWorkspaces.workspaceId, workspaceId), - eq(usersToWorkspaces.role, "owner"), - ), - ) - .all(); + const owners = await listWorkspaceOwners({ input: { workspaceId }, db }); return owners.map((owner) => owner.email); } @@ -153,6 +160,37 @@ async function sendCancellationEmails(args: { } } +async function attachSetupPaymentMethod(session: Stripe.Checkout.Session) { + if (typeof session.setup_intent !== "string" || !session.customer) return; + const customerId = + typeof session.customer === "string" + ? session.customer + : session.customer.id; + + const setupIntent = await stripe.setupIntents.retrieve(session.setup_intent); + const paymentMethod = + typeof setupIntent.payment_method === "string" + ? setupIntent.payment_method + : setupIntent.payment_method?.id; + if (!paymentMethod) return; + + await stripe.customers.update(customerId, { + invoice_settings: { default_payment_method: paymentMethod }, + }); + + const subscriptionId = + setupIntent.metadata?.subscriptionId || + (await getCurrentSubscription(customerId)).current?.id; + if (!subscriptionId) return; + + const subscription = await stripe.subscriptions.retrieve(subscriptionId); + if (customerIdOf(subscription) !== customerId) return; + + await stripe.subscriptions.update(subscriptionId, { + default_payment_method: paymentMethod, + }); +} + // Never mount this on an app router: the procedures trust `event`, and only // the signature-verifying HTTP route may call them. export const webhookRouter = createTRPCRouter({ @@ -227,6 +265,7 @@ export const webhookRouter = createTRPCRouter({ subscriptionId: current.id, endsAt: getCurrentPeriodEnd(current), paidUntil: getCurrentPeriodEnd(current), + trialEndsAt: trialEndsAtOf(current), limits: built.limits, }, }); @@ -270,6 +309,26 @@ export const webhookRouter = createTRPCRouter({ } } + const wasTrialing = + opts.input.event.data.previous_attributes?.status === "trialing"; + if ( + eventSubscription.id === current.id && + wasTrialing && + current.status === "active" + ) { + const [owner] = await listWorkspaceOwners({ + input: { workspaceId: ws.id }, + db: opts.ctx.db, + }); + const analytics = await setupAnalytics({ + userId: owner ? `usr_${owner.id}` : undefined, + email: owner?.email ?? undefined, + workspaceId: String(ws.id), + plan: built.plan, + }); + await analytics.track(Events.ConvertTrial); + } + const newPlan = built.plan; if (newPlan !== oldPlan) { const customer = await stripe.customers.retrieve(customerId); @@ -302,6 +361,10 @@ export const webhookRouter = createTRPCRouter({ }), sessionCompleted: webhookProcedure.mutation(async (opts) => { const session = opts.input.event.data.object as Stripe.Checkout.Session; + if (session.mode === "setup") { + await attachSetupPaymentMethod(session); + return; + } if (typeof session.subscription !== "string") { throw new TRPCError({ code: "BAD_REQUEST", @@ -362,6 +425,7 @@ export const webhookRouter = createTRPCRouter({ subscriptionId: subscription.id, endsAt: getCurrentPeriodEnd(subscription), paidUntil: getCurrentPeriodEnd(subscription), + trialEndsAt: trialEndsAtOf(subscription), limits: built.limits, reason: "checkout_session_completed", }, @@ -444,6 +508,11 @@ export const webhookRouter = createTRPCRouter({ }); } + // Already downgraded, e.g. a trial cancelled by an account deletion. + if (ws.plan === "free" && !ws.subscriptionId) { + return; + } + // System actor — no user is attributable to an involuntary Stripe // cancellation. The service verb runs the whole trim in one audited // transaction; a failed audit insert rolls the downgrade back and the @@ -454,8 +523,18 @@ export const webhookRouter = createTRPCRouter({ db: opts.ctx.db, }; + // A trial that ran out without a card, or was cancelled mid-trial, is + // not a paying customer churning — keep the audit trail honest. Read + // off the subscription rather than `ws.trialEndsAt`: a delayed + // trial-to-active webhook leaves that marker stale on a paying customer. + const reason = endedDuringTrial(subscription, opts.input.event.created) + ? subscription.cancellation_details?.reason === "cancellation_requested" + ? "trial_cancelled" + : "trial_ended" + : "subscription_deleted"; + const { customDomains, ssoDisabled, trimmed } = - await downgradeWorkspaceToFree({ ctx }); + await downgradeWorkspaceToFree({ ctx, input: { reason } }); // Best-effort after commit: owners must know what the cascade removed, and // removed members that they lost access, but a mail failure must not fail diff --git a/packages/api/src/router/user.ts b/packages/api/src/router/user.ts index 06d92e35..3b540b4e 100644 --- a/packages/api/src/router/user.ts +++ b/packages/api/src/router/user.ts @@ -1,7 +1,11 @@ +import { PreconditionFailedError } from "@openstatus/services"; import { deleteAccount } from "@openstatus/services/user"; +import { listOwnedWorkspaces } from "@openstatus/services/workspace"; +import { removeDomainFromVercelIfUnused } from "../lib/vercel"; import { toServiceCtx, toTRPCError } from "../service-adapter"; import { createTRPCRouter, protectedProcedure } from "../trpc"; +import { cancelOwnedTrials } from "./stripe/trial"; export const userRouter = createTRPCRouter({ // The authed middleware already loaded this row; re-selecting it would be @@ -12,9 +16,38 @@ export const userRouter = createTRPCRouter({ deleteAccount: protectedProcedure.mutation(async ({ ctx }) => { try { + // A trial is not a paid plan the user has to cancel first — but a paid + // workspace elsewhere aborts the delete, so check before touching any + // trial or the user loses it for nothing. The trial itself must go + // before `deleteAccount`, which refuses any non-free plan; it is not + // rolled back if the delete fails, the user simply retries. + const owned = await listOwnedWorkspaces({ + input: { userId: ctx.user.id }, + db: ctx.db, + }); + // A signup workspace has no plan column yet — that is free. + if ( + owned.some((ws) => ws.plan && ws.plan !== "free" && !ws.trialEndsAt) + ) { + throw new PreconditionFailedError( + "You must cancel your subscription before deleting your account.", + ); + } + const customDomains = await cancelOwnedTrials({ + userId: ctx.user.id, + db: ctx.db, + }); // `userId` is derived from `ctx.actor` inside the service — no // input needed. await deleteAccount({ ctx: toServiceCtx(ctx) }); + for (const domain of customDomains) { + await removeDomainFromVercelIfUnused(ctx.db, domain).catch((error) => + console.error("Failed to release domain from Vercel:", { + domain, + error, + }), + ); + } } catch (err) { toTRPCError(err); } diff --git a/packages/api/src/router/workspace.ts b/packages/api/src/router/workspace.ts index ec0fd9ce..7825dc17 100644 --- a/packages/api/src/router/workspace.ts +++ b/packages/api/src/router/workspace.ts @@ -1,5 +1,6 @@ import { Events } from "@openstatus/analytics"; import { + getTrialDaysLeft, getWorkspaceUsage, updateWorkspaceName, } from "@openstatus/services/workspace"; @@ -12,7 +13,11 @@ export const workspaceRouter = createTRPCRouter({ // The authed middleware already resolved and parsed this row; re-selecting // it would be the same query. Counts live in `usage` — the shell reads // `limits` on every route, the counts only on two surfaces. - get: protectedProcedure.query(({ ctx }) => ctx.workspace), + // `trialDaysLeft` is derived here, not stored: it depends on "now". + get: protectedProcedure.query(({ ctx }) => ({ + ...ctx.workspace, + trialDaysLeft: getTrialDaysLeft(ctx.workspace.trialEndsAt), + })), usage: protectedProcedure.query(async ({ ctx }) => { try { diff --git a/packages/db/drizzle/0087_narrow_black_tarantula.sql b/packages/db/drizzle/0087_narrow_black_tarantula.sql new file mode 100644 index 00000000..f636c23b --- /dev/null +++ b/packages/db/drizzle/0087_narrow_black_tarantula.sql @@ -0,0 +1 @@ +ALTER TABLE `workspace` ADD `trial_ends_at` integer; \ No newline at end of file diff --git a/packages/db/drizzle/meta/0087_snapshot.json b/packages/db/drizzle/meta/0087_snapshot.json new file mode 100644 index 00000000..5c6c1d8a --- /dev/null +++ b/packages/db/drizzle/meta/0087_snapshot.json @@ -0,0 +1,6060 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "e1e8bcc5-f7cd-4f9f-90eb-00ace43c4365", + "prevId": "2333506c-1926-4dd8-9855-243404e86332", + "tables": { + "workspace": { + "name": "workspace", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripe_id": { + "name": "stripe_id", + "type": "text(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subscription_id": { + "name": "subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ends_at": { + "name": "ends_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "paid_until": { + "name": "paid_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trial_ends_at": { + "name": "trial_ends_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "limits": { + "name": "limits", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "workos_organization_id": { + "name": "workos_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sso_enabled": { + "name": "sso_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "dsn": { + "name": "dsn", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "workspace_slug_unique": { + "name": "workspace_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "workspace_stripe_id_unique": { + "name": "workspace_stripe_id_unique", + "columns": [ + "stripe_id" + ], + "isUnique": true + }, + "workspace_workos_organization_id_unique": { + "name": "workspace_workos_organization_id_unique", + "columns": [ + "workos_organization_id" + ], + "isUnique": true + }, + "workspace_id_dsn_unique": { + "name": "workspace_id_dsn_unique", + "columns": [ + "id", + "dsn" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspace_sso_domain": { + "name": "workspace_sso_domain", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "workspace_sso_domain_domain_unique": { + "name": "workspace_sso_domain_domain_unique", + "columns": [ + "domain" + ], + "isUnique": true + }, + "workspace_sso_domain_workspace_id_idx": { + "name": "workspace_sso_domain_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "workspace_sso_domain_workspace_id_workspace_id_fk": { + "name": "workspace_sso_domain_workspace_id_workspace_id_fk", + "tableFrom": "workspace_sso_domain", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account": { + "name": "account", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_provider_account_id_pk": { + "columns": [ + "provider", + "provider_account_id" + ], + "name": "account_provider_provider_account_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "photo_url": { + "name": "photo_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "emailVerified": { + "name": "emailVerified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "user_tenant_id_unique": { + "name": "user_tenant_id_unique", + "columns": [ + "tenant_id" + ], + "isUnique": true + }, + "user_email_idx": { + "name": "user_email_idx", + "columns": [ + "email" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users_to_workspaces": { + "name": "users_to_workspaces", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "users_to_workspaces_workspace_id_idx": { + "name": "users_to_workspaces_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "users_to_workspaces_user_id_user_id_fk": { + "name": "users_to_workspaces_user_id_user_id_fk", + "tableFrom": "users_to_workspaces", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "users_to_workspaces_workspace_id_workspace_id_fk": { + "name": "users_to_workspaces_workspace_id_workspace_id_fk", + "tableFrom": "users_to_workspaces", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "users_to_workspaces_user_id_workspace_id_pk": { + "columns": [ + "user_id", + "workspace_id" + ], + "name": "users_to_workspaces_user_id_workspace_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification_token": { + "name": "verification_token", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verification_token_identifier_token_pk": { + "columns": [ + "identifier", + "token" + ], + "name": "verification_token_identifier_token_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "status_report": { + "name": "status_report", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "page_id": { + "name": "page_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "status_report_workspace_created_idx": { + "name": "status_report_workspace_created_idx", + "columns": [ + "workspace_id", + "created_at" + ], + "isUnique": false + }, + "status_report_page_id_idx": { + "name": "status_report_page_id_idx", + "columns": [ + "page_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "status_report_workspace_id_workspace_id_fk": { + "name": "status_report_workspace_id_workspace_id_fk", + "tableFrom": "status_report", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "status_report_page_id_page_id_fk": { + "name": "status_report_page_id_page_id_fk", + "tableFrom": "status_report", + "tableTo": "page", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "status_report_update": { + "name": "status_report_update", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status_report_id": { + "name": "status_report_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "status_report_update_status_report_id_idx": { + "name": "status_report_update_status_report_id_idx", + "columns": [ + "status_report_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "status_report_update_status_report_id_status_report_id_fk": { + "name": "status_report_update_status_report_id_status_report_id_fk", + "tableFrom": "status_report_update", + "tableTo": "status_report", + "columnsFrom": [ + "status_report_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "integration": { + "name": "integration", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "credential": { + "name": "credential", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "integration_workspace_id_idx": { + "name": "integration_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "integration_workspace_id_workspace_id_fk": { + "name": "integration_workspace_id_workspace_id_fk", + "tableFrom": "integration", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "page": { + "name": "page", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "slug": { + "name": "slug", + "type": "text(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "custom_domain": { + "name": "custom_domain", + "type": "text(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "published": { + "name": "published", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "force_theme": { + "name": "force_theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'system'" + }, + "custom_theme": { + "name": "custom_theme", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password_protected": { + "name": "password_protected", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "access_type": { + "name": "access_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'public'" + }, + "auth_email_domains": { + "name": "auth_email_domains", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allowed_ip_ranges": { + "name": "allowed_ip_ranges", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "homepage_url": { + "name": "homepage_url", + "type": "text(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "contact_url": { + "name": "contact_url", + "type": "text(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_locale": { + "name": "default_locale", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'en'" + }, + "locales": { + "name": "locales", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "legacy_page": { + "name": "legacy_page", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "configuration": { + "name": "configuration", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allow_index": { + "name": "allow_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "show_monitor_values": { + "name": "show_monitor_values", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "page_slug_unique": { + "name": "page_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "page_lower_slug_idx": { + "name": "page_lower_slug_idx", + "columns": [ + "LOWER(\"slug\")" + ], + "isUnique": false + }, + "page_lower_custom_domain_idx": { + "name": "page_lower_custom_domain_idx", + "columns": [ + "LOWER(\"custom_domain\")" + ], + "isUnique": false + }, + "page_workspace_id_idx": { + "name": "page_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "page_workspace_id_workspace_id_fk": { + "name": "page_workspace_id_workspace_id_fk", + "tableFrom": "page", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "monitor": { + "name": "monitor", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "job_type": { + "name": "job_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'http'" + }, + "periodicity": { + "name": "periodicity", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'other'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "active": { + "name": "active", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "regions": { + "name": "regions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "url": { + "name": "url", + "type": "text(2048)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "external_name": { + "name": "external_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "headers": { + "name": "headers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'GET'" + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 45000 + }, + "degraded_after": { + "name": "degraded_after", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assertions": { + "name": "assertions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "otel_endpoint": { + "name": "otel_endpoint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "otel_headers": { + "name": "otel_headers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "public": { + "name": "public", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "retry": { + "name": "retry", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 3 + }, + "follow_redirects": { + "name": "follow_redirects", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "grpc_service": { + "name": "grpc_service", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "grpc_tls": { + "name": "grpc_tls", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'tls'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "monitor_workspace_id_active_idx": { + "name": "monitor_workspace_id_active_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false, + "where": "\"monitor\".\"deleted_at\" IS NULL" + } + }, + "foreignKeys": { + "monitor_workspace_id_workspace_id_fk": { + "name": "monitor_workspace_id_workspace_id_fk", + "tableFrom": "monitor", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "page_subscriber": { + "name": "page_subscriber", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "page_id": { + "name": "page_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "channel_type": { + "name": "channel_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'email'" + }, + "webhook_url": { + "name": "webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "channel_config": { + "name": "channel_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "slack_channel_id": { + "name": "slack_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'self_signup'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "accepted_at": { + "name": "accepted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "unsubscribed_at": { + "name": "unsubscribed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "page_subscriber_page_id_idx": { + "name": "page_subscriber_page_id_idx", + "columns": [ + "page_id" + ], + "isUnique": false + }, + "idx_page_subscriber_email_page_active": { + "name": "idx_page_subscriber_email_page_active", + "columns": [ + "LOWER(\"email\")", + "page_id" + ], + "isUnique": true, + "where": "\"page_subscriber\".\"unsubscribed_at\" IS NULL AND \"page_subscriber\".\"channel_type\" = 'email'" + }, + "idx_page_subscriber_webhook_page_active": { + "name": "idx_page_subscriber_webhook_page_active", + "columns": [ + "LOWER(\"webhook_url\")", + "page_id" + ], + "isUnique": true, + "where": "\"page_subscriber\".\"unsubscribed_at\" IS NULL AND \"page_subscriber\".\"channel_type\" = 'webhook'" + }, + "idx_page_subscriber_slack_channel_page_active": { + "name": "idx_page_subscriber_slack_channel_page_active", + "columns": [ + "slack_channel_id", + "page_id" + ], + "isUnique": true, + "where": "\"page_subscriber\".\"unsubscribed_at\" IS NULL AND \"page_subscriber\".\"channel_type\" = 'slack'" + } + }, + "foreignKeys": { + "page_subscriber_page_id_page_id_fk": { + "name": "page_subscriber_page_id_page_id_fk", + "tableFrom": "page_subscriber", + "tableTo": "page", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "page_subscriber_channel_check": { + "name": "page_subscriber_channel_check", + "value": "(\"page_subscriber\".\"channel_type\" = 'email' AND \"page_subscriber\".\"email\" IS NOT NULL AND \"page_subscriber\".\"webhook_url\" IS NULL) OR (\"page_subscriber\".\"channel_type\" = 'webhook' AND \"page_subscriber\".\"webhook_url\" IS NOT NULL AND \"page_subscriber\".\"email\" IS NULL) OR (\"page_subscriber\".\"channel_type\" = 'slack' AND \"page_subscriber\".\"slack_channel_id\" IS NOT NULL AND \"page_subscriber\".\"email\" IS NULL AND \"page_subscriber\".\"webhook_url\" IS NULL)" + } + } + }, + "page_subscriber_to_page_component": { + "name": "page_subscriber_to_page_component", + "columns": { + "page_subscriber_id": { + "name": "page_subscriber_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "page_component_id": { + "name": "page_component_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": {}, + "foreignKeys": { + "page_subscriber_to_page_component_page_subscriber_id_page_subscriber_id_fk": { + "name": "page_subscriber_to_page_component_page_subscriber_id_page_subscriber_id_fk", + "tableFrom": "page_subscriber_to_page_component", + "tableTo": "page_subscriber", + "columnsFrom": [ + "page_subscriber_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "page_subscriber_to_page_component_page_component_id_page_component_id_fk": { + "name": "page_subscriber_to_page_component_page_component_id_page_component_id_fk", + "tableFrom": "page_subscriber_to_page_component", + "tableTo": "page_component", + "columnsFrom": [ + "page_component_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "page_subscriber_to_page_component_page_subscriber_id_page_component_id_pk": { + "columns": [ + "page_subscriber_id", + "page_component_id" + ], + "name": "page_subscriber_to_page_component_page_subscriber_id_page_component_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notification": { + "name": "notification", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'{}'" + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "notification_workspace_id_idx": { + "name": "notification_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "notification_workspace_id_workspace_id_fk": { + "name": "notification_workspace_id_workspace_id_fk", + "tableFrom": "notification", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notification_trigger": { + "name": "notification_trigger", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notification_id": { + "name": "notification_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cron_timestamp": { + "name": "cron_timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "notification_id_monitor_id_crontimestampe": { + "name": "notification_id_monitor_id_crontimestampe", + "columns": [ + "notification_id", + "monitor_id", + "cron_timestamp" + ], + "isUnique": true + } + }, + "foreignKeys": { + "notification_trigger_monitor_id_monitor_id_fk": { + "name": "notification_trigger_monitor_id_monitor_id_fk", + "tableFrom": "notification_trigger", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notification_trigger_notification_id_notification_id_fk": { + "name": "notification_trigger_notification_id_notification_id_fk", + "tableFrom": "notification_trigger", + "tableTo": "notification", + "columnsFrom": [ + "notification_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notifications_to_monitors": { + "name": "notifications_to_monitors", + "columns": { + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "notification_id": { + "name": "notification_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "notifications_to_monitors_notification_id_idx": { + "name": "notifications_to_monitors_notification_id_idx", + "columns": [ + "notification_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "notifications_to_monitors_monitor_id_monitor_id_fk": { + "name": "notifications_to_monitors_monitor_id_monitor_id_fk", + "tableFrom": "notifications_to_monitors", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notifications_to_monitors_notification_id_notification_id_fk": { + "name": "notifications_to_monitors_notification_id_notification_id_fk", + "tableFrom": "notifications_to_monitors", + "tableTo": "notification", + "columnsFrom": [ + "notification_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "notifications_to_monitors_monitor_id_notification_id_pk": { + "columns": [ + "monitor_id", + "notification_id" + ], + "name": "notifications_to_monitors_monitor_id_notification_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notification_dead_letter": { + "name": "notification_dead_letter", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "outbox_id": { + "name": "outbox_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dedup_key": { + "name": "dedup_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notification_id": { + "name": "notification_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_status": { + "name": "from_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "to_status": { + "name": "to_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cron_timestamp": { + "name": "cron_timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "incident_id": { + "name": "incident_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "final_error": { + "name": "final_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "died_at": { + "name": "died_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "notification_dead_letter_dedup_key_idx": { + "name": "notification_dead_letter_dedup_key_idx", + "columns": [ + "dedup_key" + ], + "isUnique": true + }, + "notification_dead_letter_workspace_id_died_at_idx": { + "name": "notification_dead_letter_workspace_id_died_at_idx", + "columns": [ + "workspace_id", + "died_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "notification_dead_letter_monitor_id_monitor_id_fk": { + "name": "notification_dead_letter_monitor_id_monitor_id_fk", + "tableFrom": "notification_dead_letter", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notification_dead_letter_workspace_id_workspace_id_fk": { + "name": "notification_dead_letter_workspace_id_workspace_id_fk", + "tableFrom": "notification_dead_letter", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notification_outbox": { + "name": "notification_outbox", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "dedup_key": { + "name": "dedup_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notification_id": { + "name": "notification_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_status": { + "name": "from_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "to_status": { + "name": "to_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cron_timestamp": { + "name": "cron_timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "incident_id": { + "name": "incident_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "delivery_status": { + "name": "delivery_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deadline_at": { + "name": "deadline_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locked_by": { + "name": "locked_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "locked_until": { + "name": "locked_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "delivered_at": { + "name": "delivered_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "notification_outbox_dedup_key_idx": { + "name": "notification_outbox_dedup_key_idx", + "columns": [ + "dedup_key" + ], + "isUnique": true + }, + "notification_outbox_claim_idx": { + "name": "notification_outbox_claim_idx", + "columns": [ + "next_attempt_at" + ], + "isUnique": false, + "where": "\"notification_outbox\".\"delivery_status\" = 'pending'" + }, + "notification_outbox_notification_id_cron_timestamp_idx": { + "name": "notification_outbox_notification_id_cron_timestamp_idx", + "columns": [ + "notification_id", + "cron_timestamp" + ], + "isUnique": false + }, + "notification_outbox_channel_idx": { + "name": "notification_outbox_channel_idx", + "columns": [ + "monitor_id", + "notification_id" + ], + "isUnique": false, + "where": "\"notification_outbox\".\"delivery_status\" = 'pending'" + } + }, + "foreignKeys": { + "notification_outbox_monitor_id_monitor_id_fk": { + "name": "notification_outbox_monitor_id_monitor_id_fk", + "tableFrom": "notification_outbox", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notification_outbox_workspace_id_workspace_id_fk": { + "name": "notification_outbox_workspace_id_workspace_id_fk", + "tableFrom": "notification_outbox", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "notification_outbox_notification_id_notification_id_fk": { + "name": "notification_outbox_notification_id_notification_id_fk", + "tableFrom": "notification_outbox", + "tableTo": "notification", + "columnsFrom": [ + "notification_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notification_outbox_incident_id_incident_id_fk": { + "name": "notification_outbox_incident_id_incident_id_fk", + "tableFrom": "notification_outbox", + "tableTo": "incident", + "columnsFrom": [ + "incident_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "monitor_status": { + "name": "monitor_status", + "columns": { + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "cron_timestamp": { + "name": "cron_timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "monitor_status_idx": { + "name": "monitor_status_idx", + "columns": [ + "monitor_id", + "region" + ], + "isUnique": false + } + }, + "foreignKeys": { + "monitor_status_monitor_id_monitor_id_fk": { + "name": "monitor_status_monitor_id_monitor_id_fk", + "tableFrom": "monitor_status", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "monitor_status_monitor_id_region_pk": { + "columns": [ + "monitor_id", + "region" + ], + "name": "monitor_status_monitor_id_region_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invitation": { + "name": "invitation", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "accepted_at": { + "name": "accepted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "invitation_workspace_id_idx": { + "name": "invitation_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "incident": { + "name": "incident", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'triage'" + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "acknowledged_at": { + "name": "acknowledged_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "acknowledged_by": { + "name": "acknowledged_by", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolved_by": { + "name": "resolved_by", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "incident_screenshot_url": { + "name": "incident_screenshot_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recovery_screenshot_url": { + "name": "recovery_screenshot_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auto_resolved": { + "name": "auto_resolved", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "incident_workspace_id_started_at_idx": { + "name": "incident_workspace_id_started_at_idx", + "columns": [ + "workspace_id", + "started_at" + ], + "isUnique": false + }, + "incident_open_idx": { + "name": "incident_open_idx", + "columns": [ + "monitor_id" + ], + "isUnique": true, + "where": "\"incident\".\"resolved_at\" IS NULL" + }, + "incident_monitor_id_started_at_unique": { + "name": "incident_monitor_id_started_at_unique", + "columns": [ + "monitor_id", + "started_at" + ], + "isUnique": true + } + }, + "foreignKeys": { + "incident_monitor_id_monitor_id_fk": { + "name": "incident_monitor_id_monitor_id_fk", + "tableFrom": "incident", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set default", + "onUpdate": "no action" + }, + "incident_workspace_id_workspace_id_fk": { + "name": "incident_workspace_id_workspace_id_fk", + "tableFrom": "incident", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "incident_acknowledged_by_user_id_fk": { + "name": "incident_acknowledged_by_user_id_fk", + "tableFrom": "incident", + "tableTo": "user", + "columnsFrom": [ + "acknowledged_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "incident_resolved_by_user_id_fk": { + "name": "incident_resolved_by_user_id_fk", + "tableFrom": "incident", + "tableTo": "user", + "columnsFrom": [ + "resolved_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "monitor_tag": { + "name": "monitor_tag", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "monitor_tag_workspace_id_idx": { + "name": "monitor_tag_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "monitor_tag_workspace_id_workspace_id_fk": { + "name": "monitor_tag_workspace_id_workspace_id_fk", + "tableFrom": "monitor_tag", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "monitor_tag_to_monitor": { + "name": "monitor_tag_to_monitor", + "columns": { + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "monitor_tag_id": { + "name": "monitor_tag_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "monitor_tag_to_monitor_monitor_tag_id_idx": { + "name": "monitor_tag_to_monitor_monitor_tag_id_idx", + "columns": [ + "monitor_tag_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "monitor_tag_to_monitor_monitor_id_monitor_id_fk": { + "name": "monitor_tag_to_monitor_monitor_id_monitor_id_fk", + "tableFrom": "monitor_tag_to_monitor", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "monitor_tag_to_monitor_monitor_tag_id_monitor_tag_id_fk": { + "name": "monitor_tag_to_monitor_monitor_tag_id_monitor_tag_id_fk", + "tableFrom": "monitor_tag_to_monitor", + "tableTo": "monitor_tag", + "columnsFrom": [ + "monitor_tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "monitor_tag_to_monitor_monitor_id_monitor_tag_id_pk": { + "columns": [ + "monitor_id", + "monitor_tag_id" + ], + "name": "monitor_tag_to_monitor_monitor_id_monitor_tag_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "application": { + "name": "application", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dsn": { + "name": "dsn", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "application_dsn_unique": { + "name": "application_dsn_unique", + "columns": [ + "dsn" + ], + "isUnique": true + }, + "application_workspace_id_idx": { + "name": "application_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "application_workspace_id_workspace_id_fk": { + "name": "application_workspace_id_workspace_id_fk", + "tableFrom": "application", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "maintenance": { + "name": "maintenance", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from": { + "name": "from", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "to": { + "name": "to", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "page_id": { + "name": "page_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "maintenance_page_id_idx": { + "name": "maintenance_page_id_idx", + "columns": [ + "page_id" + ], + "isUnique": false + }, + "maintenance_workspace_id_idx": { + "name": "maintenance_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "maintenance_workspace_id_workspace_id_fk": { + "name": "maintenance_workspace_id_workspace_id_fk", + "tableFrom": "maintenance", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "maintenance_page_id_page_id_fk": { + "name": "maintenance_page_id_page_id_fk", + "tableFrom": "maintenance", + "tableTo": "page", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "check": { + "name": "check", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "regions": { + "name": "regions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "url": { + "name": "url", + "type": "text(4096)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "headers": { + "name": "headers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'GET'" + }, + "count_requests": { + "name": "count_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 1 + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "check_workspace_id_idx": { + "name": "check_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "check_workspace_id_workspace_id_fk": { + "name": "check_workspace_id_workspace_id_fk", + "tableFrom": "check", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "monitor_run": { + "name": "monitor_run", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "runned_at": { + "name": "runned_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "monitor_run_workspace_id_created_at_idx": { + "name": "monitor_run_workspace_id_created_at_idx", + "columns": [ + "workspace_id", + "created_at" + ], + "isUnique": false + }, + "monitor_run_monitor_id_idx": { + "name": "monitor_run_monitor_id_idx", + "columns": [ + "monitor_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "monitor_run_workspace_id_workspace_id_fk": { + "name": "monitor_run_workspace_id_workspace_id_fk", + "tableFrom": "monitor_run", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "monitor_run_monitor_id_monitor_id_fk": { + "name": "monitor_run_monitor_id_monitor_id_fk", + "tableFrom": "monitor_run", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "private_location_monitor_status": { + "name": "private_location_monitor_status", + "columns": { + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "private_location_id": { + "name": "private_location_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "cron_timestamp": { + "name": "cron_timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "private_location_monitor_status_pl_id_idx": { + "name": "private_location_monitor_status_pl_id_idx", + "columns": [ + "private_location_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "private_location_monitor_status_monitor_id_monitor_id_fk": { + "name": "private_location_monitor_status_monitor_id_monitor_id_fk", + "tableFrom": "private_location_monitor_status", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "private_location_monitor_status_private_location_id_private_location_id_fk": { + "name": "private_location_monitor_status_private_location_id_private_location_id_fk", + "tableFrom": "private_location_monitor_status", + "tableTo": "private_location", + "columnsFrom": [ + "private_location_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "private_location_monitor_status_monitor_id_private_location_id_pk": { + "columns": [ + "monitor_id", + "private_location_id" + ], + "name": "private_location_monitor_status_monitor_id_private_location_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "private_location": { + "name": "private_location", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'error'" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "private_location_workspace_id_idx": { + "name": "private_location_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "private_location_workspace_id_workspace_id_fk": { + "name": "private_location_workspace_id_workspace_id_fk", + "tableFrom": "private_location", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "private_location_to_monitor": { + "name": "private_location_to_monitor", + "columns": { + "private_location_id": { + "name": "private_location_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "private_location_to_monitor_private_location_id_idx": { + "name": "private_location_to_monitor_private_location_id_idx", + "columns": [ + "private_location_id" + ], + "isUnique": false + }, + "private_location_to_monitor_monitor_id_idx": { + "name": "private_location_to_monitor_monitor_id_idx", + "columns": [ + "monitor_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "private_location_to_monitor_private_location_id_private_location_id_fk": { + "name": "private_location_to_monitor_private_location_id_private_location_id_fk", + "tableFrom": "private_location_to_monitor", + "tableTo": "private_location", + "columnsFrom": [ + "private_location_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "private_location_to_monitor_monitor_id_monitor_id_fk": { + "name": "private_location_to_monitor_monitor_id_monitor_id_fk", + "tableFrom": "private_location_to_monitor", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "monitor_group": { + "name": "monitor_group", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "page_id": { + "name": "page_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "monitor_group_workspace_id_idx": { + "name": "monitor_group_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + }, + "monitor_group_page_id_idx": { + "name": "monitor_group_page_id_idx", + "columns": [ + "page_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "monitor_group_workspace_id_workspace_id_fk": { + "name": "monitor_group_workspace_id_workspace_id_fk", + "tableFrom": "monitor_group", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "monitor_group_page_id_page_id_fk": { + "name": "monitor_group_page_id_page_id_fk", + "tableFrom": "monitor_group", + "tableTo": "page", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "viewer": { + "name": "viewer", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "viewer_email_unique": { + "name": "viewer_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "viewer_accounts": { + "name": "viewer_accounts", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "viewer_accounts_user_id_viewer_id_fk": { + "name": "viewer_accounts_user_id_viewer_id_fk", + "tableFrom": "viewer_accounts", + "tableTo": "viewer", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "viewer_accounts_provider_providerAccountId_pk": { + "columns": [ + "provider", + "providerAccountId" + ], + "name": "viewer_accounts_provider_providerAccountId_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "viewer_session": { + "name": "viewer_session", + "columns": { + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "viewer_session_user_id_viewer_id_fk": { + "name": "viewer_session_user_id_viewer_id_fk", + "tableFrom": "viewer_session", + "tableTo": "viewer", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "api_key": { + "name": "api_key", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hashed_token": { + "name": "hashed_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[\"write\"]'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "api_key_prefix_unique": { + "name": "api_key_prefix_unique", + "columns": [ + "prefix" + ], + "isUnique": true + }, + "api_key_hashed_token_unique": { + "name": "api_key_hashed_token_unique", + "columns": [ + "hashed_token" + ], + "isUnique": true + }, + "api_key_prefix_idx": { + "name": "api_key_prefix_idx", + "columns": [ + "prefix" + ], + "isUnique": false + }, + "api_key_workspace_id_idx": { + "name": "api_key_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_id_user_id_fk": { + "name": "api_key_created_by_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "maintenance_to_page_component": { + "name": "maintenance_to_page_component", + "columns": { + "maintenance_id": { + "name": "maintenance_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "page_component_id": { + "name": "page_component_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "maintenance_to_page_component_page_component_id_idx": { + "name": "maintenance_to_page_component_page_component_id_idx", + "columns": [ + "page_component_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "maintenance_to_page_component_maintenance_id_maintenance_id_fk": { + "name": "maintenance_to_page_component_maintenance_id_maintenance_id_fk", + "tableFrom": "maintenance_to_page_component", + "tableTo": "maintenance", + "columnsFrom": [ + "maintenance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "maintenance_to_page_component_page_component_id_page_component_id_fk": { + "name": "maintenance_to_page_component_page_component_id_page_component_id_fk", + "tableFrom": "maintenance_to_page_component", + "tableTo": "page_component", + "columnsFrom": [ + "page_component_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "maintenance_to_page_component_maintenance_id_page_component_id_pk": { + "columns": [ + "maintenance_id", + "page_component_id" + ], + "name": "maintenance_to_page_component_maintenance_id_page_component_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "page_component": { + "name": "page_component", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "page_id": { + "name": "page_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'monitor'" + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "group_id": { + "name": "group_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "group_order": { + "name": "group_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "page_component_workspace_id_idx": { + "name": "page_component_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + }, + "page_component_page_id_monitor_id_unique": { + "name": "page_component_page_id_monitor_id_unique", + "columns": [ + "page_id", + "monitor_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "page_component_workspace_id_workspace_id_fk": { + "name": "page_component_workspace_id_workspace_id_fk", + "tableFrom": "page_component", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "page_component_page_id_page_id_fk": { + "name": "page_component_page_id_page_id_fk", + "tableFrom": "page_component", + "tableTo": "page", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "page_component_monitor_id_monitor_id_fk": { + "name": "page_component_monitor_id_monitor_id_fk", + "tableFrom": "page_component", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "page_component_group_id_page_component_groups_id_fk": { + "name": "page_component_group_id_page_component_groups_id_fk", + "tableFrom": "page_component", + "tableTo": "page_component_groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "page_component_type_check": { + "name": "page_component_type_check", + "value": "\"page_component\".\"type\" = 'monitor' AND \"page_component\".\"monitor_id\" IS NOT NULL OR \"page_component\".\"type\" = 'static' AND \"page_component\".\"monitor_id\" IS NULL" + } + } + }, + "status_report_update_to_page_component": { + "name": "status_report_update_to_page_component", + "columns": { + "status_report_update_id": { + "name": "status_report_update_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "page_component_id": { + "name": "page_component_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "impact": { + "name": "impact", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "status_report_update_to_page_component_page_component_id_idx": { + "name": "status_report_update_to_page_component_page_component_id_idx", + "columns": [ + "page_component_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "status_report_update_to_page_component_status_report_update_id_status_report_update_id_fk": { + "name": "status_report_update_to_page_component_status_report_update_id_status_report_update_id_fk", + "tableFrom": "status_report_update_to_page_component", + "tableTo": "status_report_update", + "columnsFrom": [ + "status_report_update_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "status_report_update_to_page_component_page_component_id_page_component_id_fk": { + "name": "status_report_update_to_page_component_page_component_id_page_component_id_fk", + "tableFrom": "status_report_update_to_page_component", + "tableTo": "page_component", + "columnsFrom": [ + "page_component_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "status_report_update_to_page_component_status_report_update_id_page_component_id_pk": { + "columns": [ + "status_report_update_id", + "page_component_id" + ], + "name": "status_report_update_to_page_component_status_report_update_id_page_component_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "status_report_to_page_component": { + "name": "status_report_to_page_component", + "columns": { + "status_report_id": { + "name": "status_report_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "page_component_id": { + "name": "page_component_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "status_report_to_page_component_page_component_id_idx": { + "name": "status_report_to_page_component_page_component_id_idx", + "columns": [ + "page_component_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "status_report_to_page_component_status_report_id_status_report_id_fk": { + "name": "status_report_to_page_component_status_report_id_status_report_id_fk", + "tableFrom": "status_report_to_page_component", + "tableTo": "status_report", + "columnsFrom": [ + "status_report_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "status_report_to_page_component_page_component_id_page_component_id_fk": { + "name": "status_report_to_page_component_page_component_id_page_component_id_fk", + "tableFrom": "status_report_to_page_component", + "tableTo": "page_component", + "columnsFrom": [ + "page_component_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "status_report_to_page_component_status_report_id_page_component_id_pk": { + "columns": [ + "status_report_id", + "page_component_id" + ], + "name": "status_report_to_page_component_status_report_id_page_component_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "page_component_groups": { + "name": "page_component_groups", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "page_id": { + "name": "page_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "default_open": { + "name": "default_open", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "page_component_groups_page_id_idx": { + "name": "page_component_groups_page_id_idx", + "columns": [ + "page_id" + ], + "isUnique": false + }, + "page_component_groups_workspace_id_idx": { + "name": "page_component_groups_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "page_component_groups_workspace_id_workspace_id_fk": { + "name": "page_component_groups_workspace_id_workspace_id_fk", + "tableFrom": "page_component_groups", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "page_component_groups_page_id_page_id_fk": { + "name": "page_component_groups_page_id_page_id_fk", + "tableFrom": "page_component_groups", + "tableTo": "page", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "feedback": { + "name": "feedback", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "blocker": { + "name": "blocker", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "feedback_workspace_id_idx": { + "name": "feedback_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "feedback_workspace_id_workspace_id_fk": { + "name": "feedback_workspace_id_workspace_id_fk", + "tableFrom": "feedback", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "feedback_user_id_user_id_fk": { + "name": "feedback_user_id_user_id_fk", + "tableFrom": "feedback", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audit_log": { + "name": "audit_log", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_type": { + "name": "actor_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "before": { + "name": "before", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "after": { + "name": "after", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "changed_fields": { + "name": "changed_fields", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "audit_log_workspace_created_idx": { + "name": "audit_log_workspace_created_idx", + "columns": [ + "workspace_id", + "created_at" + ], + "isUnique": false + }, + "audit_log_entity_idx": { + "name": "audit_log_entity_idx", + "columns": [ + "workspace_id", + "entity_type", + "entity_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_service": { + "name": "external_service", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "aliases": { + "name": "aliases", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(json_array())" + }, + "name": { + "name": "name", + "type": "text(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status_page_url": { + "name": "status_page_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "industry": { + "name": "industry", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "api_config": { + "name": "api_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "external_service_slug_unique": { + "name": "external_service_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "external_service_deleted_at_idx": { + "name": "external_service_deleted_at_idx", + "columns": [ + "deleted_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_service_component": { + "name": "external_service_component", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "external_service_id": { + "name": "external_service_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "upstream_component_id": { + "name": "upstream_component_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "aliases": { + "name": "aliases", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(json_array())" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "group_name": { + "name": "group_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "indicator": { + "name": "indicator", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "external_service_component_unique_idx": { + "name": "external_service_component_unique_idx", + "columns": [ + "external_service_id", + "upstream_component_id" + ], + "isUnique": true + }, + "external_service_component_slug_unique_idx": { + "name": "external_service_component_slug_unique_idx", + "columns": [ + "external_service_id", + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": { + "external_service_component_external_service_id_external_service_id_fk": { + "name": "external_service_component_external_service_id_external_service_id_fk", + "tableFrom": "external_service_component", + "tableTo": "external_service", + "columnsFrom": [ + "external_service_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_service_incident": { + "name": "external_service_incident", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "external_service_id": { + "name": "external_service_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_incident_id": { + "name": "provider_incident_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "impact": { + "name": "impact", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "shortlink": { + "name": "shortlink", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "affected_component_ids": { + "name": "affected_component_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "raw_payload": { + "name": "raw_payload", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "raw_payload_purged_at": { + "name": "raw_payload_purged_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "external_service_incident_unique_idx": { + "name": "external_service_incident_unique_idx", + "columns": [ + "external_service_id", + "provider_incident_id" + ], + "isUnique": true + }, + "external_service_incident_started_at_idx": { + "name": "external_service_incident_started_at_idx", + "columns": [ + "external_service_id", + "started_at" + ], + "isUnique": false + }, + "external_service_incident_resolved_at_idx": { + "name": "external_service_incident_resolved_at_idx", + "columns": [ + "resolved_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "external_service_incident_external_service_id_external_service_id_fk": { + "name": "external_service_incident_external_service_id_external_service_id_fk", + "tableFrom": "external_service_incident", + "tableTo": "external_service", + "columnsFrom": [ + "external_service_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_service_report": { + "name": "external_service_report", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "external_service_id": { + "name": "external_service_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "external_service_component_id": { + "name": "external_service_component_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reporter_hash": { + "name": "reporter_hash", + "type": "text(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "country": { + "name": "country", + "type": "text(2)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "external_service_report_service_idx": { + "name": "external_service_report_service_idx", + "columns": [ + "external_service_id", + "created_at" + ], + "isUnique": false + }, + "external_service_report_component_idx": { + "name": "external_service_report_component_idx", + "columns": [ + "external_service_component_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "external_service_report_external_service_id_external_service_id_fk": { + "name": "external_service_report_external_service_id_external_service_id_fk", + "tableFrom": "external_service_report", + "tableTo": "external_service", + "columnsFrom": [ + "external_service_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "external_service_report_external_service_component_id_external_service_component_id_fk": { + "name": "external_service_report_external_service_component_id_external_service_component_id_fk", + "tableFrom": "external_service_report", + "tableTo": "external_service_component", + "columnsFrom": [ + "external_service_component_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chat_session": { + "name": "chat_session", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "messages": { + "name": "messages", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "chat_session_workspace_user_updated_idx": { + "name": "chat_session_workspace_user_updated_idx", + "columns": [ + "workspace_id", + "user_id", + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "chat_session_workspace_id_workspace_id_fk": { + "name": "chat_session_workspace_id_workspace_id_fk", + "tableFrom": "chat_session", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_session_user_id_user_id_fk": { + "name": "chat_session_user_id_user_id_fk", + "tableFrom": "chat_session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "frozen_monitor_uptime": { + "name": "frozen_monitor_uptime", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "month": { + "name": "month", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "days": { + "name": "days", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "frozen_monitor_uptime_workspace_id_idx": { + "name": "frozen_monitor_uptime_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + }, + "frozen_monitor_uptime_monitor_id_month_unique": { + "name": "frozen_monitor_uptime_monitor_id_month_unique", + "columns": [ + "monitor_id", + "month" + ], + "isUnique": true + } + }, + "foreignKeys": { + "frozen_monitor_uptime_workspace_id_workspace_id_fk": { + "name": "frozen_monitor_uptime_workspace_id_workspace_id_fk", + "tableFrom": "frozen_monitor_uptime", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "frozen_monitor_uptime_monitor_id_monitor_id_fk": { + "name": "frozen_monitor_uptime_monitor_id_monitor_id_fk", + "tableFrom": "frozen_monitor_uptime", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "monitor_transition": { + "name": "monitor_transition", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cron_timestamp": { + "name": "cron_timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_status": { + "name": "from_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "to_status": { + "name": "to_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "quorum_count": { + "name": "quorum_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "region_count": { + "name": "region_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "transitioned": { + "name": "transitioned", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "outbox_rows": { + "name": "outbox_rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "monitor_transition_monitor_id_cron_timestamp_idx": { + "name": "monitor_transition_monitor_id_cron_timestamp_idx", + "columns": [ + "monitor_id", + "cron_timestamp" + ], + "isUnique": false + }, + "monitor_transition_created_at_idx": { + "name": "monitor_transition_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "monitor_transition_monitor_id_monitor_id_fk": { + "name": "monitor_transition_monitor_id_monitor_id_fk", + "tableFrom": "monitor_transition", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_client": { + "name": "oauth_client", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'MCP Client'" + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "oauth_client_client_id_unique": { + "name": "oauth_client_client_id_unique", + "columns": [ + "client_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_session": { + "name": "oauth_session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "code_challenge": { + "name": "code_challenge", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "code_challenge_method": { + "name": "code_challenge_method", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'S256'" + }, + "decided_at": { + "name": "decided_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "oauth_session_client_id_idx": { + "name": "oauth_session_client_id_idx", + "columns": [ + "client_id" + ], + "isUnique": false + }, + "oauth_session_expires_at_idx": { + "name": "oauth_session_expires_at_idx", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "oauth_session_client_id_oauth_client_client_id_fk": { + "name": "oauth_session_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_session", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_authorization_code": { + "name": "oauth_authorization_code", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "code_challenge": { + "name": "code_challenge", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "consumed_at": { + "name": "consumed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "grant_id": { + "name": "grant_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "oauth_authorization_code_hash_unique": { + "name": "oauth_authorization_code_hash_unique", + "columns": [ + "hash" + ], + "isUnique": true + }, + "oauth_authorization_code_expires_at_idx": { + "name": "oauth_authorization_code_expires_at_idx", + "columns": [ + "expires_at" + ], + "isUnique": false + }, + "oauth_authorization_code_client_id_idx": { + "name": "oauth_authorization_code_client_id_idx", + "columns": [ + "client_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "oauth_authorization_code_client_id_oauth_client_client_id_fk": { + "name": "oauth_authorization_code_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_authorization_code", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_authorization_code_user_id_user_id_fk": { + "name": "oauth_authorization_code_user_id_user_id_fk", + "tableFrom": "oauth_authorization_code", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_authorization_code_workspace_id_workspace_id_fk": { + "name": "oauth_authorization_code_workspace_id_workspace_id_fk", + "tableFrom": "oauth_authorization_code", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_authorization_code_grant_id_oauth_grant_id_fk": { + "name": "oauth_authorization_code_grant_id_oauth_grant_id_fk", + "tableFrom": "oauth_authorization_code", + "tableTo": "oauth_grant", + "columnsFrom": [ + "grant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_grant": { + "name": "oauth_grant", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token_hash": { + "name": "access_token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token_hash": { + "name": "refresh_token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "previous_refresh_token_hash": { + "name": "previous_refresh_token_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rotated_at": { + "name": "rotated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "oauth_grant_access_token_hash_unique": { + "name": "oauth_grant_access_token_hash_unique", + "columns": [ + "access_token_hash" + ], + "isUnique": true + }, + "oauth_grant_refresh_token_hash_unique": { + "name": "oauth_grant_refresh_token_hash_unique", + "columns": [ + "refresh_token_hash" + ], + "isUnique": true + }, + "oauth_grant_workspace_id_idx": { + "name": "oauth_grant_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + }, + "oauth_grant_client_id_user_id_idx": { + "name": "oauth_grant_client_id_user_id_idx", + "columns": [ + "client_id", + "user_id" + ], + "isUnique": false + }, + "oauth_grant_user_id_idx": { + "name": "oauth_grant_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "oauth_grant_previous_refresh_token_hash_idx": { + "name": "oauth_grant_previous_refresh_token_hash_idx", + "columns": [ + "previous_refresh_token_hash" + ], + "isUnique": false + }, + "oauth_grant_refresh_token_expires_at_idx": { + "name": "oauth_grant_refresh_token_expires_at_idx", + "columns": [ + "refresh_token_expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "oauth_grant_client_id_oauth_client_client_id_fk": { + "name": "oauth_grant_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_grant", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_grant_user_id_user_id_fk": { + "name": "oauth_grant_user_id_user_id_fk", + "tableFrom": "oauth_grant", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_grant_workspace_id_workspace_id_fk": { + "name": "oauth_grant_workspace_id_workspace_id_fk", + "tableFrom": "oauth_grant", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": { + "page_lower_slug_idx": { + "columns": { + "LOWER(\"slug\")": { + "isExpression": true + } + } + }, + "page_lower_custom_domain_idx": { + "columns": { + "LOWER(\"custom_domain\")": { + "isExpression": true + } + } + }, + "idx_page_subscriber_email_page_active": { + "columns": { + "LOWER(\"email\")": { + "isExpression": true + } + } + }, + "idx_page_subscriber_webhook_page_active": { + "columns": { + "LOWER(\"webhook_url\")": { + "isExpression": true + } + } + } + } + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 0fa8fef8..9d3fcf2c 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -610,6 +610,13 @@ "when": 1788721405307, "tag": "0086_robust_quicksilver", "breakpoints": true + }, + { + "idx": 87, + "version": "6", + "when": 1790175988767, + "tag": "0087_narrow_black_tarantula", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/workspaces/workspace.ts b/packages/db/src/schema/workspaces/workspace.ts index e8413a32..e2d384ce 100644 --- a/packages/db/src/schema/workspaces/workspace.ts +++ b/packages/db/src/schema/workspaces/workspace.ts @@ -21,6 +21,7 @@ export const workspace = sqliteTable( plan: text("plan", { enum: workspacePlans }), endsAt: integer("ends_at", { mode: "timestamp" }), paidUntil: integer("paid_until", { mode: "timestamp" }), + trialEndsAt: integer("trial_ends_at", { mode: "timestamp" }), limits: text("limits").default("{}").notNull(), workosOrganizationId: text("workos_organization_id").unique(), diff --git a/packages/db/src/test/factories.ts b/packages/db/src/test/factories.ts index 6af944c3..b9815b94 100644 --- a/packages/db/src/test/factories.ts +++ b/packages/db/src/test/factories.ts @@ -18,7 +18,9 @@ import { } from "../schema"; import { TEAM_WORKSPACE_LIMITS } from "../seed/limits"; -type Db = typeof defaultDb; +type Db = + | typeof defaultDb + | Parameters[0]>[0]; type WorkspaceInsert = typeof workspace.$inferInsert; type MonitorInsert = typeof monitor.$inferInsert; type UserInsert = typeof user.$inferInsert; diff --git a/packages/emails/emails/welcome.tsx b/packages/emails/emails/welcome.tsx index b65a8810..7441ac59 100644 --- a/packages/emails/emails/welcome.tsx +++ b/packages/emails/emails/welcome.tsx @@ -3,6 +3,7 @@ import { Link, Text } from "react-email"; import { Footer } from "./_components/footer"; +import { formatDay } from "./_components/format"; import { Heading } from "./_components/heading"; import { Layout } from "./_components/layout"; import { Signature } from "./_components/signature"; @@ -35,7 +36,11 @@ const links = [ }, ]; -const WelcomeEmail = () => { +export interface WelcomeEmailProps { + trialEndsAt?: Date; +} + +const WelcomeEmail = ({ trialEndsAt }: WelcomeEmailProps = {}) => { return ( { Thanks for signing up. A few places worth knowing about while you get set up. + {trialEndsAt ? ( + + Your workspace is on a 14-day Starter trial until{" "} + {formatDay(trialEndsAt)}, no card needed. + + ) : null} { ); }; +WelcomeEmail.PreviewProps = { + trialEndsAt: new Date("2026-10-07T00:00:00Z"), +} satisfies WelcomeEmailProps; + export default WelcomeEmail; diff --git a/packages/emails/src/templates.test.tsx b/packages/emails/src/templates.test.tsx index 5dfad596..b56f5789 100644 --- a/packages/emails/src/templates.test.tsx +++ b/packages/emails/src/templates.test.tsx @@ -32,6 +32,7 @@ import StatusReportEmail, { statusReportPreheader, } from "../emails/status-report"; import TeamInvitationEmail from "../emails/team-invitation"; +import WelcomeEmail from "../emails/welcome"; const alert = { type: "alert", @@ -678,6 +679,7 @@ describe("every transactional template", () => { invitation: , subscription: , magicLink: , + welcome: , }; for (const [name, element] of Object.entries(all)) { @@ -692,3 +694,16 @@ describe("every transactional template", () => { }); } }); + +describe("WelcomeEmail", () => { + test("mentions the trial only when one started", async () => { + const withTrial = await render( + , + { plainText: true }, + ); + expect(withTrial).toContain("14-day Starter trial until Wed 7 Oct 2026"); + + const withoutTrial = await render(, { plainText: true }); + expect(withoutTrial).not.toContain("trial"); + }); +}); diff --git a/packages/services/src/invitation/__tests__/invitation.test.ts b/packages/services/src/invitation/__tests__/invitation.test.ts index 75d4cc64..02127565 100644 --- a/packages/services/src/invitation/__tests__/invitation.test.ts +++ b/packages/services/src/invitation/__tests__/invitation.test.ts @@ -12,7 +12,7 @@ import { readAuditLog, withTestTransaction, } from "../../../test/helpers"; -import type { ServiceContext } from "../../context"; +import type { DB, ServiceContext } from "../../context"; import { ForbiddenError, LimitExceededError, @@ -23,6 +23,7 @@ import { createInvitation, deleteInvitation, getInvitationByToken, + hasPendingInvitation, listInvitations, } from "../index.ts"; @@ -268,3 +269,63 @@ describe("acceptInvitation", () => { }); }); }); + +describe("hasPendingInvitation", () => { + async function insertInvitation( + tx: DB, + values: { email: string; expiresAt: Date; acceptedAt?: Date }, + ) { + await tx.insert(invitation).values({ + workspaceId: teamCtx.workspace.id, + token: crypto.randomUUID(), + ...values, + }); + } + + test("true for a pending invitation, ignoring case", async () => { + await withTestTransaction(async (tx) => { + const email = `${TEST_PREFIX}-pending-${Date.now()}@example.com`; + await insertInvitation(tx, { + email, + expiresAt: new Date(Date.now() + 60_000), + }); + + expect( + await hasPendingInvitation({ email: email.toUpperCase(), db: tx }), + ).toBe(true); + }); + }); + + test("false for an expired invitation", async () => { + await withTestTransaction(async (tx) => { + const email = `${TEST_PREFIX}-expired-${Date.now()}@example.com`; + await insertInvitation(tx, { + email, + expiresAt: new Date(Date.now() - 60_000), + }); + + expect(await hasPendingInvitation({ email, db: tx })).toBe(false); + }); + }); + + test("false for an accepted invitation", async () => { + await withTestTransaction(async (tx) => { + const email = `${TEST_PREFIX}-accepted-${Date.now()}@example.com`; + await insertInvitation(tx, { + email, + expiresAt: new Date(Date.now() + 60_000), + acceptedAt: new Date(), + }); + + expect(await hasPendingInvitation({ email, db: tx })).toBe(false); + }); + }); + + test("false without any invitation", async () => { + expect( + await hasPendingInvitation({ + email: `${TEST_PREFIX}-none-${Date.now()}@example.com`, + }), + ).toBe(false); + }); +}); diff --git a/packages/services/src/invitation/index.ts b/packages/services/src/invitation/index.ts index 80d3fe90..4be00420 100644 --- a/packages/services/src/invitation/index.ts +++ b/packages/services/src/invitation/index.ts @@ -6,6 +6,7 @@ export { type InvitationWithWorkspace, listInvitations, } from "./list"; +export { hasPendingInvitation } from "./pending"; export { AcceptInvitationInput, CreateInvitationInput, diff --git a/packages/services/src/invitation/pending.ts b/packages/services/src/invitation/pending.ts new file mode 100644 index 00000000..cbd742a1 --- /dev/null +++ b/packages/services/src/invitation/pending.ts @@ -0,0 +1,25 @@ +import { and, db as defaultDb, gte, isNull, sql } from "@openstatus/db"; +import { invitation } from "@openstatus/db/src/schema"; + +import type { DB } from "../context"; + +export async function hasPendingInvitation(args: { + email: string; + db?: DB; +}): Promise { + const db = args.db ?? defaultDb; + + const row = await db + .select({ id: invitation.id }) + .from(invitation) + .where( + and( + sql`lower(${invitation.email}) = ${args.email.trim().toLowerCase()}`, + isNull(invitation.acceptedAt), + gte(invitation.expiresAt, new Date()), + ), + ) + .get(); + + return Boolean(row); +} diff --git a/packages/services/src/workspace/__tests__/billing.test.ts b/packages/services/src/workspace/__tests__/billing.test.ts new file mode 100644 index 00000000..2161d9e9 --- /dev/null +++ b/packages/services/src/workspace/__tests__/billing.test.ts @@ -0,0 +1,368 @@ +import { eq } from "@openstatus/db"; +import { selectWorkspaceSchema, workspace } from "@openstatus/db/src/schema"; +import { getLimits } from "@openstatus/db/src/schema/plan/utils"; +import { + addUserToWorkspace, + createTestWorkspace, + createUser, +} from "@openstatus/db/src/test/factories"; +import { expect } from "@std/expect"; +import { describe, test } from "@std/testing/bdd"; +import { ZodError } from "zod"; + +import { + expectAuditRow, + makeApiKeyCtx, + makeUserCtx, + readAuditLog, + withTestTransaction, +} from "../../../test/helpers"; +import { ConflictError, ForbiddenError } from "../../errors"; +import { + getWorkspaceForMember, + listWorkspaceOwners, + updateWorkspaceLimits, + updateWorkspaceStripeId, +} from "../index.ts"; + +describe("getWorkspaceForMember", () => { + test("resolves the workspace by slug for a member, with their email", async () => { + const { workspace: ws, user } = await createTestWorkspace({ plan: "free" }); + + const found = await getWorkspaceForMember({ + input: { slug: ws.slug, userId: user.id }, + }); + + expect(found?.workspace.id).toBe(ws.id); + expect(found?.email).toBe(user.email); + expect(typeof found?.workspace.limits).toBe("object"); + }); + + test("returns null for a user who is not a member", async () => { + const { workspace: ws } = await createTestWorkspace({ plan: "free" }); + const stranger = await createUser(); + + const found = await getWorkspaceForMember({ + input: { slug: ws.slug, userId: stranger.id }, + }); + + expect(found).toBeNull(); + }); + + test("returns null for an unknown slug", async () => { + const { user } = await createTestWorkspace({ plan: "free" }); + const found = await getWorkspaceForMember({ + input: { slug: `missing-${crypto.randomUUID()}`, userId: user.id }, + }); + expect(found).toBeNull(); + }); +}); + +describe("listWorkspaceOwners", () => { + test("lists owners only", async () => { + const { workspace: ws, user: owner } = await createTestWorkspace({ + plan: "free", + }); + const member = await createUser(); + await addUserToWorkspace(member.id, ws.id, "member"); + + const owners = await listWorkspaceOwners({ + input: { workspaceId: ws.id }, + }); + + expect(owners).toEqual([{ id: owner.id, email: owner.email }]); + }); + + test("skips owners whose account was deleted", async () => { + await withTestTransaction(async (tx) => { + const { workspace: ws, user: owner } = await createTestWorkspace( + { plan: "free" }, + tx, + ); + const gone = await createUser({ deletedAt: new Date() }, tx); + await addUserToWorkspace(gone.id, ws.id, "owner", tx); + + const owners = await listWorkspaceOwners({ + input: { workspaceId: ws.id }, + db: tx, + }); + + expect(owners).toEqual([{ id: owner.id, email: owner.email }]); + }); + }); +}); + +describe("updateWorkspaceStripeId", () => { + test("links the customer and audits the change", async () => { + await withTestTransaction(async (tx) => { + const { workspace: ws, user } = await createTestWorkspace( + { plan: "free", stripeId: null }, + tx, + ); + const ctx = { + ...makeUserCtx(selectWorkspaceSchema.parse(ws), { userId: user.id }), + db: tx, + }; + + await updateWorkspaceStripeId({ ctx, input: { stripeId: "cus_linked" } }); + + const after = await tx + .select() + .from(workspace) + .where(eq(workspace.id, ws.id)) + .get(); + expect(after?.stripeId).toBe("cus_linked"); + + await expectAuditRow({ + workspaceId: ws.id, + action: "workspace.update", + entityType: "workspace", + entityId: ws.id, + actorType: "user", + db: tx, + }); + const [audit] = await readAuditLog({ + workspaceId: ws.id, + entityType: "workspace", + entityId: ws.id, + db: tx, + }); + expect(audit?.changedFields).toContain("stripeId"); + }); + }); + + test("refuses to replace a customer linked by a concurrent request", async () => { + await withTestTransaction(async (tx) => { + const { workspace: ws, user } = await createTestWorkspace( + { plan: "free", stripeId: "cus_first" }, + tx, + ); + // Snapshot from before the other request linked its customer. + const ctx = { + ...makeUserCtx(selectWorkspaceSchema.parse({ ...ws, stripeId: null }), { + userId: user.id, + }), + db: tx, + }; + + await expect( + updateWorkspaceStripeId({ ctx, input: { stripeId: "cus_second" } }), + ).rejects.toBeInstanceOf(ConflictError); + + const after = await tx + .select({ stripeId: workspace.stripeId }) + .from(workspace) + .where(eq(workspace.id, ws.id)) + .get(); + expect(after?.stripeId).toBe("cus_first"); + }); + }); + + test("re-linking the same customer is a no-op", async () => { + await withTestTransaction(async (tx) => { + const { workspace: ws, user } = await createTestWorkspace( + { plan: "free", stripeId: "cus_same" }, + tx, + ); + const ctx = { + ...makeUserCtx(selectWorkspaceSchema.parse(ws), { userId: user.id }), + db: tx, + }; + + await updateWorkspaceStripeId({ ctx, input: { stripeId: "cus_same" } }); + + const audits = await readAuditLog({ + workspaceId: ws.id, + entityType: "workspace", + entityId: ws.id, + db: tx, + }); + expect(audits).toHaveLength(0); + }); + }); + + test("rejects a read-only api key actor", async () => { + await withTestTransaction(async (tx) => { + const { workspace: ws } = await createTestWorkspace({ plan: "free" }, tx); + const ctx = { + ...makeApiKeyCtx(selectWorkspaceSchema.parse(ws), { + keyId: "k-read", + userId: 1, + scopes: ["read"], + }), + db: tx, + }; + await expect( + updateWorkspaceStripeId({ ctx, input: { stripeId: "cus_x" } }), + ).rejects.toBeInstanceOf(ForbiddenError); + }); + }); +}); + +describe("updateWorkspaceLimits", () => { + test("applies the addon, clears the trial when told, and stamps the reason", async () => { + await withTestTransaction(async (tx) => { + const trialEndsAt = new Date("2027-01-15T00:00:00Z"); + const { workspace: ws, user } = await createTestWorkspace( + { + plan: "starter", + trialEndsAt, + limits: JSON.stringify(getLimits("starter")), + }, + tx, + ); + const ctx = { + ...makeUserCtx(selectWorkspaceSchema.parse(ws), { userId: user.id }), + db: tx, + }; + + await updateWorkspaceLimits({ + ctx, + input: { + addon: "monitors", + value: 99, + trialEndsAt: null, + reason: "trial_converted", + }, + }); + + const after = await tx + .select() + .from(workspace) + .where(eq(workspace.id, ws.id)) + .get(); + expect(JSON.parse(after?.limits ?? "{}")).toEqual({ + ...getLimits("starter"), + monitors: 99, + }); + expect(after?.trialEndsAt).toBeNull(); + + const [audit] = await readAuditLog({ + workspaceId: ws.id, + entityType: "workspace", + entityId: ws.id, + db: tx, + }); + expect(audit?.changedFields).toContain("limits"); + expect(audit?.changedFields).toContain("trialEndsAt"); + expect(audit?.metadata).toMatchObject({ reason: "trial_converted" }); + }); + }); + + test("merges into the current limits, not the caller's snapshot", async () => { + await withTestTransaction(async (tx) => { + const { workspace: ws, user } = await createTestWorkspace( + { plan: "starter", limits: JSON.stringify(getLimits("starter")) }, + tx, + ); + // Both calls carry the same pre-change workspace snapshot. + const ctx = { + ...makeUserCtx(selectWorkspaceSchema.parse(ws), { userId: user.id }), + db: tx, + }; + + await updateWorkspaceLimits({ + ctx, + input: { addon: "white-label", value: true }, + }); + await updateWorkspaceLimits({ + ctx, + input: { addon: "monitors", value: 42 }, + }); + + const after = await tx + .select() + .from(workspace) + .where(eq(workspace.id, ws.id)) + .get(); + expect(JSON.parse(after?.limits ?? "{}")).toEqual({ + ...getLimits("starter"), + "white-label": true, + monitors: 42, + }); + }); + }); + + test("leaves trialEndsAt alone when not given", async () => { + await withTestTransaction(async (tx) => { + const trialEndsAt = new Date("2027-01-15T00:00:00Z"); + const { workspace: ws, user } = await createTestWorkspace( + { + plan: "starter", + trialEndsAt, + limits: JSON.stringify(getLimits("starter")), + }, + tx, + ); + const ctx = { + ...makeUserCtx(selectWorkspaceSchema.parse(ws), { userId: user.id }), + db: tx, + }; + + await updateWorkspaceLimits({ + ctx, + input: { addon: "monitors", value: 5 }, + }); + + const after = await tx + .select() + .from(workspace) + .where(eq(workspace.id, ws.id)) + .get(); + expect(after?.trialEndsAt).toEqual(trialEndsAt); + }); + }); + + test("rejects a value of the wrong kind instead of persisting unchanged limits", async () => { + await withTestTransaction(async (tx) => { + const { workspace: ws, user } = await createTestWorkspace( + { plan: "starter", limits: JSON.stringify(getLimits("starter")) }, + tx, + ); + const ctx = { + ...makeUserCtx(selectWorkspaceSchema.parse(ws), { userId: user.id }), + db: tx, + }; + + await expect( + updateWorkspaceLimits({ + ctx, + input: { addon: "monitors", value: false }, + }), + ).rejects.toBeInstanceOf(ZodError); + await expect( + updateWorkspaceLimits({ + ctx, + input: { addon: "white-label", value: 3 }, + }), + ).rejects.toBeInstanceOf(ZodError); + + const after = await tx + .select() + .from(workspace) + .where(eq(workspace.id, ws.id)) + .get(); + expect(JSON.parse(after?.limits ?? "{}")).toEqual(getLimits("starter")); + }); + }); + + test("rejects a read-only api key actor", async () => { + await withTestTransaction(async (tx) => { + const { workspace: ws } = await createTestWorkspace({ plan: "free" }, tx); + const ctx = { + ...makeApiKeyCtx(selectWorkspaceSchema.parse(ws), { + keyId: "k-read", + userId: 1, + scopes: ["read"], + }), + db: tx, + }; + await expect( + updateWorkspaceLimits({ + ctx, + input: { addon: "white-label", value: true }, + }), + ).rejects.toBeInstanceOf(ForbiddenError); + }); + }); +}); diff --git a/packages/services/src/workspace/__tests__/downgrade.test.ts b/packages/services/src/workspace/__tests__/downgrade.test.ts index 9723db7f..669e7f4b 100644 --- a/packages/services/src/workspace/__tests__/downgrade.test.ts +++ b/packages/services/src/workspace/__tests__/downgrade.test.ts @@ -189,6 +189,10 @@ describe("downgradeWorkspaceToFree", () => { actor: { type: "system", job: "stripe-subscription-deleted" }, db: tx, }; + await tx + .update(workspace) + .set({ trialEndsAt: new Date("2027-01-01T00:00:00Z") }) + .where(eq(workspace.id, s.ws.id)); await downgradeWorkspaceToFree({ ctx }); @@ -201,6 +205,7 @@ describe("downgradeWorkspaceToFree", () => { expect(after?.subscriptionId).toBeNull(); expect(after?.paidUntil).toBeNull(); expect(after?.endsAt).toBeNull(); + expect(after?.trialEndsAt).toBeNull(); // Compare parsed content, not the raw string — the verb persists // `limitsSchema`-canonicalised JSON (key order differs from the // config object returned by `getLimits`). @@ -229,6 +234,40 @@ describe("downgradeWorkspaceToFree", () => { }); }); + test("stamps the given reason instead of the default", async () => { + await withTestTransaction(async (tx) => { + const s = await seedTeamWorkspace(tx); + const withSso = await tx + .update(workspace) + .set({ workosOrganizationId: "org_downgrade_reason", ssoEnabled: true }) + .where(eq(workspace.id, s.ws.id)) + .returning() + .get(); + const ctx: ServiceContext = { + workspace: selectWorkspaceSchema.parse(withSso), + actor: { type: "system", job: "stripe-subscription-deleted" }, + db: tx, + }; + + await downgradeWorkspaceToFree({ ctx, input: { reason: "trial_ended" } }); + + const [wsAudit] = await readAuditLog({ + workspaceId: s.ws.id, + entityType: "workspace", + entityId: s.ws.id, + db: tx, + }); + expect(wsAudit?.metadata).toMatchObject({ reason: "trial_ended" }); + const [ssoAudit] = await readAuditLog({ + workspaceId: s.ws.id, + entityType: "workspace_sso", + entityId: s.ws.id, + db: tx, + }); + expect(ssoAudit?.metadata).toMatchObject({ reason: "trial_ended" }); + }); + }); + test("deactivates all but the oldest active monitor", async () => { await withTestTransaction(async (tx) => { const s = await seedTeamWorkspace(tx); diff --git a/packages/services/src/workspace/__tests__/trial.test.ts b/packages/services/src/workspace/__tests__/trial.test.ts new file mode 100644 index 00000000..614cbe47 --- /dev/null +++ b/packages/services/src/workspace/__tests__/trial.test.ts @@ -0,0 +1,120 @@ +import { + addUserToWorkspace, + createTestWorkspace, + createUser, + createWorkspace, +} from "@openstatus/db/src/test/factories"; +import { expect } from "@std/expect"; +import { describe, test } from "@std/testing/bdd"; + +import { + findTrialEligibleWorkspace, + getTrialDaysLeft, + listOwnedTrialWorkspaces, +} from "../index.ts"; + +const DAY_MS = 86_400_000; + +describe("getTrialDaysLeft", () => { + const now = new Date("2026-09-23T12:00:00Z").getTime(); + + test("null without a trial or once it has passed", () => { + expect(getTrialDaysLeft(null, now)).toBeNull(); + expect(getTrialDaysLeft(undefined, now)).toBeNull(); + expect(getTrialDaysLeft(new Date(now), now)).toBeNull(); + expect(getTrialDaysLeft(new Date(now - 1), now)).toBeNull(); + }); + + test("rounds a partial day up", () => { + expect(getTrialDaysLeft(new Date(now + 1), now)).toBe(1); + expect(getTrialDaysLeft(new Date(now + DAY_MS), now)).toBe(1); + expect(getTrialDaysLeft(new Date(now + DAY_MS + 1), now)).toBe(2); + expect(getTrialDaysLeft(new Date(now + 14 * DAY_MS), now)).toBe(14); + }); +}); + +describe("findTrialEligibleWorkspace", () => { + test("an owned free workspace never linked to Stripe", async () => { + const { workspace: ws, user } = await createTestWorkspace({ + plan: "free", + stripeId: null, + subscriptionId: null, + }); + const found = await findTrialEligibleWorkspace({ + input: { userId: user.id }, + }); + expect(found?.id).toBe(ws.id); + }); + + test("a signup workspace with no plan column yet counts as free", async () => { + const ws = await createWorkspace({ + plan: null, + stripeId: null, + subscriptionId: null, + }); + const owner = await createUser(); + await addUserToWorkspace(owner.id, ws.id, "owner"); + + const found = await findTrialEligibleWorkspace({ + input: { userId: owner.id }, + }); + expect(found?.id).toBe(ws.id); + }); + + test("null when the workspace already has a Stripe customer", async () => { + const { user } = await createTestWorkspace({ + plan: "free", + stripeId: `cus_${crypto.randomUUID()}`, + }); + expect( + await findTrialEligibleWorkspace({ input: { userId: user.id } }), + ).toBeNull(); + }); + + test("null when the workspace is on a paid plan", async () => { + const { user } = await createTestWorkspace({ + plan: "team", + stripeId: null, + subscriptionId: null, + }); + expect( + await findTrialEligibleWorkspace({ input: { userId: user.id } }), + ).toBeNull(); + }); + + test("null for a member who is not the owner", async () => { + const { workspace: ws } = await createTestWorkspace({ + plan: "free", + stripeId: null, + }); + const member = await createUser(); + await addUserToWorkspace(member.id, ws.id, "member"); + expect( + await findTrialEligibleWorkspace({ input: { userId: member.id } }), + ).toBeNull(); + }); +}); + +describe("listOwnedTrialWorkspaces", () => { + test("only owned workspaces with a trial date", async () => { + // Second precision: the timestamp column drops milliseconds. + const trialEndsAt = new Date( + Math.floor((Date.now() + 7 * DAY_MS) / 1000) * 1000, + ); + const { workspace: trial, user: owner } = await createTestWorkspace({ + plan: "starter", + trialEndsAt, + }); + const paid = await createWorkspace({ plan: "team", trialEndsAt: null }); + await addUserToWorkspace(owner.id, paid.id, "owner"); + const memberOf = await createWorkspace({ plan: "starter", trialEndsAt }); + await addUserToWorkspace(owner.id, memberOf.id, "member"); + + const rows = await listOwnedTrialWorkspaces({ + input: { userId: owner.id }, + }); + + expect(rows.map((r) => r.id)).toEqual([trial.id]); + expect(rows[0]?.trialEndsAt).toEqual(trialEndsAt); + }); +}); diff --git a/packages/services/src/workspace/__tests__/workspace.test.ts b/packages/services/src/workspace/__tests__/workspace.test.ts index e79a8669..2825c81f 100644 --- a/packages/services/src/workspace/__tests__/workspace.test.ts +++ b/packages/services/src/workspace/__tests__/workspace.test.ts @@ -335,6 +335,35 @@ async function insertPlanWorkspace( } describe("updateWorkspacePlan", () => { + test("writes trialEndsAt only when given", async () => { + await withTestTransaction(async (tx) => { + const ws = await insertPlanWorkspace(tx, { + plan: "free", + slug: "svc-plan-trial", + }); + const ctx = { ...makeSystemCtx(ws, { job: "signup-trial" }), db: tx }; + const trialEndsAt = new Date("2027-01-15T00:00:00Z"); + const base = { + plan: "starter" as const, + subscriptionId: "sub_trial", + paidUntil: trialEndsAt, + endsAt: trialEndsAt, + limits: getLimits("starter"), + }; + const read = () => + tx.select().from(workspace).where(eq(workspace.id, ws.id)).get(); + + await updateWorkspacePlan({ ctx, input: { ...base, trialEndsAt } }); + expect((await read())?.trialEndsAt).toEqual(trialEndsAt); + + await updateWorkspacePlan({ ctx, input: base }); + expect((await read())?.trialEndsAt).toEqual(trialEndsAt); + + await updateWorkspacePlan({ ctx, input: { ...base, trialEndsAt: null } }); + expect((await read())?.trialEndsAt).toBeNull(); + }); + }); + test("writes the new plan + limits and audits the change", async () => { await withTestTransaction(async (tx) => { const ws = await insertPlanWorkspace(tx, { diff --git a/packages/services/src/workspace/downgrade.ts b/packages/services/src/workspace/downgrade.ts index 7ffbcc09..20b6cd6e 100644 --- a/packages/services/src/workspace/downgrade.ts +++ b/packages/services/src/workspace/downgrade.ts @@ -21,6 +21,7 @@ import { updatePagePasswordProtection, } from "../page"; import { disableSso } from "../sso"; +import { DowngradeWorkspaceInput } from "./schemas"; import { updateWorkspacePlan } from "./update"; /** @@ -63,6 +64,7 @@ export type DowngradeTrim = { // oxlint-disable-next-line openstatus/services-mutation-guards export async function downgradeWorkspaceToFree(args: { ctx: ServiceContext; + input?: DowngradeWorkspaceInput; }): Promise<{ customDomains: string[]; ssoDisabled: boolean; @@ -70,11 +72,17 @@ export async function downgradeWorkspaceToFree(args: { }> { const { ctx } = args; requireScope(ctx, "write"); + const input = DowngradeWorkspaceInput.parse(args.input ?? {}); + const reason = input.reason ?? "subscription_deleted"; const workspaceId = ctx.workspace.id; return withTransaction(ctx, async (tx) => { const txCtx: ServiceContext = { ...ctx, db: tx }; + // `trialEndsAt` means "the trial in effect ends at", not trial history: + // the dashboard derives "is trialing" from it, so a cancelled trial must + // clear it or a free workspace keeps showing trial banners until the date + // passes. History lives in the audit row and the Stripe customer metadata. await updateWorkspacePlan({ ctx: txCtx, input: { @@ -82,8 +90,9 @@ export async function downgradeWorkspaceToFree(args: { subscriptionId: null, paidUntil: null, endsAt: null, + trialEndsAt: null, limits: getLimits("free"), - reason: "subscription_deleted", + reason, }, }); @@ -91,10 +100,7 @@ export async function downgradeWorkspaceToFree(args: { // otherwise re-add the members this downgrade is about to trim. const ssoWasEnabled = ctx.workspace.ssoEnabled; if (ssoWasEnabled) { - await disableSso({ - ctx: txCtx, - input: { reason: "subscription_deleted" }, - }); + await disableSso({ ctx: txCtx, input: { reason } }); } const activeMonitors = await tx diff --git a/packages/services/src/workspace/index.ts b/packages/services/src/workspace/index.ts index 52d00598..8d4b87c6 100644 --- a/packages/services/src/workspace/index.ts +++ b/packages/services/src/workspace/index.ts @@ -1,7 +1,10 @@ export { getWorkspace, getWorkspaceByStripeId, + getWorkspaceForMember, getWorkspaceUsage, + listOwnedWorkspaces, + listWorkspaceOwners, listWorkspaces, type WorkspaceUsage, } from "./list"; @@ -11,12 +14,28 @@ export { downgradeWorkspaceToFree, previewWorkspaceDowngrade, } from "./downgrade"; -export { updateWorkspaceName, updateWorkspacePlan } from "./update"; export { + findTrialEligibleWorkspace, + getTrialDaysLeft, + listOwnedTrialWorkspaces, +} from "./trial"; +export { + updateWorkspaceLimits, + updateWorkspaceName, + updateWorkspacePlan, + updateWorkspaceStripeId, +} from "./update"; +export { + DowngradeWorkspaceInput, GetWorkspaceByStripeIdInput, + GetWorkspaceForMemberInput, GetWorkspaceInput, GetWorkspaceUsageInput, + ListWorkspaceOwnersInput, ListWorkspacesInput, + OwnedWorkspacesInput, + UpdateWorkspaceLimitsInput, UpdateWorkspaceNameInput, UpdateWorkspacePlanInput, + UpdateWorkspaceStripeIdInput, } from "./schemas"; diff --git a/packages/services/src/workspace/list.ts b/packages/services/src/workspace/list.ts index 3f45e3fa..7762b467 100644 --- a/packages/services/src/workspace/list.ts +++ b/packages/services/src/workspace/list.ts @@ -6,6 +6,7 @@ import { pageComponent, selectWorkspaceSchema, statusReport, + user, usersToWorkspaces, workspace, } from "@openstatus/db/src/schema"; @@ -15,8 +16,11 @@ import { NotFoundError } from "../errors"; import type { Workspace } from "../types"; import { GetWorkspaceByStripeIdInput, + GetWorkspaceForMemberInput, type GetWorkspaceUsageInput, + ListWorkspaceOwnersInput, ListWorkspacesInput, + OwnedWorkspacesInput, } from "./schemas"; /** @@ -156,3 +160,84 @@ export async function listWorkspaces(args: { .array() .parse(rows.map(({ workspace }) => workspace)); } + +/** + * A workspace by slug, only if the user is a member. Billing procedures take + * the slug as input, so this runs before a `ctx.workspace` exists for it. + * Returns the member's email alongside for the Stripe customer record. + */ +export async function getWorkspaceForMember(args: { + input: GetWorkspaceForMemberInput; + db?: DB; +}): Promise<{ workspace: Workspace; email: string | null } | null> { + const input = GetWorkspaceForMemberInput.parse(args.input); + const db = args.db ?? defaultDb; + + const row = await db + .select({ workspace, email: user.email }) + .from(usersToWorkspaces) + .innerJoin(workspace, eq(workspace.id, usersToWorkspaces.workspaceId)) + .innerJoin(user, eq(user.id, usersToWorkspaces.userId)) + .where( + and( + eq(workspace.slug, input.slug), + eq(usersToWorkspaces.userId, input.userId), + ), + ) + .get(); + + if (!row) return null; + return { + workspace: selectWorkspaceSchema.parse(row.workspace), + email: row.email, + }; +} + +/** Workspaces the user owns. Account deletion checks these for a paid plan. */ +export async function listOwnedWorkspaces(args: { + input: OwnedWorkspacesInput; + db?: DB; +}): Promise { + const input = OwnedWorkspacesInput.parse(args.input); + const db = args.db ?? defaultDb; + + const rows = await db + .select({ workspace }) + .from(usersToWorkspaces) + .innerJoin(workspace, eq(workspace.id, usersToWorkspaces.workspaceId)) + .where( + and( + eq(usersToWorkspaces.userId, input.userId), + eq(usersToWorkspaces.role, "owner"), + ), + ) + .all(); + + return selectWorkspaceSchema.array().parse(rows.map((r) => r.workspace)); +} + +/** + * Owners of a workspace — billing mail recipients and trial attribution. + * Account deletion keeps the owner membership and only soft-deletes the + * user, so those rows are filtered out here. + */ +export async function listWorkspaceOwners(args: { + input: ListWorkspaceOwnersInput; + db?: DB; +}): Promise<{ id: number; email: string | null }[]> { + const input = ListWorkspaceOwnersInput.parse(args.input); + const db = args.db ?? defaultDb; + + return db + .select({ id: user.id, email: user.email }) + .from(usersToWorkspaces) + .innerJoin(user, eq(user.id, usersToWorkspaces.userId)) + .where( + and( + eq(usersToWorkspaces.workspaceId, input.workspaceId), + eq(usersToWorkspaces.role, "owner"), + isNull(user.deletedAt), + ), + ) + .all(); +} diff --git a/packages/services/src/workspace/schemas.ts b/packages/services/src/workspace/schemas.ts index eec8be63..ff795cbf 100644 --- a/packages/services/src/workspace/schemas.ts +++ b/packages/services/src/workspace/schemas.ts @@ -1,5 +1,6 @@ import { workspacePlanSchema } from "@openstatus/db/src/schema"; -import { limitsSchema } from "@openstatus/db/src/schema/plan/schema"; +import { addons, limitsSchema } from "@openstatus/db/src/schema/plan/schema"; +import { isAddonQuantityKey } from "@openstatus/db/src/schema/plan/utils"; import { z } from "zod"; export const GetWorkspaceInput = z.object({}).strict(); @@ -18,6 +19,61 @@ export type GetWorkspaceByStripeIdInput = z.infer< typeof GetWorkspaceByStripeIdInput >; +export const GetWorkspaceForMemberInput = z.object({ + slug: z.string().min(1), + userId: z.number().int(), +}); +export type GetWorkspaceForMemberInput = z.infer< + typeof GetWorkspaceForMemberInput +>; + +export const ListWorkspaceOwnersInput = z.object({ + workspaceId: z.number().int(), +}); +export type ListWorkspaceOwnersInput = z.infer; + +export const OwnedWorkspacesInput = z.object({ userId: z.number().int() }); +export type OwnedWorkspacesInput = z.infer; + +export const UpdateWorkspaceStripeIdInput = z.object({ + stripeId: z.string().min(1), +}); +export type UpdateWorkspaceStripeIdInput = z.infer< + typeof UpdateWorkspaceStripeIdInput +>; + +/** + * One addon bought or removed, applied to the current limits without + * touching the plan. `trialEndsAt: null` records that buying the addon + * ended the trial; `reason` lands in the audit metadata. + */ +export const UpdateWorkspaceLimitsInput = z + .object({ + addon: z.enum(addons), + value: z.union([z.boolean(), z.number()]), + trialEndsAt: z.date().nullable().optional(), + reason: z.string().optional(), + }) + // `updateAddonInLimits` ignores a value of the wrong kind, which would + // record the change in Stripe but never in the limits. + .refine( + (i) => (typeof i.value === "number") === isAddonQuantityKey(i.addon), + { message: "Value does not match the addon type", path: ["value"] }, + ); +export type UpdateWorkspaceLimitsInput = z.infer< + typeof UpdateWorkspaceLimitsInput +>; + +/** + * `reason` is stamped on the plan-flip audit row (and the SSO one) so a + * trial that ran out reads differently from a paying customer churning. + * Defaults to `subscription_deleted`. + */ +export const DowngradeWorkspaceInput = z.object({ + reason: z.string().optional(), +}); +export type DowngradeWorkspaceInput = z.infer; + export const UpdateWorkspaceNameInput = z.object({ name: z.string().trim().min(1), }); @@ -36,6 +92,7 @@ export const UpdateWorkspacePlanInput = z.object({ subscriptionId: z.string().nullable(), paidUntil: z.date().nullable(), endsAt: z.date().nullable(), + trialEndsAt: z.date().nullable().optional(), limits: limitsSchema, reason: z.string().optional(), }); diff --git a/packages/services/src/workspace/trial.ts b/packages/services/src/workspace/trial.ts new file mode 100644 index 00000000..a46f67a4 --- /dev/null +++ b/packages/services/src/workspace/trial.ts @@ -0,0 +1,85 @@ +import { + and, + db as defaultDb, + eq, + isNotNull, + isNull, + or, +} from "@openstatus/db"; +import { + selectWorkspaceSchema, + usersToWorkspaces, + workspace, +} from "@openstatus/db/src/schema"; + +import type { DB } from "../context"; +import type { Workspace } from "../types"; +import { OwnedWorkspacesInput } from "./schemas"; + +const DAY_MS = 86_400_000; + +/** Whole days until the trial ends, or `null` when there is no live trial. */ +export function getTrialDaysLeft( + trialEndsAt: Date | null | undefined, + now = Date.now(), +): number | null { + if (!trialEndsAt) return null; + const msLeft = trialEndsAt.getTime() - now; + if (msLeft <= 0) return null; + return Math.ceil(msLeft / DAY_MS); +} + +/** + * The workspace a signup trial can be started on: owned by the user, on the + * free plan, and never linked to Stripe. Runs at signup, before any + * `ctx.workspace` is resolved. + */ +export async function findTrialEligibleWorkspace(args: { + input: OwnedWorkspacesInput; + db?: DB; +}): Promise { + const input = OwnedWorkspacesInput.parse(args.input); + const db = args.db ?? defaultDb; + + const row = await db + .select({ workspace }) + .from(usersToWorkspaces) + .innerJoin(workspace, eq(workspace.id, usersToWorkspaces.workspaceId)) + .where( + and( + eq(usersToWorkspaces.userId, input.userId), + eq(usersToWorkspaces.role, "owner"), + isNull(workspace.stripeId), + isNull(workspace.subscriptionId), + // A fresh signup workspace has no plan column yet — that is free. + or(isNull(workspace.plan), eq(workspace.plan, "free")), + ), + ) + .get(); + + return row ? selectWorkspaceSchema.parse(row.workspace) : null; +} + +/** Workspaces the user owns that are mid-trial. */ +export async function listOwnedTrialWorkspaces(args: { + input: OwnedWorkspacesInput; + db?: DB; +}): Promise { + const input = OwnedWorkspacesInput.parse(args.input); + const db = args.db ?? defaultDb; + + const rows = await db + .select({ workspace }) + .from(usersToWorkspaces) + .innerJoin(workspace, eq(workspace.id, usersToWorkspaces.workspaceId)) + .where( + and( + eq(usersToWorkspaces.userId, input.userId), + eq(usersToWorkspaces.role, "owner"), + isNotNull(workspace.trialEndsAt), + ), + ) + .all(); + + return selectWorkspaceSchema.array().parse(rows.map((r) => r.workspace)); +} diff --git a/packages/services/src/workspace/update.ts b/packages/services/src/workspace/update.ts index 1d1be03e..8b3492db 100644 --- a/packages/services/src/workspace/update.ts +++ b/packages/services/src/workspace/update.ts @@ -1,11 +1,18 @@ -import { eq } from "@openstatus/db"; +import { and, eq, isNull } from "@openstatus/db"; import { workspace } from "@openstatus/db/src/schema"; +import { limitsSchema } from "@openstatus/db/src/schema/plan/schema"; +import { updateAddonInLimits } from "@openstatus/db/src/schema/plan/utils"; import { emitAudit } from "../audit"; import { requireScope } from "../auth"; import { type ServiceContext, withTransaction } from "../context"; -import { NotFoundError } from "../errors"; -import { UpdateWorkspaceNameInput, UpdateWorkspacePlanInput } from "./schemas"; +import { ConflictError, NotFoundError } from "../errors"; +import { + UpdateWorkspaceLimitsInput, + UpdateWorkspaceNameInput, + UpdateWorkspacePlanInput, + UpdateWorkspaceStripeIdInput, +} from "./schemas"; /** * Rename the caller's workspace. No conflict check — workspace names are @@ -77,6 +84,9 @@ export async function updateWorkspacePlan(args: { subscriptionId: input.subscriptionId, paidUntil: input.paidUntil, endsAt: input.endsAt, + ...(input.trialEndsAt !== undefined && { + trialEndsAt: input.trialEndsAt, + }), limits: JSON.stringify(input.limits), updatedAt: new Date(), }) @@ -102,3 +112,101 @@ export async function updateWorkspacePlan(args: { }); }); } + +/** + * Link the workspace to its Stripe customer. Set once; never cleared. The + * write is gated on `stripe_id IS NULL`, so two requests that both saw no + * customer cannot both link: the loser gets a `ConflictError` and must + * discard the customer it created. Re-linking the same id is a no-op. + */ +export async function updateWorkspaceStripeId(args: { + ctx: ServiceContext; + input: UpdateWorkspaceStripeIdInput; +}): Promise { + const { ctx } = args; + requireScope(ctx, "write"); + const input = UpdateWorkspaceStripeIdInput.parse(args.input); + + await withTransaction(ctx, async (tx) => { + const existing = await tx + .select() + .from(workspace) + .where(eq(workspace.id, ctx.workspace.id)) + .get(); + if (!existing) throw new NotFoundError("workspace", ctx.workspace.id); + if (existing.stripeId === input.stripeId) return; + + const updated = await tx + .update(workspace) + .set({ stripeId: input.stripeId, updatedAt: new Date() }) + .where( + and(eq(workspace.id, ctx.workspace.id), isNull(workspace.stripeId)), + ) + .returning() + .get(); + if (!updated) { + throw new ConflictError( + `Workspace ${ctx.workspace.id} is already linked to a Stripe customer`, + ); + } + + await emitAudit(tx, ctx, { + action: "workspace.update", + entityType: "workspace", + entityId: ctx.workspace.id, + before: existing, + after: updated, + }); + }); +} + +/** + * Apply one addon change to the workspace's limits. The addon is merged into + * the limits read inside the transaction, not into the caller's snapshot, + * so two concurrent addon changes cannot overwrite each other. + */ +export async function updateWorkspaceLimits(args: { + ctx: ServiceContext; + input: UpdateWorkspaceLimitsInput; +}): Promise { + const { ctx } = args; + requireScope(ctx, "write"); + const input = UpdateWorkspaceLimitsInput.parse(args.input); + + await withTransaction(ctx, async (tx) => { + const existing = await tx + .select() + .from(workspace) + .where(eq(workspace.id, ctx.workspace.id)) + .get(); + if (!existing) throw new NotFoundError("workspace", ctx.workspace.id); + + const limits = updateAddonInLimits( + limitsSchema.parse(JSON.parse(existing.limits)), + input.addon, + input.value, + ); + + const updated = await tx + .update(workspace) + .set({ + limits: JSON.stringify(limits), + ...(input.trialEndsAt !== undefined && { + trialEndsAt: input.trialEndsAt, + }), + updatedAt: new Date(), + }) + .where(eq(workspace.id, ctx.workspace.id)) + .returning() + .get(); + + await emitAudit(tx, ctx, { + action: "workspace.update", + entityType: "workspace", + entityId: ctx.workspace.id, + before: existing, + after: updated, + ...(input.reason ? { metadata: { reason: input.reason } } : {}), + }); + }); +} diff --git a/packages/ui/src/hooks/use-cookie-state.ts b/packages/ui/src/hooks/use-cookie-state.ts index 8d52d588..68472963 100644 --- a/packages/ui/src/hooks/use-cookie-state.ts +++ b/packages/ui/src/hooks/use-cookie-state.ts @@ -12,11 +12,10 @@ export function useCookieState( const handleChange = useCallback( (value: T) => { if (document) { - const date = new Date(); - date.setTime(date.getTime() + 365 * 24 * 60 * 60 * 1000); // in one year - document.cookie = `${name}=${value}; path=/; expires=${ - config?.expires ?? date.toUTCString() - }`; + // `expires` is a duration in ms; the cookie needs an absolute date + const ttl = config?.expires ?? 365 * 24 * 60 * 60 * 1000; // one year + const expires = new Date(Date.now() + ttl).toUTCString(); + document.cookie = `${name}=${value}; path=/; expires=${expires}`; setState(value); } }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eed5dcf4..c88bfd1e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -488,6 +488,9 @@ catalogs: lucide-react: specifier: 0.525.0 version: 0.525.0 + mailchecker: + specifier: 6.0.21 + version: 6.0.21 marked: specifier: 15.0.12 version: 15.0.12 @@ -903,13 +906,13 @@ importers: version: 16.3.5(@babel/core@7.29.7(supports-color@8.1.1))(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) next-auth: specifier: 'catalog:' - version: 5.0.0-beta.32(next@16.3.5(@babel/core@7.29.7(supports-color@8.1.1))(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react@19.3.0))(react@19.3.0) + version: 5.0.0-beta.32(next@16.3.5(@babel/core@7.29.7(supports-color@8.1.1))(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0) next-themes: specifier: 'catalog:' version: 0.4.6(react-dom@19.3.0(react@19.3.0))(react@19.3.0) nuqs: specifier: 'catalog:' - version: 2.10.1(next@16.3.5(@babel/core@7.29.7(supports-color@8.1.1))(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0) + version: 2.10.1(next@16.3.5(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0) random-word-slugs: specifier: 'catalog:' version: 0.1.7 @@ -1389,7 +1392,7 @@ importers: version: 16.3.5(@babel/core@7.29.7(supports-color@8.1.1))(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) next-auth: specifier: 'catalog:' - version: 5.0.0-beta.32(next@16.3.5(@babel/core@7.29.7(supports-color@8.1.1))(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react@19.3.0))(react@19.3.0) + version: 5.0.0-beta.32(next@16.3.5(@babel/core@7.29.7(supports-color@8.1.1))(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0) next-intl: specifier: 'catalog:' version: 4.14.6(@swc/helpers@0.5.23)(next@16.3.5(@babel/core@7.29.7(supports-color@8.1.1))(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0) @@ -1401,7 +1404,7 @@ importers: version: 0.4.6(react-dom@19.3.0(react@19.3.0))(react@19.3.0) nuqs: specifier: 'catalog:' - version: 2.10.1(next@16.3.5(@babel/core@7.29.7(supports-color@8.1.1))(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0) + version: 2.10.1(next@16.3.5(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0) react: specifier: 'catalog:' version: 19.3.0 @@ -1504,7 +1507,7 @@ importers: version: 0.17.4 '@openpanel/nextjs': specifier: 'catalog:' - version: 1.5.1(next@16.3.5(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + version: 1.5.1(next@16.3.5(@babel/core@7.29.7(supports-color@8.1.1))(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@openstatus/analytics': specifier: workspace:* version: link:../../packages/analytics @@ -1579,7 +1582,7 @@ importers: version: link:../../packages/utils '@sentry/nextjs': specifier: 'catalog:' - version: 10.75.1(@opentelemetry/core@2.11.0(@opentelemetry/api@1.9.1))(next@16.3.5(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0)(supports-color@8.1.1)(webpack@5.111.1(@swc/core@1.16.2)(esbuild@0.28.2)(lightningcss@1.32.0)(postcss@8.5.28)(sharp@0.35.4(@types/node@26.6.2))) + version: 10.75.1(@opentelemetry/core@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.11.0(@opentelemetry/api@1.9.1))(next@16.3.5(@babel/core@7.29.7(supports-color@8.1.1))(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0)(supports-color@8.1.1)(webpack@5.111.1(@swc/core@1.16.2)(esbuild@0.28.2)(lightningcss@1.32.0)(postcss@8.5.28)(sharp@0.35.4(@types/node@26.6.2))) '@t3-oss/env-nextjs': specifier: 'catalog:' version: 0.13.11(typescript@7.0.2)(zod@4.6.5) @@ -1603,7 +1606,7 @@ importers: version: 11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2) '@trpc/next': specifier: 'catalog:' - version: 11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/react-query@11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/server@11.19.0(typescript@7.0.2))(react@19.3.0)(typescript@7.0.2))(@trpc/server@11.19.0(typescript@7.0.2))(next@16.3.5(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@7.0.2) + version: 11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/react-query@11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/server@11.19.0(typescript@7.0.2))(react@19.3.0)(typescript@7.0.2))(@trpc/server@11.19.0(typescript@7.0.2))(next@16.3.5(@babel/core@7.29.7(supports-color@8.1.1))(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@7.0.2) '@trpc/react-query': specifier: 'catalog:' version: 11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/server@11.19.0(typescript@7.0.2))(react@19.3.0)(typescript@7.0.2) @@ -1651,16 +1654,16 @@ importers: version: 6.0.1 next: specifier: 'catalog:' - version: 16.3.5(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + version: 16.3.5(@babel/core@7.29.7(supports-color@8.1.1))(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) next-auth: specifier: 'catalog:' - version: 5.0.0-beta.32(next@16.3.5(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0) + version: 5.0.0-beta.32(next@16.3.5(@babel/core@7.29.7(supports-color@8.1.1))(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0) next-mdx-remote: specifier: 'catalog:' version: 6.0.0(@types/react@19.3.0)(react@19.3.0)(supports-color@8.1.1) next-plausible: specifier: 'catalog:' - version: 3.12.5(next@16.3.5(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + version: 3.12.5(next@16.3.5(@babel/core@7.29.7(supports-color@8.1.1))(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0) next-themes: specifier: 'catalog:' version: 0.4.6(react-dom@19.3.0(react@19.3.0))(react@19.3.0) @@ -2076,6 +2079,9 @@ importers: isomorphic-dompurify: specifier: 'catalog:' version: 3.23.0 + mailchecker: + specifier: 'catalog:' + version: 6.0.21 nanoid: specifier: 'catalog:' version: 6.0.1 @@ -2084,7 +2090,7 @@ importers: version: 5.0.0 next: specifier: 'catalog:' - version: 16.3.5(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + version: 16.3.5(@babel/core@7.29.7(supports-color@8.1.1))(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) random-word-slugs: specifier: 'catalog:' version: 0.1.7 @@ -2192,7 +2198,7 @@ importers: version: 0.31.11 next-auth: specifier: 'catalog:' - version: 5.0.0-beta.32(next@16.3.5(@babel/core@7.29.7(supports-color@8.1.1))(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react@19.3.0))(react@19.3.0) + version: 5.0.0-beta.32(next@16.3.5(@babel/core@7.29.7(supports-color@8.1.1))(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0) typescript: specifier: 'catalog:' version: 7.0.2 @@ -2229,7 +2235,7 @@ importers: version: link:../tsconfig '@react-email/ui': specifier: 'catalog:' - version: 6.9.5(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + version: 6.9.5(@babel/core@7.29.7(supports-color@8.1.1))(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@std/expect': specifier: jsr:^1.0.19 version: '@jsr/std__expect@1.0.20' @@ -3203,7 +3209,7 @@ importers: version: 0.525.0(react@19.3.0) next: specifier: 'catalog:' - version: 16.3.5(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + version: 16.3.5(@babel/core@7.29.7(supports-color@8.1.1))(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) next-themes: specifier: 'catalog:' version: 0.4.6(react-dom@19.3.0(react@19.3.0))(react@19.3.0) @@ -9936,6 +9942,10 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + mailchecker@6.0.21: + resolution: {integrity: sha512-Zd2Lgj4RMnC0IBbbSjnF76Wbd+kkgKMRONgV9yOMKJhj6hcKIAQtj9MrmiGnT2Db+Dy6MsMQoG2HEhPk2YPuGA==} + engines: {node: '>=0.10'} + markdown-extensions@2.0.0: resolution: {integrity: sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==} engines: {node: '>=16'} @@ -13843,13 +13853,6 @@ snapshots: react: 19.3.0 react-dom: 19.3.0(react@19.3.0) - '@openpanel/nextjs@1.5.1(next@16.3.5(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': - dependencies: - '@openpanel/web': 1.4.1 - next: 16.3.5(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) - react: 19.3.0 - react-dom: 19.3.0(react@19.3.0) - '@openpanel/sdk@1.3.1': {} '@openpanel/web@1.4.1': @@ -15170,10 +15173,10 @@ snapshots: react: 19.3.0 react-dom: 19.3.0(react@19.3.0) - '@react-email/ui@6.9.5(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + '@react-email/ui@6.9.5(@babel/core@7.29.7(supports-color@8.1.1))(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: esbuild: 0.28.1 - next: 16.3.3(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + next: 16.3.3(@babel/core@7.29.7(supports-color@8.1.1))(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) transitivePeerDependencies: - '@babel/core' - '@opentelemetry/api' @@ -15464,7 +15467,7 @@ snapshots: - supports-color - webpack - '@sentry/nextjs@10.75.1(@opentelemetry/core@2.11.0(@opentelemetry/api@1.9.1))(next@16.3.5(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0)(supports-color@8.1.1)(webpack@5.111.1(@swc/core@1.16.2)(esbuild@0.28.2)(lightningcss@1.32.0)(postcss@8.5.28)(sharp@0.35.4(@types/node@26.6.2)))': + '@sentry/nextjs@10.75.1(@opentelemetry/core@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.11.0(@opentelemetry/api@1.9.1))(next@16.3.5(@babel/core@7.29.7(supports-color@8.1.1))(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0)(supports-color@8.1.1)(webpack@5.111.1(@swc/core@1.16.2)(esbuild@0.28.2)(lightningcss@1.32.0)(postcss@8.5.28)(sharp@0.35.4(@types/node@26.6.2)))': dependencies: '@opentelemetry/api': 1.9.1 '@rollup/plugin-commonjs': 28.0.1(rollup@4.63.4) @@ -15478,7 +15481,7 @@ snapshots: '@sentry/server-utils': 10.75.1 '@sentry/vercel-edge': 10.75.1 '@sentry/webpack-plugin': 5.4.0(rollup@4.63.4)(supports-color@8.1.1)(webpack@5.111.1(@swc/core@1.16.2)(esbuild@0.28.2)(lightningcss@1.32.0)(postcss@8.5.28)(sharp@0.35.4(@types/node@26.6.2))) - next: 16.3.5(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + next: 16.3.5(@babel/core@7.29.7(supports-color@8.1.1))(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) rollup: 4.63.4 stacktrace-parser: 0.1.11 transitivePeerDependencies: @@ -15972,18 +15975,6 @@ snapshots: '@tanstack/react-query': 5.103.2(react@19.3.0) '@trpc/react-query': 11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/server@11.19.0(typescript@7.0.2))(react@19.3.0)(typescript@7.0.2) - '@trpc/next@11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/react-query@11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/server@11.19.0(typescript@7.0.2))(react@19.3.0)(typescript@7.0.2))(@trpc/server@11.19.0(typescript@7.0.2))(next@16.3.5(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@7.0.2)': - dependencies: - '@trpc/client': 11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2) - '@trpc/server': 11.19.0(typescript@7.0.2) - next: 16.3.5(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) - react: 19.3.0 - react-dom: 19.3.0(react@19.3.0) - typescript: 7.0.2 - optionalDependencies: - '@tanstack/react-query': 5.103.2(react@19.3.0) - '@trpc/react-query': 11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/server@11.19.0(typescript@7.0.2))(react@19.3.0)(typescript@7.0.2) - '@trpc/react-query@11.19.0(@tanstack/react-query@5.103.2(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/server@11.19.0(typescript@7.0.2))(react@19.3.0)(typescript@7.0.2)': dependencies: '@tanstack/react-query': 5.103.2(react@19.3.0) @@ -18076,6 +18067,8 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 + mailchecker@6.0.21: {} + markdown-extensions@2.0.0: {} markdown-table@3.0.4: {} @@ -18646,18 +18639,12 @@ snapshots: neverthrow@7.2.0: {} - next-auth@5.0.0-beta.32(next@16.3.5(@babel/core@7.29.7(supports-color@8.1.1))(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react@19.3.0))(react@19.3.0): + next-auth@5.0.0-beta.32(next@16.3.5(@babel/core@7.29.7(supports-color@8.1.1))(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0): dependencies: '@auth/core': 0.41.3 next: 16.3.5(@babel/core@7.29.7(supports-color@8.1.1))(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) react: 19.3.0 - next-auth@5.0.0-beta.32(next@16.3.5(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0): - dependencies: - '@auth/core': 0.41.3 - next: 16.3.5(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) - react: 19.3.0 - next-intl-swc-plugin-extractor@4.14.6: {} next-intl@4.14.6(@swc/helpers@0.5.23)(next@16.3.5(@babel/core@7.29.7(supports-color@8.1.1))(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0): @@ -18697,18 +18684,12 @@ snapshots: react: 19.3.0 react-dom: 19.3.0(react@19.3.0) - next-plausible@3.12.5(next@16.3.5(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0): - dependencies: - next: 16.3.5(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) - react: 19.3.0 - react-dom: 19.3.0(react@19.3.0) - next-themes@0.4.6(react-dom@19.3.0(react@19.3.0))(react@19.3.0): dependencies: react: 19.3.0 react-dom: 19.3.0(react@19.3.0) - next@16.3.3(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0): + next@16.3.3(@babel/core@7.29.7(supports-color@8.1.1))(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0): dependencies: '@next/env': 16.3.3 '@swc/helpers': 0.5.23 @@ -18760,32 +18741,6 @@ snapshots: - '@types/node' - babel-plugin-macros - next@16.3.5(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0): - dependencies: - '@next/env': 16.3.5 - '@swc/helpers': 0.5.23 - baseline-browser-mapping: 2.11.25 - caniuse-lite: 1.0.30001810 - postcss: 8.5.23 - react: 19.3.0 - react-dom: 19.3.0(react@19.3.0) - styled-jsx: 5.1.6(@babel/core@7.29.7(supports-color@8.1.1))(babel-plugin-macros@3.1.0)(react@19.3.0) - optionalDependencies: - '@next/swc-darwin-arm64': 16.3.5 - '@next/swc-darwin-x64': 16.3.5 - '@next/swc-linux-arm64-gnu': 16.3.5 - '@next/swc-linux-arm64-musl': 16.3.5 - '@next/swc-linux-x64-gnu': 16.3.5 - '@next/swc-linux-x64-musl': 16.3.5 - '@next/swc-win32-arm64-msvc': 16.3.5 - '@next/swc-win32-x64-msvc': 16.3.5 - '@opentelemetry/api': 1.9.1 - sharp: 0.35.4(@types/node@26.6.2) - transitivePeerDependencies: - - '@babel/core' - - '@types/node' - - babel-plugin-macros - node-addon-api@7.1.1: {} node-domexception@1.0.0: {} @@ -18822,19 +18777,12 @@ snapshots: dependencies: boolbase: 1.0.0 - nuqs@2.10.1(next@16.3.5(@babel/core@7.29.7(supports-color@8.1.1))(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0): - dependencies: - '@standard-schema/spec': 1.1.0 - react: 19.3.0 - optionalDependencies: - next: 16.3.5(@babel/core@7.29.7(supports-color@8.1.1))(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) - nuqs@2.10.1(next@16.3.5(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0): dependencies: '@standard-schema/spec': 1.1.0 react: 19.3.0 optionalDependencies: - next: 16.3.5(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + next: 16.3.5(@babel/core@7.29.7(supports-color@8.1.1))(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) nypm@0.6.6: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 119da032..1c2ef2ea 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -163,6 +163,7 @@ catalog: knip: 6.37.0 limiter: 4.1.0 lucide-react: 0.525.0 + mailchecker: 6.0.21 marked: 15.0.12 nanoid: 6.0.1 nanoid-dictionary: 5.0.0 -- 2.51.2