From ab52019e79955f81a57e2559a58076ac7647ef14 Mon Sep 17 00:00:00 2001 From: shaurya Date: Tue, 4 Aug 2026 19:51:00 +0530 Subject: [PATCH 1/2] docs: update importers README paths to packages/services/src/import (#2540) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README still pointed at packages/api/src/service/import.ts, which no longer exists — that directory only holds telegram-updates.ts. The import service now lives in packages/services/src/import, and the router is a thin wrapper that re-exports previewImport/runImport from there. Repointed each reference at the file that actually holds it: the phase writers, createProvider(), and the importProviders enum in schemas.ts (the z.enum is built from that list, not declared in the router). Co-authored-by: shaurya <19599684+no-hup@users.noreply.github.com> --- packages/importers/README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/importers/README.md b/packages/importers/README.md index c134725f..22332d62 100644 --- a/packages/importers/README.md +++ b/packages/importers/README.md @@ -51,7 +51,7 @@ type ImportProvider = { A `run()` call returns an `ImportSummary` containing sequential `PhaseResult` entries. Each phase (e.g. `"monitors"`, `"page"`, `"components"`, `"incidents"`) contains an array of `ResourceResult` items with a `status` of `"created"`, `"skipped"`, or `"failed"` and an opaque `data` payload that the service layer writes to the database. -The importers package is **read-only** -- it fetches data from external APIs and maps it into OpenStatus shapes. The actual database writes happen in `packages/api/src/service/import.ts`. +The importers package is **read-only** -- it fetches data from external APIs and maps it into OpenStatus shapes. The actual database writes happen in `packages/services/src/import/phase-writers.ts`. ## How the Import Pipeline Works @@ -64,7 +64,7 @@ API Router (packages/api/src/router/import.ts) | | Calls previewImport() or runImport() v -Service Layer (packages/api/src/service/import.ts) +Service Layer (packages/services/src/import/) | | 1. Creates provider via createProvider(name) | 2. Validates API key via provider.validate() @@ -170,15 +170,15 @@ Implement `ImportProvider`: Each phase produces `ResourceResult[]` with the mapped data in `data`. The service layer reads `data` to write to the database. -The `data` shape must match what the corresponding phase writer in `packages/api/src/service/import.ts` casts it to. For incidents, this means the mapper must return `{ report, updates, sourceComponentIds }` -- even if `sourceComponentIds` is empty. +The `data` shape must match what the corresponding phase writer in `packages/services/src/import/phase-writers.ts` casts it to. For incidents, this means the mapper must return `{ report, updates, sourceComponentIds }` -- even if `sourceComponentIds` is empty. ### 6. Register the provider 1. Create `index.ts` with barrel exports 2. Add to `IMPORT_PROVIDERS` in `src/index.ts` 3. Add export paths in `package.json` -4. Add a case in `createProvider()` in `packages/api/src/service/import.ts` -5. Add the provider name to the `z.enum` in `packages/api/src/router/import.ts` +4. Add a case in `createProvider()` in `packages/services/src/import/provider.ts` +5. Add the provider name to `importProviders` in `packages/services/src/import/schemas.ts` 6. Add a radio button in `apps/dashboard/src/components/forms/components/form-import.tsx` ### 7. Write tests -- 2.51.2 From e1aed597d72eb382f71532c3982bba99d2d0e6ee Mon Sep 17 00:00:00 2001 From: Maximilian Kaske <56969857+mxkaske@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:41:59 +0200 Subject: [PATCH 2/2] chore: split workspace usage from get, tidy dashboard tRPC setup (#2542) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: split usage from get workspace * chore: trpc setup * chore: don't prefetch workspace.usage in the dashboard shell The counts are only read by the sidebar's getting-started banner, which is gated behind the `sidebar_banner_checklist` cookie — the prefetch was not. Workspaces that finished onboarding but never dismissed the banner paid five count(*) queries per shell render to render nothing. NavBannerChecklist owns the query, so it now runs once per page load and only when the banner is actually shown. Billing already fetched it this way. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- .../(dashboard)/settings/billing/client.tsx | 9 ++- .../status-pages/create/client.tsx | 3 +- apps/dashboard/src/app/onboarding/client.tsx | 6 +- .../monitors/data-table-action-bar.tsx | 2 +- .../components/forms/components/update.tsx | 4 +- .../components/forms/status-page/update.tsx | 4 +- .../components/nav/nav-banner-checklist.tsx | 16 ++-- .../src/components/nav/nav-monitors.tsx | 4 +- .../src/components/nav/nav-status-pages.tsx | 2 +- apps/dashboard/src/lib/trpc/client.tsx | 15 +--- apps/dashboard/src/lib/trpc/server.tsx | 18 ++++- packages/api/src/router/workspace.ts | 11 ++- .../src/workspace/__tests__/workspace.test.ts | 31 ++++---- packages/services/src/workspace/index.ts | 5 +- packages/services/src/workspace/list.ts | 76 ++++++++----------- packages/services/src/workspace/schemas.ts | 6 +- 16 files changed, 100 insertions(+), 112 deletions(-) diff --git a/apps/dashboard/src/app/(dashboard)/settings/billing/client.tsx b/apps/dashboard/src/app/(dashboard)/settings/billing/client.tsx index 0c86f124..c3182ef0 100644 --- a/apps/dashboard/src/app/(dashboard)/settings/billing/client.tsx +++ b/apps/dashboard/src/app/(dashboard)/settings/billing/client.tsx @@ -81,6 +81,7 @@ export function Client() { const [isPending, startTransition] = useTransition(); const [{ success }, setSearchParams] = useQueryStates(searchParamsParsers); const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); + const { data: usage } = useQuery(trpc.workspace.usage.queryOptions()); const customerPortalMutation = useMutation( trpc.stripeRouter.getUserCustomerPortal.mutationOptions({ onSuccess: (url) => { @@ -152,22 +153,22 @@ export function Client() {
{ refetch(); - // NOTE: invalidate workspace to update the usage queryClient.invalidateQueries({ - queryKey: trpc.workspace.get.queryKey(), + queryKey: trpc.workspace.usage.queryKey(), }); startTransition(() => { router.push(`/status-pages/${data.id}/edit`); diff --git a/apps/dashboard/src/app/onboarding/client.tsx b/apps/dashboard/src/app/onboarding/client.tsx index 81e2e2f7..ffc90cf6 100644 --- a/apps/dashboard/src/app/onboarding/client.tsx +++ b/apps/dashboard/src/app/onboarding/client.tsx @@ -83,14 +83,14 @@ export function Client() { const { data: monitors } = useQuery(trpc.monitor.list.queryOptions()); const { data: pages } = useQuery(trpc.page.list.queryOptions()); - // Invalidate so workspace counts (sidebar quota, plan gates) stay fresh. + // Invalidate so the getting-started counts stay fresh. const createMonitorMutation = useMutation( trpc.monitor.new.mutationOptions({ onSuccess: async () => { await setSearchParams({ monitor: "completed" }); await Promise.all([ queryClient.invalidateQueries({ - queryKey: trpc.workspace.get.queryKey(), + queryKey: trpc.workspace.usage.queryKey(), }), queryClient.invalidateQueries({ queryKey: trpc.monitor.list.queryKey(), @@ -105,7 +105,7 @@ export function Client() { await setSearchParams({ page: "completed" }); await Promise.all([ queryClient.invalidateQueries({ - queryKey: trpc.workspace.get.queryKey(), + queryKey: trpc.workspace.usage.queryKey(), }), queryClient.invalidateQueries({ queryKey: trpc.page.list.queryKey(), diff --git a/apps/dashboard/src/components/data-table/monitors/data-table-action-bar.tsx b/apps/dashboard/src/components/data-table/monitors/data-table-action-bar.tsx index 93423d78..cc6d895a 100644 --- a/apps/dashboard/src/components/data-table/monitors/data-table-action-bar.tsx +++ b/apps/dashboard/src/components/data-table/monitors/data-table-action-bar.tsx @@ -65,7 +65,7 @@ export function MonitorDataTableActionBar({ queryKey: trpc.monitor.list.queryKey(), }); queryClient.invalidateQueries({ - queryKey: trpc.workspace.get.queryKey(), + queryKey: trpc.workspace.usage.queryKey(), }); // Clear selection once deletion succeeds table.toggleAllRowsSelected(false); diff --git a/apps/dashboard/src/components/forms/components/update.tsx b/apps/dashboard/src/components/forms/components/update.tsx index 340eae0a..10d10ab6 100644 --- a/apps/dashboard/src/components/forms/components/update.tsx +++ b/apps/dashboard/src/components/forms/components/update.tsx @@ -36,9 +36,9 @@ export function FormComponentsUpdate() { // "removed" (delete) and placeholders as "new" (create). const refetchAndRemount = async (...refetches: Promise[]) => { await Promise.all(refetches); - // invalidate workspace to update the usage (getting-started checklist) + // the getting-started checklist reads these counts queryClient.invalidateQueries({ - queryKey: trpc.workspace.get.queryKey(), + queryKey: trpc.workspace.usage.queryKey(), }); setFormKey((k) => k + 1); }; diff --git a/apps/dashboard/src/components/forms/status-page/update.tsx b/apps/dashboard/src/components/forms/status-page/update.tsx index 8f3c600f..faeb9e5f 100644 --- a/apps/dashboard/src/components/forms/status-page/update.tsx +++ b/apps/dashboard/src/components/forms/status-page/update.tsx @@ -61,11 +61,9 @@ export function FormStatusPageUpdate() { trpc.page.delete.mutationOptions({ onSuccess: () => { router.push("/status-pages"); - // NOTE: invalidate workspace to update the usage queryClient.invalidateQueries({ - queryKey: trpc.workspace.get.queryKey(), + queryKey: trpc.workspace.usage.queryKey(), }); - // NOTE: invalidate status page list to update the usage queryClient.invalidateQueries({ queryKey: trpc.page.list.queryKey(), }); diff --git a/apps/dashboard/src/components/nav/nav-banner-checklist.tsx b/apps/dashboard/src/components/nav/nav-banner-checklist.tsx index 6f7820d3..b08269f5 100644 --- a/apps/dashboard/src/components/nav/nav-banner-checklist.tsx +++ b/apps/dashboard/src/components/nav/nav-banner-checklist.tsx @@ -19,18 +19,20 @@ export function NavBannerChecklist({ handleClose: () => void; }) { const trpc = useTRPC(); - const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); + const { data: usage } = useQuery(trpc.workspace.usage.queryOptions()); const { data: pages } = useQuery(trpc.page.list.queryOptions()); - if (!workspace) return null; + // Hide until the counts land — every item would read as unchecked and the + // banner would flash "0/5" before correcting itself. + if (!usage) return null; const onlyPage = pages?.length === 1 ? pages[0] : undefined; - const hasMonitors = (workspace.usage?.monitors ?? 0) > 0; - const hasStatusPages = (workspace.usage?.pages ?? 0) > 0; - const hasPageComponents = (workspace.usage?.pageComponents ?? 0) > 0; - const hasNotifications = (workspace.usage?.notifications ?? 0) > 0; - const hasStatusReports = (workspace.usage?.statusReports ?? 0) > 0; + const hasMonitors = usage.monitors > 0; + const hasStatusPages = usage.pages > 0; + const hasPageComponents = usage.pageComponents > 0; + const hasNotifications = usage.notifications > 0; + const hasStatusReports = usage.statusReports > 0; const items = [ { diff --git a/apps/dashboard/src/components/nav/nav-monitors.tsx b/apps/dashboard/src/components/nav/nav-monitors.tsx index a8618ba1..8464b927 100644 --- a/apps/dashboard/src/components/nav/nav-monitors.tsx +++ b/apps/dashboard/src/components/nav/nav-monitors.tsx @@ -58,7 +58,7 @@ export function NavMonitors() { onSuccess: () => { refetch(); queryClient.invalidateQueries({ - queryKey: trpc.workspace.get.queryKey(), + queryKey: trpc.workspace.usage.queryKey(), }); }, }), @@ -68,7 +68,7 @@ export function NavMonitors() { onSuccess: (newMonitor) => { refetch(); queryClient.invalidateQueries({ - queryKey: trpc.workspace.get.queryKey(), + queryKey: trpc.workspace.usage.queryKey(), }); router.push(`/monitors/${newMonitor.id}`); }, diff --git a/apps/dashboard/src/components/nav/nav-status-pages.tsx b/apps/dashboard/src/components/nav/nav-status-pages.tsx index 177c2b86..68ac1bda 100644 --- a/apps/dashboard/src/components/nav/nav-status-pages.tsx +++ b/apps/dashboard/src/components/nav/nav-status-pages.tsx @@ -54,7 +54,7 @@ export function NavStatusPages() { onSuccess: () => { refetch(); queryClient.invalidateQueries({ - queryKey: trpc.workspace.get.queryKey(), + queryKey: trpc.workspace.usage.queryKey(), }); }, }), diff --git a/apps/dashboard/src/lib/trpc/client.tsx b/apps/dashboard/src/lib/trpc/client.tsx index 6a98c272..653d8b48 100644 --- a/apps/dashboard/src/lib/trpc/client.tsx +++ b/apps/dashboard/src/lib/trpc/client.tsx @@ -1,27 +1,18 @@ "use client"; import type { AppRouter } from "@openstatus/api"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { QueryClient } from "@tanstack/react-query"; +import { QueryClientProvider } from "@tanstack/react-query"; import { createTRPCClient } from "@trpc/client"; import { createTRPCContext } from "@trpc/tanstack-react-query"; import { useState } from "react"; +import { makeQueryClient } from "@/lib/trpc/query-client"; import { endingLink, sentryLoggerLink } from "@/lib/trpc/shared"; export const { TRPCProvider, useTRPC, useTRPCClient } = createTRPCContext(); -function makeQueryClient() { - return new QueryClient({ - defaultOptions: { - queries: { - // With SSR, we usually want to set some default staleTime - // above 0 to avoid refetching immediately on the client - staleTime: 60 * 1000, - }, - }, - }); -} let browserQueryClient: QueryClient | undefined = undefined; function getQueryClient() { if (typeof window === "undefined") { diff --git a/apps/dashboard/src/lib/trpc/server.tsx b/apps/dashboard/src/lib/trpc/server.tsx index 2356eb07..6965fd79 100644 --- a/apps/dashboard/src/lib/trpc/server.tsx +++ b/apps/dashboard/src/lib/trpc/server.tsx @@ -2,7 +2,7 @@ import "server-only"; import type { AppRouter } from "@openstatus/api"; import { HydrationBoundary } from "@tanstack/react-query"; import { dehydrate } from "@tanstack/react-query"; -import { TRPCClientError, createTRPCClient } from "@trpc/client"; +import { createTRPCClient } from "@trpc/client"; import { type ResolverDef, type TRPCQueryOptions, @@ -91,6 +91,18 @@ export function batchPrefetch>>( } } +// Duck-typed rather than `instanceof TRPCClientError`: that check fails across +// bundle boundaries, and an in-process caller throws `TRPCError` (bare `code`, +// no `data`). tRPC matches on the same fields internally for this reason. +function isNotFoundError(error: unknown): boolean { + if (typeof error !== "object" || error === null) return false; + const { code, data } = error as { + code?: unknown; + data?: { code?: unknown } | null; + }; + return code === "NOT_FOUND" || data?.code === "NOT_FOUND"; +} + /** * Fetches a query and calls `notFound()` if the server returns NOT_FOUND. * Use this for gating queries in layouts where the resource must exist. @@ -104,9 +116,7 @@ export async function fetchQueryOrNotFound< ReturnType unknown>> >; } catch (error) { - if (error instanceof TRPCClientError && error.data?.code === "NOT_FOUND") { - notFound(); - } + if (isNotFoundError(error)) notFound(); throw error; } } diff --git a/packages/api/src/router/workspace.ts b/packages/api/src/router/workspace.ts index dc62ff93..ec0fd9ce 100644 --- a/packages/api/src/router/workspace.ts +++ b/packages/api/src/router/workspace.ts @@ -1,6 +1,6 @@ import { Events } from "@openstatus/analytics"; import { - getWorkspaceWithUsage, + getWorkspaceUsage, updateWorkspaceName, } from "@openstatus/services/workspace"; import { z } from "zod"; @@ -9,9 +9,14 @@ import { toServiceCtx, toTRPCError } from "../service-adapter"; import { createTRPCRouter, protectedProcedure } from "../trpc"; export const workspaceRouter = createTRPCRouter({ - get: protectedProcedure.query(async ({ ctx }) => { + // 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), + + usage: protectedProcedure.query(async ({ ctx }) => { try { - return await getWorkspaceWithUsage({ ctx: toServiceCtx(ctx) }); + return await getWorkspaceUsage({ ctx: toServiceCtx(ctx) }); } catch (err) { toTRPCError(err); } diff --git a/packages/services/src/workspace/__tests__/workspace.test.ts b/packages/services/src/workspace/__tests__/workspace.test.ts index d000fedd..e79a8669 100644 --- a/packages/services/src/workspace/__tests__/workspace.test.ts +++ b/packages/services/src/workspace/__tests__/workspace.test.ts @@ -26,7 +26,7 @@ import { ForbiddenError } from "../../errors"; import { getWorkspace, getWorkspaceByStripeId, - getWorkspaceWithUsage, + getWorkspaceUsage, listWorkspaces, updateWorkspaceName, updateWorkspacePlan, @@ -51,19 +51,18 @@ describe("getWorkspace", () => { }); }); -describe("getWorkspaceWithUsage", () => { - test("attaches a zero-or-positive usage block", async () => { +describe("getWorkspaceUsage", () => { + test("returns a zero-or-positive count for every key", async () => { await withTestTransaction(async (tx) => { - const result = await getWorkspaceWithUsage({ + const result = await getWorkspaceUsage({ ctx: { ...teamCtx, db: tx }, }); - expect(result.id).toBe(teamCtx.workspace.id); // Iterate the usage object *before* any `toMatchObject` call — // the `toMatchObject` implementation mutates the received // object in place, replacing number fields with the // `expect.any(Number)` asymmetric-matcher stub on the expected - // side. Subsequent reads of `result.usage.` then return the + // side. Subsequent reads of `result.` then return the // matcher object (typeof "object"), not the original count. Do // the value-shape + non-negative check first. for (const key of [ @@ -74,13 +73,13 @@ describe("getWorkspaceWithUsage", () => { "statusReports", "checks", ] as const) { - const value = result.usage[key]; + const value = result[key]; expect(typeof value).toBe("number"); if (typeof value === "number") { expect(value).toBeGreaterThanOrEqual(0); } } - expect(result.usage.checks).toBe(0); + expect(result.checks).toBe(0); }); }); @@ -91,10 +90,10 @@ describe("getWorkspaceWithUsage", () => { status: "investigating", title: "svc-ws-test-status-report", }); - const result = await getWorkspaceWithUsage({ + const result = await getWorkspaceUsage({ ctx: { ...teamCtx, db: tx }, }); - expect(result.usage.statusReports).toBeGreaterThanOrEqual(1); + expect(result.statusReports).toBeGreaterThanOrEqual(1); }); }); @@ -104,7 +103,7 @@ describe("getWorkspaceWithUsage", () => { await withTestTransaction(async (tx) => { const ctx = { ...teamCtx, db: tx }; const workspaceId = teamCtx.workspace.id; - const before = (await getWorkspaceWithUsage({ ctx })).usage; + const before = await getWorkspaceUsage({ ctx }); await tx.insert(monitor).values([ { workspaceId, url: "https://a.example.dev", name: "svc-ws-usage-1" }, @@ -149,7 +148,7 @@ describe("getWorkspaceWithUsage", () => { title: "svc-ws-usage-report", }); - const after = (await getWorkspaceWithUsage({ ctx })).usage; + const after = await getWorkspaceUsage({ ctx }); expect(after.monitors).toBe(before.monitors + 2); expect(after.notifications).toBe(before.notifications + 1); expect(after.pages).toBe(before.pages + 1); @@ -163,7 +162,7 @@ describe("getWorkspaceWithUsage", () => { await withTestTransaction(async (tx) => { const ctx = { ...teamCtx, db: tx }; const workspaceId = teamCtx.workspace.id; - const before = (await getWorkspaceWithUsage({ ctx })).usage.monitors; + const before = (await getWorkspaceUsage({ ctx })).monitors; await tx.insert(monitor).values({ workspaceId, @@ -172,7 +171,7 @@ describe("getWorkspaceWithUsage", () => { deletedAt: new Date(), }); - const after = (await getWorkspaceWithUsage({ ctx })).usage.monitors; + const after = (await getWorkspaceUsage({ ctx })).monitors; expect(after).toBe(before); }); }); @@ -180,7 +179,7 @@ describe("getWorkspaceWithUsage", () => { test("does not count another workspace's rows", async () => { await withTestTransaction(async (tx) => { const ctx = { ...teamCtx, db: tx }; - const before = (await getWorkspaceWithUsage({ ctx })).usage; + const before = await getWorkspaceUsage({ ctx }); const [foreign] = await tx .insert(workspace) @@ -203,7 +202,7 @@ describe("getWorkspaceWithUsage", () => { title: "svc-ws-usage-foreign-report", }); - const after = (await getWorkspaceWithUsage({ ctx })).usage; + const after = await getWorkspaceUsage({ ctx }); expect(after.monitors).toBe(before.monitors); expect(after.statusReports).toBe(before.statusReports); }); diff --git a/packages/services/src/workspace/index.ts b/packages/services/src/workspace/index.ts index 4e8e953f..04d385a1 100644 --- a/packages/services/src/workspace/index.ts +++ b/packages/services/src/workspace/index.ts @@ -1,17 +1,16 @@ export { getWorkspace, getWorkspaceByStripeId, - getWorkspaceWithUsage, + getWorkspaceUsage, listWorkspaces, type WorkspaceUsage, - type WorkspaceWithUsage, } from "./list"; export { downgradeWorkspaceToFree } from "./downgrade"; export { updateWorkspaceName, updateWorkspacePlan } from "./update"; export { GetWorkspaceByStripeIdInput, GetWorkspaceInput, - GetWorkspaceWithUsageInput, + GetWorkspaceUsageInput, ListWorkspacesInput, UpdateWorkspaceNameInput, UpdateWorkspacePlanInput, diff --git a/packages/services/src/workspace/list.ts b/packages/services/src/workspace/list.ts index 094d8744..3f45e3fa 100644 --- a/packages/services/src/workspace/list.ts +++ b/packages/services/src/workspace/list.ts @@ -15,7 +15,7 @@ import { NotFoundError } from "../errors"; import type { Workspace } from "../types"; import { GetWorkspaceByStripeIdInput, - type GetWorkspaceWithUsageInput, + type GetWorkspaceUsageInput, ListWorkspacesInput, } from "./schemas"; @@ -33,8 +33,6 @@ export type WorkspaceUsage = { checks: number; }; -export type WorkspaceWithUsage = Workspace & { usage: WorkspaceUsage }; - /** Load the workspace the caller is scoped to. */ export async function getWorkspace(args: { ctx: ServiceContext; @@ -55,32 +53,12 @@ export async function getWorkspace(args: { return selectWorkspaceSchema.parse(result); } -/** - * Workspace plus the four usage counts the dashboard surfaces alongside plan - * limits. Active monitors only (`deletedAt IS NULL`); notifications / pages / - * page-components are unconditional counts scoped to the workspace. - */ -export async function getWorkspaceWithUsage(args: { - ctx: ServiceContext; - input?: GetWorkspaceWithUsageInput; -}): Promise { - const { ctx } = args; - const db = ctx.db ?? defaultDb; - const workspaceId = ctx.workspace.id; +// Counts, not rows: the previous relational read materialized every page, +// component, monitor (with its config blobs) and notification just to call +// `.length` on them. All five are single-table and index-covered. +function usageCountQueries(db: DB, workspaceId: number) { const total = sql`count(*)`; - - // Counts, not rows: the previous relational read materialized every page, - // component, monitor (with its config blobs) and notification just to call - // `.length` on them. - const [ - workspaceRows, - monitorRows, - notificationRows, - pageRows, - pageComponentRows, - statusReportRows, - ] = await batchReads(db, [ - db.select().from(workspace).where(eq(workspace.id, workspaceId)), + return [ db .select({ count: total }) .from(monitor) @@ -103,28 +81,36 @@ export async function getWorkspaceWithUsage(args: { .select({ count: total }) .from(statusReport) .where(eq(statusReport.workspaceId, workspaceId)), - ]); - - const result = workspaceRows[0]; - - // Same guard as `getWorkspace` — unreachable in practice (workspace - // resolved upstream) but keeps the error shape consistent with every - // other service, rather than letting `parse(undefined)` surface as - // a `ZodError`. - if (!result) throw new NotFoundError("workspace", workspaceId); - - const usage: WorkspaceUsage = { - monitors: monitorRows[0]?.count ?? 0, - notifications: notificationRows[0]?.count ?? 0, - pages: pageRows[0]?.count ?? 0, - pageComponents: pageComponentRows[0]?.count ?? 0, - statusReports: statusReportRows[0]?.count ?? 0, + ] as const; +} + +function toUsage(rows: { count: number }[][]): WorkspaceUsage { + const [monitors, notifications, pages, pageComponents, statusReports] = rows; + return { + monitors: monitors?.[0]?.count ?? 0, + notifications: notifications?.[0]?.count ?? 0, + pages: pages?.[0]?.count ?? 0, + pageComponents: pageComponents?.[0]?.count ?? 0, + statusReports: statusReports?.[0]?.count ?? 0, // Parity with the legacy router — checks usage was previously commented // out pending a real source and left as 0. Preserved here. checks: 0, }; +} + +/** + * The usage counts the dashboard surfaces alongside plan limits. Active + * monitors only (`deletedAt IS NULL`); notifications / pages / page-components + * are unconditional counts scoped to the workspace. + */ +export async function getWorkspaceUsage(args: { + ctx: ServiceContext; + input?: GetWorkspaceUsageInput; +}): Promise { + const { ctx } = args; + const db = ctx.db ?? defaultDb; - return { ...selectWorkspaceSchema.parse(result), usage }; + return toUsage(await batchReads(db, usageCountQueries(db, ctx.workspace.id))); } /** diff --git a/packages/services/src/workspace/schemas.ts b/packages/services/src/workspace/schemas.ts index 33917227..4a80811f 100644 --- a/packages/services/src/workspace/schemas.ts +++ b/packages/services/src/workspace/schemas.ts @@ -5,10 +5,8 @@ import { z } from "zod"; export const GetWorkspaceInput = z.object({}).strict(); export type GetWorkspaceInput = z.infer; -export const GetWorkspaceWithUsageInput = z.object({}).strict(); -export type GetWorkspaceWithUsageInput = z.infer< - typeof GetWorkspaceWithUsageInput ->; +export const GetWorkspaceUsageInput = z.object({}).strict(); +export type GetWorkspaceUsageInput = z.infer; export const ListWorkspacesInput = z.object({ userId: z.number().int() }); export type ListWorkspacesInput = z.infer; -- 2.51.2