diff --git a/apps/dashboard/src/components/forms/components/update.tsx b/apps/dashboard/src/components/forms/components/update.tsx index fb656973b..2055371b5 100644 --- a/apps/dashboard/src/components/forms/components/update.tsx +++ b/apps/dashboard/src/components/forms/components/update.tsx @@ -2,6 +2,7 @@ import { FormComponents } from "@/components/forms/components/form-components"; import { useTRPC } from "@/lib/trpc/client"; +import type { PageConfiguration } from "@openstatus/db/src/schema"; import { useMutation, useQuery } from "@tanstack/react-query"; import { useParams } from "next/navigation"; import { useState } from "react"; @@ -119,14 +120,22 @@ export function FormComponentsUpdate() { onSubmit={async (values) => { await updatePageConfigurationMutation.mutateAsync({ id: Number.parseInt(id), + // `FormConfiguration` keeps its internal values loose + // (strings from radios/selects). Casts use `PageConfiguration` + // derived from the service input so the enum members stay in + // sync with `pageConfigurationSchema` — an invalid submit + // surfaces as a zod error from the router. configuration: { uptime: typeof values.configuration.uptime === "boolean" ? values.configuration.uptime : values.configuration.uptime === "true", - value: values.configuration.value ?? "duration", - type: values.configuration.type ?? "absolute", - theme: values.configuration.theme ?? undefined, + value: (values.configuration.value ?? + "duration") as PageConfiguration["value"], + type: (values.configuration.type ?? + "absolute") as PageConfiguration["type"], + theme: (values.configuration.theme ?? + undefined) as PageConfiguration["theme"], }, }); }} diff --git a/apps/dashboard/src/components/forms/status-page/update.tsx b/apps/dashboard/src/components/forms/status-page/update.tsx index 5b460a4ac..2cc590459 100644 --- a/apps/dashboard/src/components/forms/status-page/update.tsx +++ b/apps/dashboard/src/components/forms/status-page/update.tsx @@ -2,6 +2,7 @@ import { Link } from "@/components/common/link"; import { Note } from "@/components/common/note"; import { FormCardGroup } from "@/components/forms/form-card"; import { useTRPC } from "@/lib/trpc/client"; +import type { ThemeKey } from "@openstatus/theme-store"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Info } from "lucide-react"; import { useParams, useRouter } from "next/navigation"; @@ -145,7 +146,14 @@ export function FormStatusPageUpdate() { await updatePageAppearanceMutation.mutateAsync({ id: Number.parseInt(id), forceTheme: values.forceTheme, - configuration: values.configuration, + // `FormAppearance` keeps its internal `theme` value as a + // loose `string`; cast to the canonical `ThemeKey` from + // `@openstatus/theme-store` (same source the service input + // enum is derived from). Invalid submits are caught by the + // router's zod parse. + configuration: { + theme: values.configuration.theme as ThemeKey, + }, }); }} /> diff --git a/apps/server/package.json b/apps/server/package.json index 0b5c88a46..58f4b4c22 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -42,6 +42,7 @@ "@openstatus/error": "workspace:*", "@openstatus/proto": "workspace:*", "@openstatus/regions": "workspace:*", + "@openstatus/theme-store": "workspace:*", "@openstatus/tinybird": "workspace:*", "@openstatus/tracker": "workspace:*", "@openstatus/upstash": "workspace:*", diff --git a/apps/server/src/routes/rpc/services/health/index.ts b/apps/server/src/routes/rpc/handlers/health/index.ts similarity index 100% rename from apps/server/src/routes/rpc/services/health/index.ts rename to apps/server/src/routes/rpc/handlers/health/index.ts diff --git a/apps/server/src/routes/rpc/services/maintenance/__tests__/maintenance.test.ts b/apps/server/src/routes/rpc/handlers/maintenance/__tests__/maintenance.test.ts similarity index 98% rename from apps/server/src/routes/rpc/services/maintenance/__tests__/maintenance.test.ts rename to apps/server/src/routes/rpc/handlers/maintenance/__tests__/maintenance.test.ts index 24fa218bd..96e885dbb 100644 --- a/apps/server/src/routes/rpc/services/maintenance/__tests__/maintenance.test.ts +++ b/apps/server/src/routes/rpc/handlers/maintenance/__tests__/maintenance.test.ts @@ -375,7 +375,11 @@ describe("MaintenanceService.CreateMaintenance", () => { expect(res.status).toBe(400); const data = await res.json(); - expect(data.message).toContain("does not match the page ID"); + // Service throws `ConflictError("Selected components belong to page + // X, which does not match the maintenance's page Y.")` — wording + // shifted during the services migration; loosened to the stable + // fragment. + expect(data.message).toContain("does not match"); }); test("creates maintenance when pageId matches component page", async () => { @@ -576,7 +580,7 @@ describe("MaintenanceService.CreateMaintenance", () => { expect(res.status).toBe(400); const data = await res.json(); - expect(data.message).toContain("Start time (from) must be before end time"); + expect(data.message).toContain("End date must be after start date"); }); test("returns error for non-existent page", async () => { @@ -598,7 +602,11 @@ describe("MaintenanceService.CreateMaintenance", () => { expect(res.status).toBe(404); const data = await res.json(); - expect(data.message).toContain("Page not found"); + // Service throws `NotFoundError("page", id)` → `"page not found"` + // (lowercase `page`). Pre-migration handler emitted + // `"Page not found"`; assertion loosened to `"not found"` to cover + // both. + expect(data.message).toContain("not found"); }); }); @@ -1020,7 +1028,7 @@ describe("MaintenanceService.UpdateMaintenance", () => { expect(res.status).toBe(400); const data = await res.json(); - expect(data.message).toContain("Start time (from) must be before end time"); + expect(data.message).toContain("End date must be after start date"); }); test("title-only update preserves component associations and pageId", async () => { diff --git a/apps/server/src/routes/rpc/services/maintenance/converters.ts b/apps/server/src/routes/rpc/handlers/maintenance/converters.ts similarity index 100% rename from apps/server/src/routes/rpc/services/maintenance/converters.ts rename to apps/server/src/routes/rpc/handlers/maintenance/converters.ts diff --git a/apps/server/src/routes/rpc/services/maintenance/errors.ts b/apps/server/src/routes/rpc/handlers/maintenance/errors.ts similarity index 64% rename from apps/server/src/routes/rpc/services/maintenance/errors.ts rename to apps/server/src/routes/rpc/handlers/maintenance/errors.ts index 28e86ec0a..ab1a6d184 100644 --- a/apps/server/src/routes/rpc/services/maintenance/errors.ts +++ b/apps/server/src/routes/rpc/handlers/maintenance/errors.ts @@ -8,10 +8,6 @@ export const ErrorReason = { MAINTENANCE_ID_REQUIRED: "MAINTENANCE_ID_REQUIRED", MAINTENANCE_CREATE_FAILED: "MAINTENANCE_CREATE_FAILED", MAINTENANCE_UPDATE_FAILED: "MAINTENANCE_UPDATE_FAILED", - PAGE_COMPONENT_NOT_FOUND: "PAGE_COMPONENT_NOT_FOUND", - PAGE_COMPONENTS_MIXED_PAGES: "PAGE_COMPONENTS_MIXED_PAGES", - PAGE_ID_COMPONENT_MISMATCH: "PAGE_ID_COMPONENT_MISMATCH", - PAGE_NOT_FOUND: "PAGE_NOT_FOUND", INVALID_DATE_FORMAT: "INVALID_DATE_FORMAT", INVALID_DATE_RANGE: "INVALID_DATE_RANGE", } as const; @@ -91,45 +87,6 @@ export function maintenanceUpdateFailedError( ); } -/** - * Creates a "page component not found" error. - */ -export function pageComponentNotFoundError( - pageComponentId: string, -): ConnectError { - return createError( - "Page component not found", - Code.NotFound, - ErrorReason.PAGE_COMPONENT_NOT_FOUND, - { "page-component-id": pageComponentId }, - ); -} - -/** - * Creates a "page components from mixed pages" error. - */ -export function pageComponentsMixedPagesError(): ConnectError { - return createError( - "All page components must belong to the same page", - Code.InvalidArgument, - ErrorReason.PAGE_COMPONENTS_MIXED_PAGES, - ); -} - -/** - * Creates a "page not found" error. - */ -export function pageNotFoundError(pageId: string): ConnectError { - return createError( - "Page not found", - Code.NotFound, - ErrorReason.PAGE_NOT_FOUND, - { - "page-id": pageId, - }, - ); -} - /** * Creates an "invalid date format" error. */ @@ -153,21 +110,3 @@ export function invalidDateRangeError(from: string, to: string): ConnectError { { from, to }, ); } - -/** - * Creates a "page ID and component page mismatch" error. - */ -export function pageIdComponentMismatchError( - providedPageId: string, - componentPageId: string, -): ConnectError { - return createError( - `Page ID ${providedPageId} does not match the page ID ${componentPageId} of the provided components`, - Code.InvalidArgument, - ErrorReason.PAGE_ID_COMPONENT_MISMATCH, - { - "provided-page-id": providedPageId, - "component-page-id": componentPageId, - }, - ); -} diff --git a/apps/server/src/routes/rpc/handlers/maintenance/index.ts b/apps/server/src/routes/rpc/handlers/maintenance/index.ts new file mode 100644 index 000000000..ac2721897 --- /dev/null +++ b/apps/server/src/routes/rpc/handlers/maintenance/index.ts @@ -0,0 +1,204 @@ +import { Code, ConnectError, type ServiceImpl } from "@connectrpc/connect"; +import type { MaintenanceService } from "@openstatus/proto/maintenance/v1"; +import { + createMaintenance, + deleteMaintenance, + getMaintenance, + listMaintenances, + notifyMaintenance, + updateMaintenance, +} from "@openstatus/services/maintenance"; + +import { toConnectError, toServiceCtx } from "../../adapter"; +import { getRpcContext } from "../../interceptors"; +import { + dbMaintenanceToProto, + dbMaintenanceToProtoSummary, +} from "./converters"; +import { invalidDateFormatError, maintenanceIdRequiredError } from "./errors"; + +function parseDate(dateString: string): Date { + const date = new Date(dateString); + if (Number.isNaN(date.getTime())) { + throw invalidDateFormatError(dateString); + } + return date; +} + +function parsePageComponentIds(ids: ReadonlyArray): number[] { + return ids.map((id) => { + // `Number.parseInt(id, 10)` rather than `Number(id)` — `Number("")` + // is `0` (finite!), so the previous guard silently coerced an + // empty-string id into component 0 and the service's + // `NotFoundError` ended up as a misleading 404 on the wire. + // `parseInt` returns NaN for `""`, which fails the finite check + // and surfaces the correct `InvalidArgument` here. + const n = Number.parseInt(id, 10); + if (!Number.isFinite(n)) { + throw new ConnectError( + `Invalid page component id: "${id}"`, + Code.InvalidArgument, + ); + } + return n; + }); +} + +export const maintenanceServiceImpl: ServiceImpl = { + async createMaintenance(req, ctx) { + try { + const rpcCtx = getRpcContext(ctx); + const sCtx = toServiceCtx(rpcCtx); + + const record = await createMaintenance({ + ctx: sCtx, + input: { + title: req.title, + message: req.message, + from: parseDate(req.from), + to: parseDate(req.to), + pageId: Number(req.pageId), + pageComponentIds: parsePageComponentIds(req.pageComponentIds), + }, + }); + + if (req.notify) { + // Intentional non-atomic step: `createMaintenance` already + // committed the row, so a throw here surfaces as 500 to the + // Connect client and a retry can produce a duplicate + // maintenance. We accept that — wrapping the dispatch in the + // write transaction would hold a row-level lock across the + // external notification fan-out, which is a worse trade. + // `status-report` takes the same shape (create → commit → + // notify). The tRPC path avoids this by splitting into two + // explicit client calls (`maintenance.new` + + // `email.sendMaintenance`); the Connect surface doesn't have + // that affordance. + await notifyMaintenance({ + ctx: sCtx, + input: { maintenanceId: record.id }, + }); + } + + // Re-fetch so the response reflects stored IDs (dedup + validation + // applied), not the raw request. Matches updateMaintenance and + // createNotification. + const full = await getMaintenance({ + ctx: sCtx, + input: { id: record.id }, + }); + return { + maintenance: dbMaintenanceToProto( + full, + full.pageComponentIds.map(String), + ), + }; + } catch (err) { + toConnectError(err); + } + }, + + async getMaintenance(req, ctx) { + try { + const rpcCtx = getRpcContext(ctx); + if (!req.id || req.id.trim() === "") { + throw maintenanceIdRequiredError(); + } + const full = await getMaintenance({ + ctx: toServiceCtx(rpcCtx), + input: { id: Number(req.id) }, + }); + return { + maintenance: dbMaintenanceToProto( + full, + full.pageComponentIds.map(String), + ), + }; + } catch (err) { + toConnectError(err); + } + }, + + async listMaintenances(req, ctx) { + try { + const rpcCtx = getRpcContext(ctx); + + const pageId = + req.pageId && req.pageId.trim() !== "" ? Number(req.pageId) : undefined; + + const { items, totalSize } = await listMaintenances({ + ctx: toServiceCtx(rpcCtx), + input: { + limit: Math.min(Math.max(req.limit ?? 50, 1), 100), + offset: req.offset ?? 0, + pageId, + order: "desc", + }, + }); + + return { + maintenances: items.map((r) => + dbMaintenanceToProtoSummary(r, r.pageComponentIds.map(String)), + ), + totalSize, + }; + } catch (err) { + toConnectError(err); + } + }, + + async updateMaintenance(req, ctx) { + try { + const rpcCtx = getRpcContext(ctx); + const sCtx = toServiceCtx(rpcCtx); + if (!req.id || req.id.trim() === "") { + throw maintenanceIdRequiredError(); + } + + const id = Number(req.id); + await updateMaintenance({ + ctx: sCtx, + input: { + id, + title: + req.title !== undefined && req.title !== "" ? req.title : undefined, + message: + req.message !== undefined && req.message !== "" + ? req.message + : undefined, + from: req.from ? parseDate(req.from) : undefined, + to: req.to ? parseDate(req.to) : undefined, + pageComponentIds: req.updatePageComponentIds + ? parsePageComponentIds(req.pageComponentIds) + : undefined, + }, + }); + + const full = await getMaintenance({ ctx: sCtx, input: { id } }); + return { + maintenance: dbMaintenanceToProto( + full, + full.pageComponentIds.map(String), + ), + }; + } catch (err) { + toConnectError(err); + } + }, + + async deleteMaintenance(req, ctx) { + try { + const rpcCtx = getRpcContext(ctx); + if (!req.id || req.id.trim() === "") { + throw maintenanceIdRequiredError(); + } + await deleteMaintenance({ + ctx: toServiceCtx(rpcCtx), + input: { id: Number(req.id) }, + }); + return { success: true }; + } catch (err) { + toConnectError(err); + } + }, +}; diff --git a/apps/server/src/routes/rpc/services/monitor/__tests__/monitor.test.ts b/apps/server/src/routes/rpc/handlers/monitor/__tests__/monitor.test.ts similarity index 100% rename from apps/server/src/routes/rpc/services/monitor/__tests__/monitor.test.ts rename to apps/server/src/routes/rpc/handlers/monitor/__tests__/monitor.test.ts diff --git a/apps/server/src/routes/rpc/services/monitor/converters/assertions.ts b/apps/server/src/routes/rpc/handlers/monitor/converters/assertions.ts similarity index 100% rename from apps/server/src/routes/rpc/services/monitor/converters/assertions.ts rename to apps/server/src/routes/rpc/handlers/monitor/converters/assertions.ts diff --git a/apps/server/src/routes/rpc/services/monitor/converters/comparators.ts b/apps/server/src/routes/rpc/handlers/monitor/converters/comparators.ts similarity index 100% rename from apps/server/src/routes/rpc/services/monitor/converters/comparators.ts rename to apps/server/src/routes/rpc/handlers/monitor/converters/comparators.ts diff --git a/apps/server/src/routes/rpc/services/monitor/converters/defaults.ts b/apps/server/src/routes/rpc/handlers/monitor/converters/defaults.ts similarity index 100% rename from apps/server/src/routes/rpc/services/monitor/converters/defaults.ts rename to apps/server/src/routes/rpc/handlers/monitor/converters/defaults.ts diff --git a/apps/server/src/routes/rpc/services/monitor/converters/enums.ts b/apps/server/src/routes/rpc/handlers/monitor/converters/enums.ts similarity index 100% rename from apps/server/src/routes/rpc/services/monitor/converters/enums.ts rename to apps/server/src/routes/rpc/handlers/monitor/converters/enums.ts diff --git a/apps/server/src/routes/rpc/services/monitor/converters/headers.ts b/apps/server/src/routes/rpc/handlers/monitor/converters/headers.ts similarity index 100% rename from apps/server/src/routes/rpc/services/monitor/converters/headers.ts rename to apps/server/src/routes/rpc/handlers/monitor/converters/headers.ts diff --git a/apps/server/src/routes/rpc/services/monitor/converters/index.ts b/apps/server/src/routes/rpc/handlers/monitor/converters/index.ts similarity index 100% rename from apps/server/src/routes/rpc/services/monitor/converters/index.ts rename to apps/server/src/routes/rpc/handlers/monitor/converters/index.ts diff --git a/apps/server/src/routes/rpc/services/monitor/converters/monitors.ts b/apps/server/src/routes/rpc/handlers/monitor/converters/monitors.ts similarity index 100% rename from apps/server/src/routes/rpc/services/monitor/converters/monitors.ts rename to apps/server/src/routes/rpc/handlers/monitor/converters/monitors.ts diff --git a/apps/server/src/routes/rpc/services/monitor/converters/regions.ts b/apps/server/src/routes/rpc/handlers/monitor/converters/regions.ts similarity index 100% rename from apps/server/src/routes/rpc/services/monitor/converters/regions.ts rename to apps/server/src/routes/rpc/handlers/monitor/converters/regions.ts diff --git a/apps/server/src/routes/rpc/services/monitor/errors.ts b/apps/server/src/routes/rpc/handlers/monitor/errors.ts similarity index 100% rename from apps/server/src/routes/rpc/services/monitor/errors.ts rename to apps/server/src/routes/rpc/handlers/monitor/errors.ts diff --git a/apps/server/src/routes/rpc/services/monitor/index.ts b/apps/server/src/routes/rpc/handlers/monitor/index.ts similarity index 100% rename from apps/server/src/routes/rpc/services/monitor/index.ts rename to apps/server/src/routes/rpc/handlers/monitor/index.ts diff --git a/apps/server/src/routes/rpc/services/monitor/limits.ts b/apps/server/src/routes/rpc/handlers/monitor/limits.ts similarity index 100% rename from apps/server/src/routes/rpc/services/monitor/limits.ts rename to apps/server/src/routes/rpc/handlers/monitor/limits.ts diff --git a/apps/server/src/routes/rpc/services/monitor/validators.ts b/apps/server/src/routes/rpc/handlers/monitor/validators.ts similarity index 100% rename from apps/server/src/routes/rpc/services/monitor/validators.ts rename to apps/server/src/routes/rpc/handlers/monitor/validators.ts diff --git a/apps/server/src/routes/rpc/services/notification/__tests__/notification.test.ts b/apps/server/src/routes/rpc/handlers/notification/__tests__/notification.test.ts similarity index 100% rename from apps/server/src/routes/rpc/services/notification/__tests__/notification.test.ts rename to apps/server/src/routes/rpc/handlers/notification/__tests__/notification.test.ts diff --git a/apps/server/src/routes/rpc/services/notification/converters.ts b/apps/server/src/routes/rpc/handlers/notification/converters.ts similarity index 94% rename from apps/server/src/routes/rpc/services/notification/converters.ts rename to apps/server/src/routes/rpc/handlers/notification/converters.ts index ad23e2406..9ffe3872a 100644 --- a/apps/server/src/routes/rpc/services/notification/converters.ts +++ b/apps/server/src/routes/rpc/handlers/notification/converters.ts @@ -1,4 +1,5 @@ import { create } from "@bufbuild/protobuf"; +import { Code, ConnectError } from "@connectrpc/connect"; import type { NotificationProvider as DBNotificationProvider } from "@openstatus/db/src/schema"; import type { Notification, @@ -99,7 +100,11 @@ export function validateProviderDataConsistency( } /** - * Maps proto NotificationProvider enum to DB provider string. + * Maps proto NotificationProvider enum to DB provider string. Throws + * `InvalidArgument` for unknown/unspecified values — silently falling + * back to `"email"` (the previous default) would create the wrong + * channel type for a misconfigured client and persist it, which is + * much worse than a loud error at the boundary. */ export function protoProviderToDb( provider: NotificationProvider, @@ -118,7 +123,14 @@ export function protoProviderToDb( [NotificationProvider.WEBHOOK]: "webhook", [NotificationProvider.WHATSAPP]: "whatsapp", }; - return mapping[provider] ?? "email"; + const mapped = mapping[provider]; + if (!mapped) { + throw new ConnectError( + `Unknown or unspecified notification provider: ${NotificationProvider[provider] ?? provider}`, + Code.InvalidArgument, + ); + } + return mapped; } /** diff --git a/apps/server/src/routes/rpc/services/notification/errors.ts b/apps/server/src/routes/rpc/handlers/notification/errors.ts similarity index 100% rename from apps/server/src/routes/rpc/services/notification/errors.ts rename to apps/server/src/routes/rpc/handlers/notification/errors.ts diff --git a/apps/server/src/routes/rpc/handlers/notification/index.ts b/apps/server/src/routes/rpc/handlers/notification/index.ts new file mode 100644 index 000000000..c370eb7df --- /dev/null +++ b/apps/server/src/routes/rpc/handlers/notification/index.ts @@ -0,0 +1,234 @@ +import type { ServiceImpl } from "@connectrpc/connect"; +import type { NotificationService } from "@openstatus/proto/notification/v1"; +import { + createNotification, + deleteNotification, + getNotification, + listNotifications, + updateNotification, +} from "@openstatus/services/notification"; + +import { ForbiddenError } from "@openstatus/services"; + +import { toConnectError, toServiceCtx } from "../../adapter"; +import { getRpcContext } from "../../interceptors"; +import { + dbNotificationToProto, + dbNotificationToProtoSummary, + dbProviderToProto, + protoDataToDb, + protoProviderToDb, +} from "./converters"; +import { monitorNotFoundError, notificationIdRequiredError } from "./errors"; +import { getNotificationLimitInfo } from "./limits"; +import { sendTestNotification } from "./test-providers"; + +type ProtoData = Parameters[1]; + +/** + * Translate the proto `NotificationData` oneof into the loose record shape + * the service expects (`Partial>`). Round-trips through + * the existing `protoDataToDb` helper + JSON.parse so we don't reimplement + * the per-provider mapping. + * + * A `JSON.parse` failure here would mean `protoDataToDb` itself produced + * malformed output — a programmer error, not a user-input issue. Letting + * the throw propagate surfaces it as `Code.Internal` via `toConnectError`, + * which is the signal we want; catch-and-return-empty would hide the + * bug and feed an empty object into `validateNotificationData`, + * producing a generic validation failure far from the root cause. + */ +function protoDataToServiceInput( + provider: number, + data: ProtoData | undefined, +): Record { + if (!data) return {}; + return JSON.parse(protoDataToDb(provider, data)); +} + +/** + * Remap the service's `ForbiddenError` (thrown by `validateMonitorIds` + * on an unknown or cross-workspace monitor) to `monitorNotFoundError` + * (Code.NotFound / HTTP 404). The service deliberately doesn't + * distinguish "monitor doesn't exist" from "monitor in another + * workspace" (an existence-leak guard), but the Connect contract + * surfaces this as 404 so clients keying on the status code get the + * same "resource not accessible" signal regardless of cause. + */ +function rethrowMonitorNotFound(err: unknown, monitorIds: string[]): never { + if (err instanceof ForbiddenError && err.message.startsWith("Monitor ")) { + throw monitorNotFoundError(monitorIds.join(",")); + } + throw err; +} + +export const notificationServiceImpl: ServiceImpl = + { + async createNotification(req, ctx) { + try { + const rpcCtx = getRpcContext(ctx); + const sCtx = toServiceCtx(rpcCtx); + + const record = await createNotification({ + ctx: sCtx, + input: { + name: req.name, + provider: protoProviderToDb(req.provider), + data: protoDataToServiceInput(req.provider, req.data) as never, + monitors: req.monitorIds.map((id) => Number(id)), + }, + }).catch((err) => rethrowMonitorNotFound(err, req.monitorIds)); + // Re-fetch to get the authoritative persisted monitor set — + // mirrors the `updateNotification` pattern and removes the + // approximation that used to echo `req.monitorIds` deduped. + // `createNotification` returns only the base row; the monitor + // ids come from the `notifications_to_monitors` associations + // which `getNotification` batches alongside the read. + const full = await getNotification({ + ctx: sCtx, + input: { id: record.id }, + }); + return { + notification: dbNotificationToProto( + full, + full.monitors.map((m) => String(m.id)), + ), + }; + } catch (err) { + toConnectError(err); + } + }, + + async getNotification(req, ctx) { + try { + const rpcCtx = getRpcContext(ctx); + if (!req.id || req.id.trim() === "") { + throw notificationIdRequiredError(); + } + + const full = await getNotification({ + ctx: toServiceCtx(rpcCtx), + input: { id: Number(req.id) }, + }); + return { + notification: dbNotificationToProto( + full, + full.monitors.map((m) => String(m.id)), + ), + }; + } catch (err) { + toConnectError(err); + } + }, + + async listNotifications(req, ctx) { + try { + const rpcCtx = getRpcContext(ctx); + + const { items, totalSize } = await listNotifications({ + ctx: toServiceCtx(rpcCtx), + input: { + limit: Math.min(Math.max(req.limit ?? 50, 1), 100), + offset: req.offset ?? 0, + order: "desc", + }, + }); + + return { + notifications: items.map((n) => + dbNotificationToProtoSummary(n, n.monitors.length), + ), + totalSize, + }; + } catch (err) { + toConnectError(err); + } + }, + + async updateNotification(req, ctx) { + try { + const rpcCtx = getRpcContext(ctx); + const sCtx = toServiceCtx(rpcCtx); + if (!req.id || req.id.trim() === "") { + throw notificationIdRequiredError(); + } + + const id = Number(req.id); + // Connect's update is partial — read the stored record so we can + // supply the missing fields to the service (which expects a full + // update payload). + const existing = await getNotification({ ctx: sCtx, input: { id } }); + const provider = dbProviderToProto(existing.provider); + + await updateNotification({ + ctx: sCtx, + input: { + id, + name: + req.name !== undefined && req.name !== "" + ? req.name + : existing.name, + data: + req.data !== undefined + ? (protoDataToServiceInput(provider, req.data) as never) + : // Drizzle infers the column as `string | null` (the + // schema has a `default("{}")` but no `.notNull()`), + // so the fallback to `"{}"` prevents a + // `SyntaxError` when a legacy row left `data` NULL. + (JSON.parse(existing.data ?? "{}") as never), + monitors: req.updateMonitorIds + ? req.monitorIds.map((mid) => Number(mid)) + : existing.monitors.map((m) => m.id), + }, + }).catch((err) => rethrowMonitorNotFound(err, req.monitorIds)); + + const full = await getNotification({ ctx: sCtx, input: { id } }); + return { + notification: dbNotificationToProto( + full, + full.monitors.map((m) => String(m.id)), + ), + }; + } catch (err) { + toConnectError(err); + } + }, + + async deleteNotification(req, ctx) { + try { + const rpcCtx = getRpcContext(ctx); + if (!req.id || req.id.trim() === "") { + throw notificationIdRequiredError(); + } + await deleteNotification({ + ctx: toServiceCtx(rpcCtx), + input: { id: Number(req.id) }, + }); + return { success: true }; + } catch (err) { + toConnectError(err); + } + }, + + async sendTestNotification(req, _ctx) { + // Wrapped in `toConnectError` for symmetry with the CRUD handlers + // above — any `ServiceError` / `ZodError` thrown from within + // `test-providers.ts` (or a future helper it grows) gets mapped + // to the right gRPC status instead of falling through to the + // interceptor's generic catch. + try { + return await sendTestNotification(req.provider, req.data); + } catch (err) { + toConnectError(err); + } + }, + + async checkNotificationLimit(_req, ctx) { + const rpcCtx = getRpcContext(ctx); + const workspaceId = rpcCtx.workspace.id; + const limits = rpcCtx.workspace.limits; + + const info = await getNotificationLimitInfo(workspaceId, limits); + return info; + }, + }; diff --git a/apps/server/src/routes/rpc/services/notification/limits.ts b/apps/server/src/routes/rpc/handlers/notification/limits.ts similarity index 100% rename from apps/server/src/routes/rpc/services/notification/limits.ts rename to apps/server/src/routes/rpc/handlers/notification/limits.ts diff --git a/apps/server/src/routes/rpc/services/notification/test-providers.ts b/apps/server/src/routes/rpc/handlers/notification/test-providers.ts similarity index 100% rename from apps/server/src/routes/rpc/services/notification/test-providers.ts rename to apps/server/src/routes/rpc/handlers/notification/test-providers.ts diff --git a/apps/server/src/routes/rpc/services/status-page/__tests__/status-page.test.ts b/apps/server/src/routes/rpc/handlers/status-page/__tests__/status-page.test.ts similarity index 100% rename from apps/server/src/routes/rpc/services/status-page/__tests__/status-page.test.ts rename to apps/server/src/routes/rpc/handlers/status-page/__tests__/status-page.test.ts diff --git a/apps/server/src/routes/rpc/services/status-page/converters.ts b/apps/server/src/routes/rpc/handlers/status-page/converters.ts similarity index 100% rename from apps/server/src/routes/rpc/services/status-page/converters.ts rename to apps/server/src/routes/rpc/handlers/status-page/converters.ts diff --git a/apps/server/src/routes/rpc/services/status-page/errors.ts b/apps/server/src/routes/rpc/handlers/status-page/errors.ts similarity index 100% rename from apps/server/src/routes/rpc/services/status-page/errors.ts rename to apps/server/src/routes/rpc/handlers/status-page/errors.ts diff --git a/apps/server/src/routes/rpc/services/status-page/index.ts b/apps/server/src/routes/rpc/handlers/status-page/index.ts similarity index 62% rename from apps/server/src/routes/rpc/services/status-page/index.ts rename to apps/server/src/routes/rpc/handlers/status-page/index.ts index 5383308dd..661c02aec 100644 --- a/apps/server/src/routes/rpc/services/status-page/index.ts +++ b/apps/server/src/routes/rpc/handlers/status-page/index.ts @@ -28,13 +28,33 @@ import { OverallStatus, PageAccessType, } from "@openstatus/proto/status_page/v1"; +import { + ConflictError, + LimitExceededError, + NotFoundError, + withTransaction, +} from "@openstatus/services"; +import { + createPage, + deletePage, + getPage, + listPages, + updatePageAppearance, + updatePageCustomDomain, + updatePageGeneral, + updatePageLinks, + updatePageLocales, + updatePagePasswordProtection, +} from "@openstatus/services/page"; import { createSubscription as createSubscriptionService, detectWebhookFlavor, getChannel, upsertEmailSubscription, } from "@openstatus/subscriptions"; +import { THEME_KEYS, type ThemeKey } from "@openstatus/theme-store"; +import { toConnectError, toServiceCtx } from "../../adapter"; import { getRpcContext } from "../../interceptors"; import { type DBPageSubscriber, @@ -63,11 +83,9 @@ import { passwordRequiredError, slugAlreadyExistsError, statusPageAccessDeniedError, - statusPageCreateFailedError, statusPageIdRequiredError, statusPageNotFoundError, statusPageNotPublishedError, - statusPageUpdateFailedError, subscriberCreateFailedError, subscriberNotFoundError, } from "./errors"; @@ -78,10 +96,36 @@ import { checkNoIndexLimit, checkPageComponentLimits, checkPasswordProtectionLimit, - checkStatusPageLimits, checkStatusSubscribersLimit, } from "./limits"; +/** + * Normalize a service `Page` back into the converter's raw-db shape. + * + * The service parses `authEmailDomains` / `allowedIpRanges` into arrays + * via `selectPageSchema`; the converters (shared with the 13 still-on-db + * methods below) expect the comma-joined string form the DB stores. + */ +function serviceToConverterPage< + P extends { + authEmailDomains: string[]; + allowedIpRanges: string[]; + }, +>( + p: P, +): Omit & { + authEmailDomains: string | null; + allowedIpRanges: string | null; +} { + return { + ...p, + authEmailDomains: + p.authEmailDomains.length > 0 ? p.authEmailDomains.join(",") : null, + allowedIpRanges: + p.allowedIpRanges.length > 0 ? p.allowedIpRanges.join(",") : null, + }; +} + /** * Helper to get a status page by ID with workspace scope. */ @@ -240,376 +284,502 @@ export const statusPageServiceImpl: ServiceImpl = { // ========================================================================== // Page CRUD // ========================================================================== + // + // Known gap (predates the services migration): both `createStatusPage` + // and `updateStatusPage` accept and persist `customDomain`, but + // neither calls the Vercel add/remove API the way the tRPC + // `updateCustomDomain` procedure does. Clients setting a custom domain + // via gRPC will get a db row that says the domain is set, but routing + // won't actually work until a tRPC/dashboard round-trip picks up the + // diff. The fix is to lift the Vercel sync (`addDomainToVercel` / + // `removeDomainFromVercel`) into a shared transport-layer helper the + // Connect handlers can reuse, kept out of the service layer. Tracked + // as a follow-up; not landing here to avoid widening the behavioural + // blast radius of the migration PR on external API consumers. async createStatusPage(req, ctx) { - const rpcCtx = getRpcContext(ctx); - const workspaceId = rpcCtx.workspace.id; - const limits = rpcCtx.workspace.limits; - - // Check workspace limits for status pages - await checkStatusPageLimits(workspaceId, limits); + try { + const rpcCtx = getRpcContext(ctx); + const sCtx = toServiceCtx(rpcCtx); + const limits = rpcCtx.workspace.limits; - // Check if slug already exists - const existingPage = await getPageBySlug(req.slug); - if (existingPage) { - throw slugAlreadyExistsError(req.slug); - } + // Slug uniqueness — preserved at handler to keep the granular + // `slugAlreadyExistsError(slug)` (AlreadyExists + metadata) rather + // than the service's generic ConflictError → InvalidArgument mapping. + const existingPage = await getPageBySlug(req.slug); + if (existingPage) { + throw slugAlreadyExistsError(req.slug); + } - // Check i18n limits - if (!limits.i18n) { - if (req.defaultLocale !== undefined && req.defaultLocale !== 0) { - throw new ConnectError( - "Upgrade to configure locales.", - Code.PermissionDenied, - ); + // i18n — keep at handler to preserve PermissionDenied over the + // service's LimitExceededError → ResourceExhausted mapping. + if (!limits.i18n) { + if (req.defaultLocale !== undefined && req.defaultLocale !== 0) { + throw new ConnectError( + "Upgrade to configure locales.", + Code.PermissionDenied, + ); + } + if (req.locales.length > 0) { + throw new ConnectError( + "Upgrade to configure locales.", + Code.PermissionDenied, + ); + } } - if (req.locales.length > 0) { + + const defaultLocale = + req.defaultLocale !== undefined && req.defaultLocale !== 0 + ? protoLocaleToDb(req.defaultLocale) + : "en"; + const validLocales = req.locales.filter((l) => l !== 0); + const locales = + validLocales.length > 0 + ? [...new Set(validLocales.map(protoLocaleToDb))] + : null; + if (locales && !locales.includes(defaultLocale)) { throw new ConnectError( - "Upgrade to configure locales.", - Code.PermissionDenied, + "Default locale must be included in the locales list", + Code.InvalidArgument, ); } - } - - // Resolve locale values - const defaultLocale = - req.defaultLocale !== undefined && req.defaultLocale !== 0 - ? protoLocaleToDb(req.defaultLocale) - : "en"; - const validLocales = req.locales.filter((l) => l !== 0); - const locales = - validLocales.length > 0 - ? [...new Set(validLocales.map(protoLocaleToDb))] - : null; - - // Validate defaultLocale is included in locales when locales are provided - if (locales && !locales.includes(defaultLocale)) { - throw new ConnectError( - "Default locale must be included in the locales list", - Code.InvalidArgument, - ); - } - // Validate icon URL - const icon = req.icon ?? ""; - if (icon) { - validateIconUrl(icon); - } + // Proto-specific format validations (regex / URL shape) — these + // don't exist in the zod insert schema, so they stay at handler. + const icon = req.icon ?? ""; + if (icon) validateIconUrl(icon); - // Validate custom domain - const customDomain = req.customDomain ?? ""; - if (customDomain) { - checkCustomDomainLimit(limits); - validateCustomDomain(customDomain); - } + const customDomain = req.customDomain ?? ""; + if (customDomain) { + checkCustomDomainLimit(limits); + validateCustomDomain(customDomain); + } - // Resolve theme - const forceTheme = - req.theme !== undefined && req.theme !== 0 - ? protoThemeToDb(req.theme) - : "system"; - - // Resolve access type and associated fields - const reqAccessType = req.accessType; - const hasAccessType = reqAccessType !== undefined && reqAccessType !== 0; - const accessType = hasAccessType - ? protoAccessTypeToDb(reqAccessType) - : "public"; - - let password: string | null = null; - let authEmailDomains: string | null = null; - let allowedIpRanges: string | null = null; - - if (hasAccessType) { - if (reqAccessType === PageAccessType.PASSWORD_PROTECTED) { - checkPasswordProtectionLimit(limits); - const trimmedPassword = req.password?.trim() ?? ""; - if (!trimmedPassword) { - throw passwordRequiredError(); + const forceTheme = + req.theme !== undefined && req.theme !== 0 + ? protoThemeToDb(req.theme) + : "system"; + + const reqAccessType = req.accessType; + const hasAccessType = reqAccessType !== undefined && reqAccessType !== 0; + const accessType = hasAccessType + ? protoAccessTypeToDb(reqAccessType) + : "public"; + + let password: string | null = null; + let authEmailDomains: string[] | undefined; + let allowedIpRanges: string[] | undefined; + + if (hasAccessType) { + if (reqAccessType === PageAccessType.PASSWORD_PROTECTED) { + checkPasswordProtectionLimit(limits); + const trimmedPassword = req.password?.trim() ?? ""; + if (!trimmedPassword) throw passwordRequiredError(); + password = trimmedPassword; + } else if (reqAccessType === PageAccessType.AUTHENTICATED) { + checkEmailDomainProtectionLimit(limits); + authEmailDomains = validateAuthEmailDomains(req.authEmailDomains); + } else if (reqAccessType === PageAccessType.IP_RESTRICTED) { + checkIpRestrictionLimit(limits); + allowedIpRanges = validateAllowedIpRanges(req.allowedIpRanges ?? ""); } - password = trimmedPassword; - } else if (reqAccessType === PageAccessType.AUTHENTICATED) { - checkEmailDomainProtectionLimit(limits); - const validatedDomains = validateAuthEmailDomains(req.authEmailDomains); - authEmailDomains = validatedDomains.join(","); - } else if (reqAccessType === PageAccessType.IP_RESTRICTED) { - checkIpRestrictionLimit(limits); - const validated = validateAllowedIpRanges(req.allowedIpRanges ?? ""); - allowedIpRanges = validated.join(","); } - } - // Resolve allow_index - const allowIndex = req.allowIndex ?? true; - if (req.allowIndex !== undefined && !allowIndex) { - checkNoIndexLimit(limits); - } + const allowIndex = req.allowIndex ?? true; + if (req.allowIndex !== undefined && !allowIndex) { + checkNoIndexLimit(limits); + } - // Create the status page - const newPage = await db - .insert(page) - .values({ - workspaceId, - title: req.title, - description: req.description ?? "", - slug: req.slug, - customDomain, - published: false, - icon, - forceTheme, - accessType, - password, - authEmailDomains, - allowedIpRanges, - homepageUrl: req.homepageUrl ?? null, - contactUrl: req.contactUrl ?? null, - defaultLocale, - locales, - allowIndex, - }) - .returning() - .get(); + // `published` relies on DB default (false). The service's + // CreatePageInput type doesn't surface the column, and its behavior + // matches the legacy `published: false` write on create. + // + // `workspaceId: sCtx.workspace.id` — `CreatePageInput` re-exports + // the drizzle `insertPageSchema`, which requires `workspaceId` at + // parse time. The service destructures it and uses + // `ctx.workspace.id` on insert (so the input value is ignored), + // but the zod parse fires first and rejects without the field. + const created = await createPage({ + ctx: sCtx, + input: { + workspaceId: sCtx.workspace.id, + title: req.title, + description: req.description ?? "", + slug: req.slug, + customDomain, + icon, + forceTheme, + accessType, + password, + authEmailDomains, + allowedIpRanges, + homepageUrl: req.homepageUrl ?? null, + contactUrl: req.contactUrl ?? null, + defaultLocale, + locales, + allowIndex, + }, + }).catch((err) => { + // Same handler-layer remap as the `i18n` pre-check above — + // preserve `PermissionDenied` (403) for "plan quota reached" + // on `status-pages`. The service throws `LimitExceededError` + // which the Connect adapter maps to `ResourceExhausted` + // (429), but the gRPC contract here is 403 for "upgrade + // required". + if ( + err instanceof LimitExceededError && + err.message.startsWith("status-pages") + ) { + throw new ConnectError( + "Upgrade for more status pages.", + Code.PermissionDenied, + ); + } + throw err; + }); - if (!newPage) { - throw statusPageCreateFailedError(); + return { statusPage: dbPageToProto(serviceToConverterPage(created)) }; + } catch (err) { + toConnectError(err); } - - return { - statusPage: dbPageToProto(newPage), - }; }, async getStatusPage(req, ctx) { - const rpcCtx = getRpcContext(ctx); - const workspaceId = rpcCtx.workspace.id; - - const id = req.id?.trim(); - if (!id) { - throw statusPageIdRequiredError(); - } + try { + const rpcCtx = getRpcContext(ctx); + const id = req.id?.trim(); + if (!id) throw statusPageIdRequiredError(); - const pageData = await getPageById(Number(id), workspaceId); - if (!pageData) { - throw statusPageNotFoundError(id); + try { + const pageData = await getPage({ + ctx: toServiceCtx(rpcCtx), + input: { id: Number(id) }, + }); + return { + statusPage: dbPageToProto(serviceToConverterPage(pageData)), + }; + } catch (err) { + // Preserve the handler-specific `statusPageNotFoundError` (includes + // page-id metadata header) over the service's generic NotFoundError. + if (err instanceof NotFoundError) throw statusPageNotFoundError(id); + throw err; + } + } catch (err) { + toConnectError(err); } - - return { - statusPage: dbPageToProto(pageData), - }; }, async listStatusPages(req, ctx) { - const rpcCtx = getRpcContext(ctx); - const workspaceId = rpcCtx.workspace.id; - - const limit = Math.min(Math.max(req.limit ?? 50, 1), 100); - const offset = req.offset ?? 0; - - // Get total count - const countResult = await db - .select({ count: count() }) - .from(page) - .where(eq(page.workspaceId, workspaceId)) - .get(); + try { + const rpcCtx = getRpcContext(ctx); + const limit = Math.min(Math.max(req.limit ?? 50, 1), 100); + const offset = req.offset ?? 0; - const totalCount = countResult?.count ?? 0; + // Status-page quota is bounded per workspace (plan limit), so it's + // fine to fetch all via the service and paginate in-memory rather + // than expand the service's `ListPagesInput` shape for this migration. + const all = await listPages({ + ctx: toServiceCtx(rpcCtx), + input: { order: "desc" }, + }); - // Get pages - const pages = await db - .select() - .from(page) - .where(eq(page.workspaceId, workspaceId)) - .orderBy(desc(page.createdAt)) - .limit(limit) - .offset(offset) - .all(); - - return { - statusPages: pages.map(dbPageToProtoSummary), - totalSize: totalCount, - }; + return { + statusPages: all + .slice(offset, offset + limit) + .map((p) => dbPageToProtoSummary(serviceToConverterPage(p))), + totalSize: all.length, + }; + } catch (err) { + toConnectError(err); + } }, async updateStatusPage(req, ctx) { - const rpcCtx = getRpcContext(ctx); - const workspaceId = rpcCtx.workspace.id; - const limits = rpcCtx.workspace.limits; + try { + const rpcCtx = getRpcContext(ctx); + const sCtx = toServiceCtx(rpcCtx); + const limits = rpcCtx.workspace.limits; - const id = req.id?.trim(); - if (!id) { - throw statusPageIdRequiredError(); - } + const id = req.id?.trim(); + if (!id) throw statusPageIdRequiredError(); + const pageId = Number(id); - // Check i18n limits - if (!limits.i18n) { - if (req.defaultLocale !== undefined && req.defaultLocale !== 0) { - throw new ConnectError( - "Upgrade to configure locales.", - Code.PermissionDenied, - ); - } - if (req.locales.length > 0) { - throw new ConnectError( - "Upgrade to configure locales.", - Code.PermissionDenied, - ); + // i18n — keep at handler to preserve PermissionDenied. + if (!limits.i18n) { + if (req.defaultLocale !== undefined && req.defaultLocale !== 0) { + throw new ConnectError( + "Upgrade to configure locales.", + Code.PermissionDenied, + ); + } + if (req.locales.length > 0) { + throw new ConnectError( + "Upgrade to configure locales.", + Code.PermissionDenied, + ); + } } - } - - const pageData = await getPageById(Number(id), workspaceId); - if (!pageData) { - throw statusPageNotFoundError(id); - } - // Check if new slug conflicts with another page - if (req.slug && req.slug !== pageData.slug) { - const existingPage = await getPageBySlug(req.slug); - if (existingPage && existingPage.id !== pageData.id) { - throw slugAlreadyExistsError(req.slug); + // Load existing via service so the rest of this handler orchestrates + // per-section service calls against a consistent snapshot. + let existing: Awaited>; + try { + existing = await getPage({ ctx: sCtx, input: { id: pageId } }); + } catch (err) { + if (err instanceof NotFoundError) throw statusPageNotFoundError(id); + throw err; } - } - - // Build update values - const updateValues: Record = { - updatedAt: new Date(), - }; - if (req.title !== undefined && req.title !== "") { - updateValues.title = req.title; - } - if (req.description !== undefined) { - updateValues.description = req.description; - } - if (req.slug !== undefined && req.slug !== "") { - updateValues.slug = req.slug; - } - if (req.homepageUrl !== undefined) { - updateValues.homepageUrl = req.homepageUrl || null; - } - if (req.contactUrl !== undefined) { - updateValues.contactUrl = req.contactUrl || null; - } - if (req.defaultLocale !== undefined) { - updateValues.defaultLocale = protoLocaleToDb(req.defaultLocale); - } - const validLocales = req.locales.filter((l) => l !== 0); - updateValues.locales = - validLocales.length > 0 - ? [...new Set(validLocales.map(protoLocaleToDb))] - : null; - - // Validate defaultLocale is included in locales - const finalDefaultLocale = - (updateValues.defaultLocale as string) ?? pageData.defaultLocale; - const finalLocales = - "locales" in updateValues - ? (updateValues.locales as string[] | null) - : (pageData.locales as string[] | null); - if (finalLocales && !finalLocales.includes(finalDefaultLocale)) { - throw new ConnectError( - "Default locale must be included in the locales list", - Code.InvalidArgument, - ); - } + // Slug uniqueness — pre-check at handler to preserve the granular + // `slugAlreadyExistsError(slug)` (AlreadyExists + metadata). + if (req.slug && req.slug !== existing.slug) { + const slugRow = await getPageBySlug(req.slug); + if (slugRow && slugRow.id !== existing.id) { + throw slugAlreadyExistsError(req.slug); + } + } - // Handle icon - if (req.icon !== undefined) { - if (req.icon) { - validateIconUrl(req.icon); + // Access-type branch decides what the persisted auth fields become. + const reqAccessType = req.accessType; + const hasAccessType = reqAccessType !== undefined && reqAccessType !== 0; + + let nextAccessType = existing.accessType; + let nextPassword: string | null = existing.password ?? null; + let nextAuthEmailDomains: string[] | undefined = + existing.authEmailDomains; + let nextAllowedIpRanges: string[] | undefined = existing.allowedIpRanges; + + if (hasAccessType) { + nextAccessType = protoAccessTypeToDb(reqAccessType); + if (reqAccessType === PageAccessType.PASSWORD_PROTECTED) { + checkPasswordProtectionLimit(limits); + const trimmedPassword = req.password?.trim() ?? ""; + if (!trimmedPassword) throw passwordRequiredError(); + nextPassword = trimmedPassword; + nextAuthEmailDomains = undefined; + nextAllowedIpRanges = undefined; + } else if (reqAccessType === PageAccessType.AUTHENTICATED) { + checkEmailDomainProtectionLimit(limits); + nextAuthEmailDomains = validateAuthEmailDomains(req.authEmailDomains); + nextPassword = null; + nextAllowedIpRanges = undefined; + } else if (reqAccessType === PageAccessType.IP_RESTRICTED) { + checkIpRestrictionLimit(limits); + nextAllowedIpRanges = validateAllowedIpRanges( + req.allowedIpRanges ?? "", + ); + nextPassword = null; + nextAuthEmailDomains = undefined; + } else { + // Switching to PUBLIC or other — clear stale auth data. + nextPassword = null; + nextAuthEmailDomains = undefined; + nextAllowedIpRanges = undefined; + } } - updateValues.icon = req.icon; - } - // Handle custom domain - if (req.customDomain !== undefined) { - if (req.customDomain) { + const hasAllowIndex = req.allowIndex !== undefined; + if (hasAllowIndex && !req.allowIndex) checkNoIndexLimit(limits); + const nextAllowIndex = hasAllowIndex + ? req.allowIndex + : existing.allowIndex; + + if (req.customDomain !== undefined && req.customDomain) { checkCustomDomainLimit(limits); validateCustomDomain(req.customDomain); } - updateValues.customDomain = req.customDomain; - } + if (req.icon !== undefined && req.icon) validateIconUrl(req.icon); + + // Locale merge + cross-field validation. + const nextDefaultLocale = + req.defaultLocale !== undefined + ? protoLocaleToDb(req.defaultLocale) + : existing.defaultLocale; + const validLocales = req.locales.filter((l) => l !== 0); + const nextLocales = + validLocales.length > 0 + ? [...new Set(validLocales.map(protoLocaleToDb))] + : null; + if (nextLocales && !nextLocales.includes(nextDefaultLocale)) { + throw new ConnectError( + "Default locale must be included in the locales list", + Code.InvalidArgument, + ); + } + // `UpdateStatusPage` syncs locales on every call when the + // workspace has i18n — proto can't distinguish "field omitted" + // from "field = []", so the wire contract is "empty locales + // means clear". Gating on `req.locales.length > 0` meant omit + // and empty both became no-ops, leaving stale locales on the + // page. Skip the call only on plans without i18n, where the + // service would throw `LimitExceededError` regardless. + const localesChanged = limits.i18n === true; + + const generalChanged = + (req.title !== undefined && req.title !== "") || + req.description !== undefined || + (req.slug !== undefined && req.slug !== "") || + req.icon !== undefined; + + const linksChanged = + req.homepageUrl !== undefined || req.contactUrl !== undefined; + + // Snapshot the narrowed primitives so TS retains their non-undefined + // types inside the transaction closure below. + const themeForUpdate = + req.theme !== undefined && req.theme !== 0 ? req.theme : undefined; + const customDomainForUpdate = + req.customDomain !== undefined ? req.customDomain : undefined; + const accessChanged = hasAccessType || hasAllowIndex; + + // Wrap all per-section updates in a single transaction so partial + // failures don't leave the page in a half-updated state. Each + // per-section service call's internal `withTransaction` detects + // the pre-opened tx and skips nesting. + await withTransaction(sCtx, async (tx) => { + const txCtx = { ...sCtx, db: tx }; + + if (generalChanged) { + try { + await updatePageGeneral({ + ctx: txCtx, + input: { + id: pageId, + title: + req.title !== undefined && req.title !== "" + ? req.title + : existing.title, + slug: + req.slug !== undefined && req.slug !== "" + ? req.slug + : existing.slug, + description: + req.description !== undefined + ? req.description + : existing.description, + icon: req.icon !== undefined ? req.icon : existing.icon, + }, + }); + } catch (err) { + // Close the slug-race gap: if two callers both cleared the + // handler's pre-check and the loser's `assertSlugAvailable` + // inside the service throws `ConflictError`, the default + // mapping surfaces as `Code.InvalidArgument`. Rethrow as the + // granular `slugAlreadyExistsError` so gRPC clients keying on + // `Code.AlreadyExists` get a consistent code whether they + // lose at the pre-check or at the inner transaction. + if (err instanceof ConflictError && req.slug) { + throw slugAlreadyExistsError(req.slug); + } + throw err; + } + } - // Handle theme - if (req.theme !== undefined && req.theme !== 0) { - updateValues.forceTheme = protoThemeToDb(req.theme); - } + if (linksChanged) { + await updatePageLinks({ + ctx: txCtx, + input: { + id: pageId, + homepageUrl: + req.homepageUrl !== undefined + ? req.homepageUrl || null + : existing.homepageUrl, + contactUrl: + req.contactUrl !== undefined + ? req.contactUrl || null + : existing.contactUrl, + }, + }); + } - // Handle access type (password and authEmailDomains only written when accessType is present) - const reqAccessType = req.accessType; - const hasAccessType = reqAccessType !== undefined && reqAccessType !== 0; - if (hasAccessType) { - if (reqAccessType === PageAccessType.PASSWORD_PROTECTED) { - checkPasswordProtectionLimit(limits); - const trimmedPassword = req.password?.trim() ?? ""; - if (!trimmedPassword) { - throw passwordRequiredError(); + if (themeForUpdate !== undefined) { + const existingConfig = + typeof existing.configuration === "object" && + existing.configuration !== null + ? (existing.configuration as Record) + : {}; + // `theme` is typed on the service input as a `THEME_KEYS` + // enum member. The existing column may carry any string (or + // be missing) from pre-enforcement writes; fall back to + // `"default"` for unknown values rather than forwarding them + // through to the tighter service input. Use the canonical + // `THEME_KEYS` so a new theme added to `@openstatus/theme- + // store` stays accepted here without a manual copy. + const existingTheme: ThemeKey = + typeof existingConfig.theme === "string" && + (THEME_KEYS as ReadonlyArray).includes(existingConfig.theme) + ? (existingConfig.theme as ThemeKey) + : "default"; + await updatePageAppearance({ + ctx: txCtx, + input: { + id: pageId, + forceTheme: protoThemeToDb(themeForUpdate), + configuration: { theme: existingTheme }, + }, + }); } - updateValues.password = trimmedPassword; - updateValues.authEmailDomains = null; - updateValues.allowedIpRanges = null; - } else if (reqAccessType === PageAccessType.AUTHENTICATED) { - checkEmailDomainProtectionLimit(limits); - const validatedDomains = validateAuthEmailDomains(req.authEmailDomains); - updateValues.authEmailDomains = validatedDomains.join(","); - updateValues.password = null; - updateValues.allowedIpRanges = null; - } else if (reqAccessType === PageAccessType.IP_RESTRICTED) { - checkIpRestrictionLimit(limits); - const validated = validateAllowedIpRanges(req.allowedIpRanges ?? ""); - updateValues.allowedIpRanges = validated.join(","); - updateValues.password = null; - updateValues.authEmailDomains = null; - } else { - // Switching to PUBLIC or other — clear stale data - updateValues.password = null; - updateValues.authEmailDomains = null; - updateValues.allowedIpRanges = null; - } - updateValues.accessType = protoAccessTypeToDb(reqAccessType); - } - // Handle allow_index - if (req.allowIndex !== undefined) { - if (!req.allowIndex) { - checkNoIndexLimit(limits); - } - updateValues.allowIndex = req.allowIndex; - } + if (customDomainForUpdate !== undefined) { + await updatePageCustomDomain({ + ctx: txCtx, + input: { id: pageId, customDomain: customDomainForUpdate }, + }); + } - const updatedPage = await db - .update(page) - .set(updateValues) - .where(eq(page.id, pageData.id)) - .returning() - .get(); + if (localesChanged) { + await updatePageLocales({ + ctx: txCtx, + input: { + id: pageId, + defaultLocale: nextDefaultLocale, + locales: nextLocales, + }, + }); + } - if (!updatedPage) { - throw statusPageUpdateFailedError(req.id); - } + if (accessChanged) { + await updatePagePasswordProtection({ + ctx: txCtx, + input: { + id: pageId, + accessType: nextAccessType, + authEmailDomains: nextAuthEmailDomains, + password: nextPassword, + allowedIpRanges: nextAllowedIpRanges, + allowIndex: nextAllowIndex, + }, + }); + } + }); - return { - statusPage: dbPageToProto(updatedPage), - }; + const updated = await getPage({ ctx: sCtx, input: { id: pageId } }); + return { statusPage: dbPageToProto(serviceToConverterPage(updated)) }; + } catch (err) { + toConnectError(err); + } }, async deleteStatusPage(req, ctx) { - const rpcCtx = getRpcContext(ctx); - const workspaceId = rpcCtx.workspace.id; + try { + const rpcCtx = getRpcContext(ctx); + const id = req.id?.trim(); + if (!id) throw statusPageIdRequiredError(); - const id = req.id?.trim(); - if (!id) { - throw statusPageIdRequiredError(); - } + try { + await deletePage({ + ctx: toServiceCtx(rpcCtx), + input: { id: Number(id) }, + }); + } catch (err) { + if (err instanceof NotFoundError) throw statusPageNotFoundError(id); + throw err; + } - const pageData = await getPageById(Number(id), workspaceId); - if (!pageData) { - throw statusPageNotFoundError(id); + return { success: true }; + } catch (err) { + toConnectError(err); } - - // Delete the page (cascade will delete components, groups, subscribers) - await db.delete(page).where(eq(page.id, pageData.id)); - - return { success: true }; }, // ========================================================================== diff --git a/apps/server/src/routes/rpc/services/status-page/limits.ts b/apps/server/src/routes/rpc/handlers/status-page/limits.ts similarity index 100% rename from apps/server/src/routes/rpc/services/status-page/limits.ts rename to apps/server/src/routes/rpc/handlers/status-page/limits.ts diff --git a/apps/server/src/routes/rpc/services/status-report/__tests__/status-report.test.ts b/apps/server/src/routes/rpc/handlers/status-report/__tests__/status-report.test.ts similarity index 100% rename from apps/server/src/routes/rpc/services/status-report/__tests__/status-report.test.ts rename to apps/server/src/routes/rpc/handlers/status-report/__tests__/status-report.test.ts diff --git a/apps/server/src/routes/rpc/services/status-report/converters.ts b/apps/server/src/routes/rpc/handlers/status-report/converters.ts similarity index 100% rename from apps/server/src/routes/rpc/services/status-report/converters.ts rename to apps/server/src/routes/rpc/handlers/status-report/converters.ts diff --git a/apps/server/src/routes/rpc/services/status-report/errors.ts b/apps/server/src/routes/rpc/handlers/status-report/errors.ts similarity index 100% rename from apps/server/src/routes/rpc/services/status-report/errors.ts rename to apps/server/src/routes/rpc/handlers/status-report/errors.ts diff --git a/apps/server/src/routes/rpc/services/status-report/index.ts b/apps/server/src/routes/rpc/handlers/status-report/index.ts similarity index 100% rename from apps/server/src/routes/rpc/services/status-report/index.ts rename to apps/server/src/routes/rpc/handlers/status-report/index.ts diff --git a/apps/server/src/routes/rpc/router.ts b/apps/server/src/routes/rpc/router.ts index 9776e1f70..203ece040 100644 --- a/apps/server/src/routes/rpc/router.ts +++ b/apps/server/src/routes/rpc/router.ts @@ -6,6 +6,12 @@ import { NotificationService } from "@openstatus/proto/notification/v1"; import { StatusPageService } from "@openstatus/proto/status_page/v1"; import { StatusReportService } from "@openstatus/proto/status_report/v1"; +import { healthServiceImpl } from "./handlers/health"; +import { maintenanceServiceImpl } from "./handlers/maintenance"; +import { monitorServiceImpl } from "./handlers/monitor"; +import { notificationServiceImpl } from "./handlers/notification"; +import { statusPageServiceImpl } from "./handlers/status-page"; +import { statusReportServiceImpl } from "./handlers/status-report"; import { authInterceptor, errorInterceptor, @@ -13,12 +19,6 @@ import { trackingInterceptor, validationInterceptor, } from "./interceptors"; -import { healthServiceImpl } from "./services/health"; -import { maintenanceServiceImpl } from "./services/maintenance"; -import { monitorServiceImpl } from "./services/monitor"; -import { notificationServiceImpl } from "./services/notification"; -import { statusPageServiceImpl } from "./services/status-page"; -import { statusReportServiceImpl } from "./services/status-report"; /** * Create ConnectRPC router with services. diff --git a/apps/server/src/routes/rpc/services/maintenance/index.ts b/apps/server/src/routes/rpc/services/maintenance/index.ts deleted file mode 100644 index 6863aad82..000000000 --- a/apps/server/src/routes/rpc/services/maintenance/index.ts +++ /dev/null @@ -1,459 +0,0 @@ -import type { ServiceImpl } from "@connectrpc/connect"; -import { and, db, desc, eq, inArray, sql } from "@openstatus/db"; -import { - maintenance, - maintenancesToPageComponents, - page, - pageComponent, -} from "@openstatus/db/src/schema"; -import type { Limits } from "@openstatus/db/src/schema/plan/schema"; -import type { MaintenanceService } from "@openstatus/proto/maintenance/v1"; - -import { dispatchMaintenanceUpdate } from "@openstatus/subscriptions"; - -import { getRpcContext } from "../../interceptors"; -import { - dbMaintenanceToProto, - dbMaintenanceToProtoSummary, -} from "./converters"; -import { - invalidDateFormatError, - invalidDateRangeError, - maintenanceCreateFailedError, - maintenanceIdRequiredError, - maintenanceNotFoundError, - maintenanceUpdateFailedError, - pageComponentNotFoundError, - pageComponentsMixedPagesError, - pageIdComponentMismatchError, - pageNotFoundError, -} from "./errors"; - -// Type that works with both db instance and transaction -type DB = typeof db; -type Transaction = Parameters[0]>[0]; - -/** - * Helper to get a maintenance by ID with workspace scope. - */ -async function getMaintenanceById(id: number, workspaceId: number) { - return db - .select() - .from(maintenance) - .where( - and(eq(maintenance.id, id), eq(maintenance.workspaceId, workspaceId)), - ) - .get(); -} - -/** - * Helper to get page component IDs for a maintenance. - */ -async function getPageComponentIdsForMaintenance(maintenanceId: number) { - const components = await db - .select({ pageComponentId: maintenancesToPageComponents.pageComponentId }) - .from(maintenancesToPageComponents) - .where(eq(maintenancesToPageComponents.maintenanceId, maintenanceId)) - .all(); - - return components.map((c) => String(c.pageComponentId)); -} - -/** - * Result of validating page component IDs. - */ -interface ValidatedPageComponents { - componentIds: number[]; - pageId: number | null; -} - -/** - * Helper to validate page component IDs belong to the workspace and same page. - * Accepts an optional transaction to ensure atomicity with subsequent operations. - */ -export async function validatePageComponentIds( - pageComponentIds: string[], - workspaceId: number, - tx: DB | Transaction = db, -): Promise { - if (pageComponentIds.length === 0) { - return { componentIds: [], pageId: null }; - } - - const numericIds = pageComponentIds.map((id) => Number(id)); - - const validComponents = await tx - .select({ id: pageComponent.id, pageId: pageComponent.pageId }) - .from(pageComponent) - .where( - and( - inArray(pageComponent.id, numericIds), - eq(pageComponent.workspaceId, workspaceId), - ), - ) - .all(); - - const validComponentsMap = new Map( - validComponents.map((c) => [c.id, c.pageId]), - ); - - // Check all requested IDs exist - for (const id of numericIds) { - if (!validComponentsMap.has(id)) { - throw pageComponentNotFoundError(String(id)); - } - } - - // Validate all components belong to the same page - const pageIds = new Set(validComponents.map((c) => c.pageId)); - if (pageIds.size > 1) { - throw pageComponentsMixedPagesError(); - } - - const pageId = validComponents[0]?.pageId ?? null; - - return { componentIds: numericIds, pageId }; -} - -/** - * Helper to update page component associations for a maintenance. - * Accepts an optional transaction to ensure atomicity. - */ -export async function updatePageComponentAssociations( - maintenanceId: number, - pageComponentIds: number[], - tx: DB | Transaction = db, -) { - // Delete existing associations - await tx - .delete(maintenancesToPageComponents) - .where(eq(maintenancesToPageComponents.maintenanceId, maintenanceId)); - - // Insert new associations - if (pageComponentIds.length > 0) { - await tx.insert(maintenancesToPageComponents).values( - pageComponentIds.map((pageComponentId) => ({ - maintenanceId, - pageComponentId, - })), - ); - } -} - -/** - * Parses and validates a date string. - * Throws invalidDateFormatError if the date is invalid. - */ -function parseDate(dateString: string): Date { - const date = new Date(dateString); - if (Number.isNaN(date.getTime())) { - throw invalidDateFormatError(dateString); - } - return date; -} - -/** - * Validates that from date is before to date. - */ -function validateDateRange( - from: Date, - to: Date, - fromStr: string, - toStr: string, -): void { - if (from >= to) { - throw invalidDateRangeError(fromStr, toStr); - } -} - -/** - * Helper to validate page exists in workspace. - */ -export async function validatePageExists( - pageId: number, - workspaceId: number, - tx: DB | Transaction = db, -): Promise { - const pageRecord = await tx - .select({ id: page.id }) - .from(page) - .where(and(eq(page.id, pageId), eq(page.workspaceId, workspaceId))) - .get(); - - if (!pageRecord) { - throw pageNotFoundError(String(pageId)); - } -} - -/** - * Helper to send maintenance notifications to page subscribers. - * Uses the subscription dispatcher for component-aware filtering. - */ -export async function sendMaintenanceNotification(params: { - maintenanceId: number; - limits: Limits; -}) { - const { maintenanceId, limits } = params; - - if (!limits["status-subscribers"]) { - return; - } - - await dispatchMaintenanceUpdate(maintenanceId); -} - -/** - * Maintenance service implementation for ConnectRPC. - */ -export const maintenanceServiceImpl: ServiceImpl = { - async createMaintenance(req, ctx) { - const rpcCtx = getRpcContext(ctx); - const workspaceId = rpcCtx.workspace.id; - - // Parse and validate dates - const fromDate = parseDate(req.from); - const toDate = parseDate(req.to); - validateDateRange(fromDate, toDate, req.from, req.to); - - const providedPageId = Number(req.pageId); - - // Create maintenance and associations in a transaction - const newMaintenance = await db.transaction(async (tx) => { - // Validate page exists in workspace - await validatePageExists(providedPageId, workspaceId, tx); - - // Validate page component IDs - const validatedComponents = await validatePageComponentIds( - req.pageComponentIds, - workspaceId, - tx, - ); - - // Validate that components belong to the same page as provided pageId - if ( - validatedComponents.pageId !== null && - validatedComponents.pageId !== providedPageId - ) { - throw pageIdComponentMismatchError( - req.pageId, - String(validatedComponents.pageId), - ); - } - - // Create the maintenance - const record = await tx - .insert(maintenance) - .values({ - workspaceId, - pageId: providedPageId, - title: req.title, - message: req.message, - from: fromDate, - to: toDate, - }) - .returning() - .get(); - - if (!record) { - throw maintenanceCreateFailedError(); - } - - // Create page component associations - await updatePageComponentAssociations( - record.id, - validatedComponents.componentIds, - tx, - ); - - return record; - }); - - // Send notifications if requested (outside transaction) - if (req.notify) { - await sendMaintenanceNotification({ - maintenanceId: newMaintenance.id, - limits: rpcCtx.workspace.limits, - }); - } - - return { - maintenance: dbMaintenanceToProto(newMaintenance, req.pageComponentIds), - }; - }, - - async getMaintenance(req, ctx) { - const rpcCtx = getRpcContext(ctx); - const workspaceId = rpcCtx.workspace.id; - - if (!req.id || req.id.trim() === "") { - throw maintenanceIdRequiredError(); - } - - const record = await getMaintenanceById(Number(req.id), workspaceId); - if (!record) { - throw maintenanceNotFoundError(req.id); - } - - const pageComponentIds = await getPageComponentIdsForMaintenance(record.id); - - return { - maintenance: dbMaintenanceToProto(record, pageComponentIds), - }; - }, - - async listMaintenances(req, ctx) { - const rpcCtx = getRpcContext(ctx); - const workspaceId = rpcCtx.workspace.id; - - const limit = Math.min(Math.max(req.limit ?? 50, 1), 100); - const offset = req.offset ?? 0; - - // Build conditions - const conditions = [eq(maintenance.workspaceId, workspaceId)]; - - // Add page_id filter if provided - if (req.pageId && req.pageId.trim() !== "") { - conditions.push(eq(maintenance.pageId, Number(req.pageId))); - } - - // Get total count - const countResult = await db - .select({ count: sql`count(*)` }) - .from(maintenance) - .where(and(...conditions)) - .get(); - - const totalCount = countResult?.count ?? 0; - - // Get maintenances - const records = await db - .select() - .from(maintenance) - .where(and(...conditions)) - .orderBy(desc(maintenance.from)) - .limit(limit) - .offset(offset) - .all(); - - // Get page component IDs for each maintenance - const maintenances = await Promise.all( - records.map(async (record) => { - const pageComponentIds = await getPageComponentIdsForMaintenance( - record.id, - ); - return dbMaintenanceToProtoSummary(record, pageComponentIds); - }), - ); - - return { - maintenances, - totalSize: totalCount, - }; - }, - - async updateMaintenance(req, ctx) { - const rpcCtx = getRpcContext(ctx); - const workspaceId = rpcCtx.workspace.id; - - if (!req.id || req.id.trim() === "") { - throw maintenanceIdRequiredError(); - } - - const record = await getMaintenanceById(Number(req.id), workspaceId); - if (!record) { - throw maintenanceNotFoundError(req.id); - } - - // Parse dates if provided - const fromDate = req.from ? parseDate(req.from) : null; - const toDate = req.to ? parseDate(req.to) : null; - - // Validate date range with updated values - const effectiveFrom = fromDate ?? record.from; - const effectiveTo = toDate ?? record.to; - const fromStr = fromDate && req.from ? req.from : record.from.toISOString(); - const toStr = toDate && req.to ? req.to : record.to.toISOString(); - validateDateRange(effectiveFrom, effectiveTo, fromStr, toStr); - - // Update maintenance and associations in a transaction - const updatedMaintenance = await db.transaction(async (tx) => { - const updateValues: Record = { - updatedAt: new Date(), - }; - - if (req.title !== undefined && req.title !== "") { - updateValues.title = req.title; - } - - if (req.message !== undefined && req.message !== "") { - updateValues.message = req.message; - } - - if (fromDate) { - updateValues.from = fromDate; - } - - if (toDate) { - updateValues.to = toDate; - } - - if (req.updatePageComponentIds) { - const validatedComponents = await validatePageComponentIds( - req.pageComponentIds, - workspaceId, - tx, - ); - - updateValues.pageId = validatedComponents.pageId; - - await updatePageComponentAssociations( - record.id, - validatedComponents.componentIds, - tx, - ); - } - - // Update the maintenance - const updated = await tx - .update(maintenance) - .set(updateValues) - .where(eq(maintenance.id, record.id)) - .returning() - .get(); - - if (!updated) { - throw maintenanceUpdateFailedError(req.id); - } - - return updated; - }); - - // Fetch updated page component IDs - const pageComponentIds = await getPageComponentIdsForMaintenance( - updatedMaintenance.id, - ); - - return { - maintenance: dbMaintenanceToProto(updatedMaintenance, pageComponentIds), - }; - }, - - async deleteMaintenance(req, ctx) { - const rpcCtx = getRpcContext(ctx); - const workspaceId = rpcCtx.workspace.id; - - if (!req.id || req.id.trim() === "") { - throw maintenanceIdRequiredError(); - } - - const record = await getMaintenanceById(Number(req.id), workspaceId); - if (!record) { - throw maintenanceNotFoundError(req.id); - } - - // Delete the maintenance (cascade will delete associations) - await db.delete(maintenance).where(eq(maintenance.id, record.id)); - - return { success: true }; - }, -}; diff --git a/apps/server/src/routes/rpc/services/notification/index.ts b/apps/server/src/routes/rpc/services/notification/index.ts deleted file mode 100644 index 6a5e36699..000000000 --- a/apps/server/src/routes/rpc/services/notification/index.ts +++ /dev/null @@ -1,402 +0,0 @@ -import type { ServiceImpl } from "@connectrpc/connect"; -import { and, count, db, desc, eq, inArray, sql } from "@openstatus/db"; -import { - monitor, - notification, - notificationsToMonitors, -} from "@openstatus/db/src/schema"; -import { - NotificationProvider, - type NotificationService, -} from "@openstatus/proto/notification/v1"; - -import { getRpcContext } from "../../interceptors"; -import { - dbNotificationToProto, - dbNotificationToProtoSummary, - dbProviderToProto, - protoDataToDb, - protoProviderToDb, - validateProviderDataConsistency, -} from "./converters"; -import { - invalidNotificationDataError, - monitorNotFoundError, - notificationCreateFailedError, - notificationIdRequiredError, - notificationNotFoundError, - notificationUpdateFailedError, -} from "./errors"; -import { - checkNotificationLimit, - checkProviderAllowed, - getNotificationLimitInfo, -} from "./limits"; -import { sendTestNotification } from "./test-providers"; - -// Type that works with both db instance and transaction -type DB = typeof db; -type Transaction = Parameters[0]>[0]; - -/** - * Helper to get a notification by ID with workspace scope. - */ -async function getNotificationById(id: number, workspaceId: number) { - return db - .select() - .from(notification) - .where( - and(eq(notification.id, id), eq(notification.workspaceId, workspaceId)), - ) - .get(); -} - -/** - * Helper to get monitor IDs for a notification. - */ -async function getMonitorIdsForNotification( - notificationId: number, -): Promise { - const monitors = await db - .select({ monitorId: notificationsToMonitors.monitorId }) - .from(notificationsToMonitors) - .where(eq(notificationsToMonitors.notificationId, notificationId)) - .all(); - - return monitors.map((m) => String(m.monitorId)); -} - -/** - * Helper to get monitor count for a notification. - */ -async function getMonitorCountForNotification( - notificationId: number, -): Promise { - const result = await db - .select({ count: count() }) - .from(notificationsToMonitors) - .where(eq(notificationsToMonitors.notificationId, notificationId)) - .get(); - - return result?.count ?? 0; -} - -/** - * Validates that all monitor IDs belong to the workspace. - * Throws monitorNotFoundError if any monitor is not found. - */ -async function validateMonitorIds( - monitorIds: string[], - workspaceId: number, - tx: DB | Transaction = db, -): Promise { - if (monitorIds.length === 0) { - return []; - } - - const numericIds = monitorIds.map((id) => Number(id)); - - const validMonitors = await tx - .select({ id: monitor.id }) - .from(monitor) - .where( - and( - inArray(monitor.id, numericIds), - eq(monitor.workspaceId, workspaceId), - ), - ) - .all(); - - const validIds = new Set(validMonitors.map((m) => m.id)); - - for (const id of numericIds) { - if (!validIds.has(id)) { - throw monitorNotFoundError(String(id)); - } - } - - return numericIds; -} - -/** - * Helper to update monitor associations for a notification. - */ -async function updateMonitorAssociations( - notificationId: number, - monitorIds: number[], - tx: DB | Transaction = db, -) { - // Delete existing associations - await tx - .delete(notificationsToMonitors) - .where(eq(notificationsToMonitors.notificationId, notificationId)); - - // Insert new associations - if (monitorIds.length > 0) { - await tx.insert(notificationsToMonitors).values( - monitorIds.map((monitorId) => ({ - notificationId, - monitorId, - })), - ); - } -} - -/** - * Notification service implementation for ConnectRPC. - */ -export const notificationServiceImpl: ServiceImpl = - { - async createNotification(req, ctx) { - const rpcCtx = getRpcContext(ctx); - const workspaceId = rpcCtx.workspace.id; - const limits = rpcCtx.workspace.limits; - - // Check notification limit - await checkNotificationLimit(workspaceId, limits); - - // Check if provider is allowed for this plan - checkProviderAllowed(req.provider, limits); - - // Validate provider-data consistency - const validationError = validateProviderDataConsistency( - req.provider, - req.data, - ); - if (validationError) { - throw invalidNotificationDataError(validationError); - } - - // Create notification in a transaction - const newNotification = await db.transaction(async (tx) => { - // Validate monitor IDs - const validMonitorIds = await validateMonitorIds( - req.monitorIds, - workspaceId, - tx, - ); - - // Convert proto data to DB format - const dataStr = protoDataToDb(req.provider, req.data); - - // Create the notification - const record = await tx - .insert(notification) - .values({ - name: req.name, - provider: protoProviderToDb(req.provider), - data: dataStr, - workspaceId, - }) - .returning() - .get(); - - if (!record) { - throw notificationCreateFailedError(); - } - - // Create monitor associations - await updateMonitorAssociations(record.id, validMonitorIds, tx); - - return record; - }); - - return { - notification: dbNotificationToProto(newNotification, req.monitorIds), - }; - }, - - async getNotification(req, ctx) { - const rpcCtx = getRpcContext(ctx); - const workspaceId = rpcCtx.workspace.id; - - if (!req.id || req.id.trim() === "") { - throw notificationIdRequiredError(); - } - - const record = await getNotificationById(Number(req.id), workspaceId); - if (!record) { - throw notificationNotFoundError(req.id); - } - - const monitorIds = await getMonitorIdsForNotification(record.id); - - return { - notification: dbNotificationToProto(record, monitorIds), - }; - }, - - async listNotifications(req, ctx) { - const rpcCtx = getRpcContext(ctx); - const workspaceId = rpcCtx.workspace.id; - - const limit = Math.min(Math.max(req.limit ?? 50, 1), 100); - const offset = req.offset ?? 0; - - // Get total count - const countResult = await db - .select({ count: sql`count(*)` }) - .from(notification) - .where(eq(notification.workspaceId, workspaceId)) - .get(); - - const totalCount = countResult?.count ?? 0; - - // Get notifications - const records = await db - .select() - .from(notification) - .where(eq(notification.workspaceId, workspaceId)) - .orderBy(desc(notification.createdAt)) - .limit(limit) - .offset(offset) - .all(); - - // Get monitor counts for each notification - const notifications = await Promise.all( - records.map(async (record) => { - const monitorCount = await getMonitorCountForNotification(record.id); - return dbNotificationToProtoSummary(record, monitorCount); - }), - ); - - return { - notifications, - totalSize: totalCount, - }; - }, - - async updateNotification(req, ctx) { - const rpcCtx = getRpcContext(ctx); - const workspaceId = rpcCtx.workspace.id; - - if (!req.id || req.id.trim() === "") { - throw notificationIdRequiredError(); - } - - const record = await getNotificationById(Number(req.id), workspaceId); - if (!record) { - throw notificationNotFoundError(req.id); - } - - // Validate provider-data consistency if data is being updated - if (req.data !== undefined) { - const existingProvider = dbProviderToProto(record.provider); - const validationError = validateProviderDataConsistency( - existingProvider, - req.data, - ); - if (validationError) { - throw invalidNotificationDataError(validationError); - } - } - - // Update notification in a transaction - const updatedNotification = await db.transaction(async (tx) => { - const updateValues: Record = { - updatedAt: new Date(), - }; - - if (req.name !== undefined && req.name !== "") { - updateValues.name = req.name; - } - - if (req.data !== undefined) { - // Use the existing provider since we can't change provider on update - updateValues.data = protoDataToDb( - // Provider can't change, so we'll use the current data's case - req.data.data.case !== undefined - ? (() => { - // Map case to NotificationProvider - const caseToProvider: Record = { - discord: NotificationProvider.DISCORD, - email: NotificationProvider.EMAIL, - googleChat: NotificationProvider.GOOGLE_CHAT, - grafanaOncall: NotificationProvider.GRAFANA_ONCALL, - ntfy: NotificationProvider.NTFY, - pagerduty: NotificationProvider.PAGERDUTY, - opsgenie: NotificationProvider.OPSGENIE, - slack: NotificationProvider.SLACK, - sms: NotificationProvider.SMS, - telegram: NotificationProvider.TELEGRAM, - webhook: NotificationProvider.WEBHOOK, - whatsapp: NotificationProvider.WHATSAPP, - }; - return ( - caseToProvider[req.data.data.case] ?? - NotificationProvider.UNSPECIFIED - ); - })() - : 0, - req.data, - ); - } - - if (req.updateMonitorIds) { - const validMonitorIds = await validateMonitorIds( - req.monitorIds, - workspaceId, - tx, - ); - await updateMonitorAssociations(record.id, validMonitorIds, tx); - } - - // Update the notification - const updated = await tx - .update(notification) - .set(updateValues) - .where(eq(notification.id, record.id)) - .returning() - .get(); - - if (!updated) { - throw notificationUpdateFailedError(req.id); - } - - return updated; - }); - - // Fetch updated monitor IDs - const monitorIds = await getMonitorIdsForNotification( - updatedNotification.id, - ); - - return { - notification: dbNotificationToProto(updatedNotification, monitorIds), - }; - }, - - async deleteNotification(req, ctx) { - const rpcCtx = getRpcContext(ctx); - const workspaceId = rpcCtx.workspace.id; - - if (!req.id || req.id.trim() === "") { - throw notificationIdRequiredError(); - } - - const record = await getNotificationById(Number(req.id), workspaceId); - if (!record) { - throw notificationNotFoundError(req.id); - } - - // Delete the notification (cascade will delete associations) - await db.delete(notification).where(eq(notification.id, record.id)); - - return { success: true }; - }, - - async sendTestNotification(req, _ctx) { - const result = await sendTestNotification(req.provider, req.data); - return result; - }, - - async checkNotificationLimit(_req, ctx) { - const rpcCtx = getRpcContext(ctx); - const workspaceId = rpcCtx.workspace.id; - const limits = rpcCtx.workspace.limits; - - const info = await getNotificationLimitInfo(workspaceId, limits); - - return info; - }, - }; diff --git a/apps/server/src/routes/slack/interactions.ts b/apps/server/src/routes/slack/interactions.ts index 088c100a0..9b7303c27 100644 --- a/apps/server/src/routes/slack/interactions.ts +++ b/apps/server/src/routes/slack/interactions.ts @@ -1,10 +1,7 @@ -import { and, db, eq, inArray } from "@openstatus/db"; import { - maintenance, - maintenancesToPageComponents, - page, - pageComponent, -} from "@openstatus/db/src/schema"; + createMaintenance, + notifyMaintenance, +} from "@openstatus/services/maintenance"; import { addStatusReportUpdate, createStatusReport, @@ -14,9 +11,9 @@ import { } from "@openstatus/services/status-report"; import { WebClient } from "@slack/web-api"; import type { Context } from "hono"; -import { sendMaintenanceNotification } from "../rpc/services/maintenance"; import { consume, get } from "./confirmation-store"; import type { PendingAction } from "./confirmation-store"; +import { getPageUrl, getReportUrl } from "./page-urls"; import { toServiceCtx, toSlackMessage } from "./service-adapter"; import { resolveWorkspace } from "./workspace-resolver"; @@ -129,33 +126,6 @@ export async function handleSlackInteraction(c: Context) { return c.json({ ok: true }); } -async function getPageUrl(pageId: number): Promise { - const statusPage = await db - .select({ slug: page.slug, customDomain: page.customDomain }) - .from(page) - .where(eq(page.id, pageId)) - .get(); - - if (!statusPage) return null; - - return statusPage.customDomain - ? `https://${statusPage.customDomain}` - : `https://${statusPage.slug}.openstatus.dev`; -} - -async function getReportUrl(pageId: number, reportId: number): Promise { - const statusPage = await db - .select({ slug: page.slug, customDomain: page.customDomain }) - .from(page) - .where(eq(page.id, pageId)) - .get(); - - const baseUrl = statusPage?.customDomain - ? `https://${statusPage.customDomain}` - : `https://${statusPage?.slug}.openstatus.dev`; - return `${baseUrl}/events/report/${reportId}`; -} - async function executeAction( pending: PendingAction, notify: boolean, @@ -164,14 +134,11 @@ async function executeAction( messageTs: string, origin: { slackUserId: string; teamId: string | undefined }, ) { - const { action, workspaceId, limits } = pending; + const { action } = pending; // Hoisted out of the switch: every service-backed branch below uses // the same ctx, and `toServiceCtx` loads the workspace row from the - // db. Computing it once halves the per-action db traffic. Lazy enough - // — the `createMaintenance` branch still goes direct to db (migrates - // in PR 2) and doesn't need ctx, but the extra lookup there is - // negligible against the maintenance write volume. + // db. Computing it once halves the per-action db traffic. const ctx = await toServiceCtx({ pending, slackUserId: origin.slackUserId, @@ -297,7 +264,6 @@ async function executeAction( } case "createMaintenance": { - // Maintenance migrates in PR 2; still writes to db directly here. const { title, message, @@ -307,94 +273,23 @@ async function executeAction( pageComponentIds: maintenanceComponentIds, } = action.params; - const fromDate = new Date(from); - const toDate = new Date(to); - if (fromDate >= toDate) { - throw new Error("Start time must be before end time"); - } - - const newMaintenance = await db.transaction(async (tx) => { - const pageRecord = await tx - .select({ id: page.id }) - .from(page) - .where( - and( - eq(page.id, maintenancePageId), - eq(page.workspaceId, workspaceId), - ), - ) - .get(); - - if (!pageRecord) { - throw new Error("Page not found in this workspace"); - } - - const resolvedPageId = pageRecord.id; - - let componentIds: number[] = []; - if (maintenanceComponentIds?.length) { - const numericIds = maintenanceComponentIds.map((id) => Number(id)); - const validComponents = await tx - .select({ id: pageComponent.id, pageId: pageComponent.pageId }) - .from(pageComponent) - .where( - and( - inArray(pageComponent.id, numericIds), - eq(pageComponent.workspaceId, workspaceId), - ), - ) - .all(); - - if (validComponents.length !== numericIds.length) { - throw new Error("One or more page components not found"); - } - - const componentPageIds = new Set( - validComponents.map((c) => c.pageId), - ); - if (componentPageIds.size > 1) { - throw new Error("All components must belong to the same page"); - } - - const componentPageId = validComponents[0]?.pageId; - if (componentPageId !== null && componentPageId !== resolvedPageId) { - throw new Error( - "Selected components do not belong to the target status page", - ); - } - - componentIds = numericIds; - } - - const record = await tx - .insert(maintenance) - .values({ - workspaceId, - pageId: resolvedPageId, - title, - message, - from: fromDate, - to: toDate, - }) - .returning() - .get(); - - if (componentIds.length > 0) { - await tx.insert(maintenancesToPageComponents).values( - componentIds.map((pageComponentId) => ({ - maintenanceId: record.id, - pageComponentId, - })), - ); - } - - return record; + const newMaintenance = await createMaintenance({ + ctx, + input: { + title, + message, + from: new Date(from), + to: new Date(to), + pageId: maintenancePageId, + pageComponentIds: + maintenanceComponentIds?.map((id) => Number(id)) ?? [], + }, }); if (notify) { - await sendMaintenanceNotification({ - maintenanceId: newMaintenance.id, - limits, + await notifyMaintenance({ + ctx, + input: { maintenanceId: newMaintenance.id }, }); } diff --git a/apps/server/src/routes/slack/page-urls.ts b/apps/server/src/routes/slack/page-urls.ts new file mode 100644 index 000000000..8aab57f42 --- /dev/null +++ b/apps/server/src/routes/slack/page-urls.ts @@ -0,0 +1,44 @@ +import { db, eq } from "@openstatus/db"; +import { page } from "@openstatus/db/src/schema"; + +/** + * Slack-message URL helpers. Pure transport-layer formatting — lives here + * (not in `@openstatus/services`) because status-page URL construction is + * display-only and doesn't belong in the write-path service layer. Kept + * separate from `interactions.ts` so that file stays clean of db imports + * and can be covered by the Biome ban. + */ +export async function getPageUrl(pageId: number): Promise { + const statusPage = await db + .select({ slug: page.slug, customDomain: page.customDomain }) + .from(page) + .where(eq(page.id, pageId)) + .get(); + + if (!statusPage) return null; + return statusPage.customDomain + ? `https://${statusPage.customDomain}` + : `https://${statusPage.slug}.openstatus.dev`; +} + +export async function getReportUrl( + pageId: number, + reportId: number, +): Promise { + const statusPage = await db + .select({ slug: page.slug, customDomain: page.customDomain }) + .from(page) + .where(eq(page.id, pageId)) + .get(); + + // Mirror `getPageUrl`'s null-on-missing contract. Previously a missing + // page row produced `https://undefined.openstatus.dev/events/report/…` + // (because `statusPage?.slug` was undefined). Every existing caller + // already guards with `reportUrl ? … : …`, so returning `null` here + // is drop-in safe and keeps the two helpers symmetric. + if (!statusPage) return null; + const baseUrl = statusPage.customDomain + ? `https://${statusPage.customDomain}` + : `https://${statusPage.slug}.openstatus.dev`; + return `${baseUrl}/events/report/${reportId}`; +} diff --git a/apps/server/src/routes/slack/service-adapter.ts b/apps/server/src/routes/slack/service-adapter.ts index 93540be09..65ae52a8f 100644 --- a/apps/server/src/routes/slack/service-adapter.ts +++ b/apps/server/src/routes/slack/service-adapter.ts @@ -3,8 +3,7 @@ import { selectWorkspaceSchema, workspace as workspaceTable, } from "@openstatus/db/src/schema"; -import { type ServiceContext, ServiceError } from "@openstatus/services"; -import { ZodError } from "zod"; +import type { ServiceContext } from "@openstatus/services"; import type { PendingAction } from "./confirmation-store"; @@ -43,33 +42,21 @@ export async function toServiceCtx(args: { /** * Convert a service (or unknown) error into a Slack-friendly message. - * Slack's UI is single-line plain text, so we surface actionable text only - * and leave full error details to logtape/Sentry observability sinks. * - * `NOT_FOUND` messages are sanitised before reaching Slack because - * `NotFoundError` formats as `" not found"` (e.g. - * `"page_component 17 not found"`) — internal row IDs have no meaning - * to Slack users and shouldn't leak out of the service boundary. Other - * `ServiceError` subclasses take a hand-written message at their call - * site, so they're safe to forward verbatim. + * **Always returns the same generic string: `"Something went wrong. + * Please try again."`** Slack users aren't developers — they don't need + * (and shouldn't see) the specific error detail. The constraint is + * codified by the `does not leak internal error details` test case and + * was the existing pre-services behaviour we need to preserve. Full + * error text still flows to logtape / Sentry via the catch site. + * + * Notable things this intentionally suppresses: + * - `NotFoundError` formats as `" not found"` — internal + * row IDs have no meaning to Slack users. + * - `ConflictError` / `ValidationError` often carry SQL-ish phrasing + * or internal column names. + * - `ZodError` emits a raw JSON issue list. */ -export function toSlackMessage(err: unknown): string { - if (err instanceof ZodError) { - return ":x: The request was invalid."; - } - if (err instanceof ServiceError) { - switch (err.code) { - case "NOT_FOUND": - return ":x: Couldn't find what you were looking for."; - case "FORBIDDEN": - case "UNAUTHORIZED": - case "CONFLICT": - case "VALIDATION": - case "LIMIT_EXCEEDED": - return `:x: ${err.message}`; - case "INTERNAL": - return ":x: Something went wrong. Please try again."; - } - } +export function toSlackMessage(_err: unknown): string { return ":x: Something went wrong. Please try again."; } diff --git a/biome.jsonc b/biome.jsonc index 845914bbe..a84420999 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -80,20 +80,63 @@ ] }, // Overrides: scope the `@openstatus/db` / drizzle import ban to files that - // have been migrated onto `@openstatus/services`. The include list grows - // one domain per migration PR; when every domain has migrated, the final - // PR broadens this to cover all routers/handlers and drops the db dep - // from `packages/api` and `apps/server` outright. + // have been migrated onto `@openstatus/services`. Grown one domain per + // migration PR. + // + // Still outside the scope (each is its own follow-up): + // - `packages/api/src/router/statusPage.ts` — public viewer endpoints + // (~1280 lines) run under `publicProcedure` without an authed + // `ServiceContext`. + // - `packages/api/src/router/{member,integration,monitorTag, + // pageSubscriber,privateLocation,checker,feedback,stripe,tinybird, + // email}.ts` — small domains not yet lifted into services. + // - `apps/server/src/routes/rpc/handlers/monitor/**` — Connect monitor + // handler (~880 lines, 6 jobType-specific methods). + // - `apps/server/src/routes/rpc/handlers/status-page/**` — 13 of 18 + // methods (components / groups / subscribers / viewer) still use db; + // page CRUD is migrated but the file imports db for the rest. + // - `apps/server/src/routes/v1/**` — the public HTTP API surface; + // entirely unmigrated, intended to ride on top of services later. + // - `apps/server/src/routes/slack/**` except `interactions.ts` — tools, + // handler, oauth, workspace-resolver still use db. + // - `apps/server/src/routes/public/**` — public-facing HTTP routes. + // + // When those migrate, their paths get added here. The final consolidation + // (broadening to `router/**` + dropping the db dep from `packages/api` + + // `apps/server`) comes after. "overrides": [ { "include": [ "packages/api/src/router/statusReport.ts", - "apps/server/src/routes/rpc/services/status-report/**" + "packages/api/src/router/maintenance.ts", + "packages/api/src/router/incident.ts", + "packages/api/src/router/monitor.ts", + "packages/api/src/router/pageComponent.ts", + "packages/api/src/router/page.ts", + "packages/api/src/router/workspace.ts", + "packages/api/src/router/user.ts", + "packages/api/src/router/invitation.ts", + "packages/api/src/router/apiKey.ts", + "packages/api/src/router/import.ts", + "apps/server/src/routes/rpc/handlers/health/**", + "apps/server/src/routes/rpc/handlers/status-report/**", + "apps/server/src/routes/rpc/handlers/maintenance/**", + "apps/server/src/routes/rpc/handlers/notification/**", + "apps/server/src/routes/slack/interactions.ts" ], "ignore": [ // Tests legitimately manage db state for fixtures and cleanup. "**/__tests__/**", - "**/*.test.ts" + "**/*.test.ts", + // Notification handler siblings that still read db directly — + // `limits.ts` queries workspace quotas, `converters.ts` needs + // db enum shapes for proto round-trip. Both stay exempt until + // those responsibilities move into services. Using `**` above + // + these ignores (instead of whitelisting just `index.ts`) + // means new files added to the notification handler are + // enforced by default rather than silently slipping through. + "apps/server/src/routes/rpc/handlers/notification/limits.ts", + "apps/server/src/routes/rpc/handlers/notification/converters.ts" ], "linter": { "rules": { diff --git a/packages/api/src/router/apiKey.ts b/packages/api/src/router/apiKey.ts index 1935e3ce9..9141fe021 100644 --- a/packages/api/src/router/apiKey.ts +++ b/packages/api/src/router/apiKey.ts @@ -1,99 +1,52 @@ import { z } from "zod"; import { Events } from "@openstatus/analytics"; -import { db, eq } from "@openstatus/db"; -import { user, usersToWorkspaces, workspace } from "@openstatus/db/src/schema"; -import { createApiKeySchema } from "@openstatus/db/src/schema/api-keys/validation"; - -import { TRPCError } from "@trpc/server"; import { - createApiKey as createCustomApiKey, - getApiKeys, + CreateApiKeyInput, + createApiKey, + listApiKeys, revokeApiKey, -} from "../service/apiKey"; +} from "@openstatus/services/api-key"; + +import { toServiceCtx, toTRPCError } from "../service-adapter"; import { createTRPCRouter, protectedProcedure } from "../trpc"; export const apiKeyRouter = createTRPCRouter({ create: protectedProcedure .meta({ track: Events.CreateAPI }) - .input(createApiKeySchema) - .mutation(async ({ input, ctx }) => { - // Verify user has access to the workspace - const allowedWorkspaces = await db - .select() - .from(usersToWorkspaces) - .innerJoin(user, eq(user.id, usersToWorkspaces.userId)) - .innerJoin(workspace, eq(workspace.id, usersToWorkspaces.workspaceId)) - .where(eq(user.id, ctx.user.id)) - .all(); - - const allowedIds = allowedWorkspaces.map((i) => i.workspace.id); - - if (!allowedIds.includes(ctx.workspace.id)) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "Unauthorized", + .input(CreateApiKeyInput) + .mutation(async ({ ctx, input }) => { + try { + const { token, key } = await createApiKey({ + ctx: toServiceCtx(ctx), + input, }); + // One-time plaintext display; caller's UI shows it once then drops it. + return { token, key }; + } catch (err) { + toTRPCError(err); } - - // Create the API key using the custom service - const { token, key } = await createCustomApiKey( - ctx.workspace.id, - ctx.user.id, - input.name, - input.description, - input.expiresAt, - ); - - // Return both the key details and the full token (one-time display) - return { - token, - key, - }; }), revoke: protectedProcedure .meta({ track: Events.RevokeAPI }) .input(z.object({ keyId: z.number() })) - .mutation(async ({ input, ctx }) => { - // Revoke the key with workspace ownership verification - const success = await revokeApiKey(input.keyId, ctx.workspace.id); - - if (!success) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "API key not found or unauthorized", + .mutation(async ({ ctx, input }) => { + try { + await revokeApiKey({ + ctx: toServiceCtx(ctx), + input: { id: input.keyId }, }); + } catch (err) { + toTRPCError(err); } - - return; }), getAll: protectedProcedure.query(async ({ ctx }) => { - // Get all API keys for the workspace - const keys = await getApiKeys(ctx.workspace.id); - - // Fetch user information for each key's creator - const keysWithUserInfo = await Promise.all( - keys.map(async (key) => { - const creator = await db - .select({ - id: user.id, - email: user.email, - firstName: user.firstName, - lastName: user.lastName, - }) - .from(user) - .where(eq(user.id, key.createdById)) - .get(); - - return { - ...key, - createdBy: creator, - }; - }), - ); - - return keysWithUserInfo; + try { + return await listApiKeys({ ctx: toServiceCtx(ctx) }); + } catch (err) { + toTRPCError(err); + } }), }); diff --git a/packages/api/src/router/email/index.ts b/packages/api/src/router/email/index.ts index 638015f6c..9e6ebff92 100644 --- a/packages/api/src/router/email/index.ts +++ b/packages/api/src/router/email/index.ts @@ -3,16 +3,13 @@ import { z } from "zod"; import { and, eq } from "@openstatus/db"; import { invitation, - maintenance, pageSubscriber, selectWorkspaceSchema, } from "@openstatus/db/src/schema"; import { EmailClient } from "@openstatus/emails"; +import { notifyMaintenance } from "@openstatus/services/maintenance"; import { notifyStatusReport } from "@openstatus/services/status-report"; -import { - dispatchMaintenanceUpdate, - getChannel, -} from "@openstatus/subscriptions"; +import { getChannel } from "@openstatus/subscriptions"; import { TRPCError } from "@trpc/server"; import { env } from "../../env"; import { toServiceCtx, toTRPCError } from "../../service-adapter"; @@ -148,29 +145,15 @@ export const emailRouter = createTRPCRouter({ sendMaintenance: protectedProcedure .input(z.object({ id: z.number() })) .mutation(async (opts) => { - const limits = opts.ctx.workspace.limits; - - if (!limits["status-subscribers"]) { - return; - } - - const _maintenance = await opts.ctx.db.query.maintenance.findFirst({ - where: and( - eq(maintenance.id, opts.input.id), - eq(maintenance.workspaceId, opts.ctx.workspace.id), - ), - }); - - if (!_maintenance) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Maintenance not found", + try { + await notifyMaintenance({ + ctx: toServiceCtx(opts.ctx), + input: { maintenanceId: opts.input.id }, }); + return { success: true }; + } catch (err) { + toTRPCError(err); } - - await dispatchMaintenanceUpdate(opts.input.id); - - return { success: true }; }), sendTeamInvitation: protectedProcedure diff --git a/packages/api/src/router/import.test.ts b/packages/api/src/router/import.test.ts index e5684a246..de435ba0d 100644 --- a/packages/api/src/router/import.test.ts +++ b/packages/api/src/router/import.test.ts @@ -21,7 +21,6 @@ import { } from "@openstatus/importers/statuspage/fixtures"; import { edgeRouter } from "../edge"; -import { previewImport, runImport } from "../service/import"; import { createInnerTRPCContext } from "../trpc"; // --------------------------------------------------------------------------- @@ -68,13 +67,20 @@ function restoreFetch() { globalThis.fetch = originalFetch; } -function makeCaller(limitsOverride?: Partial) { +function makeCaller(limitsOverride?: Partial, workspaceId = 1) { const limits = { ...allPlans.starter.limits, ...limitsOverride }; const ctx = createInnerTRPCContext({ req: undefined, session: { user: { id: "1" } }, // @ts-expect-error - minimal workspace for test - workspace: { id: 1, limits }, + workspace: { id: workspaceId, limits }, + // Populate `user` too so the `NODE_ENV=test` escape hatch in + // `enforceUserIsAuthed` takes — otherwise the middleware would + // fall through to the DB read and replace our override limits + // with the seeded team-plan workspace, making every assertion + // below a no-op against the real team plan. + // @ts-expect-error - minimal user for test + user: { id: 1 }, }); return edgeRouter.createCaller(ctx); } @@ -414,48 +420,62 @@ test("run with includeStatusReports creates status reports", async () => { }); // --------------------------------------------------------------------------- -// Limit warnings (call service directly to control limits) +// Limit warnings (go through the caller with custom workspace limits to +// drive the service's plan gates) // --------------------------------------------------------------------------- const freeLimits = { ...allPlans.free.limits }; const starterLimits = { ...allPlans.starter.limits }; test("preview shows component limit warning on free plan", async () => { - const result = await previewImport({ + // Use workspaceId 2 (free seed) so the workspace-wide component count + // starts at 0 — workspace 1 has 2 seeded page components from the + // shared seed, which would make `remaining = 1` instead of 3 and + // change the warning wording. + const c = makeCaller({ ...freeLimits, "page-components": 3 }, 2); + const result = await c.import.preview({ + provider: "statuspage", apiKey: "test-key", - workspaceId: 1, - limits: { ...freeLimits, "page-components": 3 }, }); expect(result.errors.length).toBeGreaterThan(0); - expect(result.errors.some((e) => e.includes("3 of 4"))).toBe(true); + // Warning was reworded to clarify the worst-case count (see + // `limits.ts` — dropped the `"X of Y"` fraction because it implied + // a hard rejection count when duplicates would actually be skipped). + expect(result.errors.some((e) => e.includes("3 new component"))).toBe(true); }); test("preview shows custom domain warning on free plan", async () => { - const result = await previewImport({ + const c = makeCaller({ ...freeLimits, "custom-domain": false }); + const result = await c.import.preview({ + provider: "statuspage", apiKey: "test-key", - workspaceId: 1, - limits: { ...freeLimits, "custom-domain": false }, }); expect(result.errors.some((e) => e.includes("Custom domain"))).toBe(true); }); test("preview shows subscriber warning on free plan", async () => { - const result = await previewImport({ + const c = makeCaller({ ...freeLimits, "status-subscribers": false }); + // Pass `options.includeSubscribers: true` explicitly — the warning + // fires only when the user *intends* to import subscribers (and the + // plan disallows it). Default `includeSubscribers` is `false` + // (matches `ImportOptions`), so without the override the preview + // correctly stays silent for users who aren't importing subscribers. + const result = await c.import.preview({ + provider: "statuspage", apiKey: "test-key", - workspaceId: 1, - limits: { ...freeLimits, "status-subscribers": false }, + options: { includeSubscribers: true }, }); expect(result.errors.some((e) => e.includes("Subscribers"))).toBe(true); }); test("preview shows no warnings on starter plan", async () => { - const result = await previewImport({ + const c = makeCaller(starterLimits); + const result = await c.import.preview({ + provider: "statuspage", apiKey: "test-key", - workspaceId: 1, - limits: starterLimits, }); // Only informational warnings about non-email subscribers are expected @@ -482,11 +502,11 @@ test("run enforces component limit by truncating", async () => { if (!testPage) throw new Error("Failed to create test page"); createdIds.pages.push(testPage.id); - const result = await runImport({ + const c = makeCaller({ ...starterLimits, "page-components": 2 }); + const result = await c.import.run({ + provider: "statuspage", apiKey: "test-key", - workspaceId: 1, pageId: testPage.id, - limits: { ...starterLimits, "page-components": 2 }, options: { includeStatusReports: false, includeSubscribers: false }, }); @@ -515,10 +535,10 @@ test("run enforces component limit by truncating", async () => { }); test("run skips subscribers on free plan", async () => { - const result = await runImport({ + const c = makeCaller({ ...freeLimits, "status-subscribers": false }); + const result = await c.import.run({ + provider: "statuspage", apiKey: "test-key", - workspaceId: 1, - limits: { ...freeLimits, "status-subscribers": false }, options: { includeStatusReports: false, includeSubscribers: true }, }); diff --git a/packages/api/src/router/import.ts b/packages/api/src/router/import.ts index 72cd922a3..07d54445c 100644 --- a/packages/api/src/router/import.ts +++ b/packages/api/src/router/import.ts @@ -1,88 +1,31 @@ -import { and, db, eq } from "@openstatus/db"; -import { page } from "@openstatus/db/src/schema"; -import { TRPCError } from "@trpc/server"; -import { z } from "zod"; -import { previewImport, runImport } from "../service/import"; +import { + PreviewImportInput, + RunImportInput, + previewImport, + runImport, +} from "@openstatus/services/import"; + +import { toServiceCtx, toTRPCError } from "../service-adapter"; import { createTRPCRouter, protectedProcedure } from "../trpc"; export const importRouter = createTRPCRouter({ preview: protectedProcedure - .input( - z.object({ - provider: z.enum(["statuspage", "betterstack", "instatus"]), - apiKey: z.string().min(1), - statuspagePageId: z.string().nullish(), - betterstackStatusPageId: z.string().nullish(), - instatusPageId: z.string().nullish(), - pageId: z.number().optional(), - }), - ) - .mutation(async (opts) => { - return previewImport({ - provider: opts.input.provider, - apiKey: opts.input.apiKey, - statuspagePageId: opts.input.statuspagePageId ?? undefined, - betterstackStatusPageId: - opts.input.betterstackStatusPageId ?? undefined, - instatusPageId: opts.input.instatusPageId ?? undefined, - workspaceId: opts.ctx.workspace.id, - pageId: opts.input.pageId, - limits: opts.ctx.workspace.limits, - }); + .input(PreviewImportInput) + .mutation(async ({ ctx, input }) => { + try { + return await previewImport({ ctx: toServiceCtx(ctx), input }); + } catch (err) { + toTRPCError(err); + } }), run: protectedProcedure - .input( - z.object({ - provider: z.enum(["statuspage", "betterstack", "instatus"]), - apiKey: z.string().min(1), - pageId: z.number().optional(), - statuspagePageId: z.string().nullish(), - betterstackStatusPageId: z.string().nullish(), - instatusPageId: z.string().nullish(), - options: z - .object({ - includeStatusReports: z.boolean().default(true), - includeSubscribers: z.boolean().default(false), - includeComponents: z.boolean().default(true), - includeMonitors: z.boolean().default(true), - }) - .optional(), - }), - ) - .mutation(async (opts) => { - // If pageId provided, verify it belongs to workspace - if (opts.input.pageId) { - const existing = await db - .select() - .from(page) - .where( - and( - eq(page.id, opts.input.pageId), - eq(page.workspaceId, opts.ctx.workspace.id), - ), - ) - .get(); - - if (!existing) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Page not found or does not belong to this workspace", - }); - } + .input(RunImportInput) + .mutation(async ({ ctx, input }) => { + try { + return await runImport({ ctx: toServiceCtx(ctx), input }); + } catch (err) { + toTRPCError(err); } - - return runImport({ - provider: opts.input.provider, - apiKey: opts.input.apiKey, - statuspagePageId: opts.input.statuspagePageId ?? undefined, - betterstackStatusPageId: - opts.input.betterstackStatusPageId ?? undefined, - instatusPageId: opts.input.instatusPageId ?? undefined, - workspaceId: opts.ctx.workspace.id, - pageId: opts.input.pageId, - options: opts.input.options, - limits: opts.ctx.workspace.limits, - }); }), }); diff --git a/packages/api/src/router/incident.ts b/packages/api/src/router/incident.ts index 6357dfa57..2566c03f6 100644 --- a/packages/api/src/router/incident.ts +++ b/packages/api/src/router/incident.ts @@ -1,38 +1,33 @@ -import { z } from "zod"; - -import { type SQL, and, asc, desc, eq, gte, schema } from "@openstatus/db"; +import { Events } from "@openstatus/analytics"; +import { NotFoundError } from "@openstatus/services"; import { - incidentTable, - selectIncidentSchema, - selectMonitorSchema, -} from "@openstatus/db/src/schema"; + acknowledgeIncident, + deleteIncident, + listIncidents, + resolveIncident, +} from "@openstatus/services/incident"; +import { z } from "zod"; -import { Events } from "@openstatus/analytics"; -import { TRPCError } from "@trpc/server"; +import { toServiceCtx, toTRPCError } from "../service-adapter"; import { createTRPCRouter, protectedProcedure } from "../trpc"; -import { getPeriodDate, periods } from "./utils"; +import { periods } from "./utils"; export const incidentRouter = createTRPCRouter({ delete: protectedProcedure .meta({ track: Events.DeleteIncident }) .input(z.object({ id: z.number() })) - .mutation(async (opts) => { - const incidentToDelete = await opts.ctx.db - .select() - .from(schema.incidentTable) - .where( - and( - eq(schema.incidentTable.id, opts.input.id), - eq(schema.incidentTable.workspaceId, opts.ctx.workspace.id), - ), - ) - .get(); - if (!incidentToDelete) return; - - await opts.ctx.db - .delete(schema.incidentTable) - .where(eq(schema.incidentTable.id, incidentToDelete.id)) - .run(); + .mutation(async ({ ctx, input }) => { + try { + await deleteIncident({ + ctx: toServiceCtx(ctx), + input: { id: input.id }, + }); + } catch (err) { + // Preserve the pre-migration idempotent behaviour — the old tRPC + // delete silently returned when the row was already gone. + if (err instanceof NotFoundError) return; + toTRPCError(err); + } }), list: protectedProcedure @@ -45,121 +40,79 @@ export const incidentRouter = createTRPCRouter({ }) .optional(), ) - .query(async (opts) => { - const whereConditions: SQL[] = [ - eq(incidentTable.workspaceId, opts.ctx.workspace.id), - ]; - - if (opts.input?.period) { - whereConditions.push( - gte(incidentTable.startedAt, getPeriodDate(opts.input.period)), - ); - } - - if (opts.input?.monitorId) { - whereConditions.push(eq(incidentTable.monitorId, opts.input.monitorId)); + .query(async ({ ctx, input }) => { + try { + const { items } = await listIncidents({ + ctx: toServiceCtx(ctx), + input: { + monitorId: input?.monitorId ?? undefined, + period: input?.period, + order: input?.order ?? "desc", + // Same sentinel as status-report / maintenance — dashboard has + // no paging UI; Connect-equivalent would cap externally. + limit: 10_000, + offset: 0, + }, + }); + // Filter-and-log instead of throwing on orphaned rows: a single + // incident missing its monitor (data-migration artifact, partial + // cascade) shouldn't blow up the whole list and break the + // `/overview` / `/monitors/:id/incidents` surfaces. We still log + // so the inconsistency remains visible. + return items.filter(hasMonitor); + } catch (err) { + toTRPCError(err); } - - const result = await opts.ctx.db.query.incidentTable.findMany({ - where: and(...whereConditions), - with: { - monitor: true, - }, - orderBy: - opts.input?.order === "asc" - ? asc(incidentTable.startedAt) - : desc(incidentTable.startedAt), - }); - - return selectIncidentSchema - .extend({ - monitor: selectMonitorSchema, - }) - .array() - .parse(result); }), acknowledge: protectedProcedure .meta({ track: Events.AcknowledgeIncident }) .input(z.object({ id: z.number() })) - .mutation(async (opts) => { - const currentIncident = await opts.ctx.db - .select() - .from(schema.incidentTable) - .where( - and( - eq(schema.incidentTable.id, opts.input.id), - eq(schema.incidentTable.workspaceId, opts.ctx.workspace.id), - ), - ) - .get(); - if (!currentIncident) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Incident not found", - }); - } - if (currentIncident.acknowledgedAt) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Incident already acknowledged", + .mutation(async ({ ctx, input }) => { + try { + await acknowledgeIncident({ + ctx: toServiceCtx(ctx), + input: { id: input.id }, }); + // Old contract was `return true`; preserve. + return true; + } catch (err) { + toTRPCError(err); } - await opts.ctx.db - .update(schema.incidentTable) - .set({ - acknowledgedAt: new Date(), - acknowledgedBy: opts.ctx.user.id, - updatedAt: new Date(), - }) - .where( - and( - eq(schema.incidentTable.id, opts.input.id), - eq(schema.incidentTable.workspaceId, opts.ctx.workspace.id), - ), - ); - return true; }), resolve: protectedProcedure .meta({ track: Events.ResolveIncident }) .input(z.object({ id: z.number() })) - .mutation(async (opts) => { - const currentIncident = await opts.ctx.db - .select() - .from(schema.incidentTable) - .where( - and( - eq(schema.incidentTable.id, opts.input.id), - eq(schema.incidentTable.workspaceId, opts.ctx.workspace.id), - ), - ) - .get(); - if (!currentIncident) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Incident not found", - }); - } - if (currentIncident.resolvedAt) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Incident already resolved", + .mutation(async ({ ctx, input }) => { + try { + await resolveIncident({ + ctx: toServiceCtx(ctx), + input: { id: input.id }, }); + return true; + } catch (err) { + toTRPCError(err); } - await opts.ctx.db - .update(schema.incidentTable) - .set({ - resolvedAt: new Date(), - resolvedBy: opts.ctx.user.id, - updatedAt: new Date(), - }) - .where( - and( - eq(schema.incidentTable.id, opts.input.id), - eq(schema.incidentTable.workspaceId, opts.ctx.workspace.id), - ), - ); - return true; }), }); + +/** + * Type predicate: narrow `monitor` to non-null for the tRPC list return. + * The old zod parse required `monitor: selectMonitorSchema`, so clients + * rely on the non-null shape. An orphan here is a data-integrity signal + * (FK `set default` on delete should prevent it, but migrations / + * partial cascades have produced them historically) — we log and drop + * rather than throw, so a single bad row can't break the whole list. + */ +function hasMonitor( + incident: T, +): incident is T & { monitor: NonNullable } { + if (incident.monitor == null) { + console.warn( + `incident ${incident.id} has no associated monitor (data inconsistency); dropping from list`, + ); + return false; + } + return true; +} diff --git a/packages/api/src/router/invitation.ts b/packages/api/src/router/invitation.ts index b80117c00..cd302865b 100644 --- a/packages/api/src/router/invitation.ts +++ b/packages/api/src/router/invitation.ts @@ -1,206 +1,100 @@ -import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { Events } from "@openstatus/analytics"; -import { type SQL, and, db, eq, gte, isNull } from "@openstatus/db"; import { - insertInvitationSchema, - invitation, - selectInvitationSchema, - selectWorkspaceSchema, - usersToWorkspaces, - workspace, -} from "@openstatus/db/src/schema"; + CreateInvitationInput, + acceptInvitation, + createInvitation, + deleteInvitation, + getInvitationByToken, + listInvitations, +} from "@openstatus/services/invitation"; +import { TRPCError } from "@trpc/server"; +import { toServiceCtx, toTRPCError } from "../service-adapter"; import { createTRPCRouter, protectedProcedure } from "../trpc"; export const invitationRouter = createTRPCRouter({ create: protectedProcedure .meta({ track: Events.InviteUser, trackProps: ["email"] }) - .input(insertInvitationSchema.pick({ email: true })) - .mutation(async (opts) => { - const { email } = opts.input; - - const _members = opts.ctx.workspace.limits.members; - const membersLimit = _members === "Unlimited" ? 420 : _members; - - const usersToWorkspacesNumbers = ( - await opts.ctx.db.query.usersToWorkspaces.findMany({ - where: eq(usersToWorkspaces.workspaceId, opts.ctx.workspace.id), - }) - ).length; - - const openInvitationsNumbers = ( - await opts.ctx.db.query.invitation.findMany({ - where: and( - eq(invitation.workspaceId, opts.ctx.workspace.id), - gte(invitation.expiresAt, new Date()), - isNull(invitation.acceptedAt), - ), - }) - ).length; - - // the user has reached the limits - if (usersToWorkspacesNumbers + openInvitationsNumbers >= membersLimit) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "You reached your member limits.", + .input(CreateInvitationInput) + .mutation(async ({ ctx, input }) => { + try { + return await createInvitation({ + ctx: toServiceCtx(ctx), + input, }); + } catch (err) { + toTRPCError(err); } - - const expiresAt = new Date(); - expiresAt.setDate(expiresAt.getDate() + 7); - - const token = crypto.randomUUID(); - - const _invitation = await opts.ctx.db - .insert(invitation) - .values({ email, expiresAt, token, workspaceId: opts.ctx.workspace.id }) - .returning() - .get(); - - if (process.env.NODE_ENV === "development") { - console.log( - `>>>> Invitation token: http://localhost:3000/invite?token=${token} <<<< `, - ); - } - - return _invitation; }), delete: protectedProcedure .input(z.object({ id: z.number() })) .meta({ track: Events.DeleteInvite }) - .mutation(async (opts) => { - await opts.ctx.db - .delete(invitation) - .where( - and( - eq(invitation.id, opts.input.id), - eq(invitation.workspaceId, opts.ctx.workspace.id), - ), - ) - .run(); + .mutation(async ({ ctx, input }) => { + try { + await deleteInvitation({ + ctx: toServiceCtx(ctx), + input: { id: input.id }, + }); + } catch (err) { + toTRPCError(err); + } }), - list: protectedProcedure.query(async (opts) => { - const whereConditions: SQL[] = [ - eq(invitation.workspaceId, opts.ctx.workspace.id), - gte(invitation.expiresAt, new Date()), - isNull(invitation.acceptedAt), - ]; - - const result = await opts.ctx.db.query.invitation.findMany({ - where: and(...whereConditions), - }); - - return result; + list: protectedProcedure.query(async ({ ctx }) => { + try { + return await listInvitations({ ctx: toServiceCtx(ctx) }); + } catch (err) { + toTRPCError(err); + } }), get: protectedProcedure .input(z.object({ token: z.string().nullable() })) - .query(async (opts) => { - if (!opts.ctx.user.email) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "You are not authorized to access this resource.", - }); - } - - if (!opts.input.token) { + .query(async ({ ctx, input }) => { + if (!input.token) { throw new TRPCError({ code: "BAD_REQUEST", message: "Token is required.", }); } - - const result = await opts.ctx.db.query.invitation.findFirst({ - where: and( - eq(invitation.token, opts.input.token), - isNull(invitation.acceptedAt), - gte(invitation.expiresAt, new Date()), - eq(invitation.email, opts.ctx.user.email), - ), - with: { - workspace: true, - }, - }); - - if (!result) { + if (!ctx.user.email) { throw new TRPCError({ - code: "NOT_FOUND", - message: "Invitation not found.", + code: "UNAUTHORIZED", + message: "You are not authorized to access this resource.", }); } - - return selectInvitationSchema - .extend({ - workspace: selectWorkspaceSchema, - }) - .parse(result); + try { + return await getInvitationByToken({ + ctx: toServiceCtx(ctx), + input: { token: input.token, email: ctx.user.email }, + }); + } catch (err) { + toTRPCError(err); + } }), accept: protectedProcedure .input(z.object({ id: z.number() })) - .mutation(async (opts) => { - if (!opts.ctx.user.email) { + .mutation(async ({ ctx, input }) => { + if (!ctx.user.email) { throw new TRPCError({ code: "UNAUTHORIZED", message: "You are not authorized to access this resource.", }); } - - const _invitation = await opts.ctx.db.query.invitation.findFirst({ - where: and( - eq(invitation.id, opts.input.id), - eq(invitation.email, opts.ctx.user.email), - isNull(invitation.acceptedAt), - gte(invitation.expiresAt, new Date()), - ), - with: { - workspace: true, - }, - }); - - if (!_invitation) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Invitation not found.", - }); - } - - if (_invitation.acceptedAt) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Invitation already accepted.", + try { + return await acceptInvitation({ + ctx: toServiceCtx(ctx), + input: { + id: input.id, + email: ctx.user.email, + }, }); + } catch (err) { + toTRPCError(err); } - - const result = await db.transaction(async (tx) => { - await tx - .update(invitation) - .set({ - acceptedAt: new Date(), - }) - .where(eq(invitation.id, opts.input.id)) - .run(); - - await tx - .insert(usersToWorkspaces) - .values({ - userId: opts.ctx.user.id, - workspaceId: _invitation.workspaceId, - role: _invitation.role, - }) - .run(); - - const _workspace = await tx.query.workspace.findFirst({ - where: eq(workspace.id, _invitation.workspaceId), - }); - - return _workspace; - }); - - return result; }), }); diff --git a/packages/api/src/router/maintenance.ts b/packages/api/src/router/maintenance.ts index 57e47aa20..12b46da7c 100644 --- a/packages/api/src/router/maintenance.ts +++ b/packages/api/src/router/maintenance.ts @@ -1,34 +1,38 @@ -import { z } from "zod"; - -import { type SQL, and, asc, desc, eq, gte, inArray } from "@openstatus/db"; +import { Events } from "@openstatus/analytics"; +import { NotFoundError } from "@openstatus/services"; import { - maintenance, - maintenancesToPageComponents, - page, - pageComponent, - selectMaintenanceSchema, - selectPageComponentSchema, -} from "@openstatus/db/src/schema"; + createMaintenance, + deleteMaintenance, + listMaintenances, + updateMaintenance, +} from "@openstatus/services/maintenance"; +import { z } from "zod"; -import { Events } from "@openstatus/analytics"; -import { TRPCError } from "@trpc/server"; +import { toServiceCtx, toTRPCError } from "../service-adapter"; import { createTRPCRouter, protectedProcedure } from "../trpc"; -import { getPeriodDate, periods } from "./utils"; +import { periods } from "./utils"; export const maintenanceRouter = createTRPCRouter({ delete: protectedProcedure .meta({ track: Events.DeleteMaintenance }) .input(z.object({ id: z.number() })) - .mutation(async (opts) => { - return await opts.ctx.db - .delete(maintenance) - .where( - and( - eq(maintenance.id, opts.input.id), - eq(maintenance.workspaceId, opts.ctx.workspace.id), - ), - ) - .returning(); + .mutation(async ({ ctx, input }) => { + try { + await deleteMaintenance({ + ctx: toServiceCtx(ctx), + input: { id: input.id }, + }); + // Preserve the old contract — return an empty array that looks like + // drizzle's `.returning()` result. Callers today ignore the payload, + // only `.mutate` success/failure is observed. + return [] as Array; + } catch (err) { + // Preserve the pre-migration idempotent behaviour — the old tRPC + // delete silently succeeded when the row was already gone. Connect + // still returns 404 on missing; external API semantics preserved. + if (err instanceof NotFoundError) return [] as Array; + toTRPCError(err); + } }), list: protectedProcedure @@ -41,47 +45,24 @@ export const maintenanceRouter = createTRPCRouter({ }) .optional(), ) - .query(async (opts) => { - const whereConditions: SQL[] = [ - eq(maintenance.workspaceId, opts.ctx.workspace.id), - ]; - - if (opts.input?.period) { - whereConditions.push( - gte(maintenance.createdAt, getPeriodDate(opts.input.period)), - ); - } - - if (opts.input?.pageId) { - whereConditions.push(eq(maintenance.pageId, opts.input.pageId)); + .query(async ({ ctx, input }) => { + try { + const { items } = await listMaintenances({ + ctx: toServiceCtx(ctx), + input: { + pageId: input?.pageId, + period: input?.period, + order: input?.order ?? "desc", + // tRPC callers (dashboard) want the full set — see + // statusReport.list for the same reasoning. + limit: 10_000, + offset: 0, + }, + }); + return items; + } catch (err) { + toTRPCError(err); } - - const query = opts.ctx.db.query.maintenance.findMany({ - where: and(...whereConditions), - orderBy: - opts.input?.order === "asc" - ? asc(maintenance.createdAt) - : desc(maintenance.createdAt), - with: { - maintenancesToPageComponents: { with: { pageComponent: true } }, - }, - }); - - const result = await query; - - return selectMaintenanceSchema - .extend({ - pageComponents: z.array(selectPageComponentSchema).prefault([]), - }) - .array() - .parse( - result.map((m) => ({ - ...m, - pageComponents: m.maintenancesToPageComponents.map( - ({ pageComponent }) => pageComponent, - ), - })), - ); }), new: protectedProcedure @@ -97,71 +78,23 @@ export const maintenanceRouter = createTRPCRouter({ notifySubscribers: z.boolean().nullish(), }), ) - .mutation(async (opts) => { - const existingPage = await opts.ctx.db.query.page.findFirst({ - where: and( - eq(page.id, opts.input.pageId), - eq(page.workspaceId, opts.ctx.workspace.id), - ), - }); - - if (!existingPage) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Page not found.", + .mutation(async ({ ctx, input }) => { + try { + const record = await createMaintenance({ + ctx: toServiceCtx(ctx), + input: { + title: input.title, + message: input.message, + from: input.startDate, + to: input.endDate, + pageId: input.pageId, + pageComponentIds: input.pageComponents ?? [], + }, }); + return { ...record, notifySubscribers: input.notifySubscribers }; + } catch (err) { + toTRPCError(err); } - - // Check if the user has access to the page components - if (opts.input.pageComponents?.length) { - const whereConditions: SQL[] = [ - eq(pageComponent.workspaceId, opts.ctx.workspace.id), - inArray(pageComponent.id, opts.input.pageComponents), - ]; - const pageComponents = await opts.ctx.db - .select() - .from(pageComponent) - .where(and(...whereConditions)) - .all(); - - if (pageComponents.length !== opts.input.pageComponents.length) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "You do not have access to all the page components", - }); - } - } - - const newMaintenance = await opts.ctx.db.transaction(async (tx) => { - const newMaintenance = await tx - .insert(maintenance) - .values({ - pageId: opts.input.pageId, - workspaceId: opts.ctx.workspace.id, - title: opts.input.title, - message: opts.input.message, - from: opts.input.startDate, - to: opts.input.endDate, - }) - .returning() - .get(); - - if (opts.input.pageComponents?.length) { - await tx.insert(maintenancesToPageComponents).values( - opts.input.pageComponents.map((pageComponentId) => ({ - maintenanceId: newMaintenance.id, - pageComponentId, - })), - ); - } - - return newMaintenance; - }); - - return { - ...newMaintenance, - notifySubscribers: opts.input.notifySubscribers, - }; }), update: protectedProcedure @@ -176,81 +109,21 @@ export const maintenanceRouter = createTRPCRouter({ pageComponents: z.array(z.number()).optional(), }), ) - .mutation(async (opts) => { - // Check if the user has access to the monitors - if (opts.input.pageComponents?.length) { - const whereConditions: SQL[] = [ - eq(pageComponent.workspaceId, opts.ctx.workspace.id), - inArray(pageComponent.id, opts.input.pageComponents), - ]; - const pageComponents = await opts.ctx.db - .select() - .from(pageComponent) - .where(and(...whereConditions)) - .all(); - - if (pageComponents.length !== opts.input.pageComponents.length) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "You do not have access to all the page components", - }); - } - } - - const existing = await opts.ctx.db.query.maintenance.findFirst({ - where: and( - eq(maintenance.id, opts.input.id), - eq(maintenance.workspaceId, opts.ctx.workspace.id), - ), - }); - - if (!existing) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Maintenance not found", + .mutation(async ({ ctx, input }) => { + try { + await updateMaintenance({ + ctx: toServiceCtx(ctx), + input: { + id: input.id, + title: input.title, + message: input.message, + from: input.startDate, + to: input.endDate, + pageComponentIds: input.pageComponents, + }, }); + } catch (err) { + toTRPCError(err); } - - await opts.ctx.db.transaction(async (tx) => { - // Update the maintenance - const _maintenance = await tx - .update(maintenance) - .set({ - title: opts.input.title, - message: opts.input.message, - from: opts.input.startDate, - to: opts.input.endDate, - workspaceId: opts.ctx.workspace.id, - updatedAt: new Date(), - }) - .where( - and( - eq(maintenance.id, opts.input.id), - eq(maintenance.workspaceId, opts.ctx.workspace.id), - ), - ) - .returning() - .get(); - - // Delete all existing relations - await tx - .delete(maintenancesToPageComponents) - .where( - eq(maintenancesToPageComponents.maintenanceId, _maintenance.id), - ) - .run(); - - // Create new relations if page components are provided - if (opts.input.pageComponents?.length) { - await tx.insert(maintenancesToPageComponents).values( - opts.input.pageComponents.map((pageComponentId) => ({ - maintenanceId: _maintenance.id, - pageComponentId, - })), - ); - } - - return _maintenance; - }); }), }); diff --git a/packages/api/src/router/monitor.ts b/packages/api/src/router/monitor.ts index ee6dfd1c1..b0c21b323 100644 --- a/packages/api/src/router/monitor.ts +++ b/packages/api/src/router/monitor.ts @@ -1,329 +1,208 @@ -import { TRPCError } from "@trpc/server"; -import { z } from "zod"; - import { - type Assertion, - DnsRecordAssertion, - HeaderAssertion, - StatusAssertion, - TextBodyAssertion, headerAssertion, jsonBodyAssertion, recordAssertion, - serialize, statusAssertion, textBodyAssertion, } from "@openstatus/assertions"; -import { type SQL, and, count, eq, inArray, isNull } from "@openstatus/db"; +import { NotFoundError } from "@openstatus/services"; import { - insertMonitorSchema, - monitor, + type CreateMonitorInput, + type UpdateMonitorGeneralInput, + bulkUpdateMonitors, + cloneMonitor, + createMonitor, + deleteMonitor, + deleteMonitors, + getMonitor, + listMonitors, monitorJobTypes, monitorMethods, - monitorTag, - monitorTagsToMonitors, - notification, - notificationsToMonitors, - pageComponent, - privateLocation, - privateLocationToMonitors, - selectIncidentSchema, - selectMonitorSchema, - selectMonitorTagSchema, - selectNotificationSchema, - selectPrivateLocationSchema, -} from "@openstatus/db/src/schema"; + monitorPeriodicity, + updateMonitorFollowRedirects, + updateMonitorGeneral, + updateMonitorNotifiers, + updateMonitorOtel, + updateMonitorPublic, + updateMonitorResponseTime, + updateMonitorRetry, + updateMonitorSchedulingRegions, + updateMonitorTags, +} from "@openstatus/services/monitor"; +import { z } from "zod"; import { Events } from "@openstatus/analytics"; -import { - freeFlyRegions, - monitorPeriodicity, - monitorRegions, -} from "@openstatus/db/src/schema/constants"; -import { regionDict } from "@openstatus/regions"; +import { toServiceCtx, toTRPCError } from "../service-adapter"; import { createTRPCRouter, protectedProcedure } from "../trpc"; import { testDns, testHttp, testTcp } from "./checker"; +// tRPC-side input schemas. These preserve the existing wire contract exactly. + +const headerPair = z.object({ key: z.string(), value: z.string() }); +const assertionUnion = z.discriminatedUnion("type", [ + statusAssertion, + headerAssertion, + textBodyAssertion, + jsonBodyAssertion, + recordAssertion, +]); + +const newMonitorTRPCInput = z.object({ + name: z.string(), + jobType: z.enum(monitorJobTypes), + url: z.string(), + method: z.enum(monitorMethods), + headers: z.array(headerPair), + body: z.string().optional(), + assertions: z.array(assertionUnion), + active: z.boolean().prefault(false), + saveCheck: z.boolean().prefault(false), + skipCheck: z.boolean().prefault(false), +}); + +const updateGeneralTRPCInput = z.object({ + id: z.number(), + jobType: z.enum(monitorJobTypes), + url: z.string(), + method: z.enum(monitorMethods), + headers: z.array(headerPair), + body: z.string().optional(), + name: z.string(), + assertions: z.array(assertionUnion), + active: z.boolean().prefault(true), + skipCheck: z.boolean().prefault(true), + saveCheck: z.boolean().prefault(false), +}); + export const monitorRouter = createTRPCRouter({ delete: protectedProcedure .meta({ track: Events.DeleteMonitor }) .input(z.object({ id: z.number() })) - .mutation(async (opts) => { - const monitorToDelete = await opts.ctx.db - .select() - .from(monitor) - .where( - and( - eq(monitor.id, opts.input.id), - eq(monitor.workspaceId, opts.ctx.workspace.id), - ), - ) - .get(); - if (!monitorToDelete) return; - - await opts.ctx.db.transaction(async (tx) => { - await tx - .update(monitor) - .set({ deletedAt: new Date(), active: false }) - .where(eq(monitor.id, monitorToDelete.id)); - await tx - .delete(monitorTagsToMonitors) - .where(eq(monitorTagsToMonitors.monitorId, monitorToDelete.id)); - await tx - .delete(notificationsToMonitors) - .where(eq(notificationsToMonitors.monitorId, monitorToDelete.id)); - await tx - .delete(pageComponent) - .where(eq(pageComponent.monitorId, monitorToDelete.id)); - }); + .mutation(async ({ ctx, input }) => { + try { + await deleteMonitor({ + ctx: toServiceCtx(ctx), + input: { id: input.id }, + }); + } catch (err) { + // Preserve the pre-migration idempotent behaviour — old code + // silently returned when the row was already gone. + if (err instanceof NotFoundError) return; + toTRPCError(err); + } }), deleteMonitors: protectedProcedure .input(z.object({ ids: z.number().array() })) - .mutation(async (opts) => { - const _monitors = await opts.ctx.db - .select() - .from(monitor) - .where( - and( - inArray(monitor.id, opts.input.ids), - eq(monitor.workspaceId, opts.ctx.workspace.id), - ), - ) - .all(); - - if (_monitors.length !== opts.input.ids.length) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Monitor not found.", + .mutation(async ({ ctx, input }) => { + try { + await deleteMonitors({ + ctx: toServiceCtx(ctx), + input: { ids: input.ids }, }); + } catch (err) { + toTRPCError(err); } - - await opts.ctx.db.transaction(async (tx) => { - await tx - .update(monitor) - .set({ deletedAt: new Date(), active: false }) - .where(inArray(monitor.id, opts.input.ids)); - await tx - .delete(monitorTagsToMonitors) - .where(inArray(monitorTagsToMonitors.monitorId, opts.input.ids)); - await tx - .delete(notificationsToMonitors) - .where(inArray(notificationsToMonitors.monitorId, opts.input.ids)); - await tx - .delete(pageComponent) - .where(inArray(pageComponent.monitorId, opts.input.ids)); - }); }), updateMonitors: protectedProcedure .input( - insertMonitorSchema - .pick({ public: true, active: true }) - .partial() // batched updates - .extend({ ids: z.number().array() }), // array of monitor ids to update + z.object({ + ids: z.number().array(), + public: z.boolean().optional(), + active: z.boolean().optional(), + }), ) - .mutation(async (opts) => { - await opts.ctx.db - .update(monitor) - .set(opts.input) - .where( - and( - inArray(monitor.id, opts.input.ids), - eq(monitor.workspaceId, opts.ctx.workspace.id), - isNull(monitor.deletedAt), - ), - ); + .mutation(async ({ ctx, input }) => { + try { + await bulkUpdateMonitors({ + ctx: toServiceCtx(ctx), + input: { + ids: input.ids, + public: input.public, + active: input.active, + }, + }); + } catch (err) { + toTRPCError(err); + } }), list: protectedProcedure - .input( - z - .object({ - order: z.enum(["asc", "desc"]).optional(), - }) - .optional(), - ) - .query(async (opts) => { - const whereConditions: SQL[] = [ - eq(monitor.workspaceId, opts.ctx.workspace.id), - isNull(monitor.deletedAt), - ]; - - const result = await opts.ctx.db.query.monitor.findMany({ - where: and(...whereConditions), - with: { - monitorTagsToMonitors: { - with: { monitorTag: true }, - }, - incidents: { - orderBy: (incident, { desc }) => [desc(incident.createdAt)], + .input(z.object({ order: z.enum(["asc", "desc"]).optional() }).optional()) + .query(async ({ ctx, input }) => { + try { + const { items } = await listMonitors({ + ctx: toServiceCtx(ctx), + input: { + // tRPC consumers (dashboard) want the full set; no paging UI. + limit: 10_000, + offset: 0, + order: input?.order ?? "desc", }, - }, - orderBy: (monitor, { asc, desc }) => - opts.input?.order === "asc" - ? [asc(monitor.active), asc(monitor.createdAt)] - : [desc(monitor.active), desc(monitor.createdAt)], - }); - - return z - .array( - selectMonitorSchema.extend({ - tags: z.array(selectMonitorTagSchema).prefault([]), - incidents: z.array(selectIncidentSchema).prefault([]), - }), - ) - .parse( - result.map((data) => ({ - ...data, - tags: data.monitorTagsToMonitors.map((t) => t.monitorTag), - })), - ); + }); + return items; + } catch (err) { + toTRPCError(err); + } }), get: protectedProcedure .input(z.object({ id: z.coerce.number() })) .query(async ({ ctx, input }) => { - const whereConditions: SQL[] = [ - eq(monitor.id, input.id), - eq(monitor.workspaceId, ctx.workspace.id), - isNull(monitor.deletedAt), - ]; - - const data = await ctx.db.query.monitor.findFirst({ - where: and(...whereConditions), - with: { - monitorsToNotifications: { - with: { notification: true }, - }, - monitorTagsToMonitors: { - with: { monitorTag: true }, - }, - incidents: true, - privateLocationToMonitors: { - with: { privateLocation: true }, - }, - }, - }); - - if (!data) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Monitor not found", + try { + return await getMonitor({ + ctx: toServiceCtx(ctx), + input: { id: input.id }, }); + } catch (err) { + toTRPCError(err); } - - return selectMonitorSchema - .extend({ - notifications: z.array(selectNotificationSchema).prefault([]), - tags: z.array(selectMonitorTagSchema).prefault([]), - incidents: z.array(selectIncidentSchema).prefault([]), - privateLocations: z.array(selectPrivateLocationSchema).prefault([]), - }) - .parse({ - ...data, - notifications: data.monitorsToNotifications.map( - (m) => m.notification, - ), - tags: data.monitorTagsToMonitors.map((t) => t.monitorTag), - incidents: data.incidents, - privateLocations: data.privateLocationToMonitors.map( - (p) => p.privateLocation, - ), - }); }), clone: protectedProcedure .meta({ track: Events.CloneMonitor }) .input(z.object({ id: z.number() })) .mutation(async ({ ctx, input }) => { - const whereConditions: SQL[] = [ - eq(monitor.id, input.id), - eq(monitor.workspaceId, ctx.workspace.id), - isNull(monitor.deletedAt), - ]; - - const _monitors = await ctx.db.query.monitor.findMany({ - where: and( - eq(monitor.workspaceId, ctx.workspace.id), - isNull(monitor.deletedAt), - ), - }); - - if (_monitors.length >= ctx.workspace.limits.monitors) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "You have reached the maximum number of monitors.", + try { + return await cloneMonitor({ + ctx: toServiceCtx(ctx), + input: { id: input.id }, }); + } catch (err) { + toTRPCError(err); } - - const data = await ctx.db.query.monitor.findFirst({ - where: and(...whereConditions), - }); - - if (!data) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Monitor not found.", - }); - } - - const [newMonitor] = await ctx.db - .insert(monitor) - .values({ - ...data, - id: undefined, // let the db generate the id - name: `${data.name} (Copy)`, - createdAt: new Date(), - updatedAt: new Date(), - }) - .returning(); - - if (!newMonitor) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Failed to clone monitor.", - }); - } - - return newMonitor; }), updateRetry: protectedProcedure .meta({ track: Events.UpdateMonitor }) .input(z.object({ id: z.number(), retry: z.number() })) .mutation(async ({ ctx, input }) => { - const whereConditions: SQL[] = [ - eq(monitor.id, input.id), - eq(monitor.workspaceId, ctx.workspace.id), - isNull(monitor.deletedAt), - ]; - - await ctx.db - .update(monitor) - .set({ retry: input.retry, updatedAt: new Date() }) - .where(and(...whereConditions)) - .run(); + try { + await updateMonitorRetry({ + ctx: toServiceCtx(ctx), + input: { id: input.id, retry: input.retry }, + }); + } catch (err) { + toTRPCError(err); + } }), updateFollowRedirects: protectedProcedure .meta({ track: Events.UpdateMonitor }) .input(z.object({ id: z.number(), followRedirects: z.boolean() })) .mutation(async ({ ctx, input }) => { - const whereConditions: SQL[] = [ - eq(monitor.id, input.id), - eq(monitor.workspaceId, ctx.workspace.id), - isNull(monitor.deletedAt), - ]; - - await ctx.db - .update(monitor) - .set({ - followRedirects: input.followRedirects, - updatedAt: new Date(), - }) - .where(and(...whereConditions)) - .run(); + try { + await updateMonitorFollowRedirects({ + ctx: toServiceCtx(ctx), + input: { + id: input.id, + followRedirects: input.followRedirects, + }, + }); + } catch (err) { + toTRPCError(err); + } }), updateOtel: protectedProcedure @@ -332,46 +211,36 @@ export const monitorRouter = createTRPCRouter({ z.object({ id: z.number(), otelEndpoint: z.string(), - otelHeaders: z - .array(z.object({ key: z.string(), value: z.string() })) - .optional(), + otelHeaders: z.array(headerPair).optional(), }), ) .mutation(async ({ ctx, input }) => { - const whereConditions: SQL[] = [ - eq(monitor.id, input.id), - eq(monitor.workspaceId, ctx.workspace.id), - isNull(monitor.deletedAt), - ]; - - await ctx.db - .update(monitor) - .set({ - otelEndpoint: input.otelEndpoint, - otelHeaders: input.otelHeaders - ? JSON.stringify(input.otelHeaders) - : undefined, - updatedAt: new Date(), - }) - .where(and(...whereConditions)) - .run(); + try { + await updateMonitorOtel({ + ctx: toServiceCtx(ctx), + input: { + id: input.id, + otelEndpoint: input.otelEndpoint, + otelHeaders: input.otelHeaders, + }, + }); + } catch (err) { + toTRPCError(err); + } }), updatePublic: protectedProcedure .meta({ track: Events.UpdateMonitor }) .input(z.object({ id: z.number(), public: z.boolean() })) .mutation(async ({ ctx, input }) => { - const whereConditions: SQL[] = [ - eq(monitor.id, input.id), - eq(monitor.workspaceId, ctx.workspace.id), - isNull(monitor.deletedAt), - ]; - - await ctx.db - .update(monitor) - .set({ public: input.public, updatedAt: new Date() }) - .where(and(...whereConditions)) - .run(); + try { + await updateMonitorPublic({ + ctx: toServiceCtx(ctx), + input: { id: input.id, public: input.public }, + }); + } catch (err) { + toTRPCError(err); + } }), updateSchedulingRegions: protectedProcedure @@ -385,90 +254,19 @@ export const monitorRouter = createTRPCRouter({ }), ) .mutation(async ({ ctx, input }) => { - const whereConditions: SQL[] = [ - eq(monitor.id, input.id), - eq(monitor.workspaceId, ctx.workspace.id), - isNull(monitor.deletedAt), - ]; - - const limits = ctx.workspace.limits; - - if (!limits.periodicity.includes(input.periodicity)) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "Upgrade to check more often.", - }); - } - - if (limits["max-regions"] < input.regions.length) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "You have reached the maximum number of regions.", - }); - } - - if ( - input.regions.length > 0 && - !input.regions.every((r) => - limits.regions.includes(r as (typeof limits)["regions"][number]), - ) - ) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "You don't have access to this region.", - }); - } - - const existingMonitor = await ctx.db.query.monitor.findFirst({ - where: and(...whereConditions), - }); - - if (!existingMonitor) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Monitor not found.", - }); - } - - if (input.privateLocations && input.privateLocations.length > 0) { - const validLocations = await ctx.db.query.privateLocation.findMany({ - where: and( - eq(privateLocation.workspaceId, ctx.workspace.id), - inArray(privateLocation.id, input.privateLocations), - ), + try { + await updateMonitorSchedulingRegions({ + ctx: toServiceCtx(ctx), + input: { + id: input.id, + regions: input.regions, + periodicity: input.periodicity, + privateLocations: input.privateLocations, + }, }); - if (validLocations.length !== input.privateLocations.length) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "Invalid private location IDs.", - }); - } + } catch (err) { + toTRPCError(err); } - - await ctx.db.transaction(async (tx) => { - await tx - .update(monitor) - .set({ - regions: input.regions.join(","), - periodicity: input.periodicity, - updatedAt: new Date(), - }) - .where(and(...whereConditions)) - .run(); - - await tx - .delete(privateLocationToMonitors) - .where(eq(privateLocationToMonitors.monitorId, input.id)); - - if (input.privateLocations && input.privateLocations.length > 0) { - await tx.insert(privateLocationToMonitors).values( - input.privateLocations.map((privateLocationId) => ({ - monitorId: input.id, - privateLocationId, - })), - ); - } - }); }), updateResponseTime: protectedProcedure @@ -481,333 +279,147 @@ export const monitorRouter = createTRPCRouter({ }), ) .mutation(async ({ ctx, input }) => { - const whereConditions: SQL[] = [ - eq(monitor.id, input.id), - eq(monitor.workspaceId, ctx.workspace.id), - isNull(monitor.deletedAt), - ]; - - await ctx.db - .update(monitor) - .set({ - timeout: input.timeout, - degradedAfter: input.degradedAfter, - updatedAt: new Date(), - }) - .where(and(...whereConditions)) - .run(); + try { + await updateMonitorResponseTime({ + ctx: toServiceCtx(ctx), + input: { + id: input.id, + timeout: input.timeout, + degradedAfter: input.degradedAfter, + }, + }); + } catch (err) { + toTRPCError(err); + } }), updateTags: protectedProcedure .meta({ track: Events.UpdateMonitor }) .input(z.object({ id: z.number(), tags: z.array(z.number()) })) .mutation(async ({ ctx, input }) => { - const existingMonitor = await ctx.db.query.monitor.findFirst({ - where: and( - eq(monitor.id, input.id), - eq(monitor.workspaceId, ctx.workspace.id), - ), - }); - - if (!existingMonitor) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Monitor not found.", + try { + await updateMonitorTags({ + ctx: toServiceCtx(ctx), + input: { id: input.id, tags: input.tags }, }); + } catch (err) { + toTRPCError(err); } - - const allTags = await ctx.db.query.monitorTag.findMany({ - where: and( - eq(monitorTag.workspaceId, ctx.workspace.id), - inArray(monitorTag.id, input.tags), - ), - }); - - if (allTags.length !== input.tags.length) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "You don't have access to this tag.", - }); - } - - await ctx.db.transaction(async (tx) => { - await tx - .delete(monitorTagsToMonitors) - .where(and(eq(monitorTagsToMonitors.monitorId, input.id))); - - if (input.tags.length > 0) { - await tx.insert(monitorTagsToMonitors).values( - input.tags.map((tagId) => ({ - monitorId: input.id, - monitorTagId: tagId, - })), - ); - } - }); }), updateGeneral: protectedProcedure .meta({ track: Events.UpdateMonitor }) - .input( - z.object({ - id: z.number(), - jobType: z.enum(monitorJobTypes), - url: z.string(), - method: z.enum(monitorMethods), - headers: z.array(z.object({ key: z.string(), value: z.string() })), - body: z.string().optional(), - name: z.string(), - assertions: z.array( - z.discriminatedUnion("type", [ - statusAssertion, - headerAssertion, - textBodyAssertion, - jsonBodyAssertion, - recordAssertion, - ]), - ), - active: z.boolean().prefault(true), - // skip the test check if assertions are OK - skipCheck: z.boolean().prefault(true), - // save check in db (iff success? -> e.g. onboarding to get a first ping) - saveCheck: z.boolean().prefault(false), - }), - ) + .input(updateGeneralTRPCInput) .mutation(async ({ ctx, input }) => { - const whereConditions: SQL[] = [ - eq(monitor.id, input.id), - eq(monitor.workspaceId, ctx.workspace.id), - isNull(monitor.deletedAt), - ]; - - const assertions: Assertion[] = []; - for (const a of input.assertions ?? []) { - if (a.type === "status") { - assertions.push(new StatusAssertion(a)); - } - if (a.type === "header") { - assertions.push(new HeaderAssertion(a)); + try { + // Pre-save endpoint check — kept at the tRPC layer because the + // `testHttp` / `testTcp` / `testDns` helpers hit external URLs and + // are tRPC-specific UX; services unconditionally save. + if (!input.skipCheck && input.active) { + if (input.jobType === "http") { + await testHttp({ + url: input.url, + method: input.method, + headers: input.headers, + body: input.body, + assertions: input.assertions.filter( + (a) => a.type !== "dnsRecord", + ), + region: "ams", + }); + } else if (input.jobType === "tcp") { + await testTcp({ url: input.url, region: "ams" }); + } else if (input.jobType === "dns") { + await testDns({ + url: input.url, + region: "ams", + assertions: input.assertions.filter( + (a) => a.type === "dnsRecord", + ), + }); + } } - if (a.type === "textBody") { - assertions.push(new TextBodyAssertion(a)); - } - if (a.type === "dnsRecord") { - assertions.push(new DnsRecordAssertion(a)); - } - } - - // NOTE: we are checking the endpoint before saving - if (!input.skipCheck && input.active) { - if (input.jobType === "http") { - await testHttp({ - url: input.url, - method: input.method, - headers: input.headers, - body: input.body, - // Filter out DNS record assertions as they can't be validated via HTTP - assertions: input.assertions.filter((a) => a.type !== "dnsRecord"), - region: "ams", - }); - } else if (input.jobType === "tcp") { - await testTcp({ - url: input.url, - region: "ams", - }); - } else if (input.jobType === "dns") { - await testDns({ - url: input.url, - region: "ams", - assertions: input.assertions.filter((a) => a.type === "dnsRecord"), - }); - } - } - await ctx.db - .update(monitor) - .set({ + const serviceInput: UpdateMonitorGeneralInput = { + id: input.id, name: input.name, jobType: input.jobType, url: input.url, method: input.method, - headers: input.headers ? JSON.stringify(input.headers) : undefined, + headers: input.headers, body: input.body, + assertions: input.assertions, active: input.active, - assertions: serialize(assertions), - updatedAt: new Date(), - }) - .where(and(...whereConditions)) - .run(); + }; + await updateMonitorGeneral({ + ctx: toServiceCtx(ctx), + input: serviceInput, + }); + } catch (err) { + toTRPCError(err); + } }), updateNotifiers: protectedProcedure .meta({ track: Events.UpdateMonitor }) .input(z.object({ id: z.number(), notifiers: z.array(z.number()) })) .mutation(async ({ ctx, input }) => { - const existingMonitor = await ctx.db.query.monitor.findFirst({ - where: and( - eq(monitor.id, input.id), - eq(monitor.workspaceId, ctx.workspace.id), - ), - }); - - if (!existingMonitor) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Monitor not found.", + try { + await updateMonitorNotifiers({ + ctx: toServiceCtx(ctx), + input: { id: input.id, notifiers: input.notifiers }, }); + } catch (err) { + toTRPCError(err); } - - const allNotifiers = await ctx.db.query.notification.findMany({ - where: and( - eq(notification.workspaceId, ctx.workspace.id), - inArray(notification.id, input.notifiers), - ), - }); - - if (allNotifiers.length !== input.notifiers.length) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "You don't have access to this notifier.", - }); - } - - await ctx.db.transaction(async (tx) => { - await tx - .delete(notificationsToMonitors) - .where(and(eq(notificationsToMonitors.monitorId, input.id))); - - if (input.notifiers.length > 0) { - await tx.insert(notificationsToMonitors).values( - input.notifiers.map((notifierId) => ({ - monitorId: input.id, - notificationId: notifierId, - })), - ); - } - }); }), new: protectedProcedure .meta({ track: Events.CreateMonitor, trackProps: ["url", "jobType"] }) - .input( - z.object({ - name: z.string(), - jobType: z.enum(monitorJobTypes), - url: z.string(), - method: z.enum(monitorMethods), - headers: z.array(z.object({ key: z.string(), value: z.string() })), - body: z.string().optional(), - assertions: z.array( - z.discriminatedUnion("type", [ - statusAssertion, - headerAssertion, - textBodyAssertion, - jsonBodyAssertion, - recordAssertion, - ]), - ), - active: z.boolean().prefault(false), - saveCheck: z.boolean().prefault(false), - skipCheck: z.boolean().prefault(false), - }), - ) + .input(newMonitorTRPCInput) .mutation(async ({ ctx, input }) => { - const limits = ctx.workspace.limits; - - const res = await ctx.db - .select({ count: count() }) - .from(monitor) - .where( - and( - eq(monitor.workspaceId, ctx.workspace.id), - isNull(monitor.deletedAt), - ), - ) - .get(); - - // the user has reached the limits - if (res && res.count >= limits.monitors) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "You reached your monitor limits.", - }); - } - - const assertions: Assertion[] = []; - for (const a of input.assertions ?? []) { - if (a.type === "status") { - assertions.push(new StatusAssertion(a)); - } - if (a.type === "header") { - assertions.push(new HeaderAssertion(a)); - } - if (a.type === "textBody") { - assertions.push(new TextBodyAssertion(a)); - } - if (a.type === "dnsRecord") { - assertions.push(new DnsRecordAssertion(a)); + try { + if (!input.skipCheck) { + if (input.jobType === "http") { + await testHttp({ + url: input.url, + method: input.method, + headers: input.headers, + body: input.body, + assertions: input.assertions.filter( + (a) => a.type !== "dnsRecord", + ), + region: "ams", + }); + } else if (input.jobType === "tcp") { + await testTcp({ url: input.url, region: "ams" }); + } else if (input.jobType === "dns") { + await testDns({ + url: input.url, + region: "ams", + assertions: input.assertions.filter( + (a) => a.type === "dnsRecord", + ), + }); + } } - } - - // NOTE: we are checking the endpoint before saving - if (!input.skipCheck) { - if (input.jobType === "http") { - await testHttp({ - url: input.url, - method: input.method, - headers: input.headers, - body: input.body, - // Filter out DNS record assertions as they can't be validated via HTTP - assertions: input.assertions.filter((a) => a.type !== "dnsRecord"), - region: "ams", - }); - } else if (input.jobType === "tcp") { - await testTcp({ - url: input.url, - region: "ams", - }); - } else if (input.jobType === "dns") { - await testDns({ - url: input.url, - region: "ams", - assertions: input.assertions.filter((a) => a.type === "dnsRecord"), - }); - } - } - - const selectableRegions = - ctx.workspace.plan === "free" ? freeFlyRegions : monitorRegions; - const randomRegions = ctx.workspace.plan === "free" ? 4 : 6; - const regions = [...selectableRegions] - // NOTE: make sure we don't use deprecated regions - .filter((r) => { - const deprecated = regionDict[r].deprecated; - if (!deprecated) return true; - return false; - }) - .sort(() => 0.5 - Math.random()) - .slice(0, randomRegions); - - const newMonitor = await ctx.db - .insert(monitor) - .values({ + const serviceInput: CreateMonitorInput = { name: input.name, jobType: input.jobType, url: input.url, method: input.method, - headers: input.headers ? JSON.stringify(input.headers) : undefined, + headers: input.headers, body: input.body, + assertions: input.assertions, active: input.active, - workspaceId: ctx.workspace.id, - periodicity: ctx.workspace.plan === "free" ? "30m" : "1m", - regions: regions.join(","), - assertions: serialize(assertions), - updatedAt: new Date(), - }) - .returning() - .get(); - - return newMonitor; + }; + return await createMonitor({ + ctx: toServiceCtx(ctx), + input: serviceInput, + }); + } catch (err) { + toTRPCError(err); + } }), }); diff --git a/packages/api/src/router/notification.ts b/packages/api/src/router/notification.ts index 6e7364a2e..a3b9bafba 100644 --- a/packages/api/src/router/notification.ts +++ b/packages/api/src/router/notification.ts @@ -1,26 +1,27 @@ import { TRPCError } from "@trpc/server"; import { z } from "zod"; -import { and, count, eq, inArray } from "@openstatus/db"; import { - NotificationDataSchema, discordDataSchema, googleChatDataSchema, grafanaOncallDataSchema, - monitor, - notification, notificationProvider, - notificationsToMonitors, ntfyDataSchema, opsgenieDataSchema, pagerdutyDataSchema, - selectMonitorSchema, - selectNotificationSchema, slackDataSchema, telegramDataSchema, webhookDataSchema, whatsappDataSchema, } from "@openstatus/db/src/schema"; +import { NotFoundError } from "@openstatus/services"; +import { + NotificationDataInputSchema, + createNotification, + deleteNotification, + listNotifications, + updateNotification, +} from "@openstatus/services/notification"; import { Events } from "@openstatus/analytics"; import { SchemaError } from "@openstatus/error"; @@ -40,6 +41,7 @@ import { sendTest as sendWebhookTest } from "@openstatus/notification-webhook"; import { redis } from "@openstatus/upstash"; import { nanoid } from "nanoid"; +import { toServiceCtx, toTRPCError } from "../service-adapter"; import { type TelegramGetUpdatesResponse, processTelegramUpdates, @@ -47,29 +49,22 @@ import { import { createTRPCRouter, protectedProcedure } from "../trpc"; export const notificationRouter = createTRPCRouter({ - list: protectedProcedure.query(async (opts) => { - const notifications = await opts.ctx.db.query.notification.findMany({ - where: eq(notification.workspaceId, opts.ctx.workspace.id), - with: { - monitor: { - with: { - monitor: true, - }, + list: protectedProcedure.query(async ({ ctx }) => { + try { + const { items } = await listNotifications({ + ctx: toServiceCtx(ctx), + input: { + // Dashboard has no paging UI; match the sentinel pattern used + // across the other domains. + limit: 10_000, + offset: 0, + order: "desc", }, - }, - }); - - return selectNotificationSchema - .extend({ - monitors: selectMonitorSchema.array(), - }) - .array() - .parse( - notifications.map((notification) => ({ - ...notification, - monitors: notification.monitor.map(({ monitor }) => monitor), - })), - ); + }); + return items; + } catch (err) { + toTRPCError(err); + } }), // TODO: rename to update after migration @@ -79,92 +74,24 @@ export const notificationRouter = createTRPCRouter({ z.object({ id: z.number(), name: z.string(), - data: z.partialRecord( - z.enum(notificationProvider), - z - .string() - .or( - z.record( - z.string(), - z - .string() - .or( - z.array(z.object({ key: z.string(), value: z.string() })), - ), - ), - ), - ), + data: NotificationDataInputSchema, monitors: z.array(z.number()), }), ) - .mutation(async (opts) => { - const existing = await opts.ctx.db.query.notification.findFirst({ - where: and( - eq(notification.id, opts.input.id), - eq(notification.workspaceId, opts.ctx.workspace.id), - ), - }); - - if (!existing) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Notification not found", - }); - } - - const allMonitors = await opts.ctx.db.query.monitor.findMany({ - where: and( - eq(monitor.workspaceId, opts.ctx.workspace.id), - inArray(monitor.id, opts.input.monitors), - ), - }); - - if (allMonitors.length !== opts.input.monitors.length) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "You don't have access to all the monitors.", - }); - } - - const _data = NotificationDataSchema.safeParse(opts.input.data); - - if (!_data.success) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: SchemaError.fromZod(_data.error, opts.input).message, + .mutation(async ({ ctx, input }) => { + try { + await updateNotification({ + ctx: toServiceCtx(ctx), + input: { + id: input.id, + name: input.name, + data: input.data, + monitors: input.monitors, + }, }); + } catch (err) { + toTRPCError(err); } - - await opts.ctx.db.transaction(async (tx) => { - await tx - .update(notification) - .set({ - name: opts.input.name, - data: JSON.stringify(opts.input.data), - updatedAt: new Date(), - }) - .where( - and( - eq(notification.id, opts.input.id), - eq(notification.workspaceId, opts.ctx.workspace.id), - ), - ); - - await tx - .delete(notificationsToMonitors) - .where( - and(eq(notificationsToMonitors.notificationId, opts.input.id)), - ); - - if (opts.input.monitors.length) { - await tx.insert(notificationsToMonitors).values( - opts.input.monitors.map((monitorId) => ({ - notificationId: opts.input.id, - monitorId, - })), - ); - } - }); }), new: protectedProcedure @@ -172,145 +99,49 @@ export const notificationRouter = createTRPCRouter({ .input( z.object({ provider: z.enum(notificationProvider), - data: z.partialRecord( - z.enum(notificationProvider), - z - .record( - z.string(), - z - .string() - .or(z.array(z.object({ key: z.string(), value: z.string() }))), - ) - .or(z.string()), - ), + data: NotificationDataInputSchema, name: z.string(), monitors: z.array(z.number()).prefault([]), }), ) - .mutation(async (opts) => { - const limits = opts.ctx.workspace.limits; - - const res = await opts.ctx.db - .select({ count: count() }) - .from(notification) - .where(eq(notification.workspaceId, opts.ctx.workspace.id)) - .get(); - - // the user has reached the limits - if (res && res.count >= limits["notification-channels"]) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "You reached your notification limits.", - }); - } - - const allMonitors = await opts.ctx.db.query.monitor.findMany({ - where: and( - eq(monitor.workspaceId, opts.ctx.workspace.id), - inArray(monitor.id, opts.input.monitors), - ), - }); - - if (allMonitors.length !== opts.input.monitors.length) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "You don't have access to all the monitors.", - }); - } - - const limitedProviders = [ - "sms", - "pagerduty", - "opsgenie", - "grafana-oncall", - "whatsapp", - ] as const; - // biome-ignore lint/suspicious/noExplicitAny: - if (limitedProviders.includes(opts.input.provider as any)) { - const isAllowed = - opts.ctx.workspace.limits[ - opts.input.provider as - | "sms" - | "pagerduty" - | "opsgenie" - | "grafana-oncall" - | "whatsapp" - ]; - - if (!isAllowed) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "Upgrade to use the notification channel.", - }); - } - } - - const _data = NotificationDataSchema.safeParse(opts.input.data); - - if (!_data.success) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: SchemaError.fromZod(_data.error, opts.input).message, + .mutation(async ({ ctx, input }) => { + try { + return await createNotification({ + ctx: toServiceCtx(ctx), + input: { + name: input.name, + provider: input.provider, + data: input.data, + monitors: input.monitors, + }, }); + } catch (err) { + toTRPCError(err); } - - const _notification = await opts.ctx.db.transaction(async (tx) => { - const _notification = await tx - .insert(notification) - .values({ - name: opts.input.name, - provider: opts.input.provider, - data: JSON.stringify(opts.input.data), - workspaceId: opts.ctx.workspace.id, - }) - .returning() - .get(); - - if (opts.input.monitors.length) { - await tx.insert(notificationsToMonitors).values( - opts.input.monitors.map((monitorId) => ({ - notificationId: _notification.id, - monitorId, - })), - ); - } - - return _notification; - }); - - return _notification; }), delete: protectedProcedure .meta({ track: Events.DeleteNotification }) .input(z.object({ id: z.number() })) - .mutation(async (opts) => { - await opts.ctx.db - .delete(notification) - .where( - and( - eq(notification.id, opts.input.id), - eq(notification.workspaceId, opts.ctx.workspace.id), - ), - ) - .run(); + .mutation(async ({ ctx, input }) => { + try { + await deleteNotification({ + ctx: toServiceCtx(ctx), + input: { id: input.id }, + }); + } catch (err) { + // Preserve the pre-migration idempotent behaviour — the old tRPC + // delete didn't throw on missing. + if (err instanceof NotFoundError) return; + toTRPCError(err); + } }), sendTest: protectedProcedure .input( z.object({ provider: z.enum(notificationProvider), - data: z.partialRecord( - z.enum(notificationProvider), - z - .record( - z.string(), - z - .string() - .or(z.array(z.object({ key: z.string(), value: z.string() }))), - ) - .or(z.string()), - ), + data: NotificationDataInputSchema, }), ) .mutation(async (opts) => { diff --git a/packages/api/src/router/page.ts b/packages/api/src/router/page.ts index a1ea9e5f6..8c88fe544 100644 --- a/packages/api/src/router/page.ts +++ b/packages/api/src/router/page.ts @@ -1,30 +1,44 @@ import { TRPCError } from "@trpc/server"; import { z } from "zod"; -import { type SQL, and, desc, eq, inArray, isNull, sql } from "@openstatus/db"; +import { Events } from "@openstatus/analytics"; +import { locales } from "@openstatus/locales"; +import { NotFoundError } from "@openstatus/services"; import { - insertPageSchema, - monitor, - page, + type CreatePageInput, + // `CreatePageInput` re-exports the drizzle insert schema so routers + // don't need to import it directly from `@openstatus/db`. + CreatePageInput as CreatePageInputSchema, + UpdatePageAppearanceInput, + UpdatePageConfigurationInput, + UpdatePageCustomDomainInput, + createPage, + deletePage, + getPage, + getPageCustomDomain, + getSlugAvailable, + listPages, + newPage, pageAccessTypes, - pageComponent, - selectMaintenanceSchema, - selectPageComponentGroupSchema, - selectPageComponentSchema, - selectPageSchema, - subdomainSafeList, -} from "@openstatus/db/src/schema"; + updatePageAppearance, + updatePageConfiguration, + updatePageCustomDomain, + updatePageGeneral, + updatePageLinks, + updatePageLocales, + updatePagePasswordProtection, +} from "@openstatus/services/page"; -import { Events } from "@openstatus/analytics"; -import { locales } from "@openstatus/locales"; import { env } from "../env"; +import { toServiceCtx, toTRPCError } from "../service-adapter"; import { createTRPCRouter, protectedProcedure } from "../trpc"; if (process.env.NODE_ENV === "test") { require("../test/preload"); } -// Helper functions to reuse Vercel API logic +// Vercel domain helpers — transport-layer external integrations that +// don't belong in the service layer. async function addDomainToVercel(domain: string) { const response = await fetch( `https://api.vercel.com/v9/projects/${env.PROJECT_ID_VERCEL}/domains?teamId=${env.TEAM_ID_VERCEL}`, @@ -78,217 +92,70 @@ async function removeDomainFromVercel(domain: string) { export const pageRouter = createTRPCRouter({ create: protectedProcedure .meta({ track: Events.CreatePage, trackProps: ["slug"] }) - .input(insertPageSchema) - .mutation(async (opts) => { - const { monitors, workspaceId, id, configuration, ...pageProps } = - opts.input; - - const monitorIds = monitors?.map((item) => item.monitorId) || []; - - const pageNumbers = ( - await opts.ctx.db.query.page.findMany({ - where: eq(page.workspaceId, opts.ctx.workspace.id), - }) - ).length; - - const limit = opts.ctx.workspace.limits; - - // the user has reached the status page number limits - if (pageNumbers >= limit["status-pages"]) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "You reached your status-page limits.", - }); - } - - // the user is not eligible for password protection - if ( - limit["password-protection"] === false && - opts.input.passwordProtected === true - ) { - throw new TRPCError({ - code: "FORBIDDEN", - message: - "Password protection is not available for your current plan.", - }); - } - - if ( - limit["ip-restriction"] === false && - opts.input.accessType === "ip-restriction" - ) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "IP restriction is not available for your current plan.", + .input(CreatePageInputSchema) + .mutation(async ({ ctx, input }) => { + try { + return await createPage({ + ctx: toServiceCtx(ctx), + input: input as CreatePageInput, }); + } catch (err) { + toTRPCError(err); } - - if ( - opts.input.accessType === "ip-restriction" && - (!opts.input.allowedIpRanges || opts.input.allowedIpRanges.length === 0) - ) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "At least one IP range is required for IP restriction.", - }); - } - - const newPage = await opts.ctx.db - .insert(page) - .values({ - workspaceId: opts.ctx.workspace.id, - configuration: JSON.stringify(configuration), - ...pageProps, - authEmailDomains: pageProps.authEmailDomains?.join(","), - allowedIpRanges: pageProps.allowedIpRanges?.join(","), - }) - .returning() - .get(); - - if (monitorIds.length) { - // We should make sure the user has access to the monitors AND they are active - const allMonitors = await opts.ctx.db.query.monitor.findMany({ - where: and( - inArray(monitor.id, monitorIds), - eq(monitor.workspaceId, opts.ctx.workspace.id), - eq(monitor.active, true), // Only allow active monitors - isNull(monitor.deletedAt), - ), - }); - - if (allMonitors.length !== monitorIds.length) { - throw new TRPCError({ - code: "FORBIDDEN", - message: - "You don't have access to all the monitors or some monitors are inactive.", - }); - } - - // Build a map for quick lookup - const monitorMap = new Map(allMonitors.map((m) => [m.id, m])); - - // Build pageComponent values (primary table) - const pageComponentValues = monitors - .map(({ monitorId }, index) => { - const m = monitorMap.get(monitorId); - if (!m || !m.workspaceId) return null; - return { - workspaceId: m.workspaceId, - pageId: newPage.id, - type: "monitor" as const, - monitorId, - name: m.externalName || m.name, - order: index, - groupId: null, - groupOrder: 0, - }; - }) - .filter((v): v is NonNullable => v !== null); - - // Insert into pageComponents (primary table) - await opts.ctx.db - .insert(pageComponent) - .values(pageComponentValues) - .run(); - } - - return newPage; }), delete: protectedProcedure .meta({ track: Events.DeletePage }) .input(z.object({ id: z.number() })) - .mutation(async (opts) => { - const whereConditions: SQL[] = [ - eq(page.id, opts.input.id), - eq(page.workspaceId, opts.ctx.workspace.id), - ]; - - await opts.ctx.db - .delete(page) - .where(and(...whereConditions)) - .run(); + .mutation(async ({ ctx, input }) => { + try { + await deletePage({ + ctx: toServiceCtx(ctx), + input: { id: input.id }, + }); + } catch (err) { + if (err instanceof NotFoundError) return; + toTRPCError(err); + } }), getSlugUniqueness: protectedProcedure .input(z.object({ slug: z.string().toLowerCase() })) - .query(async (opts) => { - // had filter on some words we want to keep for us - if (subdomainSafeList.includes(opts.input.slug)) { - return false; + .query(async ({ ctx, input }) => { + try { + return await getSlugAvailable({ + ctx: toServiceCtx(ctx), + input: { slug: input.slug }, + }); + } catch (err) { + toTRPCError(err); } - const result = await opts.ctx.db.query.page.findMany({ - where: sql`lower(${page.slug}) = ${opts.input.slug}`, - }); - return !(result?.length > 0); }), list: protectedProcedure - .input( - z - .object({ - order: z.enum(["asc", "desc"]).optional(), - }) - .optional(), - ) - .query(async (opts) => { - const whereConditions: SQL[] = [ - eq(page.workspaceId, opts.ctx.workspace.id), - ]; - - const result = await opts.ctx.db.query.page.findMany({ - where: and(...whereConditions), - with: { - statusReports: true, - }, - orderBy: (pages, { asc }) => [ - opts.input?.order === "asc" - ? asc(pages.createdAt) - : desc(pages.createdAt), - ], - }); - - return result; + .input(z.object({ order: z.enum(["asc", "desc"]).optional() }).optional()) + .query(async ({ ctx, input }) => { + try { + return await listPages({ + ctx: toServiceCtx(ctx), + input: { order: input?.order ?? "desc" }, + }); + } catch (err) { + toTRPCError(err); + } }), get: protectedProcedure .input(z.object({ id: z.number() })) - .query(async (opts) => { - const whereConditions: SQL[] = [ - eq(page.workspaceId, opts.ctx.workspace.id), - eq(page.id, opts.input.id), - ]; - - const data = await opts.ctx.db.query.page.findFirst({ - where: and(...whereConditions), - with: { - maintenances: true, - pageComponents: true, - pageComponentGroups: true, - }, - }); - - if (!data) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Page not found", + .query(async ({ ctx, input }) => { + try { + return await getPage({ + ctx: toServiceCtx(ctx), + input: { id: input.id }, }); + } catch (err) { + toTRPCError(err); } - - return selectPageSchema - .extend({ - pageComponentGroups: z - .array(selectPageComponentGroupSchema) - .prefault([]), - maintenances: z.array(selectMaintenanceSchema).prefault([]), - pageComponents: z.array(selectPageComponentSchema).prefault([]), - }) - .parse({ - ...data, - pageComponentGroups: data.pageComponentGroups ?? [], - maintenances: data.maintenances, - pageComponents: data.pageComponents, - }); }), // TODO: rename to create @@ -302,59 +169,20 @@ export const pageRouter = createTRPCRouter({ description: z.string().nullish(), }), ) - .mutation(async (opts) => { - const pageNumbers = ( - await opts.ctx.db.query.page.findMany({ - where: eq(page.workspaceId, opts.ctx.workspace.id), - }) - ).length; - - const limit = opts.ctx.workspace.limits; - - // the user has reached the status page number limits - if (pageNumbers >= limit["status-pages"]) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "You reached your status-page limits.", - }); - } - - const result = await opts.ctx.db.query.page.findMany({ - where: sql`lower(${page.slug}) = ${opts.input.slug}`, - }); - - if (subdomainSafeList.includes(opts.input.slug) || result?.length > 0) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "This slug is already taken. Please choose another one.", + .mutation(async ({ ctx, input }) => { + try { + return await newPage({ + ctx: toServiceCtx(ctx), + input: { + title: input.title, + slug: input.slug, + icon: input.icon, + description: input.description, + }, }); + } catch (err) { + toTRPCError(err); } - - // REMINDER: default config from legacy page - const defaultConfiguration = { - type: "absolute", - value: "requests", - uptime: true, - theme: "default-rounded", - } satisfies Record; - - const newPage = await opts.ctx.db - .insert(page) - .values({ - workspaceId: opts.ctx.workspace.id, - title: opts.input.title, - slug: opts.input.slug, - description: opts.input.description ?? "", - icon: opts.input.icon ?? "", - legacyPage: false, - configuration: defaultConfiguration, - customDomain: "", // TODO: make nullable - allowIndex: true, - }) - .returning() - .get(); - - return newPage; }), updateGeneral: protectedProcedure @@ -368,112 +196,72 @@ export const pageRouter = createTRPCRouter({ icon: z.string().nullish(), }), ) - .mutation(async (opts) => { - const whereConditions: SQL[] = [ - eq(page.workspaceId, opts.ctx.workspace.id), - eq(page.id, opts.input.id), - ]; - - const result = await opts.ctx.db.query.page.findMany({ - where: sql`lower(${page.slug}) = ${opts.input.slug}`, - }); - - const oldSlug = await opts.ctx.db.query.page.findFirst({ - where: and(...whereConditions), - }); - - if ( - subdomainSafeList.includes(opts.input.slug) || - (oldSlug?.slug !== opts.input.slug && result?.length > 0) - ) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "This slug is already taken. Please choose another one.", + .mutation(async ({ ctx, input }) => { + try { + await updatePageGeneral({ + ctx: toServiceCtx(ctx), + input, }); + } catch (err) { + toTRPCError(err); } - - await opts.ctx.db - .update(page) - .set({ - title: opts.input.title, - slug: opts.input.slug, - description: opts.input.description ?? "", - icon: opts.input.icon ?? "", - updatedAt: new Date(), - }) - .where(and(...whereConditions)) - .run(); }), updateCustomDomain: protectedProcedure .meta({ track: Events.UpdatePageDomain, trackProps: ["customDomain"] }) - .input(z.object({ id: z.number(), customDomain: z.string().toLowerCase() })) - .mutation(async (opts) => { - const whereConditions: SQL[] = [ - eq(page.workspaceId, opts.ctx.workspace.id), - eq(page.id, opts.input.id), - ]; - - if (opts.input.customDomain.includes("openstatus")) { + // Validate customDomain *before* the handler body runs — reusing + // the service's `UpdatePageCustomDomainInput` (backed by the + // canonical `customDomainSchema`) guarantees that malformed + // domains (`http://…`, `www.…`, format garbage) are rejected + // with a `ZodError` at tRPC's input layer, before any Vercel + // add/remove call fires. Previously the format check ran inside + // the service — reached only *after* Vercel mutations, which + // meant a bad input could leave Vercel holding a domain the db + // had then rejected. + .input(UpdatePageCustomDomainInput) + .mutation(async ({ ctx, input }) => { + if (input.customDomain.includes("openstatus")) { throw new TRPCError({ code: "BAD_REQUEST", message: "Domain cannot contain 'openstatus'", }); } - // Get the current page to check the existing custom domain - const currentPage = await opts.ctx.db.query.page.findFirst({ - where: and(...whereConditions), - }); - - if (!currentPage) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Page not found", + // Resolve the existing domain via the service so the Vercel diff below + // sees the true pre-change state, then the service persists the new + // value. Vercel add/remove calls stay at the transport layer. + // + // `getPageCustomDomain` (narrow one-column read) instead of + // `getPage` (3 batched relation queries) — Vercel only needs the + // old domain string, so fanning out the full-relations read on + // every domain update was wasteful. + try { + const sCtx = toServiceCtx(ctx); + const oldDomain = await getPageCustomDomain({ + ctx: sCtx, + input: { id: input.id }, }); - } - - const oldDomain = currentPage.customDomain; - const newDomain = opts.input.customDomain; - - // Vercel operations first — if they fail, DB is not touched - if (newDomain && !oldDomain) { - // Adding a new domain - await addDomainToVercel(newDomain); - - await opts.ctx.db - .update(page) - .set({ customDomain: newDomain, updatedAt: new Date() }) - .where(and(...whereConditions)) - .run(); - } else if (oldDomain && newDomain && newDomain !== oldDomain) { - // Changing domain - add new first, then remove old - await addDomainToVercel(newDomain); - await removeDomainFromVercel(oldDomain); - - await opts.ctx.db - .update(page) - .set({ customDomain: newDomain, updatedAt: new Date() }) - .where(and(...whereConditions)) - .run(); - } else if (oldDomain && newDomain === "") { - // Removing domain - await removeDomainFromVercel(oldDomain); - - await opts.ctx.db - .update(page) - .set({ customDomain: "", updatedAt: new Date() }) - .where(and(...whereConditions)) - .run(); - } else if (newDomain) { - // Same domain re-submitted — ensure it's synced to Vercel - await addDomainToVercel(newDomain); + const newDomain = input.customDomain; + + if (newDomain && !oldDomain) { + await addDomainToVercel(newDomain); + } else if (oldDomain && newDomain && newDomain !== oldDomain) { + await addDomainToVercel(newDomain); + await removeDomainFromVercel(oldDomain); + } else if (oldDomain && newDomain === "") { + await removeDomainFromVercel(oldDomain); + } else if (newDomain) { + await addDomainToVercel(newDomain); + } else { + return; + } - await opts.ctx.db - .update(page) - .set({ customDomain: newDomain, updatedAt: new Date() }) - .where(and(...whereConditions)) - .run(); + await updatePageCustomDomain({ + ctx: sCtx, + input: { id: input.id, customDomain: newDomain }, + }); + } catch (err) { + toTRPCError(err); } }), @@ -499,128 +287,33 @@ export const pageRouter = createTRPCRouter({ allowIndex: z.boolean().optional(), }), ) - .mutation(async (opts) => { - const whereConditions: SQL[] = [ - eq(page.workspaceId, opts.ctx.workspace.id), - eq(page.id, opts.input.id), - ]; - - const limit = opts.ctx.workspace.limits; - - // the user is not eligible for password protection - if ( - limit["password-protection"] === false && - opts.input.accessType === "password" - ) { - throw new TRPCError({ - code: "FORBIDDEN", - message: - "Password protection is not available for your current plan.", - }); - } - - if ( - limit["email-domain-protection"] === false && - opts.input.accessType === "email-domain" - ) { - throw new TRPCError({ - code: "FORBIDDEN", - message: - "Email domain protection is not available for your current plan.", - }); - } - - if ( - limit["ip-restriction"] === false && - opts.input.accessType === "ip-restriction" - ) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "IP restriction is not available for your current plan.", - }); - } - - if ( - opts.input.accessType === "ip-restriction" && - (!opts.input.allowedIpRanges || opts.input.allowedIpRanges.length === 0) - ) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "At least one IP range is required for IP restriction.", - }); - } - - if (opts.input.allowIndex === false && limit["no-index"] === false) { - throw new TRPCError({ - code: "FORBIDDEN", - message: - "Disabling search engine indexing is not available for your current plan.", + .mutation(async ({ ctx, input }) => { + try { + await updatePagePasswordProtection({ + ctx: toServiceCtx(ctx), + input, }); + } catch (err) { + toTRPCError(err); } - - await opts.ctx.db - .update(page) - .set({ - accessType: opts.input.accessType, - authEmailDomains: opts.input.authEmailDomains?.join(","), - password: opts.input.password, - allowedIpRanges: opts.input.allowedIpRanges?.join(",") ?? null, - ...(opts.input.allowIndex !== undefined && { - allowIndex: opts.input.allowIndex, - }), - updatedAt: new Date(), - }) - .where(and(...whereConditions)) - .run(); }), updateAppearance: protectedProcedure .meta({ track: Events.UpdatePage }) - .input( - z.object({ - id: z.number(), - forceTheme: z.enum(["light", "dark", "system"]), - configuration: z.object({ - theme: z.string(), - }), - }), - ) - .mutation(async (opts) => { - const whereConditions: SQL[] = [ - eq(page.workspaceId, opts.ctx.workspace.id), - eq(page.id, opts.input.id), - ]; - - const _page = await opts.ctx.db.query.page.findFirst({ - where: and(...whereConditions), - }); - - if (!_page) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Page not found", + // Reuse the service schema so `configuration.theme` is validated + // against `THEME_KEYS` at the tRPC boundary; the prior local + // `z.object({ theme: z.string() })` accepted arbitrary strings + // that later failed the status-page read-parse. + .input(UpdatePageAppearanceInput) + .mutation(async ({ ctx, input }) => { + try { + await updatePageAppearance({ + ctx: toServiceCtx(ctx), + input, }); + } catch (err) { + toTRPCError(err); } - - const currentConfiguration = - (typeof _page.configuration === "object" && - _page.configuration !== null && - _page.configuration) || - {}; - const updatedConfiguration = { - ...currentConfiguration, - theme: opts.input.configuration.theme, - }; - - await opts.ctx.db - .update(page) - .set({ - forceTheme: opts.input.forceTheme, - configuration: updatedConfiguration, - updatedAt: new Date(), - }) - .where(and(...whereConditions)) - .run(); }), updateLinks: protectedProcedure @@ -632,21 +325,15 @@ export const pageRouter = createTRPCRouter({ contactUrl: z.string().nullish(), }), ) - .mutation(async (opts) => { - const whereConditions: SQL[] = [ - eq(page.workspaceId, opts.ctx.workspace.id), - eq(page.id, opts.input.id), - ]; - - await opts.ctx.db - .update(page) - .set({ - homepageUrl: opts.input.homepageUrl, - contactUrl: opts.input.contactUrl, - updatedAt: new Date(), - }) - .where(and(...whereConditions)) - .run(); + .mutation(async ({ ctx, input }) => { + try { + await updatePageLinks({ + ctx: toServiceCtx(ctx), + input, + }); + } catch (err) { + toTRPCError(err); + } }), updateLocales: protectedProcedure @@ -659,86 +346,40 @@ export const pageRouter = createTRPCRouter({ locales: z.array(z.enum(locales)).nullable(), }) .refine( - (data) => { - if (data.locales) { - return data.locales.includes(data.defaultLocale); - } - return true; - }, + (data) => + data.locales ? data.locales.includes(data.defaultLocale) : true, { message: "Default locale must be included in the locales list", path: ["defaultLocale"], }, ), ) - .mutation(async (opts) => { - if (!opts.ctx.workspace.limits.i18n) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "Upgrade to configure locales.", + .mutation(async ({ ctx, input }) => { + try { + await updatePageLocales({ + ctx: toServiceCtx(ctx), + input, }); + } catch (err) { + toTRPCError(err); } - - const whereConditions: SQL[] = [ - eq(page.workspaceId, opts.ctx.workspace.id), - eq(page.id, opts.input.id), - ]; - - await opts.ctx.db - .update(page) - .set({ - defaultLocale: opts.input.defaultLocale, - locales: opts.input.locales, - updatedAt: new Date(), - }) - .where(and(...whereConditions)) - .run(); }), updatePageConfiguration: protectedProcedure .meta({ track: Events.UpdatePage }) - .input( - z.object({ - id: z.number(), - configuration: z - .record(z.string(), z.string().or(z.boolean()).optional()) - .nullish(), - }), - ) - .mutation(async (opts) => { - const whereConditions: SQL[] = [ - eq(page.workspaceId, opts.ctx.workspace.id), - eq(page.id, opts.input.id), - ]; - - const _page = await opts.ctx.db.query.page.findFirst({ - where: and(...whereConditions), - }); - - if (!_page) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Page not found", + // Reuse the service's canonical `pageConfigurationSchema`-backed + // input — the old `z.record(z.string(), z.string()|z.boolean())` + // accepted any key and any string value, persisting configurations + // the status-page read parser would reject. + .input(UpdatePageConfigurationInput) + .mutation(async ({ ctx, input }) => { + try { + await updatePageConfiguration({ + ctx: toServiceCtx(ctx), + input, }); + } catch (err) { + toTRPCError(err); } - - const currentConfiguration = - (typeof _page.configuration === "object" && - _page.configuration !== null && - _page.configuration) || - {}; - const updatedConfiguration = { - ...currentConfiguration, - ...opts.input.configuration, - }; - - await opts.ctx.db - .update(page) - .set({ - configuration: updatedConfiguration, - updatedAt: new Date(), - }) - .where(and(...whereConditions)) - .run(); }), }); diff --git a/packages/api/src/router/pageComponent.ts b/packages/api/src/router/pageComponent.ts index d2e65637b..4ef9ce26e 100644 --- a/packages/api/src/router/pageComponent.ts +++ b/packages/api/src/router/pageComponent.ts @@ -1,20 +1,15 @@ import { z } from "zod"; -import { type SQL, and, asc, desc, eq, inArray, ne, sql } from "@openstatus/db"; +import { Events } from "@openstatus/analytics"; +import { NotFoundError } from "@openstatus/services"; import { - monitor, - page, - pageComponent, - pageComponentGroup, - selectMaintenanceSchema, - selectMonitorSchema, - selectPageComponentGroupSchema, - selectPageComponentSchema, - selectStatusReportSchema, -} from "@openstatus/db/src/schema"; + UpdatePageComponentOrderInput, + deletePageComponent, + listPageComponents, + updatePageComponentOrder, +} from "@openstatus/services/page-component"; -import { Events } from "@openstatus/analytics"; -import { TRPCError } from "@trpc/server"; +import { toServiceCtx, toTRPCError } from "../service-adapter"; import { createTRPCRouter, protectedProcedure } from "../trpc"; export const pageComponentRouter = createTRPCRouter({ @@ -27,432 +22,57 @@ export const pageComponentRouter = createTRPCRouter({ }) .optional(), ) - .query(async (opts) => { - const whereConditions: SQL[] = [ - eq(pageComponent.workspaceId, opts.ctx.workspace.id), - ]; - - if (opts.input?.pageId) { - whereConditions.push(eq(pageComponent.pageId, opts.input.pageId)); - } - - const result = await opts.ctx.db.query.pageComponent.findMany({ - where: and(...whereConditions), - orderBy: - opts.input?.order === "desc" - ? desc(pageComponent.order) - : asc(pageComponent.order), - with: { - monitor: true, - group: true, - statusReportsToPageComponents: { - with: { - statusReport: true, - }, - orderBy: (statusReportsToPageComponents, { desc }) => - desc(statusReportsToPageComponents.createdAt), - }, - maintenancesToPageComponents: { - with: { - maintenance: true, - }, - orderBy: (maintenancesToPageComponents, { desc }) => - desc(maintenancesToPageComponents.createdAt), + .query(async ({ ctx, input }) => { + try { + return await listPageComponents({ + ctx: toServiceCtx(ctx), + input: { + pageId: input?.pageId, + order: input?.order ?? "asc", }, - }, - }); - - // Transform and parse the result to flatten the junction tables - return selectPageComponentSchema - .extend({ - monitor: selectMonitorSchema.nullish(), - group: selectPageComponentGroupSchema.nullish(), - statusReports: z.array(selectStatusReportSchema).default([]), - maintenances: z.array(selectMaintenanceSchema).default([]), - }) - .array() - .parse( - result.map((component) => ({ - ...component, - statusReports: - component.statusReportsToPageComponents?.map( - (sr) => sr.statusReport, - ) ?? [], - maintenances: - component.maintenancesToPageComponents?.map( - (m) => m.maintenance, - ) ?? [], - })), - ); + }); + } catch (err) { + toTRPCError(err); + } }), delete: protectedProcedure .meta({ track: Events.DeletePageComponent, trackProps: ["id"] }) .input(z.object({ id: z.number() })) - .mutation(async (opts) => { - return await opts.ctx.db - .delete(pageComponent) - .where( - and( - eq(pageComponent.id, opts.input.id), - eq(pageComponent.workspaceId, opts.ctx.workspace.id), - ), - ) - .returning(); + .mutation(async ({ ctx, input }) => { + try { + await deletePageComponent({ + ctx: toServiceCtx(ctx), + input: { id: input.id }, + }); + // Old contract returned drizzle `.returning()` — callers today + // ignore the payload, only `.mutate` success/failure matters. + return [] as Array; + } catch (err) { + // Preserve the pre-migration idempotent behaviour — the old tRPC + // delete silently succeeded when the row was already gone. + if (err instanceof NotFoundError) return [] as Array; + toTRPCError(err); + } }), updateOrder: protectedProcedure .meta({ track: Events.UpdatePageComponentOrder, trackProps: ["pageId"] }) - .input( - z.object({ - pageId: z.number(), - components: z.array( - z.object({ - id: z.number().optional(), // Optional for new components - monitorId: z.number().nullish(), - order: z.number(), - name: z.string(), - description: z.string().nullish(), - type: z.enum(["monitor", "static"]), - }), - ), - groups: z.array( - z.object({ - order: z.number(), - name: z.string(), - defaultOpen: z.boolean().optional().default(false), - components: z.array( - z.object({ - id: z.number().optional(), // Optional for new components - monitorId: z.number().nullish(), - order: z.number(), - name: z.string(), - description: z.string().nullish(), - type: z.enum(["monitor", "static"]), - }), - ), - }), - ), - }), - ) - .mutation(async (opts) => { - await opts.ctx.db.transaction(async (tx) => { - // Verify the page belongs to the current workspace - const ownedPage = await tx - .select({ id: page.id }) - .from(page) - .where( - and( - eq(page.id, opts.input.pageId), - eq(page.workspaceId, opts.ctx.workspace.id), - ), - ) - .get(); - - if (!ownedPage) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "You don't have access to this page.", - }); - } - - const pageComponentLimit = opts.ctx.workspace.limits["page-components"]; - - // Get existing state - const existingComponents = await tx - .select() - .from(pageComponent) - .where( - and( - eq(pageComponent.pageId, opts.input.pageId), - eq(pageComponent.workspaceId, opts.ctx.workspace.id), - ), - ) - .all(); - - // Count components on OTHER pages in this workspace - const otherPagesComponentCount = await tx - .select({ id: pageComponent.id }) - .from(pageComponent) - .where( - and( - eq(pageComponent.workspaceId, opts.ctx.workspace.id), - ne(pageComponent.pageId, opts.input.pageId), - ), - ) - .all(); - - const inputComponentCount = - opts.input.components.length + - opts.input.groups.reduce((sum, g) => sum + g.components.length, 0); - - const totalAfterUpdate = - otherPagesComponentCount.length + inputComponentCount; - - if (totalAfterUpdate > pageComponentLimit) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "You reached your page component limits.", - }); - } - - const existingGroups = await tx - .select() - .from(pageComponentGroup) - .where( - and( - eq(pageComponentGroup.pageId, opts.input.pageId), - eq(pageComponentGroup.workspaceId, opts.ctx.workspace.id), - ), - ) - .all(); - - const existingGroupIds = existingGroups.map((g) => g.id); - - // Collect all monitorIds from input (for monitor-type components) - const inputMonitorIds = [ - ...opts.input.components - .filter((c) => c.type === "monitor" && c.monitorId) - .map((c) => c.monitorId), - ...opts.input.groups.flatMap((g) => - g.components - .filter((c) => c.type === "monitor" && c.monitorId) - .map((c) => c.monitorId), - ), - ] as number[]; - - if (inputMonitorIds.length > 0) { - const validMonitors = await tx.query.monitor.findMany({ - where: and( - eq(monitor.workspaceId, opts.ctx.workspace.id), - inArray(monitor.id, inputMonitorIds), - ), - }); - if (validMonitors.length !== inputMonitorIds.length) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "Invalid monitor IDs.", - }); - } - } - - // Collect IDs for static components that have IDs in input - const inputStaticComponentIds = [ - ...opts.input.components - .filter((c) => c.type === "static" && c.id) - .map((c) => c.id), - ...opts.input.groups.flatMap((g) => - g.components - .filter((c) => c.type === "static" && c.id) - .map((c) => c.id), - ), - ] as number[]; - - // Find components that are being removed - // For monitor components: those with monitorIds not in the input - // For static components with IDs: those with IDs not in the input - // For static components without IDs in input: delete all existing static components - const removedMonitorComponents = existingComponents.filter( - (c) => - c.type === "monitor" && - c.monitorId && - !inputMonitorIds.includes(c.monitorId), - ); - - const hasStaticComponentsInInput = - opts.input.components.some((c) => c.type === "static") || - opts.input.groups.some((g) => - g.components.some((c) => c.type === "static"), - ); - - // If input has static components but they don't have IDs, we need to delete old ones - // If input has static components with IDs, only delete those not in input - const removedStaticComponents = existingComponents.filter((c) => { - if (c.type !== "static") return false; - // If we have static components in input - if (hasStaticComponentsInInput) { - // If the input has IDs, only remove those not in the list - if (inputStaticComponentIds.length > 0) { - return !inputStaticComponentIds.includes(c.id); - } - // If input doesn't have IDs, remove all existing static components - return true; - } - // If no static components in input at all, remove existing ones - return true; + // Reuse the service's canonical input schema directly — the local + // flat `z.object` that lived here previously let components slip + // through with `type: "monitor"` but no `monitorId`. The service + // schema carries a `.refine` enforcing that invariant, so sharing + // it keeps router validation in lockstep with the service. + .input(UpdatePageComponentOrderInput) + .mutation(async ({ ctx, input }) => { + try { + await updatePageComponentOrder({ + ctx: toServiceCtx(ctx), + input, }); - - const removedComponentIds = [ - ...removedMonitorComponents.map((c) => c.id), - ...removedStaticComponents.map((c) => c.id), - ]; - - // Delete removed components - if (removedComponentIds.length > 0) { - await tx - .delete(pageComponent) - .where( - and( - eq(pageComponent.pageId, opts.input.pageId), - eq(pageComponent.workspaceId, opts.ctx.workspace.id), - inArray(pageComponent.id, removedComponentIds), - ), - ); - } - - // Clear groupId from all components before deleting groups - // This prevents foreign key constraint errors - if (existingGroupIds.length > 0) { - await tx - .update(pageComponent) - .set({ groupId: null }) - .where( - and( - eq(pageComponent.pageId, opts.input.pageId), - eq(pageComponent.workspaceId, opts.ctx.workspace.id), - inArray(pageComponent.groupId, existingGroupIds), - ), - ); - } - - // Delete old groups and create new ones - if (existingGroupIds.length > 0) { - await tx - .delete(pageComponentGroup) - .where( - and( - eq(pageComponentGroup.pageId, opts.input.pageId), - eq(pageComponentGroup.workspaceId, opts.ctx.workspace.id), - ), - ); - } - - // Create new groups - const newGroups: Array<{ id: number; name: string }> = []; - if (opts.input.groups.length > 0) { - const createdGroups = await tx - .insert(pageComponentGroup) - .values( - opts.input.groups.map((g) => ({ - pageId: opts.input.pageId, - workspaceId: opts.ctx.workspace.id, - name: g.name, - defaultOpen: g.defaultOpen, - })), - ) - .returning(); - newGroups.push(...createdGroups); - } - - // Prepare values for upsert - both grouped and ungrouped components - const groupComponentValues = opts.input.groups.flatMap((g, i) => - g.components.map((c) => ({ - id: c.id, // Will be undefined for new components - pageId: opts.input.pageId, - workspaceId: opts.ctx.workspace.id, - name: c.name, - description: c.description, - type: c.type, - monitorId: c.monitorId, - order: g.order, - groupId: newGroups[i].id, - groupOrder: c.order, - })), - ); - - const standaloneComponentValues = opts.input.components.map((c) => ({ - id: c.id, // Will be undefined for new components - pageId: opts.input.pageId, - workspaceId: opts.ctx.workspace.id, - name: c.name, - description: c.description, - type: c.type, - monitorId: c.monitorId, - order: c.order, - groupId: null as number | null, - groupOrder: null as number | null, - })); - - const allComponentValues = [ - ...groupComponentValues, - ...standaloneComponentValues, - ]; - - // Separate monitor and static components for different upsert strategies - const monitorComponents = allComponentValues.filter( - (c) => c.type === "monitor" && c.monitorId, - ); - const staticComponents = allComponentValues.filter( - (c) => c.type === "static", - ); - - // Upsert monitor components using SQL-level conflict resolution - // This uses the (pageId, monitorId) unique constraint to preserve component IDs - if (monitorComponents.length > 0) { - await tx - .insert(pageComponent) - .values(monitorComponents) - .onConflictDoUpdate({ - target: [pageComponent.pageId, pageComponent.monitorId], - set: { - name: sql.raw("excluded.`name`"), - description: sql.raw("excluded.`description`"), - order: sql.raw("excluded.`order`"), - groupId: sql.raw("excluded.`group_id`"), - groupOrder: sql.raw("excluded.`group_order`"), - updatedAt: sql`(strftime('%s', 'now'))`, - }, - }); - } - - // Handle static components - // If they have a valid existing ID, update them; otherwise insert new ones - const existingComponentIds = new Set( - existingComponents.map((c) => c.id), - ); - - for (const componentValue of staticComponents) { - if ( - componentValue.id && - existingComponentIds.has(componentValue.id) - ) { - // Update existing static component (preserves ID and relationships) - await tx - .update(pageComponent) - .set({ - name: componentValue.name, - description: componentValue.description, - type: componentValue.type, - monitorId: componentValue.monitorId, - order: componentValue.order, - groupId: componentValue.groupId, - groupOrder: componentValue.groupOrder, - updatedAt: new Date(), - }) - .where( - and( - eq(pageComponent.id, componentValue.id), - eq(pageComponent.pageId, opts.input.pageId), - eq(pageComponent.workspaceId, opts.ctx.workspace.id), - ), - ); - } else { - // Insert new static component - await tx.insert(pageComponent).values({ - pageId: componentValue.pageId, - workspaceId: componentValue.workspaceId, - name: componentValue.name, - description: componentValue.description, - type: componentValue.type, - monitorId: componentValue.monitorId, - order: componentValue.order, - groupId: componentValue.groupId, - groupOrder: componentValue.groupOrder, - }); - } - } - }); - - return { success: true }; + return { success: true }; + } catch (err) { + toTRPCError(err); + } }), }); diff --git a/packages/api/src/router/user.ts b/packages/api/src/router/user.ts index 6fc6b1456..bf18a9a6e 100644 --- a/packages/api/src/router/user.ts +++ b/packages/api/src/router/user.ts @@ -1,78 +1,27 @@ -import { and, eq, isNull, ne } from "@openstatus/db"; -import { - account, - session, - user, - usersToWorkspaces, -} from "@openstatus/db/src/schema"; +import { deleteAccount, getUser } from "@openstatus/services/user"; -import { TRPCError } from "@trpc/server"; +import { toServiceCtx, toTRPCError } from "../service-adapter"; import { createTRPCRouter, protectedProcedure } from "../trpc"; export const userRouter = createTRPCRouter({ - get: protectedProcedure.query(async (opts) => { - return await opts.ctx.db - .select() - .from(user) - .where(and(eq(user.id, opts.ctx.user.id), isNull(user.deletedAt))) - .get(); - }), - - deleteAccount: protectedProcedure.mutation(async (opts) => { - const userId = opts.ctx.user.id; - - // Check if user owns any workspace with a paid plan - const ownedWorkspaces = await opts.ctx.db.query.usersToWorkspaces.findMany({ - where: and( - eq(usersToWorkspaces.userId, userId), - eq(usersToWorkspaces.role, "owner"), - ), - with: { - workspace: true, - }, - }); - - const hasPaidWorkspace = ownedWorkspaces.some( - ({ workspace }) => workspace.plan && workspace.plan !== "free", - ); - - if (hasPaidWorkspace) { - throw new TRPCError({ - code: "PRECONDITION_FAILED", - message: - "You must cancel your subscription before deleting your account.", + get: protectedProcedure.query(async ({ ctx }) => { + try { + return await getUser({ + ctx: toServiceCtx(ctx), + input: { userId: ctx.user.id }, }); + } catch (err) { + toTRPCError(err); } + }), - await opts.ctx.db.transaction(async (tx) => { - // Remove from non-owned workspaces - await tx - .delete(usersToWorkspaces) - .where( - and( - eq(usersToWorkspaces.userId, userId), - ne(usersToWorkspaces.role, "owner"), - ), - ); - - // Delete sessions - await tx.delete(session).where(eq(session.userId, userId)); - - // Delete OAuth accounts - await tx.delete(account).where(eq(account.userId, userId)); - - // Soft delete user - await tx - .update(user) - .set({ - deletedAt: new Date(), - email: "", - firstName: "", - lastName: "", - photoUrl: "", - name: "", - }) - .where(eq(user.id, userId)); - }); + deleteAccount: protectedProcedure.mutation(async ({ ctx }) => { + try { + // `userId` is derived from `ctx.actor` inside the service — no + // input needed. + await deleteAccount({ ctx: toServiceCtx(ctx) }); + } catch (err) { + toTRPCError(err); + } }), }); diff --git a/packages/api/src/router/workspace.ts b/packages/api/src/router/workspace.ts index 0cbe32146..ea880b116 100644 --- a/packages/api/src/router/workspace.ts +++ b/packages/api/src/router/workspace.ts @@ -1,79 +1,55 @@ import { z } from "zod"; import { Events } from "@openstatus/analytics"; -import { type SQL, and, eq, isNull } from "@openstatus/db"; import { - monitor, - selectWorkspaceSchema, - usersToWorkspaces, - workspace, -} from "@openstatus/db/src/schema"; + getWorkspace, + getWorkspaceWithUsage, + listWorkspaces, + updateWorkspaceName, +} from "@openstatus/services/workspace"; +import { toServiceCtx, toTRPCError } from "../service-adapter"; import { createTRPCRouter, protectedProcedure } from "../trpc"; export const workspaceRouter = createTRPCRouter({ - getWorkspace: protectedProcedure.query(async (opts) => { - const result = await opts.ctx.db.query.workspace.findFirst({ - where: eq(workspace.id, opts.ctx.workspace.id), - }); - - return selectWorkspaceSchema.parse(result); + getWorkspace: protectedProcedure.query(async ({ ctx }) => { + try { + return await getWorkspace({ ctx: toServiceCtx(ctx) }); + } catch (err) { + toTRPCError(err); + } }), - get: protectedProcedure.query(async (opts) => { - const whereConditions: SQL[] = [eq(workspace.id, opts.ctx.workspace.id)]; - - const result = await opts.ctx.db.query.workspace.findFirst({ - where: and(...whereConditions), - with: { - pages: { - with: { - pageComponents: true, - }, - }, - monitors: { - where: isNull(monitor.deletedAt), - }, - notifications: true, - }, - }); - - return selectWorkspaceSchema.parse({ - ...result, - usage: { - monitors: result?.monitors?.length || 0, - notifications: result?.notifications?.length || 0, - pages: result?.pages?.length || 0, - pageComponents: - result?.pages?.flatMap((page) => page.pageComponents)?.length || 0, - // checks: result?.checks?.length || 0, - checks: 0, - }, - }); + get: protectedProcedure.query(async ({ ctx }) => { + try { + return await getWorkspaceWithUsage({ ctx: toServiceCtx(ctx) }); + } catch (err) { + toTRPCError(err); + } }), - list: protectedProcedure.query(async (opts) => { - const result = await opts.ctx.db.query.usersToWorkspaces.findMany({ - where: eq(usersToWorkspaces.userId, opts.ctx.user.id), - with: { - workspace: true, - }, - }); - - return selectWorkspaceSchema - .array() - .parse(result.map(({ workspace }) => workspace)); + list: protectedProcedure.query(async ({ ctx }) => { + try { + return await listWorkspaces({ + ctx: toServiceCtx(ctx), + input: { userId: ctx.user.id }, + }); + } catch (err) { + toTRPCError(err); + } }), updateName: protectedProcedure .meta({ track: Events.UpdateWorkspace }) .input(z.object({ name: z.string() })) - .mutation(async (opts) => { - const whereConditions: SQL[] = [eq(workspace.id, opts.ctx.workspace.id)]; - - await opts.ctx.db - .update(workspace) - .set({ name: opts.input.name, updatedAt: new Date() }) - .where(and(...whereConditions)); + .mutation(async ({ ctx, input }) => { + try { + await updateWorkspaceName({ + ctx: toServiceCtx(ctx), + input: { name: input.name }, + }); + } catch (err) { + toTRPCError(err); + } }), }); diff --git a/packages/api/src/service-adapter.ts b/packages/api/src/service-adapter.ts index 48f127c5a..737e5fce9 100644 --- a/packages/api/src/service-adapter.ts +++ b/packages/api/src/service-adapter.ts @@ -63,6 +63,11 @@ export function toTRPCError(err: unknown): never { code: "TOO_MANY_REQUESTS", message: err.message, }); + case "PRECONDITION_FAILED": + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: err.message, + }); case "INTERNAL": throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", diff --git a/packages/api/src/service/apiKey.test.ts b/packages/api/src/service/apiKey.test.ts deleted file mode 100644 index 1ce21a821..000000000 --- a/packages/api/src/service/apiKey.test.ts +++ /dev/null @@ -1,434 +0,0 @@ -import { afterAll, beforeAll, describe, expect, test } from "bun:test"; - -import { db, eq } from "@openstatus/db"; -import { apiKey } from "@openstatus/db/src/schema"; -import { verifyApiKeyHash } from "@openstatus/db/src/utils/api-key"; - -import { - createApiKey, - getApiKeys, - revokeApiKey, - updateLastUsed, - verifyApiKey, -} from "./apiKey"; - -// Test data setup -let testWorkspaceId: number; -let testUserId: number; -let testApiKeyId: number; -let testToken: string; - -beforeAll(async () => { - // Clean up any existing test data - await db.delete(apiKey).where(eq(apiKey.name, "Test API Key")); - await db.delete(apiKey).where(eq(apiKey.name, "Test Key with Description")); - await db.delete(apiKey).where(eq(apiKey.name, "Test Key with Expiration")); - - // Use existing test workspace and user from seed data - testWorkspaceId = 1; - testUserId = 1; -}); - -afterAll(async () => { - // Clean up test data - await db.delete(apiKey).where(eq(apiKey.name, "Test API Key")); - await db.delete(apiKey).where(eq(apiKey.name, "Test Key with Description")); - await db.delete(apiKey).where(eq(apiKey.name, "Test Key with Expiration")); -}); - -describe("createApiKey", () => { - test("should create API key with minimal parameters", async () => { - const result = await createApiKey( - testWorkspaceId, - testUserId, - "Test API Key", - ); - - expect(result).toBeDefined(); - expect(result.token).toMatch(/^os_[a-f0-9]{32}$/); - expect(result.key).toMatchObject({ - name: "Test API Key", - workspaceId: testWorkspaceId, - createdById: testUserId, - description: null, - expiresAt: null, - }); - expect(result.key.prefix).toBe(result.token.slice(0, 11)); - expect(await verifyApiKeyHash(result.token, result.key.hashedToken)).toBe( - true, - ); - - // Save for later tests - testApiKeyId = result.key.id; - testToken = result.token; - }); - - test("should create API key with description", async () => { - const description = "This is a test API key for integration testing"; - const result = await createApiKey( - testWorkspaceId, - testUserId, - "Test Key with Description", - description, - ); - - expect(result.key.description).toBe(description); - }); - - test("should create API key with expiration", async () => { - const expiresAt = new Date(Date.now() + 86400000); // 1 day from now - const result = await createApiKey( - testWorkspaceId, - testUserId, - "Test Key with Expiration", - undefined, - expiresAt, - ); - - // SQLite stores timestamps with second precision, so compare with tolerance - expect(result.key.expiresAt?.getTime()).toBeCloseTo( - expiresAt.getTime(), - -4, - ); - }); - - test("should create API key with both description and expiration", async () => { - const description = "Full featured key"; - const expiresAt = new Date(Date.now() + 86400000); - const result = await createApiKey( - testWorkspaceId, - testUserId, - "Full Featured Key", - description, - expiresAt, - ); - - expect(result.key).toMatchObject({ - name: "Full Featured Key", - description, - expiresAt, - }); - - // Clean up - await db.delete(apiKey).where(eq(apiKey.id, result.key.id)); - }); - - test("should generate unique tokens", async () => { - const result1 = await createApiKey( - testWorkspaceId, - testUserId, - "Unique Key 1", - ); - const result2 = await createApiKey( - testWorkspaceId, - testUserId, - "Unique Key 2", - ); - - expect(result1.token).not.toBe(result2.token); - expect(result1.key.prefix).not.toBe(result2.key.prefix); - expect(result1.key.hashedToken).not.toBe(result2.key.hashedToken); - - // Clean up - await db.delete(apiKey).where(eq(apiKey.id, result1.key.id)); - await db.delete(apiKey).where(eq(apiKey.id, result2.key.id)); - }); -}); - -describe("verifyApiKey", () => { - test("should verify valid API key", async () => { - const result = await verifyApiKey(testToken); - - expect(result).not.toBeNull(); - expect(result).toMatchObject({ - id: testApiKeyId, - name: "Test API Key", - workspaceId: testWorkspaceId, - createdById: testUserId, - }); - }); - - test("should return null for invalid token format", async () => { - const invalidToken = "os_invalid"; - const result = await verifyApiKey(invalidToken); - - expect(result).toBeNull(); - }); - - test("should return null for non-existent token", async () => { - const nonExistentToken = `os_${"a".repeat(32)}`; - const result = await verifyApiKey(nonExistentToken); - - expect(result).toBeNull(); - }); - - test("should return null for token with incorrect hash", async () => { - // Create a token with same prefix but different hash - const wrongToken = testToken.slice(0, 11) + "0".repeat(24); - const result = await verifyApiKey(wrongToken); - - expect(result).toBeNull(); - }); - - test("should return null for expired token", async () => { - // Create an expired key - const expiredDate = new Date(Date.now() - 86400000); // 1 day ago - const expiredKey = await createApiKey( - testWorkspaceId, - testUserId, - "Expired Key", - undefined, - expiredDate, - ); - - const result = await verifyApiKey(expiredKey.token); - - expect(result).toBeNull(); - - // Clean up - await db.delete(apiKey).where(eq(apiKey.id, expiredKey.key.id)); - }); - - test("should verify token that expires in the future", async () => { - // Create a key that expires in the future - const futureDate = new Date(Date.now() + 86400000); // 1 day from now - const futureKey = await createApiKey( - testWorkspaceId, - testUserId, - "Future Expiry Key", - undefined, - futureDate, - ); - - const result = await verifyApiKey(futureKey.token); - - expect(result).not.toBeNull(); - expect(result?.id).toBe(futureKey.key.id); - - // Clean up - await db.delete(apiKey).where(eq(apiKey.id, futureKey.key.id)); - }); -}); - -describe("revokeApiKey", () => { - test("should revoke API key successfully", async () => { - // Create a key to revoke - const keyToRevoke = await createApiKey( - testWorkspaceId, - testUserId, - "Key to Revoke", - ); - - const result = await revokeApiKey(keyToRevoke.key.id, testWorkspaceId); - - expect(result).toBe(true); - - // Verify key is deleted - const deletedKey = await db - .select() - .from(apiKey) - .where(eq(apiKey.id, keyToRevoke.key.id)) - .get(); - - expect(deletedKey).toBeUndefined(); - }); - - test("should return false for non-existent key", async () => { - const result = await revokeApiKey(999999, testWorkspaceId); - - expect(result).toBe(false); - }); - - test("should return false when workspace ID doesn't match", async () => { - // Create a key - const key = await createApiKey(testWorkspaceId, testUserId, "Test Key"); - - // Try to revoke with wrong workspace ID - const result = await revokeApiKey(key.key.id, 999); - - expect(result).toBe(false); - - // Verify key still exists - const stillExists = await db - .select() - .from(apiKey) - .where(eq(apiKey.id, key.key.id)) - .get(); - - expect(stillExists).toBeDefined(); - - // Clean up - await db.delete(apiKey).where(eq(apiKey.id, key.key.id)); - }); -}); - -describe("getApiKeys", () => { - test("should get all API keys for a workspace", async () => { - // Create multiple keys - const key1 = await createApiKey( - testWorkspaceId, - testUserId, - "Workspace Key 1", - ); - const key2 = await createApiKey( - testWorkspaceId, - testUserId, - "Workspace Key 2", - ); - const key3 = await createApiKey( - testWorkspaceId, - testUserId, - "Workspace Key 3", - ); - - const keys = await getApiKeys(testWorkspaceId); - - // Should include at least the 3 keys we just created plus the test key from earlier - expect(keys.length).toBeGreaterThanOrEqual(4); - expect(keys.some((k) => k.name === "Workspace Key 1")).toBe(true); - expect(keys.some((k) => k.name === "Workspace Key 2")).toBe(true); - expect(keys.some((k) => k.name === "Workspace Key 3")).toBe(true); - - // All keys should belong to the test workspace - keys.forEach((key) => { - expect(key.workspaceId).toBe(testWorkspaceId); - }); - - // Clean up - await db.delete(apiKey).where(eq(apiKey.id, key1.key.id)); - await db.delete(apiKey).where(eq(apiKey.id, key2.key.id)); - await db.delete(apiKey).where(eq(apiKey.id, key3.key.id)); - }); - - test("should return empty array for workspace with no keys", async () => { - // Use a non-existent workspace ID - const keys = await getApiKeys(999999); - - expect(keys).toEqual([]); - }); - - test("should not include keys from other workspaces", async () => { - // Assuming there might be other workspaces, verify isolation - const keys = await getApiKeys(testWorkspaceId); - - keys.forEach((key) => { - expect(key.workspaceId).toBe(testWorkspaceId); - }); - }); -}); - -describe("updateLastUsed", () => { - test("should update lastUsedAt when never used", async () => { - const key = await createApiKey( - testWorkspaceId, - testUserId, - "Never Used Key", - ); - - const result = await updateLastUsed(key.key.id, null); - - expect(result).toBe(true); - - // Verify the update - const updatedKey = await db - .select() - .from(apiKey) - .where(eq(apiKey.id, key.key.id)) - .get(); - - expect(updatedKey?.lastUsedAt).not.toBeNull(); - expect(updatedKey?.lastUsedAt).toBeInstanceOf(Date); - - // Clean up - await db.delete(apiKey).where(eq(apiKey.id, key.key.id)); - }); - - test("should update lastUsedAt when debounce period has passed", async () => { - const key = await createApiKey( - testWorkspaceId, - testUserId, - "Debounce Test Key", - ); - - // Set lastUsedAt to 10 minutes ago (beyond 5-minute debounce) - const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000); - await db - .update(apiKey) - .set({ lastUsedAt: tenMinutesAgo }) - .where(eq(apiKey.id, key.key.id)); - - const result = await updateLastUsed(key.key.id, tenMinutesAgo); - - expect(result).toBe(true); - - // Verify the update - const updatedKey = await db - .select() - .from(apiKey) - .where(eq(apiKey.id, key.key.id)) - .get(); - - expect(updatedKey?.lastUsedAt?.getTime()).toBeGreaterThan( - tenMinutesAgo.getTime(), - ); - - // Clean up - await db.delete(apiKey).where(eq(apiKey.id, key.key.id)); - }); - - test("should not update lastUsedAt within debounce period", async () => { - const key = await createApiKey( - testWorkspaceId, - testUserId, - "Recent Use Key", - ); - - // Set lastUsedAt to 2 minutes ago (within 5-minute debounce) - const twoMinutesAgo = new Date(Date.now() - 2 * 60 * 1000); - await db - .update(apiKey) - .set({ lastUsedAt: twoMinutesAgo }) - .where(eq(apiKey.id, key.key.id)); - - const result = await updateLastUsed(key.key.id, twoMinutesAgo); - - expect(result).toBe(false); - - // Verify no update occurred (compare with tolerance due to SQLite timestamp precision) - const updatedKey = await db - .select() - .from(apiKey) - .where(eq(apiKey.id, key.key.id)) - .get(); - - expect(updatedKey?.lastUsedAt?.getTime()).toBeCloseTo( - twoMinutesAgo.getTime(), - -4, - ); - - // Clean up - await db.delete(apiKey).where(eq(apiKey.id, key.key.id)); - }); - - test("should update at exactly 5 minutes (boundary test)", async () => { - const key = await createApiKey( - testWorkspaceId, - testUserId, - "Boundary Test Key", - ); - - // Set lastUsedAt to exactly 5 minutes and 1ms ago - const fiveMinutesAgo = new Date(Date.now() - (5 * 60 * 1000 + 1)); - await db - .update(apiKey) - .set({ lastUsedAt: fiveMinutesAgo }) - .where(eq(apiKey.id, key.key.id)); - - const result = await updateLastUsed(key.key.id, fiveMinutesAgo); - - expect(result).toBe(true); - - // Clean up - await db.delete(apiKey).where(eq(apiKey.id, key.key.id)); - }); -}); diff --git a/packages/api/src/service/apiKey.ts b/packages/api/src/service/apiKey.ts deleted file mode 100644 index 9150b92f1..000000000 --- a/packages/api/src/service/apiKey.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { eq } from "@openstatus/db"; -import { db } from "@openstatus/db"; -import { apiKey } from "@openstatus/db/src/schema"; -import { - shouldUpdateLastUsed as checkShouldUpdateLastUsed, - generateApiKey as generateKey, - verifyApiKeyHash, -} from "@openstatus/db/src/utils/api-key"; - -/** - * Creates a new API key for a workspace - * @param workspaceId - The workspace ID - * @param createdById - The ID of the user creating the key - * @param name - The name of the API key - * @param description - Optional description for the key - * @param expiresAt - Optional expiration date - * @returns The full token (only shown once) and the created key details - */ -export async function createApiKey( - workspaceId: number, - createdById: number, - name: string, - description?: string, - expiresAt?: Date, -): Promise<{ token: string; key: typeof apiKey.$inferSelect }> { - const { token, prefix, hash } = await generateKey(); - - const [key] = await db - .insert(apiKey) - .values({ - name, - description, - prefix, - hashedToken: hash, - workspaceId, - createdById, - expiresAt, - }) - .returning(); - - if (!key) { - throw new Error("Failed to create API key"); - } - - return { token, key }; -} - -/** - * Verifies an API key token - * @param token - The API key token to verify - * @returns The API key details if valid, null otherwise - */ -export async function verifyApiKey( - token: string, -): Promise { - // Validate token format before database query - if (!/^os_[a-f0-9]{32}$/.test(token)) { - return null; - } - - // Extract prefix from token - const prefix = token.slice(0, 11); // "os_" + 8 chars = 11 total - - // Look up key by prefix - const key = await db - .select() - .from(apiKey) - .where(eq(apiKey.prefix, prefix)) - .get(); - - if (!key) { - return null; - } - - // Verify hash using bcrypt-compatible verification - if (!(await verifyApiKeyHash(token, key.hashedToken))) { - return null; - } - - // Check expiration - if (key.expiresAt && key.expiresAt < new Date()) { - return null; - } - - return key; -} - -/** - * Revokes (deletes) an API key - * @param id - The API key ID - * @param workspaceId - The workspace ID for ownership verification - * @returns True if successfully revoked, false otherwise - */ -export async function revokeApiKey( - id: number, - workspaceId: number, -): Promise { - // First, verify the key exists and belongs to the workspace - const key = await db.select().from(apiKey).where(eq(apiKey.id, id)).get(); - - if (!key || key.workspaceId !== workspaceId) { - return false; - } - - // Delete the key - await db.delete(apiKey).where(eq(apiKey.id, id)); - - return true; -} - -/** - * Gets all API keys for a workspace - * @param workspaceId - The workspace ID - * @returns Array of API keys for the workspace - */ -export async function getApiKeys( - workspaceId: number, -): Promise> { - const keys = await db - .select() - .from(apiKey) - .where(eq(apiKey.workspaceId, workspaceId)) - .all(); - - return keys; -} - -/** - * Updates the lastUsedAt timestamp for an API key (with debouncing) - * @param id - The API key ID - * @param lastUsedAt - The current lastUsedAt value (or null) - * @returns True if updated, false if skipped due to debounce - */ -export async function updateLastUsed( - id: number, - lastUsedAt: Date | null, -): Promise { - // Check if update is needed (5-minute debounce) - if (!checkShouldUpdateLastUsed(lastUsedAt)) { - return false; - } - - await db - .update(apiKey) - .set({ lastUsedAt: new Date() }) - .where(eq(apiKey.id, id)); - - return true; -} diff --git a/packages/api/src/service/import.ts b/packages/api/src/service/import.ts deleted file mode 100644 index 07eec23e8..000000000 --- a/packages/api/src/service/import.ts +++ /dev/null @@ -1,1042 +0,0 @@ -import { and, count, db, eq, isNull } from "@openstatus/db"; -import { - maintenance, - maintenancesToPageComponents, - monitor, - page, - pageComponent, - pageComponentGroup, - pageSubscriber, - pageSubscriberToPageComponent, - statusReport, - statusReportUpdate, - statusReportsToPageComponents, -} from "@openstatus/db/src/schema"; -import type { Limits } from "@openstatus/db/src/schema/plan/schema"; -import type { - ImportProvider, - ImportSummary, - PhaseResult, - ResourceResult, -} from "@openstatus/importers"; -import { createBetterstackProvider } from "@openstatus/importers/betterstack"; -import { createInstatusProvider } from "@openstatus/importers/instatus"; -import { createStatuspageProvider } from "@openstatus/importers/statuspage"; -import { TRPCError } from "@trpc/server"; - -type ImportOptions = { - includeStatusReports?: boolean; - includeSubscribers?: boolean; - includeComponents?: boolean; - includeMonitors?: boolean; -}; - -type ProviderName = "statuspage" | "betterstack" | "instatus"; - -function createProvider(name: ProviderName): ImportProvider { - switch (name) { - case "betterstack": - return createBetterstackProvider(); - case "instatus": - return createInstatusProvider(); - default: - return createStatuspageProvider(); - } -} - -function buildProviderConfig(config: { - provider: ProviderName; - apiKey: string; - workspaceId: number; - pageId?: number; - statuspagePageId?: string; - betterstackStatusPageId?: string; - instatusPageId?: string; -}) { - const { provider, ...rest } = config; - switch (provider) { - case "betterstack": - return { - ...rest, - betterstackStatusPageId: config.betterstackStatusPageId, - }; - case "instatus": - return { ...rest, instatusPageId: config.instatusPageId }; - default: - return { ...rest, statuspagePageId: config.statuspagePageId }; - } -} - -/** - * Inspect an ImportSummary and push warnings into `summary.errors` - * for any limits that would be hit during import. - * - * Used by both preview (to show warnings upfront) and run (before writes). - */ -export async function addLimitWarnings( - summary: ImportSummary, - config: { - limits: Limits; - workspaceId: number; - pageId?: number; - }, -): Promise { - // 1. Component count limit - const componentsPhase = summary.phases.find((p) => p.phase === "components"); - if (componentsPhase && componentsPhase.resources.length > 0) { - const maxComponents = config.limits["page-components"]; - let existingCount = 0; - if (config.pageId) { - const [result] = await db - .select({ count: count() }) - .from(pageComponent) - .where( - and( - eq(pageComponent.pageId, config.pageId), - eq(pageComponent.workspaceId, config.workspaceId), - ), - ); - existingCount = result?.count ?? 0; - } - const remaining = maxComponents - existingCount; - if (remaining <= 0) { - summary.errors.push( - `Component limit reached (${maxComponents}). Upgrade your plan to import components.`, - ); - } else if (componentsPhase.resources.length > remaining) { - summary.errors.push( - `Only ${remaining} of ${componentsPhase.resources.length} components can be imported due to plan limit (${maxComponents}).`, - ); - } - } - - // 2. Custom domain - if (!config.limits["custom-domain"]) { - const pagePhase = summary.phases.find((p) => p.phase === "page"); - const pageData = pagePhase?.resources[0]?.data as - | { customDomain?: string } - | undefined; - if (pageData?.customDomain) { - summary.errors.push( - "Custom domain will be stripped during import. Upgrade your plan to use custom domains.", - ); - } - } - - // 3. Monitor count limit - const monitorsPhase = summary.phases.find((p) => p.phase === "monitors"); - if (monitorsPhase && monitorsPhase.resources.length > 0) { - const maxMonitors = config.limits.monitors; - const [monitorCount] = await db - .select({ count: count() }) - .from(monitor) - .where( - and( - eq(monitor.workspaceId, config.workspaceId), - isNull(monitor.deletedAt), - ), - ); - const remaining = maxMonitors - (monitorCount?.count ?? 0); - if (remaining <= 0) { - summary.errors.push( - `Monitor limit reached (${maxMonitors}). Upgrade your plan to import monitors.`, - ); - } else if (monitorsPhase.resources.length > remaining) { - summary.errors.push( - `Only ${remaining} of ${monitorsPhase.resources.length} monitors can be imported due to plan limit (${maxMonitors}).`, - ); - } - } - - // 4. Monitor periodicity clamping - if (monitorsPhase && monitorsPhase.resources.length > 0) { - const allowedPeriodicity: string[] = config.limits.periodicity; - const clamped = monitorsPhase.resources.filter((r) => { - const data = r.data as { periodicity?: string } | undefined; - return ( - data?.periodicity && !allowedPeriodicity.includes(data.periodicity) - ); - }); - if (clamped.length > 0) { - summary.errors.push( - `${clamped.length} monitor${clamped.length === 1 ? "'s" : "s'"} check frequency will be adjusted to fit your plan's allowed intervals.`, - ); - } - } - - // 5. Subscribers - if (!config.limits["status-subscribers"]) { - const subscribersPhase = summary.phases.find( - (p) => p.phase === "subscribers", - ); - if (subscribersPhase && subscribersPhase.resources.length > 0) { - summary.errors.push( - "Subscribers cannot be imported on your current plan. Upgrade to enable status page subscribers.", - ); - } - } -} - -export async function previewImport(config: { - provider: ProviderName; - apiKey: string; - statuspagePageId?: string; - betterstackStatusPageId?: string; - instatusPageId?: string; - workspaceId: number; - pageId?: number; - limits: Limits; -}): Promise { - const provider = createProvider(config.provider); - const providerConfig = buildProviderConfig(config); - - const validation = await provider.validate({ - ...providerConfig, - dryRun: true, - }); - if (!validation.valid) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Provider validation failed: ${validation.error}`, - }); - } - - const summary = await provider.run({ ...providerConfig, dryRun: true }); - await addLimitWarnings(summary, config); - return summary; -} - -export async function runImport(config: { - provider: ProviderName; - apiKey: string; - statuspagePageId?: string; - betterstackStatusPageId?: string; - instatusPageId?: string; - workspaceId: number; - pageId?: number; - options?: ImportOptions; - limits: Limits; -}): Promise { - const provider = createProvider(config.provider); - const providerConfig = buildProviderConfig(config); - - const validation = await provider.validate(providerConfig); - if (!validation.valid) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Provider validation failed: ${validation.error}`, - }); - } - - // Fetch and map all data - const summary = await provider.run(providerConfig); - - // Add limit warnings (same as preview) - await addLimitWarnings(summary, config); - - // Now write to DB phase by phase - const idMaps = { - groups: new Map(), // sourceId -> openstatusId - components: new Map(), // sourceId -> openstatusId - monitors: new Map(), // sourceId -> openstatusId - }; - - let targetPageId = config.pageId; - let phaseAborted = false; - - for (const phase of summary.phases) { - if (phaseAborted) { - phase.status = "skipped"; - continue; - } - - try { - switch (phase.phase) { - case "monitors": - if (config.options?.includeMonitors !== false) { - await writeMonitorsPhase( - phase, - config.workspaceId, - idMaps.monitors, - config.limits, - ); - } else { - phase.status = "skipped"; - } - break; - case "page": - targetPageId = await writePagePhase( - phase, - config.workspaceId, - config.pageId, - config.limits, - ); - break; - case "componentGroups": - if (targetPageId && config.options?.includeComponents !== false) { - await writeComponentGroupsPhase( - phase, - config.workspaceId, - targetPageId, - idMaps.groups, - ); - } else if (config.options?.includeComponents === false) { - phase.status = "skipped"; - } - break; - case "components": - if (targetPageId && config.options?.includeComponents !== false) { - // Check page-components limit - const [compCount] = await db - .select({ count: count() }) - .from(pageComponent) - .where( - and( - eq(pageComponent.pageId, targetPageId), - eq(pageComponent.workspaceId, config.workspaceId), - ), - ); - const maxComponents = config.limits["page-components"]; - const remaining = maxComponents - (compCount?.count ?? 0); - if (remaining <= 0) { - phase.status = "failed"; - break; - } - if (phase.resources.length > remaining) { - // Trim resources to fit within limit - const skipped = phase.resources.splice(remaining); - for (const r of skipped) { - r.status = "skipped"; - r.error = `Skipped: would exceed component limit (${maxComponents})`; - } - phase.resources.push(...skipped); - } - await writeComponentsPhase( - phase, - config.workspaceId, - targetPageId, - idMaps.groups, - idMaps.components, - idMaps.monitors, - ); - } else if (config.options?.includeComponents === false) { - phase.status = "skipped"; - } - break; - case "incidents": - if (targetPageId && config.options?.includeStatusReports !== false) { - await writeIncidentsPhase( - phase, - config.workspaceId, - targetPageId, - idMaps.components, - ); - } else if (config.options?.includeStatusReports === false) { - phase.status = "skipped"; - } - break; - case "maintenances": - if (targetPageId && config.options?.includeStatusReports !== false) { - await writeMaintenancesPhase( - phase, - config.workspaceId, - targetPageId, - idMaps.components, - ); - } else if (config.options?.includeStatusReports === false) { - phase.status = "skipped"; - } - break; - case "subscribers": - if (targetPageId && config.options?.includeSubscribers) { - if (!config.limits["status-subscribers"]) { - phase.status = "skipped"; - break; - } - await writeSubscribersPhase(phase, targetPageId, idMaps.components); - } else { - phase.status = "skipped"; - } - break; - } - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - summary.errors.push(`Phase "${phase.phase}" failed: ${msg}`); - phase.status = "failed"; - phaseAborted = true; - } - } - - // Compute overall status - const hasFailures = summary.phases.some((p) => p.status === "failed"); - const hasPartial = summary.phases.some((p) => p.status === "partial"); - const allSkippedOrCompleted = summary.phases.every( - (p) => p.status === "completed" || p.status === "skipped", - ); - - summary.status = hasFailures - ? "failed" - : hasPartial - ? "partial" - : allSkippedOrCompleted - ? "completed" - : "partial"; - summary.completedAt = new Date(); - - return summary; -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -const PERIODICITY_ORDER = ["30s", "1m", "5m", "10m", "30m", "1h"] as const; - -/** - * Clamp a periodicity to the nearest allowed value for the plan. - * Picks the closest allowed periodicity that is >= the requested one - * (i.e. never faster than what the plan permits). - */ -export function clampPeriodicity(requested: string, allowed: string[]): string { - if (allowed.includes(requested)) return requested; - const reqIdx = PERIODICITY_ORDER.indexOf( - requested as (typeof PERIODICITY_ORDER)[number], - ); - // Find the smallest allowed periodicity that is >= requested - for (let i = Math.max(reqIdx, 0); i < PERIODICITY_ORDER.length; i++) { - if (allowed.includes(PERIODICITY_ORDER[i])) { - return PERIODICITY_ORDER[i]; - } - } - // Fallback to the slowest allowed - return allowed[allowed.length - 1] ?? "10m"; -} - -// --------------------------------------------------------------------------- -// Phase writers -// --------------------------------------------------------------------------- - -export function computePhaseStatus( - resources: ResourceResult[], -): PhaseResult["status"] { - if (resources.length === 0) return "completed"; - - const allFailed = resources.every((r) => r.status === "failed"); - if (allFailed) return "failed"; - - const hasFailed = resources.some((r) => r.status === "failed"); - const hasSkipped = resources.some((r) => r.status === "skipped"); - const allSkipped = resources.every((r) => r.status === "skipped"); - - if (hasFailed || (hasSkipped && !allSkipped)) return "partial"; - - return "completed"; -} - -async function writePagePhase( - phase: PhaseResult, - workspaceId: number, - existingPageId?: number, - limits?: Limits, -): Promise { - const resource = phase.resources[0]; - if (!resource?.data) { - throw new Error("No page data found in phase"); - } - - const data = resource.data as { - workspaceId: number; - title: string; - description: string; - slug: string; - customDomain: string; - published: boolean; - icon: string; - }; - - // Strip custom domain if not allowed by plan - if (limits && !limits["custom-domain"]) { - data.customDomain = ""; - } - - // If a page ID was provided, verify and update it - if (existingPageId) { - const existing = await db - .select() - .from(page) - .where( - and(eq(page.id, existingPageId), eq(page.workspaceId, workspaceId)), - ) - .get(); - - if (!existing) { - throw new Error( - "Provided page not found or does not belong to workspace", - ); - } - - await db - .update(page) - .set({ title: data.title, description: data.description }) - .where(eq(page.id, existingPageId)); - - resource.openstatusId = existingPageId; - resource.status = "skipped"; - phase.status = computePhaseStatus(phase.resources); - return existingPageId; - } - - // Check idempotency by slug (scoped to workspace) - const existingBySlug = await db - .select() - .from(page) - .where(and(eq(page.slug, data.slug), eq(page.workspaceId, workspaceId))) - .get(); - - if (existingBySlug) { - resource.openstatusId = existingBySlug.id; - resource.status = "skipped"; - phase.status = computePhaseStatus(phase.resources); - return existingBySlug.id; - } - - // Insert new page - const [inserted] = await db - .insert(page) - .values({ - workspaceId: data.workspaceId, - title: data.title, - description: data.description, - slug: data.slug, - customDomain: data.customDomain, - published: data.published, - icon: data.icon, - }) - .returning({ id: page.id }); - - if (!inserted) { - throw new Error("Failed to insert page"); - } - - resource.openstatusId = inserted.id; - resource.status = "created"; - phase.status = computePhaseStatus(phase.resources); - return inserted.id; -} - -async function writeComponentGroupsPhase( - phase: PhaseResult, - workspaceId: number, - pageId: number, - groupIdMap: Map, -): Promise { - for (const resource of phase.resources) { - try { - const data = resource.data as { - workspaceId: number; - pageId: number; - name: string; - }; - - // Check idempotency by name + pageId - const existing = await db - .select() - .from(pageComponentGroup) - .where( - and( - eq(pageComponentGroup.name, data.name), - eq(pageComponentGroup.pageId, pageId), - ), - ) - .get(); - - if (existing) { - groupIdMap.set(resource.sourceId, existing.id); - resource.openstatusId = existing.id; - resource.status = "skipped"; - continue; - } - - const [inserted] = await db - .insert(pageComponentGroup) - .values({ - workspaceId, - pageId, - name: data.name, - }) - .returning({ id: pageComponentGroup.id }); - - if (!inserted) { - resource.status = "failed"; - resource.error = "Insert returned no result"; - continue; - } - - groupIdMap.set(resource.sourceId, inserted.id); - resource.openstatusId = inserted.id; - resource.status = "created"; - } catch (err) { - resource.status = "failed"; - resource.error = err instanceof Error ? err.message : String(err); - } - } - - phase.status = computePhaseStatus(phase.resources); -} - -async function writeComponentsPhase( - phase: PhaseResult, - workspaceId: number, - pageId: number, - groupIdMap: Map, - componentIdMap: Map, - monitorIdMap?: Map, -): Promise { - for (const resource of phase.resources) { - if (resource.status === "skipped") continue; - - try { - const data = resource.data as { - workspaceId: number; - pageId: number; - type: "static" | "monitor"; - monitorId: number | null; - sourceMonitorId?: string | null; - name: string; - description: string | null; - order: number; - sourceGroupId: string | null; - }; - - // Resolve monitor ID from source monitor ID - if (data.type === "monitor") { - if (data.sourceMonitorId && monitorIdMap) { - data.monitorId = monitorIdMap.get(data.sourceMonitorId) ?? null; - } - if (!data.monitorId) { - // Monitor wasn't imported or no source — fall back to static - data.type = "static"; - } - } - - // Check idempotency by name + pageId - const existing = await db - .select() - .from(pageComponent) - .where( - and( - eq(pageComponent.name, data.name), - eq(pageComponent.pageId, pageId), - ), - ) - .get(); - - if (existing) { - componentIdMap.set(resource.sourceId, existing.id); - resource.openstatusId = existing.id; - resource.status = "skipped"; - continue; - } - - // Resolve group ID from source group ID - const resolvedGroupId = data.sourceGroupId - ? groupIdMap.get(data.sourceGroupId) ?? null - : null; - - const [inserted] = await db - .insert(pageComponent) - .values({ - workspaceId, - pageId, - type: data.type, - monitorId: data.monitorId, - name: data.name, - description: data.description, - order: data.order, - groupId: resolvedGroupId, - }) - .returning({ id: pageComponent.id }); - - if (!inserted) { - resource.status = "failed"; - resource.error = "Insert returned no result"; - continue; - } - - componentIdMap.set(resource.sourceId, inserted.id); - resource.openstatusId = inserted.id; - resource.status = "created"; - } catch (err) { - resource.status = "failed"; - resource.error = err instanceof Error ? err.message : String(err); - } - } - - phase.status = computePhaseStatus(phase.resources); -} - -async function writeIncidentsPhase( - phase: PhaseResult, - workspaceId: number, - pageId: number, - componentIdMap: Map, -): Promise { - for (const resource of phase.resources) { - try { - const data = resource.data as { - report: { - title: string; - status: "investigating" | "identified" | "monitoring" | "resolved"; - workspaceId: number; - pageId: number; - }; - updates: Array<{ - status: "investigating" | "identified" | "monitoring" | "resolved"; - message: string; - date: Date; - }>; - sourceComponentIds: string[]; - }; - - // Insert status report - const [insertedReport] = await db - .insert(statusReport) - .values({ - title: data.report.title, - status: data.report.status, - workspaceId, - pageId, - }) - .returning({ id: statusReport.id }); - - if (!insertedReport) { - resource.status = "failed"; - resource.error = "Failed to insert status report"; - continue; - } - - // Insert status report updates - if (data.updates.length > 0) { - await db.insert(statusReportUpdate).values( - data.updates.map((u) => ({ - status: u.status, - message: u.message, - date: u.date, - statusReportId: insertedReport.id, - })), - ); - } - - // Link to page components - const componentLinks: Array<{ - statusReportId: number; - pageComponentId: number; - }> = []; - for (const sourceCompId of data.sourceComponentIds) { - const osCompId = componentIdMap.get(sourceCompId); - if (osCompId) { - componentLinks.push({ - statusReportId: insertedReport.id, - pageComponentId: osCompId, - }); - } - } - if (componentLinks.length > 0) { - await db.insert(statusReportsToPageComponents).values(componentLinks); - } - - resource.openstatusId = insertedReport.id; - resource.status = "created"; - } catch (err) { - resource.status = "failed"; - resource.error = err instanceof Error ? err.message : String(err); - } - } - - phase.status = computePhaseStatus(phase.resources); -} - -async function writeMaintenancesPhase( - phase: PhaseResult, - workspaceId: number, - pageId: number, - componentIdMap: Map, -): Promise { - for (const resource of phase.resources) { - try { - const data = resource.data as { - title: string; - message: string; - from: Date; - to: Date; - workspaceId: number; - pageId: number; - sourceComponentIds: string[]; - }; - - // Insert maintenance - const [inserted] = await db - .insert(maintenance) - .values({ - title: data.title, - message: data.message, - from: data.from, - to: data.to, - workspaceId, - pageId, - }) - .returning({ id: maintenance.id }); - - if (!inserted) { - resource.status = "failed"; - resource.error = "Failed to insert maintenance"; - continue; - } - - // Link to page components - const componentLinks: Array<{ - maintenanceId: number; - pageComponentId: number; - }> = []; - for (const sourceCompId of data.sourceComponentIds) { - const osCompId = componentIdMap.get(sourceCompId); - if (osCompId) { - componentLinks.push({ - maintenanceId: inserted.id, - pageComponentId: osCompId, - }); - } - } - if (componentLinks.length > 0) { - await db.insert(maintenancesToPageComponents).values(componentLinks); - } - - resource.openstatusId = inserted.id; - resource.status = "created"; - } catch (err) { - resource.status = "failed"; - resource.error = err instanceof Error ? err.message : String(err); - } - } - - phase.status = computePhaseStatus(phase.resources); -} - -async function writeMonitorsPhase( - phase: PhaseResult, - workspaceId: number, - monitorIdMap: Map, - limits: Limits, -): Promise { - const [monitorCount] = await db - .select({ count: count() }) - .from(monitor) - .where( - and(eq(monitor.workspaceId, workspaceId), isNull(monitor.deletedAt)), - ); - const maxMonitors = limits.monitors; - const remaining = maxMonitors - (monitorCount?.count ?? 0); - - if (remaining <= 0) { - for (const resource of phase.resources) { - resource.status = "skipped"; - resource.error = `Skipped: monitor limit reached (${maxMonitors})`; - } - phase.status = computePhaseStatus(phase.resources); - return; - } - - let importedCount = 0; - for (const resource of phase.resources) { - if (importedCount >= remaining) { - resource.status = "skipped"; - resource.error = `Skipped: would exceed monitor limit (${maxMonitors})`; - continue; - } - - try { - const data = resource.data as { - workspaceId: number; - jobType: string; - periodicity: string; - status: string; - active: boolean; - regions: string; - url: string; - name: string; - description: string; - headers: string; - body: string; - method: string; - timeout: number; - sourceMonitorGroupId: string | null; - }; - - // Idempotency check by url + workspaceId (exclude soft-deleted) - const existing = await db - .select() - .from(monitor) - .where( - and( - eq(monitor.url, data.url), - eq(monitor.workspaceId, workspaceId), - isNull(monitor.deletedAt), - ), - ) - .get(); - - if (existing) { - monitorIdMap.set(resource.sourceId, existing.id); - resource.openstatusId = existing.id; - resource.status = "skipped"; - continue; - } - - // Clamp periodicity to what the plan allows - const periodicity = clampPeriodicity( - data.periodicity, - limits.periodicity, - ); - - const [inserted] = await db - .insert(monitor) - .values({ - workspaceId, - jobType: data.jobType as - | "http" - | "tcp" - | "icmp" - | "udp" - | "dns" - | "ssl", - periodicity: periodicity as - | "30s" - | "1m" - | "5m" - | "10m" - | "30m" - | "1h" - | "other", - status: "active", - active: data.active, - regions: data.regions, - url: data.url, - name: data.name, - description: data.description, - headers: data.headers, - body: data.body, - method: data.method as - | "GET" - | "POST" - | "HEAD" - | "PUT" - | "PATCH" - | "DELETE" - | "TRACE" - | "CONNECT" - | "OPTIONS", - timeout: data.timeout, - }) - .returning({ id: monitor.id }); - - if (!inserted) { - resource.status = "failed"; - resource.error = "Insert returned no result"; - continue; - } - - monitorIdMap.set(resource.sourceId, inserted.id); - resource.openstatusId = inserted.id; - resource.status = "created"; - importedCount++; - } catch (err) { - resource.status = "failed"; - resource.error = err instanceof Error ? err.message : String(err); - } - } - - phase.status = computePhaseStatus(phase.resources); -} - -// TODO: migrate to new `pageSubscription` + `pageSubscriptionToPageComponent` tables -async function writeSubscribersPhase( - phase: PhaseResult, - pageId: number, - componentIdMap: Map, -): Promise { - for (const resource of phase.resources) { - try { - const data = resource.data as { - email: string; - pageId: number; - confirmed: boolean; - sourceComponentIds: string[]; - }; - - const email = data.email.toLowerCase(); - - // Idempotency check by email + pageId - const existing = await db - .select() - .from(pageSubscriber) - .where( - and( - eq(pageSubscriber.email, email), - eq(pageSubscriber.pageId, pageId), - eq(pageSubscriber.channelType, "email"), - ), - ) - .get(); - - if (existing) { - resource.openstatusId = existing.id; - resource.status = "skipped"; - continue; - } - - const [inserted] = await db - .insert(pageSubscriber) - .values({ - email, - pageId, - channelType: "email", - source: "import", - token: crypto.randomUUID(), - acceptedAt: data.confirmed ? new Date() : undefined, - }) - .returning({ id: pageSubscriber.id }); - - if (!inserted) { - resource.status = "failed"; - resource.error = "Insert returned no result"; - continue; - } - - // Link to page components - const componentLinks: Array<{ - pageSubscriberId: number; - pageComponentId: number; - }> = []; - for (const sourceCompId of data.sourceComponentIds) { - const osCompId = componentIdMap.get(sourceCompId); - if (osCompId) { - componentLinks.push({ - pageSubscriberId: inserted.id, - pageComponentId: osCompId, - }); - } - } - if (componentLinks.length > 0) { - await db.insert(pageSubscriberToPageComponent).values(componentLinks); - } - - resource.openstatusId = inserted.id; - resource.status = "created"; - } catch (err) { - resource.status = "failed"; - resource.error = err instanceof Error ? err.message : String(err); - } - } - - phase.status = computePhaseStatus(phase.resources); -} diff --git a/packages/api/src/trpc.ts b/packages/api/src/trpc.ts index ba5401d6d..44f07a733 100644 --- a/packages/api/src/trpc.ts +++ b/packages/api/src/trpc.ts @@ -149,6 +149,23 @@ const enforceUserIsAuthed = t.middleware(async (opts) => { throw new TRPCError({ code: "UNAUTHORIZED" }); } + // Test escape hatch: when NODE_ENV=test and the caller already + // populated `workspace` + `user` on the inner context (via + // `createInnerTRPCContext` in a test helper), trust it and skip the + // DB round-trip. Without this, router-level limit tests that pass + // an override `workspace.limits` object see it immediately replaced + // by the seeded team-plan workspace below — the override never + // reaches the service and the test asserts against the wrong plan. + if ( + process.env.NODE_ENV === "test" && + ctx.workspace != null && + ctx.user != null + ) { + return opts.next({ + ctx: { ...ctx, user: ctx.user, workspace: ctx.workspace }, + }); + } + // /** // * Attach `user` and `workspace` | `activeWorkspace` infos to context by // * comparing the `user.tenantId` to clerk's `auth.userId` diff --git a/packages/db/src/schema/pages/validation.ts b/packages/db/src/schema/pages/validation.ts index ab307e763..4bc8e5c75 100644 --- a/packages/db/src/schema/pages/validation.ts +++ b/packages/db/src/schema/pages/validation.ts @@ -7,7 +7,11 @@ import { z } from "zod"; import { pageAccessTypes } from "./constants"; import { page } from "./page"; -const slugSchema = z +// Exported so `@openstatus/services` can reuse the canonical rules in +// its own `NewPageInput` / `UpdatePage*Input` schemas without +// duplicating the regex. Keep these as the single source of truth for +// slug and custom-domain shape validation. +export const slugSchema = z .string() .regex( /^[A-Za-z0-9-]+$/, @@ -16,7 +20,7 @@ const slugSchema = z .min(3) .toLowerCase(); -const customDomainSchema = z +export const customDomainSchema = z .string() .regex( /^(?!https?:\/\/|www.)([a-zA-Z0-9]+(.[a-zA-Z0-9]+)+.*)$/, @@ -89,6 +93,7 @@ export const pageConfigurationSchema = z.object({ .nullish() .prefault("default"), }); +export type PageConfiguration = z.infer; export const selectPageSchema = createSelectSchema(page).extend({ password: z.string().optional().nullable().prefault(""), diff --git a/packages/services/package.json b/packages/services/package.json index a0cc8360f..e9c8c565d 100644 --- a/packages/services/package.json +++ b/packages/services/package.json @@ -12,6 +12,50 @@ "./status-report": { "import": "./src/status-report/index.ts", "types": "./src/status-report/index.ts" + }, + "./maintenance": { + "import": "./src/maintenance/index.ts", + "types": "./src/maintenance/index.ts" + }, + "./incident": { + "import": "./src/incident/index.ts", + "types": "./src/incident/index.ts" + }, + "./monitor": { + "import": "./src/monitor/index.ts", + "types": "./src/monitor/index.ts" + }, + "./notification": { + "import": "./src/notification/index.ts", + "types": "./src/notification/index.ts" + }, + "./page-component": { + "import": "./src/page-component/index.ts", + "types": "./src/page-component/index.ts" + }, + "./page": { + "import": "./src/page/index.ts", + "types": "./src/page/index.ts" + }, + "./workspace": { + "import": "./src/workspace/index.ts", + "types": "./src/workspace/index.ts" + }, + "./user": { + "import": "./src/user/index.ts", + "types": "./src/user/index.ts" + }, + "./invitation": { + "import": "./src/invitation/index.ts", + "types": "./src/invitation/index.ts" + }, + "./api-key": { + "import": "./src/api-key/index.ts", + "types": "./src/api-key/index.ts" + }, + "./import": { + "import": "./src/import/index.ts", + "types": "./src/import/index.ts" } }, "scripts": { @@ -19,8 +63,13 @@ }, "dependencies": { "@logtape/logtape": "2.0.1", + "@openstatus/assertions": "workspace:*", "@openstatus/db": "workspace:*", + "@openstatus/importers": "workspace:*", + "@openstatus/locales": "workspace:*", + "@openstatus/regions": "workspace:*", "@openstatus/subscriptions": "workspace:*", + "@openstatus/theme-store": "workspace:*", "zod": "4.1.13" }, "devDependencies": { diff --git a/packages/services/src/api-key/__tests__/api-key.test.ts b/packages/services/src/api-key/__tests__/api-key.test.ts new file mode 100644 index 000000000..1b7de6465 --- /dev/null +++ b/packages/services/src/api-key/__tests__/api-key.test.ts @@ -0,0 +1,190 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + test, +} from "bun:test"; +import { db, eq, inArray } from "@openstatus/db"; +import { apiKey } from "@openstatus/db/src/schema"; + +import { + createApiKey, + listApiKeys, + revokeApiKey, + updateApiKeyLastUsed, + verifyApiKey, +} from ".."; +import { SEEDED_WORKSPACE_TEAM_ID } from "../../../test/fixtures"; +import { + expectAuditRow, + loadSeededWorkspace, + makeUserCtx, + withAuditBuffer, +} from "../../../test/helpers"; +import type { AuditLogRecord } from "../../audit"; +import type { ServiceContext } from "../../context"; +import { NotFoundError } from "../../errors"; + +const TEST_PREFIX = "svc-apikey-test"; + +let teamCtx: ServiceContext; +let auditBuffer: AuditLogRecord[]; +let auditReset: () => void; +const createdKeyIds: number[] = []; + +beforeAll(async () => { + const team = await loadSeededWorkspace(SEEDED_WORKSPACE_TEAM_ID); + teamCtx = makeUserCtx(team, { userId: 1 }); +}); + +afterAll(async () => { + if (createdKeyIds.length > 0) { + await db.delete(apiKey).where(inArray(apiKey.id, createdKeyIds)); + } +}); + +beforeEach(() => { + const session = withAuditBuffer(); + auditBuffer = session.buffer; + auditReset = session.reset; +}); +afterEach(() => auditReset()); + +describe("createApiKey", () => { + test("returns plaintext token once and stores a bcrypt hash", async () => { + const { token, key } = await createApiKey({ + ctx: teamCtx, + input: { + name: `${TEST_PREFIX}-create`, + }, + }); + createdKeyIds.push(key.id); + + expect(token).toMatch(/^os_[a-f0-9]{32}$/); + expect(key.workspaceId).toBe(SEEDED_WORKSPACE_TEAM_ID); + + // `hashedToken` is stripped from the service response (see + // `api-key/create.ts` — the caller only needs the plaintext + // `token` once). Verify the bcrypt hash is actually persisted by + // reading the row back from the db. + const stored = await db + .select({ hashedToken: apiKey.hashedToken }) + .from(apiKey) + .where(eq(apiKey.id, key.id)) + .get(); + expect(stored?.hashedToken).toMatch(/^\$2[aby]\$/); + + await expectAuditRow(auditBuffer, { + action: "api_key.create", + entityType: "api_key", + entityId: key.id, + }); + }); +}); + +describe("listApiKeys", () => { + test("enriches each key with creator info", async () => { + const { key } = await createApiKey({ + ctx: teamCtx, + input: { name: `${TEST_PREFIX}-list` }, + }); + createdKeyIds.push(key.id); + + const rows = await listApiKeys({ ctx: teamCtx }); + const found = rows.find((r) => r.id === key.id); + expect(found).toBeDefined(); + expect(found?.createdBy?.id).toBe(1); + }); +}); + +describe("revokeApiKey", () => { + test("deletes the key and throws NotFoundError for unknown ids", async () => { + const { key } = await createApiKey({ + ctx: teamCtx, + input: { name: `${TEST_PREFIX}-revoke` }, + }); + + await revokeApiKey({ ctx: teamCtx, input: { id: key.id } }); + + const row = await db + .select({ id: apiKey.id }) + .from(apiKey) + .where(eq(apiKey.id, key.id)) + .get(); + expect(row).toBeUndefined(); + + await expect( + revokeApiKey({ ctx: teamCtx, input: { id: 999_999_999 } }), + ).rejects.toBeInstanceOf(NotFoundError); + }); +}); + +describe("verifyApiKey", () => { + test("resolves the stored row for a valid token", async () => { + const { token, key } = await createApiKey({ + ctx: teamCtx, + input: { name: `${TEST_PREFIX}-verify` }, + }); + createdKeyIds.push(key.id); + + const row = await verifyApiKey({ token }); + expect(row?.id).toBe(key.id); + }); + + test("returns null for malformed tokens", async () => { + const row = await verifyApiKey({ token: "not-an-os-key" }); + expect(row).toBeNull(); + }); + + test("returns null for a prefix-match with wrong body", async () => { + const { token, key } = await createApiKey({ + ctx: teamCtx, + input: { name: `${TEST_PREFIX}-verify-wrong` }, + }); + createdKeyIds.push(key.id); + + // Swap the body after the prefix with another random hex sequence so + // prefix hits but hash comparison fails. + const wrong = `${token.slice(0, 11)}${"f".repeat(24)}`; + const row = await verifyApiKey({ token: wrong }); + expect(row).toBeNull(); + }); +}); + +describe("updateApiKeyLastUsed", () => { + test("skips the write when lastUsedAt is recent", async () => { + const { key } = await createApiKey({ + ctx: teamCtx, + input: { name: `${TEST_PREFIX}-lastused` }, + }); + createdKeyIds.push(key.id); + + const now = new Date(); + const wrote = await updateApiKeyLastUsed({ + id: key.id, + lastUsedAt: now, + }); + expect(wrote).toBe(false); + }); + + test("writes when lastUsedAt is null or past the debounce window", async () => { + const { key } = await createApiKey({ + ctx: teamCtx, + input: { name: `${TEST_PREFIX}-lastused-null` }, + }); + createdKeyIds.push(key.id); + + const wrote = await updateApiKeyLastUsed({ id: key.id, lastUsedAt: null }); + expect(wrote).toBe(true); + + const row = await db + .select({ lastUsedAt: apiKey.lastUsedAt }) + .from(apiKey) + .where(eq(apiKey.id, key.id)) + .get(); + expect(row?.lastUsedAt).toBeInstanceOf(Date); + }); +}); diff --git a/packages/services/src/api-key/create.ts b/packages/services/src/api-key/create.ts new file mode 100644 index 000000000..1407202a0 --- /dev/null +++ b/packages/services/src/api-key/create.ts @@ -0,0 +1,74 @@ +import { apiKey } from "@openstatus/db/src/schema"; +import { generateApiKey } from "@openstatus/db/src/utils/api-key"; + +import { emitAudit } from "../audit"; +import { + type ServiceContext, + tryGetActorUserId, + withTransaction, +} from "../context"; +import { InternalServiceError, UnauthorizedError } from "../errors"; +import type { PublicApiKey } from "./list"; +import { CreateApiKeyInput } from "./schemas"; + +/** + * Create a new API key for the caller's workspace. Returns the plaintext + * token *once* — the caller must display it immediately because the stored + * hash can't be reversed. + * + * `createdById` is derived from `ctx.actor` rather than taken from input: + * the column is attribution-grade data (who owns the key, who the audit + * row points at), so letting it ride in on the wire would let any caller + * forge ownership. Actors without a resolvable openstatus user id (system + * / webhook, or an api-key / slack actor with no mapping yet) get a clean + * `UnauthorizedError` instead of silently writing a bogus creator id. + */ +export async function createApiKey(args: { + ctx: ServiceContext; + input: CreateApiKeyInput; +}): Promise<{ token: string; key: PublicApiKey }> { + const { ctx } = args; + const input = CreateApiKeyInput.parse(args.input); + + const createdById = tryGetActorUserId(ctx.actor); + if (createdById == null) { + throw new UnauthorizedError( + "API keys must be created by a known user actor.", + ); + } + + const { token, prefix, hash } = await generateApiKey(); + + return withTransaction(ctx, async (tx) => { + const [key] = await tx + .insert(apiKey) + .values({ + name: input.name, + description: input.description, + prefix, + hashedToken: hash, + workspaceId: ctx.workspace.id, + createdById, + expiresAt: input.expiresAt, + }) + .returning(); + + if (!key) { + throw new InternalServiceError("Failed to create API key"); + } + + await emitAudit(tx, ctx, { + action: "api_key.create", + entityType: "api_key", + entityId: key.id, + metadata: { name: input.name }, + }); + + // Strip `hashedToken` before returning — callers only need the + // plaintext `token` (shown once) plus the metadata row. Letting + // the bcrypt hash ride out on the create response leaks the same + // column `listApiKeys` already takes pains to exclude. + const { hashedToken: _hashed, ...publicKey } = key; + return { token, key: publicKey }; + }); +} diff --git a/packages/services/src/api-key/index.ts b/packages/services/src/api-key/index.ts new file mode 100644 index 000000000..b77c27e99 --- /dev/null +++ b/packages/services/src/api-key/index.ts @@ -0,0 +1,16 @@ +export { createApiKey } from "./create"; +export { + type ApiKeyCreator, + type ApiKeyWithCreator, + listApiKeys, + type PublicApiKey, +} from "./list"; +export { revokeApiKey } from "./revoke"; +export { updateApiKeyLastUsed, verifyApiKey } from "./verify"; +export { + CreateApiKeyInput, + ListApiKeysInput, + RevokeApiKeyInput, + UpdateApiKeyLastUsedInput, + VerifyApiKeyInput, +} from "./schemas"; diff --git a/packages/services/src/api-key/list.ts b/packages/services/src/api-key/list.ts new file mode 100644 index 000000000..239200211 --- /dev/null +++ b/packages/services/src/api-key/list.ts @@ -0,0 +1,99 @@ +import { db as defaultDb, eq, inArray } from "@openstatus/db"; +import { apiKey, user } from "@openstatus/db/src/schema"; + +import type { ServiceContext } from "../context"; +import type { ApiKey } from "../types"; +import type { ListApiKeysInput } from "./schemas"; + +export type ApiKeyCreator = { + id: number; + email: string | null; + firstName: string | null; + lastName: string | null; +}; + +/** + * Public projection of an `ApiKey` — strips the bcrypt `hashedToken` + * column so it never leaves the service boundary. Clients of + * `listApiKeys` see everything they need to manage a key (id, name, + * prefix, createdAt, lastUsedAt, …) but not the secret material. + */ +export type PublicApiKey = Omit; + +export type ApiKeyWithCreator = PublicApiKey & { + createdBy: ApiKeyCreator | undefined; +}; + +/** + * List API keys for the caller's workspace with creator info batched via a + * single IN query — replaces the legacy per-row `Promise.all` fan-out. + * + * Explicit column select (not `select()`) is load-bearing: the + * `hashedToken` column holds the bcrypt hash of the one-time token and + * has no business appearing in a list response. Returning `SELECT *` + * would leak it to every UI consuming the list endpoint. + */ +export async function listApiKeys(args: { + ctx: ServiceContext; + input?: ListApiKeysInput; +}): Promise { + const { ctx } = args; + const db = ctx.db ?? defaultDb; + + const keys = await db + .select({ + id: apiKey.id, + workspaceId: apiKey.workspaceId, + name: apiKey.name, + description: apiKey.description, + prefix: apiKey.prefix, + createdById: apiKey.createdById, + expiresAt: apiKey.expiresAt, + lastUsedAt: apiKey.lastUsedAt, + createdAt: apiKey.createdAt, + }) + .from(apiKey) + .where(eq(apiKey.workspaceId, ctx.workspace.id)) + .all(); + + if (keys.length === 0) return []; + + // Filter out any null `createdById` — the new `createApiKey` enforces + // a non-null creator, but legacy rows (or backfills from before the + // services migration) may have null. SQL's `x IN (NULL)` is `UNKNOWN` + // rather than a match, so passing a nullable list wouldn't cause a + // false positive — but drizzle's types model the array as `number[]`, + // so sanitising upfront keeps the type honest and avoids surprises. + const creatorIds = Array.from( + new Set( + keys.map((k) => k.createdById).filter((id): id is number => id != null), + ), + ); + // Skip the creator lookup entirely when every key pre-dates the + // services migration (all `createdById` are null). Drizzle throws + // `"At least one value must be provided"` for an empty `inArray`, + // which would crash `listApiKeys` for those workspaces. + if (creatorIds.length === 0) { + return keys.map((key) => ({ + ...(key as PublicApiKey), + createdBy: undefined, + })); + } + const creators = await db + .select({ + id: user.id, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + }) + .from(user) + .where(inArray(user.id, creatorIds)) + .all(); + + const creatorsById = new Map(creators.map((c) => [c.id, c])); + + return keys.map((key) => ({ + ...(key as PublicApiKey), + createdBy: creatorsById.get(key.createdById), + })); +} diff --git a/packages/services/src/api-key/revoke.ts b/packages/services/src/api-key/revoke.ts new file mode 100644 index 000000000..398ba69c2 --- /dev/null +++ b/packages/services/src/api-key/revoke.ts @@ -0,0 +1,41 @@ +import { and, eq } from "@openstatus/db"; +import { apiKey } from "@openstatus/db/src/schema"; + +import { emitAudit } from "../audit"; +import { type ServiceContext, withTransaction } from "../context"; +import { NotFoundError } from "../errors"; +import { RevokeApiKeyInput } from "./schemas"; + +/** + * Revoke (delete) an API key. Scoped to the caller's workspace — the + * existence check runs inside the tx so concurrent revokes don't race to + * a false "not found". Throws `NotFoundError` when the key doesn't exist + * in the workspace; surface adapters map that to their transport's 404. + */ +export async function revokeApiKey(args: { + ctx: ServiceContext; + input: RevokeApiKeyInput; +}): Promise { + const { ctx } = args; + const input = RevokeApiKeyInput.parse(args.input); + + await withTransaction(ctx, async (tx) => { + const row = await tx + .select({ id: apiKey.id }) + .from(apiKey) + .where( + and(eq(apiKey.id, input.id), eq(apiKey.workspaceId, ctx.workspace.id)), + ) + .get(); + + if (!row) throw new NotFoundError("api_key", input.id); + + await tx.delete(apiKey).where(eq(apiKey.id, input.id)); + + await emitAudit(tx, ctx, { + action: "api_key.revoke", + entityType: "api_key", + entityId: input.id, + }); + }); +} diff --git a/packages/services/src/api-key/schemas.ts b/packages/services/src/api-key/schemas.ts new file mode 100644 index 000000000..dbbb7d7e0 --- /dev/null +++ b/packages/services/src/api-key/schemas.ts @@ -0,0 +1,27 @@ +import { z } from "zod"; + +export const CreateApiKeyInput = z.object({ + name: z.string().min(1, "Name is required"), + description: z.string().optional(), + expiresAt: z.date().optional(), +}); +export type CreateApiKeyInput = z.infer; + +export const RevokeApiKeyInput = z.object({ id: z.number().int() }); +export type RevokeApiKeyInput = z.infer; + +export const ListApiKeysInput = z.object({}).strict(); +export type ListApiKeysInput = z.infer; + +export const VerifyApiKeyInput = z.object({ + token: z.string(), +}); +export type VerifyApiKeyInput = z.infer; + +export const UpdateApiKeyLastUsedInput = z.object({ + id: z.number().int(), + lastUsedAt: z.date().nullable(), +}); +export type UpdateApiKeyLastUsedInput = z.infer< + typeof UpdateApiKeyLastUsedInput +>; diff --git a/packages/services/src/api-key/verify.ts b/packages/services/src/api-key/verify.ts new file mode 100644 index 000000000..a3b4a636c --- /dev/null +++ b/packages/services/src/api-key/verify.ts @@ -0,0 +1,69 @@ +import { db as defaultDb, eq } from "@openstatus/db"; +import { apiKey } from "@openstatus/db/src/schema"; +import { + shouldUpdateLastUsed as checkShouldUpdateLastUsed, + verifyApiKeyHash, +} from "@openstatus/db/src/utils/api-key"; + +import type { ServiceContext } from "../context"; +import type { ApiKey } from "../types"; +import { UpdateApiKeyLastUsedInput, VerifyApiKeyInput } from "./schemas"; + +/** + * Verify a plaintext token. Returns the stored key row when the token is + * well-formed, resolves to an existing row, matches the stored bcrypt + * hash, and isn't expired. Returns `null` otherwise. + * + * Does *not* take a `ServiceContext` because verification runs *before* + * the caller's workspace is known — this is the resolution step that + * derives the workspace from the token. Explicit `db` override is + * supported for tests that need a pre-opened transaction. + */ +export async function verifyApiKey( + input: VerifyApiKeyInput, + opts: { db?: ServiceContext["db"] } = {}, +): Promise { + const parsed = VerifyApiKeyInput.parse(input); + const db = opts.db ?? defaultDb; + + // Token format check avoids hitting the DB for obviously-malformed input. + if (!/^os_[a-f0-9]{32}$/.test(parsed.token)) return null; + + // "os_" (3) + 8 hex chars = 11-char prefix stored alongside the hash. + const prefix = parsed.token.slice(0, 11); + + const key = await db + .select() + .from(apiKey) + .where(eq(apiKey.prefix, prefix)) + .get(); + + if (!key) return null; + if (!(await verifyApiKeyHash(parsed.token, key.hashedToken))) return null; + if (key.expiresAt && key.expiresAt < new Date()) return null; + + return key as ApiKey; +} + +/** + * Bump `lastUsedAt` to now, debounced by the shared `shouldUpdateLastUsed` + * helper (5 minutes). Returns `true` when the row was written, `false` + * when skipped. No ctx required — called from the request auth path + * which runs before workspace resolution. + */ +export async function updateApiKeyLastUsed( + input: UpdateApiKeyLastUsedInput, + opts: { db?: ServiceContext["db"] } = {}, +): Promise { + const parsed = UpdateApiKeyLastUsedInput.parse(input); + const db = opts.db ?? defaultDb; + + if (!checkShouldUpdateLastUsed(parsed.lastUsedAt)) return false; + + await db + .update(apiKey) + .set({ lastUsedAt: new Date() }) + .where(eq(apiKey.id, parsed.id)); + + return true; +} diff --git a/packages/services/src/context.ts b/packages/services/src/context.ts index 5c0bb52aa..631bd612e 100644 --- a/packages/services/src/context.ts +++ b/packages/services/src/context.ts @@ -54,3 +54,22 @@ export function extractActorId(actor: Actor): string { return actor.externalId ?? actor.source; } } + +/** + * Return the openstatus `user.id` attributable to this actor, or `null` + * when none is available. Used by mutations that stamp a `*_by` column. + * `slack` and `apiKey` actors may carry an optional linked userId once + * the corresponding mapping layers exist. + */ +export function tryGetActorUserId(actor: Actor): number | null { + switch (actor.type) { + case "user": + return actor.userId; + case "apiKey": + case "slack": + return actor.userId ?? null; + case "system": + case "webhook": + return null; + } +} diff --git a/packages/services/src/errors.ts b/packages/services/src/errors.ts index 3a8d1a109..ca1e8d9d7 100644 --- a/packages/services/src/errors.ts +++ b/packages/services/src/errors.ts @@ -5,6 +5,7 @@ export type ServiceErrorCode = | "CONFLICT" | "VALIDATION" | "LIMIT_EXCEEDED" + | "PRECONDITION_FAILED" | "INTERNAL"; export class ServiceError extends Error { @@ -54,6 +55,19 @@ export class LimitExceededError extends ServiceError { } } +/** + * Precondition not met — the operation is semantically disallowed in + * the current state of the system (e.g. account deletion blocked by an + * active paid subscription). Distinct from `FORBIDDEN` (authorization) + * and `CONFLICT` (concurrent-write race). Maps to tRPC + * `PRECONDITION_FAILED`. + */ +export class PreconditionFailedError extends ServiceError { + constructor(message: string) { + super("PRECONDITION_FAILED", message); + } +} + export class InternalServiceError extends ServiceError { constructor(message: string, cause?: unknown) { super("INTERNAL", message, cause); diff --git a/packages/api/src/service/import.test.ts b/packages/services/src/import/__tests__/unit.test.ts similarity index 84% rename from packages/api/src/service/import.test.ts rename to packages/services/src/import/__tests__/unit.test.ts index b554379e9..699f8db26 100644 --- a/packages/api/src/service/import.test.ts +++ b/packages/services/src/import/__tests__/unit.test.ts @@ -2,11 +2,7 @@ import { describe, expect, test } from "bun:test"; import { allPlans } from "@openstatus/db/src/schema/plan/config"; import type { Limits } from "@openstatus/db/src/schema/plan/schema"; import type { ImportSummary } from "@openstatus/importers"; -import { - addLimitWarnings, - clampPeriodicity, - computePhaseStatus, -} from "./import"; +import { addLimitWarnings, clampPeriodicity, computePhaseStatus } from "../"; function makeSummary(overrides?: Partial): ImportSummary { return { @@ -45,7 +41,7 @@ describe("addLimitWarnings", () => { await addLimitWarnings(summary, { limits: makeLimits({ "page-components": 20 }), - workspaceId: 1, + workspaceId: 2, }); expect(summary.errors).toEqual([]); @@ -68,7 +64,7 @@ describe("addLimitWarnings", () => { await addLimitWarnings(summary, { limits: makeLimits({ "page-components": 3 }), - workspaceId: 1, + workspaceId: 2, }); expect(summary.errors).toEqual([]); @@ -92,11 +88,16 @@ describe("addLimitWarnings", () => { await addLimitWarnings(summary, { limits: makeLimits({ "page-components": 3 }), - workspaceId: 1, + workspaceId: 2, }); expect(summary.errors.length).toBe(1); - expect(summary.errors[0]).toContain("3 of 4"); + // Wording: "Only 3 new components may be created … 4 in the import". + // Both numbers land in the message — assert on the salient + // fragments instead of the exact string so future phrasing tweaks + // don't churn the assertion. + expect(summary.errors[0]).toContain("3 new component"); + expect(summary.errors[0]).toContain("4 in the import"); }); test("no warning when components phase is empty", async () => { @@ -106,7 +107,7 @@ describe("addLimitWarnings", () => { await addLimitWarnings(summary, { limits: makeLimits({ "page-components": 3 }), - workspaceId: 1, + workspaceId: 2, }); expect(summary.errors).toEqual([]); @@ -119,7 +120,7 @@ describe("addLimitWarnings", () => { await addLimitWarnings(summary, { limits: makeLimits({ "page-components": 3 }), - workspaceId: 1, + workspaceId: 2, }); expect(summary.errors).toEqual([]); @@ -150,7 +151,7 @@ describe("addLimitWarnings", () => { await addLimitWarnings(summary, { limits: makeLimits({ "custom-domain": false }), - workspaceId: 1, + workspaceId: 2, }); expect(summary.errors.length).toBe(1); @@ -177,7 +178,7 @@ describe("addLimitWarnings", () => { await addLimitWarnings(summary, { limits: makeLimits({ "custom-domain": false }), - workspaceId: 1, + workspaceId: 2, }); expect(summary.errors).toEqual([]); @@ -203,7 +204,7 @@ describe("addLimitWarnings", () => { await addLimitWarnings(summary, { limits: makeLimits({ "custom-domain": true }), - workspaceId: 1, + workspaceId: 2, }); expect(summary.errors).toEqual([]); @@ -228,9 +229,16 @@ describe("addLimitWarnings", () => { ], }); + // `addLimitWarnings` gates subscribers warnings on + // `options.includeSubscribers` (Cubic P2 — otherwise users who + // aren't importing subscribers get a noise warning). Default + // matches `ImportOptions` (`false`), so the warning only fires + // when the caller explicitly opts in. Tests that want to exercise + // the warning pass `options.includeSubscribers: true`. await addLimitWarnings(summary, { limits: makeLimits({ "status-subscribers": false }), - workspaceId: 1, + workspaceId: 2, + options: { includeSubscribers: true }, }); expect(summary.errors.length).toBe(1); @@ -244,7 +252,8 @@ describe("addLimitWarnings", () => { await addLimitWarnings(summary, { limits: makeLimits({ "status-subscribers": false }), - workspaceId: 1, + workspaceId: 2, + options: { includeSubscribers: true }, }); expect(summary.errors).toEqual([]); @@ -265,7 +274,8 @@ describe("addLimitWarnings", () => { await addLimitWarnings(summary, { limits: makeLimits({ "status-subscribers": true }), - workspaceId: 1, + workspaceId: 2, + options: { includeSubscribers: true }, }); expect(summary.errors).toEqual([]); @@ -311,13 +321,18 @@ describe("addLimitWarnings", () => { }); // Free plan: page-components=3, custom-domain=false, status-subscribers=false + // Opt in to subscribers so the subscribers-disallowed warning fires + // (default `includeSubscribers` is `false` → warning silent). await addLimitWarnings(summary, { limits: makeLimits(), - workspaceId: 1, + workspaceId: 2, + options: { includeSubscribers: true }, }); expect(summary.errors.length).toBe(3); - expect(summary.errors.some((e) => e.includes("3 of 4"))).toBe(true); + expect(summary.errors.some((e) => e.includes("3 new component"))).toBe( + true, + ); expect(summary.errors.some((e) => e.includes("Custom domain"))).toBe(true); expect(summary.errors.some((e) => e.includes("Subscribers"))).toBe(true); }); @@ -359,7 +374,7 @@ describe("addLimitWarnings", () => { await addLimitWarnings(summary, { limits: { ...allPlans.starter.limits }, - workspaceId: 1, + workspaceId: 2, }); expect(summary.errors).toEqual([]); @@ -460,4 +475,19 @@ describe("clampPeriodicity", () => { expect(clampPeriodicity("30s", starterPlan)).toBe("1m"); }); }); + + describe("unknown requested periodicity", () => { + // An unknown periodicity string (e.g. `"2m"` — not in + // `PERIODICITY_ORDER`) previously clamped from index 0 (the + // fastest tier), which could pick a *faster* interval than + // requested. It should fall back to the slowest allowed instead, + // erring toward the "never faster than requested" invariant. + test("falls back to slowest allowed on unknown value", () => { + expect(clampPeriodicity("2m", ["5m", "10m", "30m"])).toBe("30m"); + }); + + test("falls back to 10m when `allowed` is empty", () => { + expect(clampPeriodicity("2m", [])).toBe("10m"); + }); + }); }); diff --git a/packages/services/src/import/index.ts b/packages/services/src/import/index.ts new file mode 100644 index 000000000..6da899a41 --- /dev/null +++ b/packages/services/src/import/index.ts @@ -0,0 +1,12 @@ +export { previewImport } from "./preview"; +export { runImport } from "./run"; +export { addLimitWarnings } from "./limits"; +export { clampPeriodicity, computePhaseStatus } from "./utils"; +export { + importProviders, + type ImportOptions, + ImportOptions as ImportOptionsSchema, + type ImportProviderName, + PreviewImportInput, + RunImportInput, +} from "./schemas"; diff --git a/packages/services/src/import/limits.ts b/packages/services/src/import/limits.ts new file mode 100644 index 000000000..cf7ad6de6 --- /dev/null +++ b/packages/services/src/import/limits.ts @@ -0,0 +1,146 @@ +import { and, count, db as defaultDb, eq, isNull } from "@openstatus/db"; +import { monitor, pageComponent } from "@openstatus/db/src/schema"; +import type { Limits } from "@openstatus/db/src/schema/plan/schema"; +import type { ImportSummary } from "@openstatus/importers"; + +import type { DB } from "../context"; +import type { ImportOptions } from "./schemas"; + +/** + * Inspect an `ImportSummary` and push per-limit warning strings into + * `summary.errors`. Shared by `preview` (to surface warnings upfront) and + * `run` (re-emitted alongside the actual writes). Pure: no mutations to + * the DB, only to the summary argument. + * + * `options` gates the per-phase warnings against the same include flags + * `run` uses — without this, a user importing with `includeSubscribers: + * false` on a plan that disables subscribers would see a misleading + * "subscribers cannot be imported" warning even though the phase is + * opted out anyway. Defaults mirror `ImportOptions` defaults + * (status reports on, subscribers off, components on, monitors on). + */ +export async function addLimitWarnings( + summary: ImportSummary, + config: { + limits: Limits; + workspaceId: number; + pageId?: number; + db?: DB; + options?: Partial; + }, +): Promise { + const db = config.db ?? defaultDb; + const includeComponents = config.options?.includeComponents ?? true; + const includeMonitors = config.options?.includeMonitors ?? true; + const includeSubscribers = config.options?.includeSubscribers ?? false; + + // 1. Page component count + // + // `page-components` is a **workspace-wide** plan cap — the + // `page-component/update-order` service enforces it by counting + // every component across every page, not just the target one. This + // preview warning has to match that scope, otherwise imports into + // an empty page with plenty of workspace-wide pressure would look + // safe here and blow up at insert time. + const componentsPhase = summary.phases.find((p) => p.phase === "components"); + if ( + includeComponents && + componentsPhase && + componentsPhase.resources.length > 0 + ) { + const maxComponents = config.limits["page-components"]; + const [result] = await db + .select({ count: count() }) + .from(pageComponent) + .where(eq(pageComponent.workspaceId, config.workspaceId)); + const existingCount = result?.count ?? 0; + const remaining = maxComponents - existingCount; + if (remaining <= 0) { + summary.errors.push( + `Component limit reached (${maxComponents}). Upgrade your plan to import components.`, + ); + } else if (componentsPhase.resources.length > remaining) { + // Worst-case warning: resource count includes items that will be + // skipped as duplicates (phase writers dedupe by name + pageId at + // insert time). Phrased as "up to N new" so the number is an + // upper bound on quota consumption, not a guaranteed rejection + // count. Exact new-vs-duplicate split requires a per-component + // existence check we skip at preview time to keep the dry-run + // fast. + summary.errors.push( + `Only ${remaining} new component${remaining === 1 ? "" : "s"} may be created due to plan limit (${maxComponents}); some of the ${componentsPhase.resources.length} in the import may already exist and be skipped.`, + ); + } + } + + // 2. Custom domain — warn that it'll be stripped, not blocked. + if (!config.limits["custom-domain"]) { + const pagePhase = summary.phases.find((p) => p.phase === "page"); + const pageData = pagePhase?.resources[0]?.data as + | { customDomain?: string } + | undefined; + if (pageData?.customDomain) { + summary.errors.push( + "Custom domain will be stripped during import. Upgrade your plan to use custom domains.", + ); + } + } + + // 3. Monitor count + const monitorsPhase = summary.phases.find((p) => p.phase === "monitors"); + if (includeMonitors && monitorsPhase && monitorsPhase.resources.length > 0) { + const maxMonitors = config.limits.monitors; + const [monitorCount] = await db + .select({ count: count() }) + .from(monitor) + .where( + and( + eq(monitor.workspaceId, config.workspaceId), + isNull(monitor.deletedAt), + ), + ); + const remaining = maxMonitors - (monitorCount?.count ?? 0); + if (remaining <= 0) { + summary.errors.push( + `Monitor limit reached (${maxMonitors}). Upgrade your plan to import monitors.`, + ); + } else if (monitorsPhase.resources.length > remaining) { + // Same worst-case framing as the components warning — phase + // writers dedupe by url + workspaceId at insert time, so the + // resource count includes items that will be skipped. The exact + // new-vs-existing split needs a per-monitor existence check we + // skip at preview time. + summary.errors.push( + `Only ${remaining} new monitor${remaining === 1 ? "" : "s"} may be created due to plan limit (${maxMonitors}); some of the ${monitorsPhase.resources.length} in the import may already exist and be skipped.`, + ); + } + } + + // 4. Monitor periodicity clamping + if (includeMonitors && monitorsPhase && monitorsPhase.resources.length > 0) { + const allowedPeriodicity: string[] = config.limits.periodicity; + const clamped = monitorsPhase.resources.filter((r) => { + const data = r.data as { periodicity?: string } | undefined; + return ( + data?.periodicity && !allowedPeriodicity.includes(data.periodicity) + ); + }); + if (clamped.length > 0) { + summary.errors.push( + `${clamped.length} monitor${clamped.length === 1 ? "'s" : "s'"} check frequency will be adjusted to fit your plan's allowed intervals.`, + ); + } + } + + // 5. Subscribers on plans that disable them + if (includeSubscribers && !config.limits["status-subscribers"]) { + const subscribersPhase = summary.phases.find( + (p) => p.phase === "subscribers", + ); + if (subscribersPhase && subscribersPhase.resources.length > 0) { + summary.errors.push( + "Subscribers cannot be imported on your current plan. Upgrade to enable status page subscribers.", + ); + } + } +} diff --git a/packages/services/src/import/phase-writers.ts b/packages/services/src/import/phase-writers.ts new file mode 100644 index 000000000..5831aee1a --- /dev/null +++ b/packages/services/src/import/phase-writers.ts @@ -0,0 +1,877 @@ +import { and, count, eq, isNull } from "@openstatus/db"; +import { + maintenance, + maintenancesToPageComponents, + monitor, + page, + pageComponent, + pageComponentGroup, + pageSubscriber, + pageSubscriberToPageComponent, + statusReport, + statusReportUpdate, + statusReportsToPageComponents, +} from "@openstatus/db/src/schema"; +import type { PhaseResult } from "@openstatus/importers"; + +import { emitAudit } from "../audit"; +import type { DB, ServiceContext } from "../context"; +import type { ImportProviderName } from "./schemas"; +import { clampPeriodicity, computePhaseStatus } from "./utils"; + +/** + * Shared write-context threaded through every phase writer. Bundles the + * service ctx (actor + workspace for audit attribution), the active + * `tx`/db handle, and the provider name so per-resource audit rows carry + * `source: "import"` + the source provider. + */ +export type PhaseContext = { + ctx: ServiceContext; + tx: DB; + provider: ImportProviderName; +}; + +/** + * Each phase writer mutates the phase in place: + * - sets per-resource `status` ("created" | "skipped" | "failed"), + * - stamps `openstatusId` on success, + * - records the per-resource `error` string on failure, + * - emits one audit row per *created* resource (skipped rows already + * have their original create audit; failed rows have nothing to + * attribute), + * - rolls the phase-level `status` up from its resources. + * + * Writers never throw on a single-resource failure — they only throw on + * infrastructure errors (e.g. a required page record is missing). The + * orchestrator in `run.ts` catches throws to abort the remaining phases. + */ + +function auditMeta( + pc: PhaseContext, + extra?: Record, +): Record { + return { source: "import", provider: pc.provider, ...extra }; +} + +export async function writePagePhase( + pc: PhaseContext, + phase: PhaseResult, + existingPageId?: number, +): Promise { + const { ctx, tx } = pc; + const workspaceId = ctx.workspace.id; + const limits = ctx.workspace.limits; + + const resource = phase.resources[0]; + if (!resource?.data) { + throw new Error("No page data found in phase"); + } + + const data = resource.data as { + workspaceId: number; + title: string; + description: string; + slug: string; + customDomain: string; + published: boolean; + icon: string; + }; + + // Strip the custom domain when the plan doesn't allow one — imports are + // tolerant (skip/warn) rather than all-or-nothing. + if (!limits["custom-domain"]) { + data.customDomain = ""; + } + + if (existingPageId) { + const existing = await tx + .select() + .from(page) + .where( + and(eq(page.id, existingPageId), eq(page.workspaceId, workspaceId)), + ) + .get(); + + if (!existing) { + throw new Error( + "Provided page not found or does not belong to workspace", + ); + } + + await tx + .update(page) + .set({ title: data.title, description: data.description }) + .where(eq(page.id, existingPageId)); + + resource.openstatusId = existingPageId; + resource.status = "skipped"; + phase.status = computePhaseStatus(phase.resources); + return existingPageId; + } + + // Idempotency: slug is unique within a workspace. + const existingBySlug = await tx + .select() + .from(page) + .where(and(eq(page.slug, data.slug), eq(page.workspaceId, workspaceId))) + .get(); + + if (existingBySlug) { + resource.openstatusId = existingBySlug.id; + resource.status = "skipped"; + phase.status = computePhaseStatus(phase.resources); + return existingBySlug.id; + } + + const [inserted] = await tx + .insert(page) + .values({ + // `workspaceId` from ctx, not from provider-mapped `data`. Every + // other phase writer (monitor / components / subscriber) already + // uses the ctx-derived value; this keeps the authority consistent + // and defends against the (unlikely) case where a provider mapper + // round-trips the wrong workspace id into resource data. + workspaceId, + title: data.title, + description: data.description, + slug: data.slug, + customDomain: data.customDomain, + published: data.published, + icon: data.icon, + }) + .returning({ id: page.id }); + + if (!inserted) throw new Error("Failed to insert page"); + + resource.openstatusId = inserted.id; + resource.status = "created"; + phase.status = computePhaseStatus(phase.resources); + + await emitAudit(tx, ctx, { + action: "page.create", + entityType: "page", + entityId: inserted.id, + metadata: auditMeta(pc, { sourceId: resource.sourceId, slug: data.slug }), + }); + + return inserted.id; +} + +export async function writeComponentGroupsPhase( + pc: PhaseContext, + phase: PhaseResult, + pageId: number, + groupIdMap: Map, +): Promise { + const { ctx, tx } = pc; + const workspaceId = ctx.workspace.id; + + for (const resource of phase.resources) { + try { + const data = resource.data as { + workspaceId: number; + pageId: number; + name: string; + }; + + const existing = await tx + .select() + .from(pageComponentGroup) + .where( + and( + eq(pageComponentGroup.name, data.name), + eq(pageComponentGroup.pageId, pageId), + ), + ) + .get(); + + if (existing) { + groupIdMap.set(resource.sourceId, existing.id); + resource.openstatusId = existing.id; + resource.status = "skipped"; + continue; + } + + const [inserted] = await tx + .insert(pageComponentGroup) + .values({ workspaceId, pageId, name: data.name }) + .returning({ id: pageComponentGroup.id }); + + if (!inserted) { + resource.status = "failed"; + resource.error = "Insert returned no result"; + continue; + } + + groupIdMap.set(resource.sourceId, inserted.id); + resource.openstatusId = inserted.id; + resource.status = "created"; + + await emitAudit(tx, ctx, { + action: "page_component_group.create", + entityType: "page_component_group", + entityId: inserted.id, + metadata: auditMeta(pc, { + sourceId: resource.sourceId, + pageId, + name: data.name, + }), + }); + } catch (err) { + resource.status = "failed"; + resource.error = err instanceof Error ? err.message : String(err); + } + } + + phase.status = computePhaseStatus(phase.resources); +} + +export async function writeComponentsPhase( + pc: PhaseContext, + phase: PhaseResult, + pageId: number, + groupIdMap: Map, + componentIdMap: Map, + monitorIdMap?: Map, +): Promise { + const { ctx, tx } = pc; + const workspaceId = ctx.workspace.id; + + for (const resource of phase.resources) { + if (resource.status === "skipped") continue; + + try { + const data = resource.data as { + workspaceId: number; + pageId: number; + type: "static" | "monitor"; + monitorId: number | null; + sourceMonitorId?: string | null; + name: string; + description: string | null; + order: number; + sourceGroupId: string | null; + }; + + if (data.type === "monitor") { + if (data.sourceMonitorId && monitorIdMap) { + data.monitorId = monitorIdMap.get(data.sourceMonitorId) ?? null; + } + if (!data.monitorId) { + // Monitor wasn't imported — usually because the monitors + // phase was skipped (e.g. `options.includeMonitors === false`) + // or the monitor itself failed to import. We fall back to a + // static component so the component still lands, but flag + // the degrade so the summary doesn't quietly report a + // "created" monitor component with no monitor attached. + // `resource.error` is set even though the resource will end + // up `created` — the other phase writers follow the same + // convention when degrading. + data.type = "static"; + resource.error = data.sourceMonitorId + ? `Source monitor ${data.sourceMonitorId} was not imported; created as static instead.` + : "No source monitor available; created as static instead."; + } + } + + const existing = await tx + .select() + .from(pageComponent) + .where( + and( + eq(pageComponent.name, data.name), + eq(pageComponent.pageId, pageId), + ), + ) + .get(); + + if (existing) { + componentIdMap.set(resource.sourceId, existing.id); + resource.openstatusId = existing.id; + resource.status = "skipped"; + continue; + } + + const resolvedGroupId = data.sourceGroupId + ? groupIdMap.get(data.sourceGroupId) ?? null + : null; + + const [inserted] = await tx + .insert(pageComponent) + .values({ + workspaceId, + pageId, + type: data.type, + monitorId: data.monitorId, + name: data.name, + description: data.description, + order: data.order, + groupId: resolvedGroupId, + }) + .returning({ id: pageComponent.id }); + + if (!inserted) { + resource.status = "failed"; + resource.error = "Insert returned no result"; + continue; + } + + componentIdMap.set(resource.sourceId, inserted.id); + resource.openstatusId = inserted.id; + resource.status = "created"; + + await emitAudit(tx, ctx, { + action: "page_component.create", + entityType: "page_component", + entityId: inserted.id, + metadata: auditMeta(pc, { + sourceId: resource.sourceId, + pageId, + type: data.type, + }), + }); + } catch (err) { + resource.status = "failed"; + resource.error = err instanceof Error ? err.message : String(err); + } + } + + phase.status = computePhaseStatus(phase.resources); +} + +export async function writeIncidentsPhase( + pc: PhaseContext, + phase: PhaseResult, + pageId: number, + componentIdMap: Map, +): Promise { + const { ctx, tx } = pc; + const workspaceId = ctx.workspace.id; + + for (const resource of phase.resources) { + try { + const data = resource.data as { + report: { + title: string; + status: "investigating" | "identified" | "monitoring" | "resolved"; + workspaceId: number; + pageId: number; + }; + updates: Array<{ + status: "investigating" | "identified" | "monitoring" | "resolved"; + message: string; + date: Date; + }>; + sourceComponentIds: string[]; + }; + + // Idempotency by `(title, pageId)` — every other phase writer + // (page slug, monitor url, component name, subscriber email) + // skips on duplicate before inserting, and imports are + // explicitly re-runnable per `run.ts`'s phase-level recovery + // model. Without this check, re-running an import would land + // duplicate status reports on every pass. + const existingReport = await tx + .select({ id: statusReport.id }) + .from(statusReport) + .where( + and( + eq(statusReport.pageId, pageId), + eq(statusReport.workspaceId, workspaceId), + eq(statusReport.title, data.report.title), + ), + ) + .get(); + + if (existingReport) { + resource.openstatusId = existingReport.id; + resource.status = "skipped"; + // Reconcile component links on rerun. First run may have + // written the report with a partial `componentIdMap` (some + // components failed in their per-resource catch), so the link + // set is a strict subset of `sourceComponentIds`. On rerun the + // map is typically more complete — reinsert with + // `onConflictDoNothing` so the composite PK dedupes already- + // written pairs and new ones land. + const reconciliationLinks = data.sourceComponentIds + .map((sourceId) => componentIdMap.get(sourceId)) + .filter((id): id is number => id != null) + .map((pageComponentId) => ({ + statusReportId: existingReport.id, + pageComponentId, + })); + if (reconciliationLinks.length > 0) { + await tx + .insert(statusReportsToPageComponents) + .values(reconciliationLinks) + .onConflictDoNothing(); + } + continue; + } + + const [insertedReport] = await tx + .insert(statusReport) + .values({ + title: data.report.title, + status: data.report.status, + workspaceId, + pageId, + }) + .returning({ id: statusReport.id }); + + if (!insertedReport) { + resource.status = "failed"; + resource.error = "Failed to insert status report"; + continue; + } + + await emitAudit(tx, ctx, { + action: "status_report.create", + entityType: "status_report", + entityId: insertedReport.id, + metadata: auditMeta(pc, { + sourceId: resource.sourceId, + pageId, + title: data.report.title, + }), + }); + + // Batch the update rows with `.returning()` so per-update audit can + // attribute the specific update ids. + if (data.updates.length > 0) { + const insertedUpdates = await tx + .insert(statusReportUpdate) + .values( + data.updates.map((u) => ({ + status: u.status, + message: u.message, + date: u.date, + statusReportId: insertedReport.id, + })), + ) + .returning({ id: statusReportUpdate.id }); + + for (const row of insertedUpdates) { + await emitAudit(tx, ctx, { + action: "status_report.add_update", + entityType: "status_report_update", + entityId: row.id, + metadata: auditMeta(pc, { + sourceId: resource.sourceId, + statusReportId: insertedReport.id, + }), + }); + } + } + + const componentLinks: Array<{ + statusReportId: number; + pageComponentId: number; + }> = []; + for (const sourceCompId of data.sourceComponentIds) { + const osCompId = componentIdMap.get(sourceCompId); + if (osCompId) { + componentLinks.push({ + statusReportId: insertedReport.id, + pageComponentId: osCompId, + }); + } + } + if (componentLinks.length > 0) { + await tx.insert(statusReportsToPageComponents).values(componentLinks); + } + + resource.openstatusId = insertedReport.id; + resource.status = "created"; + } catch (err) { + resource.status = "failed"; + resource.error = err instanceof Error ? err.message : String(err); + } + } + + phase.status = computePhaseStatus(phase.resources); +} + +export async function writeMaintenancesPhase( + pc: PhaseContext, + phase: PhaseResult, + pageId: number, + componentIdMap: Map, +): Promise { + const { ctx, tx } = pc; + const workspaceId = ctx.workspace.id; + + for (const resource of phase.resources) { + try { + const data = resource.data as { + title: string; + message: string; + from: Date; + to: Date; + workspaceId: number; + pageId: number; + sourceComponentIds: string[]; + }; + + // Idempotency by `(title, pageId, from, to)` — re-running an + // import shouldn't create duplicate maintenance windows on each + // pass. `title + pageId` alone could collide on unrelated future + // maintenances sharing a name ("DB upgrade"), so the `from/to` + // pair pins the match to a specific scheduled window. Same + // reasoning as the status-report idempotency check above. + const existing = await tx + .select({ id: maintenance.id }) + .from(maintenance) + .where( + and( + eq(maintenance.pageId, pageId), + eq(maintenance.workspaceId, workspaceId), + eq(maintenance.title, data.title), + eq(maintenance.from, data.from), + eq(maintenance.to, data.to), + ), + ) + .get(); + + if (existing) { + resource.openstatusId = existing.id; + resource.status = "skipped"; + // Same reconciliation rationale as `writeIncidentsPhase` — a + // partial `componentIdMap` on the first run means the link + // set may be incomplete, and the composite PK on + // `(maintenanceId, pageComponentId)` makes the re-insert a + // no-op for pairs that already exist. + const reconciliationLinks = data.sourceComponentIds + .map((sourceId) => componentIdMap.get(sourceId)) + .filter((id): id is number => id != null) + .map((pageComponentId) => ({ + maintenanceId: existing.id, + pageComponentId, + })); + if (reconciliationLinks.length > 0) { + await tx + .insert(maintenancesToPageComponents) + .values(reconciliationLinks) + .onConflictDoNothing(); + } + continue; + } + + const [inserted] = await tx + .insert(maintenance) + .values({ + title: data.title, + message: data.message, + from: data.from, + to: data.to, + workspaceId, + pageId, + }) + .returning({ id: maintenance.id }); + + if (!inserted) { + resource.status = "failed"; + resource.error = "Failed to insert maintenance"; + continue; + } + + const componentLinks: Array<{ + maintenanceId: number; + pageComponentId: number; + }> = []; + for (const sourceCompId of data.sourceComponentIds) { + const osCompId = componentIdMap.get(sourceCompId); + if (osCompId) { + componentLinks.push({ + maintenanceId: inserted.id, + pageComponentId: osCompId, + }); + } + } + if (componentLinks.length > 0) { + await tx.insert(maintenancesToPageComponents).values(componentLinks); + } + + resource.openstatusId = inserted.id; + resource.status = "created"; + + await emitAudit(tx, ctx, { + action: "maintenance.create", + entityType: "maintenance", + entityId: inserted.id, + metadata: auditMeta(pc, { + sourceId: resource.sourceId, + pageId, + title: data.title, + }), + }); + } catch (err) { + resource.status = "failed"; + resource.error = err instanceof Error ? err.message : String(err); + } + } + + phase.status = computePhaseStatus(phase.resources); +} + +export async function writeMonitorsPhase( + pc: PhaseContext, + phase: PhaseResult, + monitorIdMap: Map, +): Promise { + const { ctx, tx } = pc; + const workspaceId = ctx.workspace.id; + const limits = ctx.workspace.limits; + + const [monitorCount] = await tx + .select({ count: count() }) + .from(monitor) + .where( + and(eq(monitor.workspaceId, workspaceId), isNull(monitor.deletedAt)), + ); + const maxMonitors = limits.monitors; + const remaining = maxMonitors - (monitorCount?.count ?? 0); + + if (remaining <= 0) { + for (const resource of phase.resources) { + resource.status = "skipped"; + resource.error = `Skipped: monitor limit reached (${maxMonitors})`; + } + phase.status = computePhaseStatus(phase.resources); + return; + } + + let importedCount = 0; + for (const resource of phase.resources) { + if (importedCount >= remaining) { + resource.status = "skipped"; + resource.error = `Skipped: would exceed monitor limit (${maxMonitors})`; + continue; + } + + try { + const data = resource.data as { + workspaceId: number; + jobType: string; + periodicity: string; + status: string; + active: boolean; + regions: string; + url: string; + name: string; + description: string; + headers: string; + body: string; + method: string; + timeout: number; + sourceMonitorGroupId: string | null; + }; + + // Idempotency: same url in the workspace (active only). + const existing = await tx + .select() + .from(monitor) + .where( + and( + eq(monitor.url, data.url), + eq(monitor.workspaceId, workspaceId), + isNull(monitor.deletedAt), + ), + ) + .get(); + + if (existing) { + monitorIdMap.set(resource.sourceId, existing.id); + resource.openstatusId = existing.id; + resource.status = "skipped"; + continue; + } + + const periodicity = clampPeriodicity( + data.periodicity, + limits.periodicity, + ); + + const [inserted] = await tx + .insert(monitor) + .values({ + workspaceId, + jobType: data.jobType as + | "http" + | "tcp" + | "icmp" + | "udp" + | "dns" + | "ssl", + periodicity: periodicity as + | "30s" + | "1m" + | "5m" + | "10m" + | "30m" + | "1h" + | "other", + status: "active", + active: data.active, + regions: data.regions, + url: data.url, + name: data.name, + description: data.description, + headers: data.headers, + body: data.body, + method: data.method as + | "GET" + | "POST" + | "HEAD" + | "PUT" + | "PATCH" + | "DELETE" + | "TRACE" + | "CONNECT" + | "OPTIONS", + timeout: data.timeout, + }) + .returning({ id: monitor.id }); + + if (!inserted) { + resource.status = "failed"; + resource.error = "Insert returned no result"; + continue; + } + + monitorIdMap.set(resource.sourceId, inserted.id); + resource.openstatusId = inserted.id; + resource.status = "created"; + importedCount++; + + await emitAudit(tx, ctx, { + action: "monitor.create", + entityType: "monitor", + entityId: inserted.id, + metadata: auditMeta(pc, { + sourceId: resource.sourceId, + url: data.url, + jobType: data.jobType, + }), + }); + } catch (err) { + resource.status = "failed"; + resource.error = err instanceof Error ? err.message : String(err); + } + } + + phase.status = computePhaseStatus(phase.resources); +} + +// TODO: migrate to new `pageSubscription` + `pageSubscriptionToPageComponent` tables +export async function writeSubscribersPhase( + pc: PhaseContext, + phase: PhaseResult, + pageId: number, + componentIdMap: Map, +): Promise { + const { ctx, tx } = pc; + + for (const resource of phase.resources) { + try { + const data = resource.data as { + email: string; + pageId: number; + confirmed: boolean; + sourceComponentIds: string[]; + }; + + const email = data.email.toLowerCase(); + + const existing = await tx + .select() + .from(pageSubscriber) + .where( + and( + eq(pageSubscriber.email, email), + eq(pageSubscriber.pageId, pageId), + eq(pageSubscriber.channelType, "email"), + ), + ) + .get(); + + if (existing) { + resource.openstatusId = existing.id; + resource.status = "skipped"; + // Same reconciliation rationale as `writeIncidentsPhase` — a + // partial `componentIdMap` on the first run means the link + // set may be incomplete, and the composite PK on + // `(pageSubscriberId, pageComponentId)` makes the re-insert a + // no-op for pairs that already exist. + const reconciliationLinks = data.sourceComponentIds + .map((sourceId) => componentIdMap.get(sourceId)) + .filter((id): id is number => id != null) + .map((pageComponentId) => ({ + pageSubscriberId: existing.id, + pageComponentId, + })); + if (reconciliationLinks.length > 0) { + await tx + .insert(pageSubscriberToPageComponent) + .values(reconciliationLinks) + .onConflictDoNothing(); + } + continue; + } + + const [inserted] = await tx + .insert(pageSubscriber) + .values({ + email, + pageId, + channelType: "email", + source: "import", + token: crypto.randomUUID(), + acceptedAt: data.confirmed ? new Date() : undefined, + }) + .returning({ id: pageSubscriber.id }); + + if (!inserted) { + resource.status = "failed"; + resource.error = "Insert returned no result"; + continue; + } + + const componentLinks: Array<{ + pageSubscriberId: number; + pageComponentId: number; + }> = []; + for (const sourceCompId of data.sourceComponentIds) { + const osCompId = componentIdMap.get(sourceCompId); + if (osCompId) { + componentLinks.push({ + pageSubscriberId: inserted.id, + pageComponentId: osCompId, + }); + } + } + if (componentLinks.length > 0) { + await tx.insert(pageSubscriberToPageComponent).values(componentLinks); + } + + resource.openstatusId = inserted.id; + resource.status = "created"; + + await emitAudit(tx, ctx, { + action: "page_subscriber.create", + entityType: "page_subscriber", + entityId: inserted.id, + metadata: auditMeta(pc, { + sourceId: resource.sourceId, + pageId, + email, + }), + }); + } catch (err) { + resource.status = "failed"; + resource.error = err instanceof Error ? err.message : String(err); + } + } + + phase.status = computePhaseStatus(phase.resources); +} diff --git a/packages/services/src/import/preview.ts b/packages/services/src/import/preview.ts new file mode 100644 index 000000000..8ff26e251 --- /dev/null +++ b/packages/services/src/import/preview.ts @@ -0,0 +1,54 @@ +import { db as defaultDb } from "@openstatus/db"; +import type { ImportSummary } from "@openstatus/importers"; + +import type { ServiceContext } from "../context"; +import { ValidationError } from "../errors"; +import { addLimitWarnings } from "./limits"; +import { buildProviderConfig, createProvider } from "./provider"; +import { PreviewImportInput } from "./schemas"; + +/** + * Dry-run an import: fetches the source provider's resources, validates + * credentials, and returns the full phase summary including warnings for + * limits that would be hit if `run` were invoked with the same input. + * Never writes to the db. + */ +export async function previewImport(args: { + ctx: ServiceContext; + input: PreviewImportInput; +}): Promise { + const { ctx } = args; + const input = PreviewImportInput.parse(args.input); + const db = ctx.db ?? defaultDb; + + const provider = createProvider(input.provider); + const providerConfig = buildProviderConfig({ + provider: input.provider, + apiKey: input.apiKey, + statuspagePageId: input.statuspagePageId, + betterstackStatusPageId: input.betterstackStatusPageId, + instatusPageId: input.instatusPageId, + workspaceId: ctx.workspace.id, + pageId: input.pageId, + }); + + const validation = await provider.validate({ + ...providerConfig, + dryRun: true, + }); + if (!validation.valid) { + throw new ValidationError( + `Provider validation failed: ${validation.error ?? "unknown error"}`, + ); + } + + const summary = await provider.run({ ...providerConfig, dryRun: true }); + await addLimitWarnings(summary, { + limits: ctx.workspace.limits, + workspaceId: ctx.workspace.id, + pageId: input.pageId, + db, + options: input.options, + }); + return summary; +} diff --git a/packages/services/src/import/provider.ts b/packages/services/src/import/provider.ts new file mode 100644 index 000000000..1fdff59f0 --- /dev/null +++ b/packages/services/src/import/provider.ts @@ -0,0 +1,62 @@ +import type { ImportProvider } from "@openstatus/importers"; +import { createBetterstackProvider } from "@openstatus/importers/betterstack"; +import { createInstatusProvider } from "@openstatus/importers/instatus"; +import { createStatuspageProvider } from "@openstatus/importers/statuspage"; + +import type { ImportProviderName } from "./schemas"; + +/** + * Factory for a specific provider adapter. Used by both preview and run. + * Unknown names throw — silently defaulting would mask a typo'd provider + * by running the Statuspage adapter against a non-Statuspage API key. + */ +export function createProvider(name: ImportProviderName): ImportProvider { + switch (name) { + case "betterstack": + return createBetterstackProvider(); + case "instatus": + return createInstatusProvider(); + case "statuspage": + return createStatuspageProvider(); + default: { + const exhaustive: never = name; + throw new Error(`Unknown import provider: ${String(exhaustive)}`); + } + } +} + +export type ProviderConfig = { + provider: ImportProviderName; + apiKey: string; + workspaceId: number; + pageId?: number; + statuspagePageId?: string; + betterstackStatusPageId?: string; + instatusPageId?: string; +}; + +/** + * Strip the discriminator and reshape provider-specific ids so the + * provider adapter receives exactly the fields it expects. Mirrors + * `createProvider`'s exhaustive-switch pattern — silently falling + * through to Statuspage for a typo'd provider would pass the wrong + * page id field and produce a confusing validation failure. + */ +export function buildProviderConfig(config: ProviderConfig) { + const { provider, ...rest } = config; + switch (provider) { + case "betterstack": + return { + ...rest, + betterstackStatusPageId: config.betterstackStatusPageId, + }; + case "instatus": + return { ...rest, instatusPageId: config.instatusPageId }; + case "statuspage": + return { ...rest, statuspagePageId: config.statuspagePageId }; + default: { + const exhaustive: never = provider; + throw new Error(`Unknown import provider: ${String(exhaustive)}`); + } + } +} diff --git a/packages/services/src/import/run.ts b/packages/services/src/import/run.ts new file mode 100644 index 000000000..22051dbad --- /dev/null +++ b/packages/services/src/import/run.ts @@ -0,0 +1,287 @@ +import { and, count, db as defaultDb, eq } from "@openstatus/db"; +import { page, pageComponent } from "@openstatus/db/src/schema"; +import type { ImportSummary } from "@openstatus/importers"; + +import { emitAudit } from "../audit"; +import type { ServiceContext } from "../context"; +import { NotFoundError, ValidationError } from "../errors"; +import { addLimitWarnings } from "./limits"; +import { + type PhaseContext, + writeComponentGroupsPhase, + writeComponentsPhase, + writeIncidentsPhase, + writeMaintenancesPhase, + writeMonitorsPhase, + writePagePhase, + writeSubscribersPhase, +} from "./phase-writers"; +import { buildProviderConfig, createProvider } from "./provider"; +import { RunImportInput } from "./schemas"; + +/** + * Execute a real import: fetches from the provider, validates, applies + * limit warnings, then walks each phase in sequence writing to the db. + * + * The orchestrator is deliberately *not* wrapped in a single + * `withTransaction`: imports can span many minutes and hold locks across + * dozens of writes, and the existing UX is phase-level recovery — a + * failing phase aborts subsequent phases but preserves earlier phases' + * writes. + * + * Audit emission is two-layered: + * - Each phase writer emits per-resource rows (`page.create`, + * `monitor.create`, etc.) for every resource it actually creates, + * matching what the domain services would have emitted for normal + * CRUD. Skipped rows have their original create audit already; + * failed rows have nothing to attribute. + * - One final `import.run` row captures the rollup (status + provider) + * so a half-broken import still shows up as a single event without + * having to scan per-resource audit. + */ +export async function runImport(args: { + ctx: ServiceContext; + input: RunImportInput; +}): Promise { + const { ctx } = args; + const input = RunImportInput.parse(args.input); + const tx = ctx.db ?? defaultDb; + + // Verify pageId belongs to the workspace before doing any provider work. + // Was previously duplicated at the router layer — owning this in the + // service means all callers (tRPC / Slack / future) get the same check. + if (input.pageId) { + const existing = await tx + .select({ id: page.id }) + .from(page) + .where( + and(eq(page.id, input.pageId), eq(page.workspaceId, ctx.workspace.id)), + ) + .get(); + + if (!existing) throw new NotFoundError("page", input.pageId); + } + + const provider = createProvider(input.provider); + const providerConfig = buildProviderConfig({ + provider: input.provider, + apiKey: input.apiKey, + statuspagePageId: input.statuspagePageId, + betterstackStatusPageId: input.betterstackStatusPageId, + instatusPageId: input.instatusPageId, + workspaceId: ctx.workspace.id, + pageId: input.pageId, + }); + + const validation = await provider.validate(providerConfig); + if (!validation.valid) { + throw new ValidationError( + `Provider validation failed: ${validation.error ?? "unknown error"}`, + ); + } + + const summary = await provider.run(providerConfig); + + await addLimitWarnings(summary, { + limits: ctx.workspace.limits, + workspaceId: ctx.workspace.id, + pageId: input.pageId, + db: tx, + options: input.options, + }); + + const idMaps = { + groups: new Map(), + components: new Map(), + monitors: new Map(), + }; + + const pc: PhaseContext = { ctx, tx, provider: input.provider }; + + let targetPageId = input.pageId; + let phaseAborted = false; + + for (const phase of summary.phases) { + if (phaseAborted) { + phase.status = "skipped"; + continue; + } + + try { + switch (phase.phase) { + case "monitors": + if (input.options?.includeMonitors !== false) { + await writeMonitorsPhase(pc, phase, idMaps.monitors); + } else { + phase.status = "skipped"; + } + break; + case "page": + targetPageId = await writePagePhase(pc, phase, input.pageId); + break; + case "componentGroups": + if (targetPageId && input.options?.includeComponents !== false) { + await writeComponentGroupsPhase( + pc, + phase, + targetPageId, + idMaps.groups, + ); + } else { + // Fall-through skip: either `includeComponents === false` + // (user opt-out) or `targetPageId` is missing (page phase + // produced nothing). Either way this phase has nothing to do. + phase.status = "skipped"; + } + break; + case "components": + if (targetPageId && input.options?.includeComponents !== false) { + // Workspace-wide count — `page-components` is the plan cap + // across every page in the workspace (see + // `page-component/update-order`). Scoping to `targetPageId` + // alone would let an import into an empty page push the + // workspace past the cap because components on other + // pages go uncounted. + const [compCount] = await tx + .select({ count: count() }) + .from(pageComponent) + .where(eq(pageComponent.workspaceId, ctx.workspace.id)); + const maxComponents = ctx.workspace.limits["page-components"]; + const remaining = maxComponents - (compCount?.count ?? 0); + if (remaining <= 0) { + // Stamp each resource with a skip reason to mirror the + // `writeMonitorsPhase` pattern — otherwise users see a + // failed phase with no explanation and stale resource + // statuses. + for (const r of phase.resources) { + r.status = "skipped"; + r.error = `Skipped: component limit reached (${maxComponents})`; + } + phase.status = "skipped"; + break; + } + if (phase.resources.length > remaining) { + // Trim the overflow: mark skipped with a clear error string + // and keep them in `resources` so the summary still reports + // them. + const skipped = phase.resources.splice(remaining); + for (const r of skipped) { + r.status = "skipped"; + r.error = `Skipped: would exceed component limit (${maxComponents})`; + } + phase.resources.push(...skipped); + } + await writeComponentsPhase( + pc, + phase, + targetPageId, + idMaps.groups, + idMaps.components, + idMaps.monitors, + ); + } else { + phase.status = "skipped"; + } + break; + case "incidents": + if (targetPageId && input.options?.includeStatusReports !== false) { + await writeIncidentsPhase( + pc, + phase, + targetPageId, + idMaps.components, + ); + } else { + phase.status = "skipped"; + } + break; + case "maintenances": + if (targetPageId && input.options?.includeStatusReports !== false) { + await writeMaintenancesPhase( + pc, + phase, + targetPageId, + idMaps.components, + ); + } else { + phase.status = "skipped"; + } + break; + case "subscribers": + if (targetPageId && input.options?.includeSubscribers) { + if (!ctx.workspace.limits["status-subscribers"]) { + phase.status = "skipped"; + break; + } + await writeSubscribersPhase( + pc, + phase, + targetPageId, + idMaps.components, + ); + } else { + phase.status = "skipped"; + } + break; + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + summary.errors.push(`Phase "${phase.phase}" failed: ${msg}`); + phase.status = "failed"; + phaseAborted = true; + } + } + + const hasFailures = summary.phases.some((p) => p.status === "failed"); + const hasPartial = summary.phases.some((p) => p.status === "partial"); + const allSkippedOrCompleted = summary.phases.every( + (p) => p.status === "completed" || p.status === "skipped", + ); + + summary.status = hasFailures + ? "failed" + : hasPartial + ? "partial" + : allSkippedOrCompleted + ? "completed" + : "partial"; + summary.completedAt = new Date(); + + // Rollup audit: per-resource rows live inside each phase writer; this + // row summarises the whole run so partial/failed imports still fire + // the observability signal without scanning the full summary blob. + // + // Entity attribution matches what the run actually touched. When a + // page was created or reused (`targetPageId` is set) the row points + // at it; when the page phase failed early and no page is in play we + // fall back to the workspace so the audit never carries a ghost + // `page 0` reference that breaks downstream "find audits for this + // entity" queries. + await emitAudit( + tx, + ctx, + targetPageId + ? { + action: "import.run", + entityType: "page", + entityId: targetPageId, + metadata: { + provider: input.provider, + status: summary.status, + pageId: targetPageId, + }, + } + : { + action: "import.run", + entityType: "workspace", + entityId: ctx.workspace.id, + metadata: { + provider: input.provider, + status: summary.status, + pageId: null, + }, + }, + ); + + return summary; +} diff --git a/packages/services/src/import/schemas.ts b/packages/services/src/import/schemas.ts new file mode 100644 index 000000000..0e53f6f4c --- /dev/null +++ b/packages/services/src/import/schemas.ts @@ -0,0 +1,63 @@ +import { z } from "zod"; + +export const importProviders = [ + "statuspage", + "betterstack", + "instatus", +] as const; +export type ImportProviderName = (typeof importProviders)[number]; + +/** + * `nullish().transform(v => v ?? undefined)` preserves the legacy + * router contract (which accepted `null` for every provider page-id + * field via `.nullish()`) while normalising the value to `undefined` + * inside the service — downstream `buildProviderConfig` expects + * `string | undefined` and doesn't want to learn about `null`. + * Switching these to plain `.optional()` would've silently rejected + * every client still sending `null`. + */ +const nullishString = z + .string() + .nullish() + .transform((v) => v ?? undefined); + +const providerFields = { + provider: z.enum(importProviders), + apiKey: z.string().min(1), + statuspagePageId: nullishString, + betterstackStatusPageId: nullishString, + instatusPageId: nullishString, +}; + +export const ImportOptions = z.object({ + includeStatusReports: z.boolean().default(true), + includeSubscribers: z.boolean().default(false), + includeComponents: z.boolean().default(true), + includeMonitors: z.boolean().default(true), +}); +export type ImportOptions = z.infer; + +export const PreviewImportInput = z.object({ + ...providerFields, + /** + * Target an existing status page — when provided the preview is scoped + * against that page's current component count so the remaining-capacity + * warnings line up with what `run` will actually do. + */ + pageId: z.number().int().optional(), + /** + * Optional same-shape `options` as `RunImportInput`. Exposed on preview + * so warning generation can match what the actual run would do — e.g. + * a user planning `includeSubscribers: false` shouldn't see a + * "subscribers cannot be imported" warning here. + */ + options: ImportOptions.partial().optional(), +}); +export type PreviewImportInput = z.infer; + +export const RunImportInput = z.object({ + ...providerFields, + pageId: z.number().int().optional(), + options: ImportOptions.partial().optional(), +}); +export type RunImportInput = z.infer; diff --git a/packages/services/src/import/utils.ts b/packages/services/src/import/utils.ts new file mode 100644 index 000000000..5937ebef8 --- /dev/null +++ b/packages/services/src/import/utils.ts @@ -0,0 +1,63 @@ +import type { PhaseResult, ResourceResult } from "@openstatus/importers"; + +/** + * Ordered periodicity tiers from fastest to slowest — `clampPeriodicity` + * walks this to find the nearest allowed slower value. + */ +export const PERIODICITY_ORDER = [ + "30s", + "1m", + "5m", + "10m", + "30m", + "1h", +] as const; + +/** + * Map a requested periodicity to the closest value the plan allows, never + * faster than the request. Falls back to the slowest allowed value when + * no slower tier is available. + * + * For unknown periodicity strings (values not in `PERIODICITY_ORDER`), + * falls back to the slowest allowed tier rather than restarting the + * walk from index 0 — the old behavior could return a *faster* + * interval than requested (e.g. `"2m"` → `"30s"`), violating the + * never-faster-than-requested invariant. + */ +export function clampPeriodicity(requested: string, allowed: string[]): string { + if (allowed.includes(requested)) return requested; + const reqIdx = PERIODICITY_ORDER.indexOf( + requested as (typeof PERIODICITY_ORDER)[number], + ); + if (reqIdx === -1) { + return allowed[allowed.length - 1] ?? "10m"; + } + for (let i = reqIdx; i < PERIODICITY_ORDER.length; i++) { + if (allowed.includes(PERIODICITY_ORDER[i])) { + return PERIODICITY_ORDER[i]; + } + } + return allowed[allowed.length - 1] ?? "10m"; +} + +/** + * Roll up the per-resource statuses in a phase into the phase-level + * status. Empty phases are `completed`; all-failed is `failed`; any mix + * of failed/skipped with successes is `partial`. + */ +export function computePhaseStatus( + resources: ResourceResult[], +): PhaseResult["status"] { + if (resources.length === 0) return "completed"; + + const allFailed = resources.every((r) => r.status === "failed"); + if (allFailed) return "failed"; + + const hasFailed = resources.some((r) => r.status === "failed"); + const hasSkipped = resources.some((r) => r.status === "skipped"); + const allSkipped = resources.every((r) => r.status === "skipped"); + + if (hasFailed || (hasSkipped && !allSkipped)) return "partial"; + + return "completed"; +} diff --git a/packages/services/src/incident/__tests__/incident.test.ts b/packages/services/src/incident/__tests__/incident.test.ts new file mode 100644 index 000000000..a850370ec --- /dev/null +++ b/packages/services/src/incident/__tests__/incident.test.ts @@ -0,0 +1,302 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + test, +} from "bun:test"; +import { db, eq } from "@openstatus/db"; +import { incidentTable, monitor } from "@openstatus/db/src/schema"; + +import { + SEEDED_WORKSPACE_FREE_ID, + SEEDED_WORKSPACE_TEAM_ID, +} from "../../../test/fixtures"; +import { + expectAuditRow, + loadSeededWorkspace, + makeUserCtx, + withAuditBuffer, +} from "../../../test/helpers"; +import type { AuditLogRecord } from "../../audit"; +import type { ServiceContext } from "../../context"; +import { ConflictError, NotFoundError } from "../../errors"; +import { acknowledgeIncident } from "../acknowledge"; +import { deleteIncident } from "../delete"; +import { getIncident, listIncidents } from "../list"; +import { resolveIncident } from "../resolve"; + +const TEST_PREFIX = "svc-incident-test"; + +let teamCtx: ServiceContext; +let freeCtx: ServiceContext; +let testMonitorId: number; +let auditBuffer: AuditLogRecord[]; +let auditReset: () => void; + +beforeAll(async () => { + const team = await loadSeededWorkspace(SEEDED_WORKSPACE_TEAM_ID); + const free = await loadSeededWorkspace(SEEDED_WORKSPACE_FREE_ID); + teamCtx = makeUserCtx(team, { userId: 1 }); + freeCtx = makeUserCtx(free, { userId: 2 }); + + const monitorRow = await db + .insert(monitor) + .values({ + workspaceId: team.id, + active: true, + url: "https://example.com", + name: `${TEST_PREFIX}-monitor`, + method: "GET", + periodicity: "10m", + regions: "ams", + }) + .returning() + .get(); + testMonitorId = monitorRow.id; +}); + +afterAll(async () => { + await db + .delete(monitor) + .where(eq(monitor.id, testMonitorId)) + .catch(() => undefined); +}); + +beforeEach(() => { + const hooks = withAuditBuffer(); + auditBuffer = hooks.buffer; + auditReset = hooks.reset; +}); + +afterEach(() => { + auditReset(); +}); + +let nextStartedAtOffset = 0; +async function insertIncident(opts: { + workspaceId: number; + monitorId: number; + acknowledgedAt?: Date; + resolvedAt?: Date; +}) { + // Unique `(monitor_id, started_at)` constraint means we bump per-test. + nextStartedAtOffset += 1; + const startedAt = new Date(Date.now() - nextStartedAtOffset * 60 * 1000); + const row = await db + .insert(incidentTable) + .values({ + workspaceId: opts.workspaceId, + monitorId: opts.monitorId, + startedAt, + acknowledgedAt: opts.acknowledgedAt ?? null, + resolvedAt: opts.resolvedAt ?? null, + }) + .returning() + .get(); + return row; +} + +describe("acknowledgeIncident", () => { + test("stamps acknowledgedAt + acknowledgedBy and emits audit", async () => { + const incident = await insertIncident({ + workspaceId: teamCtx.workspace.id, + monitorId: testMonitorId, + }); + + const updated = await acknowledgeIncident({ + ctx: teamCtx, + input: { id: incident.id }, + }); + + expect(updated.acknowledgedAt).toBeInstanceOf(Date); + expect(updated.acknowledgedBy).toBe(1); + + await expectAuditRow(auditBuffer, { + action: "incident.acknowledge", + entityType: "incident", + entityId: incident.id, + }); + + await db.delete(incidentTable).where(eq(incidentTable.id, incident.id)); + }); + + test("throws ConflictError when already acknowledged", async () => { + const incident = await insertIncident({ + workspaceId: teamCtx.workspace.id, + monitorId: testMonitorId, + acknowledgedAt: new Date(), + }); + + await expect( + acknowledgeIncident({ ctx: teamCtx, input: { id: incident.id } }), + ).rejects.toBeInstanceOf(ConflictError); + + await db.delete(incidentTable).where(eq(incidentTable.id, incident.id)); + }); + + test("throws NotFoundError for a cross-workspace incident", async () => { + const incident = await insertIncident({ + workspaceId: teamCtx.workspace.id, + monitorId: testMonitorId, + }); + + await expect( + acknowledgeIncident({ ctx: freeCtx, input: { id: incident.id } }), + ).rejects.toBeInstanceOf(NotFoundError); + + await db.delete(incidentTable).where(eq(incidentTable.id, incident.id)); + }); +}); + +describe("resolveIncident", () => { + test("stamps resolvedAt + resolvedBy", async () => { + const incident = await insertIncident({ + workspaceId: teamCtx.workspace.id, + monitorId: testMonitorId, + }); + + const updated = await resolveIncident({ + ctx: teamCtx, + input: { id: incident.id }, + }); + expect(updated.resolvedAt).toBeInstanceOf(Date); + expect(updated.resolvedBy).toBe(1); + + await db.delete(incidentTable).where(eq(incidentTable.id, incident.id)); + }); + + test("throws ConflictError when already resolved", async () => { + const incident = await insertIncident({ + workspaceId: teamCtx.workspace.id, + monitorId: testMonitorId, + resolvedAt: new Date(), + }); + + await expect( + resolveIncident({ ctx: teamCtx, input: { id: incident.id } }), + ).rejects.toBeInstanceOf(ConflictError); + + await db.delete(incidentTable).where(eq(incidentTable.id, incident.id)); + }); + + test("throws NotFoundError for a cross-workspace incident", async () => { + const incident = await insertIncident({ + workspaceId: teamCtx.workspace.id, + monitorId: testMonitorId, + }); + + await expect( + resolveIncident({ ctx: freeCtx, input: { id: incident.id } }), + ).rejects.toBeInstanceOf(NotFoundError); + + await db.delete(incidentTable).where(eq(incidentTable.id, incident.id)); + }); +}); + +describe("deleteIncident", () => { + test("removes the row and emits audit", async () => { + const incident = await insertIncident({ + workspaceId: teamCtx.workspace.id, + monitorId: testMonitorId, + }); + + await deleteIncident({ ctx: teamCtx, input: { id: incident.id } }); + + const remaining = await db + .select() + .from(incidentTable) + .where(eq(incidentTable.id, incident.id)) + .all(); + expect(remaining).toHaveLength(0); + + await expectAuditRow(auditBuffer, { + action: "incident.delete", + entityType: "incident", + entityId: incident.id, + }); + }); + + test("throws NotFoundError for cross-workspace delete", async () => { + const incident = await insertIncident({ + workspaceId: teamCtx.workspace.id, + monitorId: testMonitorId, + }); + + await expect( + deleteIncident({ ctx: freeCtx, input: { id: incident.id } }), + ).rejects.toBeInstanceOf(NotFoundError); + + await db.delete(incidentTable).where(eq(incidentTable.id, incident.id)); + }); +}); + +describe("list / get", () => { + test("respects workspace isolation and enriches monitor", async () => { + const incident = await insertIncident({ + workspaceId: teamCtx.workspace.id, + monitorId: testMonitorId, + }); + + const full = await getIncident({ + ctx: teamCtx, + input: { id: incident.id }, + }); + expect(full.monitor?.id).toBe(testMonitorId); + + await expect( + getIncident({ ctx: freeCtx, input: { id: incident.id } }), + ).rejects.toBeInstanceOf(NotFoundError); + + const { items } = await listIncidents({ + ctx: freeCtx, + input: { + limit: 100, + offset: 0, + order: "desc", + }, + }); + expect(items.find((r) => r.id === incident.id)).toBeUndefined(); + + await db.delete(incidentTable).where(eq(incidentTable.id, incident.id)); + }); + + test("list batch-enriches monitors without duplication", async () => { + const a = await insertIncident({ + workspaceId: teamCtx.workspace.id, + monitorId: testMonitorId, + }); + const b = await insertIncident({ + workspaceId: teamCtx.workspace.id, + monitorId: testMonitorId, + }); + + const { items } = await listIncidents({ + ctx: teamCtx, + input: { + limit: 100, + offset: 0, + order: "desc", + monitorId: testMonitorId, + }, + }); + + // Both incidents share the monitor — they should share the same enriched + // Monitor object (same-pageId dedup logic in the batch loader). + const ours = items.filter((i) => i.id === a.id || i.id === b.id); + expect(ours).toHaveLength(2); + expect(ours[0]?.monitor?.id).toBe(testMonitorId); + expect(ours[1]?.monitor?.id).toBe(testMonitorId); + + await db + .delete(incidentTable) + .where(eq(incidentTable.id, a.id)) + .catch(() => undefined); + await db + .delete(incidentTable) + .where(eq(incidentTable.id, b.id)) + .catch(() => undefined); + }); +}); diff --git a/packages/services/src/incident/acknowledge.ts b/packages/services/src/incident/acknowledge.ts new file mode 100644 index 000000000..65f4aa0b4 --- /dev/null +++ b/packages/services/src/incident/acknowledge.ts @@ -0,0 +1,66 @@ +import { and, eq, isNull } from "@openstatus/db"; +import { incidentTable } from "@openstatus/db/src/schema"; + +import { emitAudit } from "../audit"; +import { + type ServiceContext, + tryGetActorUserId, + withTransaction, +} from "../context"; +import { ConflictError } from "../errors"; +import type { Incident } from "../types"; +import { getIncidentInWorkspace } from "./internal"; +import { AcknowledgeIncidentInput } from "./schemas"; + +export async function acknowledgeIncident(args: { + ctx: ServiceContext; + input: AcknowledgeIncidentInput; +}): Promise { + const { ctx } = args; + const input = AcknowledgeIncidentInput.parse(args.input); + + return withTransaction(ctx, async (tx) => { + const existing = await getIncidentInWorkspace({ + tx, + id: input.id, + workspaceId: ctx.workspace.id, + }); + if (existing.acknowledgedAt) { + throw new ConflictError("Incident already acknowledged."); + } + + const now = new Date(); + // Conditional update — atomically flips `acknowledged_at` only while it + // is still NULL. A concurrent acknowledger losing the race returns no + // row, at which point we throw the same `ConflictError` the pre-read + // would have raised. + const updated = await tx + .update(incidentTable) + .set({ + acknowledgedAt: now, + acknowledgedBy: tryGetActorUserId(ctx.actor), + updatedAt: now, + }) + .where( + and( + eq(incidentTable.id, existing.id), + isNull(incidentTable.acknowledgedAt), + ), + ) + .returning() + .get(); + if (!updated) { + throw new ConflictError("Incident already acknowledged."); + } + + await emitAudit(tx, ctx, { + action: "incident.acknowledge", + entityType: "incident", + entityId: updated.id, + before: existing, + after: updated, + }); + + return updated; + }); +} diff --git a/packages/services/src/incident/delete.ts b/packages/services/src/incident/delete.ts new file mode 100644 index 000000000..3d781f00d --- /dev/null +++ b/packages/services/src/incident/delete.ts @@ -0,0 +1,32 @@ +import { eq } from "@openstatus/db"; +import { incidentTable } from "@openstatus/db/src/schema"; + +import { emitAudit } from "../audit"; +import { type ServiceContext, withTransaction } from "../context"; +import { getIncidentInWorkspace } from "./internal"; +import { DeleteIncidentInput } from "./schemas"; + +export async function deleteIncident(args: { + ctx: ServiceContext; + input: DeleteIncidentInput; +}): Promise { + const { ctx } = args; + const input = DeleteIncidentInput.parse(args.input); + + await withTransaction(ctx, async (tx) => { + const existing = await getIncidentInWorkspace({ + tx, + id: input.id, + workspaceId: ctx.workspace.id, + }); + + await tx.delete(incidentTable).where(eq(incidentTable.id, existing.id)); + + await emitAudit(tx, ctx, { + action: "incident.delete", + entityType: "incident", + entityId: existing.id, + before: existing, + }); + }); +} diff --git a/packages/services/src/incident/index.ts b/packages/services/src/incident/index.ts new file mode 100644 index 000000000..8e80a5a6d --- /dev/null +++ b/packages/services/src/incident/index.ts @@ -0,0 +1,20 @@ +export { acknowledgeIncident } from "./acknowledge"; +export { deleteIncident } from "./delete"; +export { + getIncident, + type IncidentWithRelations, + listIncidents, + type ListIncidentsResult, +} from "./list"; +export { resolveIncident } from "./resolve"; + +export { + AcknowledgeIncidentInput, + DeleteIncidentInput, + GetIncidentInput, + type IncidentListPeriod, + incidentListPeriodSchema, + incidentListPeriods, + ListIncidentsInput, + ResolveIncidentInput, +} from "./schemas"; diff --git a/packages/services/src/incident/internal.ts b/packages/services/src/incident/internal.ts new file mode 100644 index 000000000..98c5319ff --- /dev/null +++ b/packages/services/src/incident/internal.ts @@ -0,0 +1,23 @@ +import { and, eq } from "@openstatus/db"; +import { incidentTable } from "@openstatus/db/src/schema"; + +import type { DB } from "../context"; +import { NotFoundError } from "../errors"; + +/** Load an incident by id, scoped to the workspace. Throws on miss. */ +export async function getIncidentInWorkspace(args: { + tx: DB; + id: number; + workspaceId: number; +}) { + const { tx, id, workspaceId } = args; + const row = await tx + .select() + .from(incidentTable) + .where( + and(eq(incidentTable.id, id), eq(incidentTable.workspaceId, workspaceId)), + ) + .get(); + if (!row) throw new NotFoundError("incident", id); + return row; +} diff --git a/packages/services/src/incident/list.ts b/packages/services/src/incident/list.ts new file mode 100644 index 000000000..3fe880c54 --- /dev/null +++ b/packages/services/src/incident/list.ts @@ -0,0 +1,148 @@ +import { + type SQL, + and, + asc, + db as defaultDb, + desc, + eq, + gte, + inArray, + sql, +} from "@openstatus/db"; +import { + incidentTable, + monitor, + selectMonitorSchema, +} from "@openstatus/db/src/schema"; + +import type { DB, ServiceContext } from "../context"; +import type { Incident, Monitor } from "../types"; +import { getIncidentInWorkspace } from "./internal"; +import { + GetIncidentInput, + type IncidentListPeriod, + ListIncidentsInput, +} from "./schemas"; + +function periodToSince(period: IncidentListPeriod): Date { + const day = 24 * 60 * 60 * 1000; + const now = Date.now(); + switch (period) { + case "1d": + return new Date(now - 1 * day); + case "7d": + return new Date(now - 7 * day); + case "14d": + return new Date(now - 14 * day); + } +} + +export type IncidentWithRelations = Incident & { + monitor: Monitor | null; +}; + +export type ListIncidentsResult = { + items: IncidentWithRelations[]; + totalSize: number; +}; + +/** + * Load each incident's monitor in a single IN query against distinct + * `monitorId`s — avoids the per-row fetch that would balloon with the + * 10_000 sentinel tRPC passes. Scoped to `workspaceId` for defence-in-depth: + * the `incident.monitorId` column has no FK constraint against workspace + * ownership, so a cross-workspace pointer (however unlikely) should not + * leak the other workspace's monitor row. + */ +async function enrichIncidentsBatch( + db: DB, + rows: Incident[], + workspaceId: number, +): Promise { + if (rows.length === 0) return []; + + const monitorIdsSet = new Set(); + for (const r of rows) if (r.monitorId != null) monitorIdsSet.add(r.monitorId); + const monitorIds = Array.from(monitorIdsSet); + + const monitorById = new Map(); + if (monitorIds.length > 0) { + const monitorRows = await db + .select() + .from(monitor) + .where( + and( + inArray(monitor.id, monitorIds), + eq(monitor.workspaceId, workspaceId), + ), + ) + .all(); + for (const m of monitorRows) { + monitorById.set(m.id, selectMonitorSchema.parse(m)); + } + } + + return rows.map((r) => ({ + ...r, + monitor: r.monitorId != null ? monitorById.get(r.monitorId) ?? null : null, + })); +} + +export async function listIncidents(args: { + ctx: ServiceContext; + input: ListIncidentsInput; +}): Promise { + const { ctx } = args; + const input = ListIncidentsInput.parse(args.input); + const db = ctx.db ?? defaultDb; + + const conditions: SQL[] = [eq(incidentTable.workspaceId, ctx.workspace.id)]; + if (input.monitorId !== undefined) { + conditions.push(eq(incidentTable.monitorId, input.monitorId)); + } + if (input.period !== undefined) { + conditions.push(gte(incidentTable.startedAt, periodToSince(input.period))); + } + const whereClause = and(...conditions); + + const [countRow, rows] = await Promise.all([ + db + .select({ count: sql`count(*)` }) + .from(incidentTable) + .where(whereClause) + .get(), + db + .select() + .from(incidentTable) + .where(whereClause) + .orderBy( + input.order === "asc" + ? asc(incidentTable.startedAt) + : desc(incidentTable.startedAt), + ) + .limit(input.limit) + .offset(input.offset) + .all(), + ]); + + const totalSize = countRow?.count ?? 0; + const items = await enrichIncidentsBatch(db, rows, ctx.workspace.id); + return { items, totalSize }; +} + +export async function getIncident(args: { + ctx: ServiceContext; + input: GetIncidentInput; +}): Promise { + const { ctx } = args; + const input = GetIncidentInput.parse(args.input); + const db = ctx.db ?? defaultDb; + const record = await getIncidentInWorkspace({ + tx: db, + id: input.id, + workspaceId: ctx.workspace.id, + }); + const [enriched] = await enrichIncidentsBatch(db, [record], ctx.workspace.id); + // biome-ignore lint/style/noNonNullAssertion: always defined for len === 1 + return enriched!; +} diff --git a/packages/services/src/incident/resolve.ts b/packages/services/src/incident/resolve.ts new file mode 100644 index 000000000..08dcfb557 --- /dev/null +++ b/packages/services/src/incident/resolve.ts @@ -0,0 +1,65 @@ +import { and, eq, isNull } from "@openstatus/db"; +import { incidentTable } from "@openstatus/db/src/schema"; + +import { emitAudit } from "../audit"; +import { + type ServiceContext, + tryGetActorUserId, + withTransaction, +} from "../context"; +import { ConflictError } from "../errors"; +import type { Incident } from "../types"; +import { getIncidentInWorkspace } from "./internal"; +import { ResolveIncidentInput } from "./schemas"; + +export async function resolveIncident(args: { + ctx: ServiceContext; + input: ResolveIncidentInput; +}): Promise { + const { ctx } = args; + const input = ResolveIncidentInput.parse(args.input); + + return withTransaction(ctx, async (tx) => { + const existing = await getIncidentInWorkspace({ + tx, + id: input.id, + workspaceId: ctx.workspace.id, + }); + if (existing.resolvedAt) { + throw new ConflictError("Incident already resolved."); + } + + const now = new Date(); + // Conditional update — atomically flips `resolved_at` only while it is + // still NULL. Concurrent resolvers lose the race and get a no-row + // return, which we translate into the same `ConflictError`. + const updated = await tx + .update(incidentTable) + .set({ + resolvedAt: now, + resolvedBy: tryGetActorUserId(ctx.actor), + updatedAt: now, + }) + .where( + and( + eq(incidentTable.id, existing.id), + isNull(incidentTable.resolvedAt), + ), + ) + .returning() + .get(); + if (!updated) { + throw new ConflictError("Incident already resolved."); + } + + await emitAudit(tx, ctx, { + action: "incident.resolve", + entityType: "incident", + entityId: updated.id, + before: existing, + after: updated, + }); + + return updated; + }); +} diff --git a/packages/services/src/incident/schemas.ts b/packages/services/src/incident/schemas.ts new file mode 100644 index 000000000..b90054fab --- /dev/null +++ b/packages/services/src/incident/schemas.ts @@ -0,0 +1,30 @@ +import { z } from "zod"; + +/** Matches the `periods` constant in the existing tRPC router. */ +export const incidentListPeriods = ["1d", "7d", "14d"] as const; +export type IncidentListPeriod = (typeof incidentListPeriods)[number]; +export const incidentListPeriodSchema = z.enum(incidentListPeriods); + +export const AcknowledgeIncidentInput = z.object({ id: z.number().int() }); +export type AcknowledgeIncidentInput = z.infer; + +export const ResolveIncidentInput = z.object({ id: z.number().int() }); +export type ResolveIncidentInput = z.infer; + +export const DeleteIncidentInput = z.object({ id: z.number().int() }); +export type DeleteIncidentInput = z.infer; + +export const GetIncidentInput = z.object({ id: z.number().int() }); +export type GetIncidentInput = z.infer; + +// `limit` unbounded at the schema level — same convention as status-report / +// maintenance. tRPC passes the 10_000 sentinel; external surfaces cap in the +// adapter. +export const ListIncidentsInput = z.object({ + limit: z.number().int().min(1).default(50), + offset: z.number().int().min(0).default(0), + period: incidentListPeriodSchema.optional(), + monitorId: z.number().int().optional(), + order: z.enum(["asc", "desc"]).default("desc"), +}); +export type ListIncidentsInput = z.infer; diff --git a/packages/services/src/index.ts b/packages/services/src/index.ts index a4ef367f7..de22cef5c 100644 --- a/packages/services/src/index.ts +++ b/packages/services/src/index.ts @@ -6,6 +6,7 @@ export { type ServiceContext, extractActorId, isTx, + tryGetActorUserId, withTransaction, } from "./context"; @@ -15,6 +16,7 @@ export { InternalServiceError, LimitExceededError, NotFoundError, + PreconditionFailedError, ServiceError, type ServiceErrorCode, UnauthorizedError, diff --git a/packages/services/src/invitation/__tests__/invitation.test.ts b/packages/services/src/invitation/__tests__/invitation.test.ts new file mode 100644 index 000000000..f6b47cd3e --- /dev/null +++ b/packages/services/src/invitation/__tests__/invitation.test.ts @@ -0,0 +1,268 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + test, +} from "bun:test"; +import { db, eq, inArray } from "@openstatus/db"; +import { invitation, user, usersToWorkspaces } from "@openstatus/db/src/schema"; + +import { + acceptInvitation, + createInvitation, + deleteInvitation, + getInvitationByToken, + listInvitations, +} from ".."; +import { + SEEDED_WORKSPACE_FREE_ID, + SEEDED_WORKSPACE_TEAM_ID, +} from "../../../test/fixtures"; +import { + expectAuditRow, + loadSeededWorkspace, + makeUserCtx, + withAuditBuffer, +} from "../../../test/helpers"; +import type { AuditLogRecord } from "../../audit"; +import type { ServiceContext } from "../../context"; +import { LimitExceededError, NotFoundError } from "../../errors"; + +const TEST_PREFIX = "svc-inv-test"; + +let teamCtx: ServiceContext; +let freeCtx: ServiceContext; +let auditBuffer: AuditLogRecord[]; +let auditReset: () => void; +const createdInvitationIds: number[] = []; + +beforeAll(async () => { + const team = await loadSeededWorkspace(SEEDED_WORKSPACE_TEAM_ID); + const free = await loadSeededWorkspace(SEEDED_WORKSPACE_FREE_ID); + teamCtx = makeUserCtx(team, { userId: 1 }); + freeCtx = makeUserCtx(free, { userId: 2 }); + + // Seed membership for the free workspace so the `members: 1` cap + // can actually be reached by a subsequent `createInvitation` — the + // shared DB seed only adds a row for workspace 1 (team). Without + // this, the members-cap assertion below passes against `0 < 1` and + // never reaches the LimitExceededError branch. + // + // `userId: 1` instead of `2` — only user 1 is in the shared seed, + // and the `user_id` FK on `users_to_workspaces` rejects unknown + // ids. The cap check just counts rows by `workspace_id`, so which + // user occupies the slot doesn't matter. + await db + .insert(usersToWorkspaces) + .values({ workspaceId: SEEDED_WORKSPACE_FREE_ID, userId: 1 }) + .onConflictDoNothing(); + + // Seed the accepting user for the `acceptInvitation` test below. + // The test accepts as `userId: 3` and the ensuing + // `users_to_workspaces` insert has an FK on `user.id` — without a + // seeded row the insert blows up with a `SQLITE_CONSTRAINT` + // surfacing as a hook-timeout in the suite. Tear it down in + // `afterAll` alongside the membership. + await db + .insert(user) + .values({ + id: 3, + tenantId: "invitation-test-3", + firstName: "Accept", + lastName: "Tester", + email: "accept-tester@example.test", + photoUrl: "", + }) + .onConflictDoNothing(); +}); + +afterAll(async () => { + if (createdInvitationIds.length > 0) { + await db + .delete(invitation) + .where(inArray(invitation.id, createdInvitationIds)); + } + await db + .delete(usersToWorkspaces) + .where(eq(usersToWorkspaces.workspaceId, SEEDED_WORKSPACE_FREE_ID)) + .catch(() => undefined); + // `acceptInvitation` test seeded user 3 as the accepting user — + // clean up membership (test does this in-test, but harmless to + // re-run) and the user row itself. + await db + .delete(usersToWorkspaces) + .where(eq(usersToWorkspaces.userId, 3)) + .catch(() => undefined); + await db + .delete(user) + .where(eq(user.id, 3)) + .catch(() => undefined); +}); + +beforeEach(() => { + const session = withAuditBuffer(); + auditBuffer = session.buffer; + auditReset = session.reset; +}); +afterEach(() => auditReset()); + +describe("createInvitation", () => { + test("creates an invitation scoped to the caller's workspace", async () => { + const email = `${TEST_PREFIX}-${Date.now()}@example.com`; + const row = await createInvitation({ + ctx: teamCtx, + input: { email }, + }); + createdInvitationIds.push(row.id); + + expect(row.email).toBe(email); + expect(row.workspaceId).toBe(SEEDED_WORKSPACE_TEAM_ID); + expect(row.token).toBeDefined(); + expect(row.expiresAt.getTime()).toBeGreaterThan(Date.now()); + + await expectAuditRow(auditBuffer, { + action: "invitation.create", + entityType: "invitation", + entityId: row.id, + }); + }); + + test("enforces the members plan cap on free workspace", async () => { + // Free plan has `members: 1` — the owner already occupies that slot. + await expect( + createInvitation({ + ctx: freeCtx, + input: { email: `${TEST_PREFIX}-overflow@example.com` }, + }), + ).rejects.toBeInstanceOf(LimitExceededError); + }); +}); + +describe("listInvitations", () => { + test("returns pending invitations for the workspace only", async () => { + const email = `${TEST_PREFIX}-list-${Date.now()}@example.com`; + const created = await createInvitation({ + ctx: teamCtx, + input: { email }, + }); + createdInvitationIds.push(created.id); + + const rows = await listInvitations({ ctx: teamCtx }); + const ids = rows.map((r) => r.id); + expect(ids).toContain(created.id); + + const freeRows = await listInvitations({ ctx: freeCtx }); + expect(freeRows.map((r) => r.id)).not.toContain(created.id); + }); +}); + +describe("getInvitationByToken", () => { + test("resolves by token + email", async () => { + const email = `${TEST_PREFIX}-token-${Date.now()}@example.com`; + const created = await createInvitation({ + ctx: teamCtx, + input: { email }, + }); + createdInvitationIds.push(created.id); + + const row = await getInvitationByToken({ + ctx: teamCtx, + input: { token: created.token, email }, + }); + expect(row.id).toBe(created.id); + expect(row.workspace.id).toBe(SEEDED_WORKSPACE_TEAM_ID); + }); + + test("rejects a valid token for the wrong email", async () => { + const email = `${TEST_PREFIX}-mismatch-${Date.now()}@example.com`; + const created = await createInvitation({ + ctx: teamCtx, + input: { email }, + }); + createdInvitationIds.push(created.id); + + await expect( + getInvitationByToken({ + ctx: teamCtx, + input: { + token: created.token, + email: `other-${email}`, + }, + }), + ).rejects.toBeInstanceOf(NotFoundError); + }); +}); + +describe("deleteInvitation", () => { + test("removes the row and is scoped to the caller's workspace", async () => { + const email = `${TEST_PREFIX}-delete-${Date.now()}@example.com`; + const created = await createInvitation({ + ctx: teamCtx, + input: { email }, + }); + + await deleteInvitation({ ctx: teamCtx, input: { id: created.id } }); + + const row = await db + .select({ id: invitation.id }) + .from(invitation) + .where(eq(invitation.id, created.id)) + .get(); + expect(row).toBeUndefined(); + }); +}); + +describe("acceptInvitation", () => { + test("stamps acceptedAt and inserts a workspace membership", async () => { + // The user id comes from `ctx.actor`, not from input — so we build + // a ctx scoped to the accepting user rather than passing an id. + const acceptingUserId = 3; + const email = `${TEST_PREFIX}-accept-${acceptingUserId}-${Date.now()}@example.com`; + const created = await createInvitation({ + ctx: teamCtx, + input: { email }, + }); + createdInvitationIds.push(created.id); + const acceptingCtx = makeUserCtx(teamCtx.workspace, { + userId: acceptingUserId, + }); + + try { + const workspaceRow = await acceptInvitation({ + ctx: acceptingCtx, + input: { id: created.id, email }, + }); + expect(workspaceRow.id).toBe(SEEDED_WORKSPACE_TEAM_ID); + + const updated = await db + .select() + .from(invitation) + .where(eq(invitation.id, created.id)) + .get(); + expect(updated?.acceptedAt).not.toBeNull(); + + const membership = await db + .select() + .from(usersToWorkspaces) + .where(eq(usersToWorkspaces.userId, acceptingUserId)) + .get(); + expect(membership?.workspaceId).toBe(SEEDED_WORKSPACE_TEAM_ID); + } finally { + await db + .delete(usersToWorkspaces) + .where(eq(usersToWorkspaces.userId, acceptingUserId)); + } + }); + + test("throws NotFoundError for an unknown or wrong-email invitation", async () => { + await expect( + acceptInvitation({ + ctx: teamCtx, + input: { id: 999_999, email: "nope@example.com" }, + }), + ).rejects.toBeInstanceOf(NotFoundError); + }); +}); diff --git a/packages/services/src/invitation/accept.ts b/packages/services/src/invitation/accept.ts new file mode 100644 index 000000000..84aa3cc92 --- /dev/null +++ b/packages/services/src/invitation/accept.ts @@ -0,0 +1,120 @@ +import { and, eq, gte, isNull } from "@openstatus/db"; +import { + invitation, + selectWorkspaceSchema, + usersToWorkspaces, +} from "@openstatus/db/src/schema"; + +import { emitAudit } from "../audit"; +import { + type ServiceContext, + tryGetActorUserId, + withTransaction, +} from "../context"; +import { ConflictError, NotFoundError, UnauthorizedError } from "../errors"; +import type { Workspace } from "../types"; +import { AcceptInvitationInput } from "./schemas"; + +/** + * Accept an invitation: stamp `acceptedAt`, add the accepting user to the + * target workspace with the invitation's role, and return the workspace + * row. + * + * The lookup is scoped by id + email + unexpired + not-yet-accepted to + * prevent token-sharing. To stay race-safe, the acceptance write is a + * conditional UPDATE that re-asserts `acceptedAt IS NULL` — two concurrent + * callers that both pass the initial read will see exactly one row + * returned from the update; the loser throws `ConflictError` and the + * transaction aborts before the membership insert runs. + * + * The membership is inserted under `ctx.actor`'s user id — taking it + * from input would let any caller that knows the token+email create + * a membership for an arbitrary user. Same defense pattern as + * `createApiKey.createdById`. + */ +export async function acceptInvitation(args: { + ctx: ServiceContext; + input: AcceptInvitationInput; +}): Promise { + const { ctx } = args; + const input = AcceptInvitationInput.parse(args.input); + + const userId = tryGetActorUserId(ctx.actor); + if (userId == null) { + throw new UnauthorizedError( + "Invitations must be accepted by a known user actor.", + ); + } + + return withTransaction(ctx, async (tx) => { + // Load the invitation with its workspace in one round-trip — we use + // `existing.workspace` for the service's return value, so there's + // no point re-fetching it by id afterwards. Keeping a single read + // also avoids a read-skew window where a just-renamed workspace + // would appear with one name on the db fetch and another on the + // relation join. + const existing = await tx.query.invitation.findFirst({ + where: and( + eq(invitation.id, input.id), + eq(invitation.email, input.email), + isNull(invitation.acceptedAt), + gte(invitation.expiresAt, new Date()), + ), + with: { workspace: true }, + }); + + if (!existing) throw new NotFoundError("invitation", input.id); + + // Conditional UPDATE — the `isNull(acceptedAt)` predicate makes the + // write a no-op for any row another caller has already claimed, + // even if both callers passed the read check above. Rowcount == 0 + // means we lost the race. The `gte(expiresAt, now)` condition is + // re-asserted here to close a TOCTOU gap: without it, an invitation + // whose expiry is exactly the moment between the read and the write + // can be silently accepted past the deadline. + const claimed = await tx + .update(invitation) + .set({ acceptedAt: new Date() }) + .where( + and( + eq(invitation.id, input.id), + isNull(invitation.acceptedAt), + gte(invitation.expiresAt, new Date()), + ), + ) + .returning({ id: invitation.id }); + + if (claimed.length === 0) { + throw new ConflictError("Invitation already accepted."); + } + + // `onConflictDoNothing` keyed on the `(userId, workspaceId)` unique + // constraint — idempotent for an invitee who's already a member of + // the target workspace (e.g. they were added directly before the + // invitation token was redeemed). Without this the final insert + // explodes on the unique violation after the invitation row was + // already stamped accepted, leaving the token consumed with no + // membership change the caller can distinguish from success. + await tx + .insert(usersToWorkspaces) + .values({ + userId, + workspaceId: existing.workspaceId, + role: existing.role, + }) + .onConflictDoNothing(); + + if (!existing.workspace) { + throw new NotFoundError("workspace", existing.workspaceId); + } + + await emitAudit(tx, ctx, { + action: "invitation.accept", + entityType: "invitation", + entityId: input.id, + metadata: { workspaceId: existing.workspaceId, role: existing.role }, + }); + + return selectWorkspaceSchema.parse(existing.workspace); + }); +} diff --git a/packages/services/src/invitation/create.ts b/packages/services/src/invitation/create.ts new file mode 100644 index 000000000..b86a8fce1 --- /dev/null +++ b/packages/services/src/invitation/create.ts @@ -0,0 +1,97 @@ +import { and, eq, gte, isNull } from "@openstatus/db"; +import { invitation, usersToWorkspaces } from "@openstatus/db/src/schema"; + +import { emitAudit } from "../audit"; +import { type ServiceContext, withTransaction } from "../context"; +import { LimitExceededError } from "../errors"; +import type { Invitation } from "../types"; +import { CreateInvitationInput } from "./schemas"; + +/** + * "Unlimited" members resolves to this effective cap — matches the legacy + * behavior in `invitationRouter.create`. Large enough that enterprise plans + * never brush against it in practice. + */ +const UNLIMITED_MEMBERS_EFFECTIVE_CAP = 420; + +/** + * Invite a user to the caller's workspace. + * + * Enforces the `members` plan cap counting both existing workspace members + * *and* pending (unexpired, unaccepted) invitations — sending a new invite + * after an old one is still outstanding would otherwise allow the workspace + * to slip past the cap once both accept. + */ +export async function createInvitation(args: { + ctx: ServiceContext; + input: CreateInvitationInput; +}): Promise { + const { ctx } = args; + const input = CreateInvitationInput.parse(args.input); + + return withTransaction(ctx, async (tx) => { + const membersLimit = ctx.workspace.limits.members; + const effectiveLimit = + membersLimit === "Unlimited" + ? UNLIMITED_MEMBERS_EFFECTIVE_CAP + : membersLimit; + + const [memberRows, openInviteRows] = await Promise.all([ + tx.query.usersToWorkspaces.findMany({ + where: eq(usersToWorkspaces.workspaceId, ctx.workspace.id), + }), + tx.query.invitation.findMany({ + where: and( + eq(invitation.workspaceId, ctx.workspace.id), + gte(invitation.expiresAt, new Date()), + isNull(invitation.acceptedAt), + ), + }), + ]); + + if (memberRows.length + openInviteRows.length >= effectiveLimit) { + throw new LimitExceededError("members", effectiveLimit); + } + + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + 7); + + const token = crypto.randomUUID(); + + // `role` is not specified here and falls through to the schema + // default (`member`). Matches the legacy router, which only picked + // `email` out of the insert schema and never exposed role on the + // invite surface — specifying it is a future opt-in, not an + // unintentional regression. + const row = await tx + .insert(invitation) + .values({ + email: input.email, + expiresAt, + token, + workspaceId: ctx.workspace.id, + }) + .returning() + .get(); + + // Dev-mode convenience: echo the invite URL so the developer can + // click it straight from the terminal without checking the DB. + // Strict equality on `"development"` so bun:test (which sets + // `NODE_ENV=test`) and CI (typically `undefined` or `"test"`) don't + // leak tokens into logs. + if (process.env.NODE_ENV === "development") { + console.log( + `>>>> Invitation token: http://localhost:3000/invite?token=${token} <<<< `, + ); + } + + await emitAudit(tx, ctx, { + action: "invitation.create", + entityType: "invitation", + entityId: row.id, + metadata: { email: input.email }, + }); + + return row as Invitation; + }); +} diff --git a/packages/services/src/invitation/delete.ts b/packages/services/src/invitation/delete.ts new file mode 100644 index 000000000..01cbed3cc --- /dev/null +++ b/packages/services/src/invitation/delete.ts @@ -0,0 +1,50 @@ +import { and, eq, isNull } from "@openstatus/db"; +import { invitation } from "@openstatus/db/src/schema"; + +import { emitAudit } from "../audit"; +import { type ServiceContext, withTransaction } from "../context"; +import { DeleteInvitationInput } from "./schemas"; + +/** + * Delete a pending invitation. Scoped to the caller's workspace; a no-op + * (no error) when the row doesn't exist — the legacy router also issues + * an unconditional DELETE without a prior existence check. + * + * Only **pending** invitations can be deleted: the `acceptedAt IS NULL` + * guard preserves the audit trail for accepted invites and prevents a + * workspace owner from unilaterally wiping the "this user was invited + * on date X" record after they've joined. Accepted invitations survive + * as a historical breadcrumb. + * + * The audit row is only emitted when a row actually got deleted, so + * unknown-id / wrong-workspace / already-accepted calls don't generate + * misleading entries. + */ +export async function deleteInvitation(args: { + ctx: ServiceContext; + input: DeleteInvitationInput; +}): Promise { + const { ctx } = args; + const input = DeleteInvitationInput.parse(args.input); + + await withTransaction(ctx, async (tx) => { + const deleted = await tx + .delete(invitation) + .where( + and( + eq(invitation.id, input.id), + eq(invitation.workspaceId, ctx.workspace.id), + isNull(invitation.acceptedAt), + ), + ) + .returning({ id: invitation.id }); + + if (deleted.length === 0) return; + + await emitAudit(tx, ctx, { + action: "invitation.delete", + entityType: "invitation", + entityId: input.id, + }); + }); +} diff --git a/packages/services/src/invitation/index.ts b/packages/services/src/invitation/index.ts new file mode 100644 index 000000000..80d3fe908 --- /dev/null +++ b/packages/services/src/invitation/index.ts @@ -0,0 +1,15 @@ +export { acceptInvitation } from "./accept"; +export { createInvitation } from "./create"; +export { deleteInvitation } from "./delete"; +export { + getInvitationByToken, + type InvitationWithWorkspace, + listInvitations, +} from "./list"; +export { + AcceptInvitationInput, + CreateInvitationInput, + DeleteInvitationInput, + GetInvitationByTokenInput, + ListInvitationsInput, +} from "./schemas"; diff --git a/packages/services/src/invitation/list.ts b/packages/services/src/invitation/list.ts new file mode 100644 index 000000000..5090d96ae --- /dev/null +++ b/packages/services/src/invitation/list.ts @@ -0,0 +1,72 @@ +import { and, db as defaultDb, eq, gte, isNull } from "@openstatus/db"; +import { + invitation, + selectInvitationSchema, + selectWorkspaceSchema, +} from "@openstatus/db/src/schema"; + +import type { ServiceContext } from "../context"; +import { NotFoundError } from "../errors"; +import type { Invitation, Workspace } from "../types"; +import { + GetInvitationByTokenInput, + type ListInvitationsInput, +} from "./schemas"; + +/** + * List pending (unexpired, unaccepted) invitations for the caller's + * workspace. + */ +export async function listInvitations(args: { + ctx: ServiceContext; + input?: ListInvitationsInput; +}): Promise { + const { ctx } = args; + const db = ctx.db ?? defaultDb; + + const rows = await db.query.invitation.findMany({ + where: and( + eq(invitation.workspaceId, ctx.workspace.id), + gte(invitation.expiresAt, new Date()), + isNull(invitation.acceptedAt), + ), + }); + + return rows as Invitation[]; +} + +export type InvitationWithWorkspace = Invitation & { workspace: Workspace }; + +/** + * Resolve an invitation by token for a given accepting user's email. + * Intentionally scoped by email to prevent token-sharing: a different user + * cannot claim an invitation addressed to someone else. + * + * The email is a required, zod-validated field on the input schema — the + * transport layer (router) is responsible for short-circuiting with its + * own auth error when the caller isn't authenticated. Throws + * `NotFoundError` when no pending invite matches the token + email. + */ +export async function getInvitationByToken(args: { + ctx: ServiceContext; + input: GetInvitationByTokenInput; +}): Promise { + const input = GetInvitationByTokenInput.parse(args.input); + const db = args.ctx.db ?? defaultDb; + + const result = await db.query.invitation.findFirst({ + where: and( + eq(invitation.token, input.token), + isNull(invitation.acceptedAt), + gte(invitation.expiresAt, new Date()), + eq(invitation.email, input.email), + ), + with: { workspace: true }, + }); + + if (!result) throw new NotFoundError("invitation", input.token); + + return selectInvitationSchema + .extend({ workspace: selectWorkspaceSchema }) + .parse(result) as InvitationWithWorkspace; +} diff --git a/packages/services/src/invitation/schemas.ts b/packages/services/src/invitation/schemas.ts new file mode 100644 index 000000000..85dac2ef0 --- /dev/null +++ b/packages/services/src/invitation/schemas.ts @@ -0,0 +1,26 @@ +import { z } from "zod"; + +export const CreateInvitationInput = z.object({ + email: z.email(), +}); +export type CreateInvitationInput = z.infer; + +export const DeleteInvitationInput = z.object({ id: z.number().int() }); +export type DeleteInvitationInput = z.infer; + +export const ListInvitationsInput = z.object({}).strict(); +export type ListInvitationsInput = z.infer; + +export const GetInvitationByTokenInput = z.object({ + token: z.string().min(1), + email: z.email(), +}); +export type GetInvitationByTokenInput = z.infer< + typeof GetInvitationByTokenInput +>; + +export const AcceptInvitationInput = z.object({ + id: z.number().int(), + email: z.email(), +}); +export type AcceptInvitationInput = z.infer; diff --git a/packages/services/src/maintenance/__tests__/maintenance.test.ts b/packages/services/src/maintenance/__tests__/maintenance.test.ts new file mode 100644 index 000000000..680b23bd3 --- /dev/null +++ b/packages/services/src/maintenance/__tests__/maintenance.test.ts @@ -0,0 +1,513 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + test, +} from "bun:test"; +import { db, eq, inArray } from "@openstatus/db"; +import { + maintenance, + maintenancesToPageComponents, + page, + pageComponent, +} from "@openstatus/db/src/schema"; + +import { + SEEDED_WORKSPACE_FREE_ID, + SEEDED_WORKSPACE_TEAM_ID, +} from "../../../test/fixtures"; +import { + expectAuditRow, + loadSeededWorkspace, + makeSlackCtx, + makeUserCtx, + withAuditBuffer, +} from "../../../test/helpers"; +import type { AuditLogRecord } from "../../audit"; +import type { ServiceContext } from "../../context"; +import { ConflictError, ForbiddenError, NotFoundError } from "../../errors"; +import { createMaintenance } from "../create"; +import { deleteMaintenance } from "../delete"; +import { getMaintenance, listMaintenances } from "../list"; +import { notifyMaintenance } from "../notify"; +import { updateMaintenance } from "../update"; + +const subscriptionSpies = (globalThis as Record) + .__subscriptionSpies as + | { + dispatchMaintenanceUpdate: { + mockClear: () => void; + mock: { calls: unknown[][] }; + }; + } + | undefined; + +const TEST_PREFIX = "svc-maintenance-test"; + +let teamCtx: ServiceContext; +let freeCtx: ServiceContext; +let testPageId: number; +let testPageComponentId: number; +// Second page + component on the same workspace, used to exercise the +// "all components must share a page" ConflictError branch. +let otherPageId: number; +let otherPageComponentId: number; +let auditBuffer: AuditLogRecord[]; +let auditReset: () => void; + +/** + * Tests push created maintenance ids here instead of issuing an inline + * `db.delete()` at the end of their body. `afterEach` drains the array, + * so cleanup runs even when an assertion throws midway through a test — + * otherwise a failing assertion leaves orphans that flake subsequent + * runs. + */ +const createdMaintenanceIds: number[] = []; + +beforeAll(async () => { + const team = await loadSeededWorkspace(SEEDED_WORKSPACE_TEAM_ID); + const free = await loadSeededWorkspace(SEEDED_WORKSPACE_FREE_ID); + teamCtx = makeUserCtx(team, { userId: 1 }); + freeCtx = makeUserCtx(free, { userId: 2 }); + + const pageRow = await db + .insert(page) + .values({ + workspaceId: team.id, + title: `${TEST_PREFIX}-page`, + description: "test page", + slug: `${TEST_PREFIX}-page-slug`, + customDomain: "", + }) + .returning() + .get(); + testPageId = pageRow.id; + + const componentRow = await db + .insert(pageComponent) + .values({ + workspaceId: team.id, + pageId: testPageId, + name: `${TEST_PREFIX}-component`, + type: "static", + }) + .returning() + .get(); + testPageComponentId = componentRow.id; + + const otherPageRow = await db + .insert(page) + .values({ + workspaceId: team.id, + title: `${TEST_PREFIX}-other-page`, + description: "other test page", + slug: `${TEST_PREFIX}-other-page-slug`, + customDomain: "", + }) + .returning() + .get(); + otherPageId = otherPageRow.id; + + const otherComponentRow = await db + .insert(pageComponent) + .values({ + workspaceId: team.id, + pageId: otherPageId, + name: `${TEST_PREFIX}-other-component`, + type: "static", + }) + .returning() + .get(); + otherPageComponentId = otherComponentRow.id; +}); + +afterAll(async () => { + await db + .delete(pageComponent) + .where( + inArray(pageComponent.id, [testPageComponentId, otherPageComponentId]), + ) + .catch(() => undefined); + await db + .delete(page) + .where(inArray(page.id, [testPageId, otherPageId])) + .catch(() => undefined); +}); + +beforeEach(() => { + const hooks = withAuditBuffer(); + auditBuffer = hooks.buffer; + auditReset = hooks.reset; + subscriptionSpies?.dispatchMaintenanceUpdate.mockClear(); +}); + +afterEach(async () => { + auditReset(); + if (createdMaintenanceIds.length > 0) { + await db + .delete(maintenance) + .where(inArray(maintenance.id, createdMaintenanceIds)) + .catch(() => undefined); + createdMaintenanceIds.length = 0; + } +}); + +function futureRange(startIn = 60 * 60 * 1000) { + const now = Date.now(); + return { + from: new Date(now + startIn), + to: new Date(now + startIn + 30 * 60 * 1000), + }; +} + +describe("createMaintenance", () => { + test("creates + associations + audit", async () => { + const range = futureRange(); + const record = await createMaintenance({ + ctx: teamCtx, + input: { + title: `${TEST_PREFIX}-happy`, + message: "planned work", + ...range, + pageId: testPageId, + pageComponentIds: [testPageComponentId], + }, + }); + + expect(record.title).toBe(`${TEST_PREFIX}-happy`); + expect(record.pageId).toBe(testPageId); + + const assoc = await db + .select() + .from(maintenancesToPageComponents) + .where(eq(maintenancesToPageComponents.maintenanceId, record.id)) + .all(); + expect(assoc.map((a) => a.pageComponentId)).toEqual([testPageComponentId]); + + await expectAuditRow(auditBuffer, { + action: "maintenance.create", + entityType: "maintenance", + entityId: record.id, + }); + + createdMaintenanceIds.push(record.id); + }); + + test("throws when page is in another workspace", async () => { + const range = futureRange(); + await expect( + createMaintenance({ + ctx: freeCtx, + input: { + title: `${TEST_PREFIX}-cross-ws`, + message: "m", + ...range, + pageId: testPageId, + pageComponentIds: [], + }, + }), + ).rejects.toBeInstanceOf(NotFoundError); + }); + + test("throws ZodError when from >= to", async () => { + const now = new Date(); + await expect( + createMaintenance({ + ctx: teamCtx, + input: { + title: `${TEST_PREFIX}-range`, + message: "m", + from: now, + to: now, + pageId: testPageId, + pageComponentIds: [], + }, + }), + ).rejects.toThrow(); + }); + + test("deduplicates pageComponentIds", async () => { + // Duplicate ids in the input would violate the composite PK on + // `maintenances_to_page_components` if not deduped. Guard against a + // regression where the `Set` in `validatePageComponentIds` is dropped. + const record = await createMaintenance({ + ctx: teamCtx, + input: { + title: `${TEST_PREFIX}-dedupe`, + message: "m", + ...futureRange(), + pageId: testPageId, + pageComponentIds: [testPageComponentId, testPageComponentId], + }, + }); + + const assoc = await db + .select() + .from(maintenancesToPageComponents) + .where(eq(maintenancesToPageComponents.maintenanceId, record.id)) + .all(); + expect(assoc).toHaveLength(1); + expect(assoc[0].pageComponentId).toBe(testPageComponentId); + + createdMaintenanceIds.push(record.id); + }); + + test("throws ConflictError when components span multiple pages", async () => { + await expect( + createMaintenance({ + ctx: teamCtx, + input: { + title: `${TEST_PREFIX}-mixed-pages`, + message: "m", + ...futureRange(), + pageId: testPageId, + pageComponentIds: [testPageComponentId, otherPageComponentId], + }, + }), + ).rejects.toBeInstanceOf(ConflictError); + }); +}); + +describe("updateMaintenance", () => { + test("updates title + replaces associations", async () => { + const record = await createMaintenance({ + ctx: teamCtx, + input: { + title: `${TEST_PREFIX}-update`, + message: "m", + ...futureRange(), + pageId: testPageId, + pageComponentIds: [testPageComponentId], + }, + }); + + const updated = await updateMaintenance({ + ctx: teamCtx, + input: { + id: record.id, + title: `${TEST_PREFIX}-update-renamed`, + pageComponentIds: [], + }, + }); + expect(updated.title).toBe(`${TEST_PREFIX}-update-renamed`); + + const assoc = await db + .select() + .from(maintenancesToPageComponents) + .where(eq(maintenancesToPageComponents.maintenanceId, record.id)) + .all(); + expect(assoc).toHaveLength(0); + + createdMaintenanceIds.push(record.id); + }); + + test("throws NotFoundError for cross-workspace update", async () => { + const record = await createMaintenance({ + ctx: teamCtx, + input: { + title: `${TEST_PREFIX}-cross-ws-update`, + message: "m", + ...futureRange(), + pageId: testPageId, + pageComponentIds: [], + }, + }); + + await expect( + updateMaintenance({ + ctx: freeCtx, + input: { id: record.id, title: "blocked" }, + }), + ).rejects.toBeInstanceOf(NotFoundError); + + createdMaintenanceIds.push(record.id); + }); + + test("rejects partial update that crosses the from/to invariant", async () => { + // The Zod refine on CreateMaintenanceInput only catches simultaneous + // `{from, to}` submissions. A partial update that moves only `to` + // earlier than the stored `from` has to be rejected by the service's + // own effective-range check. Regression guard for that code path. + const record = await createMaintenance({ + ctx: teamCtx, + input: { + title: `${TEST_PREFIX}-range-update`, + message: "m", + ...futureRange(60 * 60 * 1000), + pageId: testPageId, + pageComponentIds: [], + }, + }); + + await expect( + updateMaintenance({ + ctx: teamCtx, + input: { id: record.id, to: new Date(Date.now() - 60 * 60 * 1000) }, + }), + ).rejects.toBeInstanceOf(ConflictError); + + createdMaintenanceIds.push(record.id); + }); + + test("throws ConflictError when pageComponentIds span multiple pages", async () => { + const record = await createMaintenance({ + ctx: teamCtx, + input: { + title: `${TEST_PREFIX}-update-mixed-pages`, + message: "m", + ...futureRange(), + pageId: testPageId, + pageComponentIds: [testPageComponentId], + }, + }); + + await expect( + updateMaintenance({ + ctx: teamCtx, + input: { + id: record.id, + pageComponentIds: [testPageComponentId, otherPageComponentId], + }, + }), + ).rejects.toBeInstanceOf(ConflictError); + + createdMaintenanceIds.push(record.id); + }); +}); + +describe("deleteMaintenance", () => { + test("cascades associations", async () => { + const record = await createMaintenance({ + ctx: teamCtx, + input: { + title: `${TEST_PREFIX}-delete`, + message: "m", + ...futureRange(), + pageId: testPageId, + pageComponentIds: [testPageComponentId], + }, + }); + + await deleteMaintenance({ ctx: teamCtx, input: { id: record.id } }); + + const remaining = await db + .select() + .from(maintenance) + .where(eq(maintenance.id, record.id)) + .all(); + const remainingAssoc = await db + .select() + .from(maintenancesToPageComponents) + .where(eq(maintenancesToPageComponents.maintenanceId, record.id)) + .all(); + expect(remaining).toHaveLength(0); + expect(remainingAssoc).toHaveLength(0); + }); +}); + +describe("list / get", () => { + test("respects workspace isolation", async () => { + const record = await createMaintenance({ + ctx: teamCtx, + input: { + title: `${TEST_PREFIX}-isolation`, + message: "m", + ...futureRange(), + pageId: testPageId, + pageComponentIds: [], + }, + }); + + await expect( + getMaintenance({ ctx: freeCtx, input: { id: record.id } }), + ).rejects.toBeInstanceOf(NotFoundError); + + const { items } = await listMaintenances({ + ctx: freeCtx, + input: { + limit: 100, + offset: 0, + pageId: testPageId, + order: "desc", + }, + }); + expect(items.find((m) => m.id === record.id)).toBeUndefined(); + + createdMaintenanceIds.push(record.id); + }); + + test("list returns totalSize and enriched relations", async () => { + const record = await createMaintenance({ + ctx: teamCtx, + input: { + title: `${TEST_PREFIX}-list-enrich`, + message: "m", + ...futureRange(), + pageId: testPageId, + pageComponentIds: [testPageComponentId], + }, + }); + + const full = await getMaintenance({ + ctx: teamCtx, + input: { id: record.id }, + }); + expect(full.pageComponents.map((c) => c.id)).toEqual([testPageComponentId]); + expect(full.pageComponentIds).toEqual([testPageComponentId]); + + createdMaintenanceIds.push(record.id); + }); +}); + +describe("notifyMaintenance", () => { + test("throws when maintenance belongs to another workspace", async () => { + const record = await createMaintenance({ + ctx: teamCtx, + input: { + title: `${TEST_PREFIX}-notify-cross-ws`, + message: "m", + ...futureRange(), + pageId: testPageId, + pageComponentIds: [], + }, + }); + + await expect( + notifyMaintenance({ + ctx: freeCtx, + input: { maintenanceId: record.id }, + }), + ).rejects.toBeInstanceOf(ForbiddenError); + + createdMaintenanceIds.push(record.id); + }); +}); + +describe("slack actor path", () => { + test("createMaintenance succeeds with a slack actor", async () => { + const ctx = makeSlackCtx(teamCtx.workspace, { + teamId: "T123", + slackUserId: "U123", + }); + const record = await createMaintenance({ + ctx, + input: { + title: `${TEST_PREFIX}-slack`, + message: "scheduled via slack", + ...futureRange(), + pageId: testPageId, + pageComponentIds: [], + }, + }); + await expectAuditRow(auditBuffer, { + action: "maintenance.create", + entityType: "maintenance", + entityId: record.id, + actorType: "slack", + }); + createdMaintenanceIds.push(record.id); + }); +}); diff --git a/packages/services/src/maintenance/create.ts b/packages/services/src/maintenance/create.ts new file mode 100644 index 000000000..b3acb957f --- /dev/null +++ b/packages/services/src/maintenance/create.ts @@ -0,0 +1,68 @@ +import { maintenance } from "@openstatus/db/src/schema"; + +import { emitAudit } from "../audit"; +import { type ServiceContext, withTransaction } from "../context"; +import { ConflictError } from "../errors"; +import type { Maintenance } from "../types"; +import { + assertPageInWorkspace, + updatePageComponentAssociations, + validatePageComponentIds, +} from "./internal"; +import { CreateMaintenanceInput } from "./schemas"; + +export async function createMaintenance(args: { + ctx: ServiceContext; + input: CreateMaintenanceInput; +}): Promise { + const { ctx } = args; + const input = CreateMaintenanceInput.parse(args.input); + + return withTransaction(ctx, async (tx) => { + await assertPageInWorkspace({ + tx, + pageId: input.pageId, + workspaceId: ctx.workspace.id, + }); + + const validated = await validatePageComponentIds({ + tx, + workspaceId: ctx.workspace.id, + pageComponentIds: input.pageComponentIds, + }); + + if (validated.pageId !== null && validated.pageId !== input.pageId) { + throw new ConflictError( + `pageId ${input.pageId} does not match the page (${validated.pageId}) of the selected components.`, + ); + } + + const record = await tx + .insert(maintenance) + .values({ + workspaceId: ctx.workspace.id, + pageId: input.pageId, + title: input.title, + message: input.message, + from: input.from, + to: input.to, + }) + .returning() + .get(); + + await updatePageComponentAssociations({ + tx, + maintenanceId: record.id, + componentIds: validated.componentIds, + }); + + await emitAudit(tx, ctx, { + action: "maintenance.create", + entityType: "maintenance", + entityId: record.id, + after: record, + }); + + return record; + }); +} diff --git a/packages/services/src/maintenance/delete.ts b/packages/services/src/maintenance/delete.ts new file mode 100644 index 000000000..7d7b50dc8 --- /dev/null +++ b/packages/services/src/maintenance/delete.ts @@ -0,0 +1,33 @@ +import { eq } from "@openstatus/db"; +import { maintenance } from "@openstatus/db/src/schema"; + +import { emitAudit } from "../audit"; +import { type ServiceContext, withTransaction } from "../context"; +import { getMaintenanceInWorkspace } from "./internal"; +import { DeleteMaintenanceInput } from "./schemas"; + +/** Delete a maintenance row. Cascade removes `maintenances_to_page_components`. */ +export async function deleteMaintenance(args: { + ctx: ServiceContext; + input: DeleteMaintenanceInput; +}): Promise { + const { ctx } = args; + const input = DeleteMaintenanceInput.parse(args.input); + + await withTransaction(ctx, async (tx) => { + const existing = await getMaintenanceInWorkspace({ + tx, + id: input.id, + workspaceId: ctx.workspace.id, + }); + + await tx.delete(maintenance).where(eq(maintenance.id, existing.id)); + + await emitAudit(tx, ctx, { + action: "maintenance.delete", + entityType: "maintenance", + entityId: existing.id, + before: existing, + }); + }); +} diff --git a/packages/services/src/maintenance/index.ts b/packages/services/src/maintenance/index.ts new file mode 100644 index 000000000..143d1d23c --- /dev/null +++ b/packages/services/src/maintenance/index.ts @@ -0,0 +1,22 @@ +export { createMaintenance } from "./create"; +export { deleteMaintenance } from "./delete"; +export { + getMaintenance, + listMaintenances, + type ListMaintenancesResult, + type MaintenanceWithRelations, +} from "./list"; +export { notifyMaintenance } from "./notify"; +export { updateMaintenance } from "./update"; + +export { + CreateMaintenanceInput, + DeleteMaintenanceInput, + GetMaintenanceInput, + ListMaintenancesInput, + type MaintenanceListPeriod, + maintenanceListPeriodSchema, + maintenanceListPeriods, + NotifyMaintenanceInput, + UpdateMaintenanceInput, +} from "./schemas"; diff --git a/packages/services/src/maintenance/internal.ts b/packages/services/src/maintenance/internal.ts new file mode 100644 index 000000000..f603a63ff --- /dev/null +++ b/packages/services/src/maintenance/internal.ts @@ -0,0 +1,117 @@ +import { and, eq, inArray } from "@openstatus/db"; +import { + maintenance, + maintenancesToPageComponents, + page, + pageComponent, +} from "@openstatus/db/src/schema"; + +import type { DB } from "../context"; +import { ConflictError, NotFoundError } from "../errors"; + +export type ValidatedPageComponents = { + componentIds: number[]; + /** The page all components belong to, or `null` for an empty input. */ + pageId: number | null; +}; + +/** + * Validate that every id in `pageComponentIds` exists, belongs to the + * workspace, and — if more than one — that they all belong to the same page. + * Duplicated deliberately from status-report's version; both talk to the same + * `pageComponent` table but bind to different association tables downstream. + */ +export async function validatePageComponentIds(args: { + tx: DB; + workspaceId: number; + pageComponentIds: ReadonlyArray; +}): Promise { + const { tx, workspaceId, pageComponentIds } = args; + if (pageComponentIds.length === 0) { + return { componentIds: [], pageId: null }; + } + + // Dedupe up-front — duplicate ids in the input would violate the composite + // PK on `maintenances_to_page_components` during insert. + const ids = Array.from(new Set(pageComponentIds)); + const valid = await tx + .select({ id: pageComponent.id, pageId: pageComponent.pageId }) + .from(pageComponent) + .where( + and( + inArray(pageComponent.id, ids), + eq(pageComponent.workspaceId, workspaceId), + ), + ) + .all(); + + const validById = new Map(valid.map((c) => [c.id, c.pageId])); + for (const id of ids) { + if (!validById.has(id)) { + throw new NotFoundError("page_component", id); + } + } + + const pageIds = new Set(valid.map((c) => c.pageId)); + if (pageIds.size > 1) { + throw new ConflictError( + "All page components must belong to the same page.", + ); + } + + return { componentIds: ids, pageId: valid[0]?.pageId ?? null }; +} + +export async function assertPageInWorkspace(args: { + tx: DB; + pageId: number; + workspaceId: number; +}): Promise { + const { tx, pageId, workspaceId } = args; + const row = await tx + .select({ id: page.id }) + .from(page) + .where(and(eq(page.id, pageId), eq(page.workspaceId, workspaceId))) + .get(); + if (!row) throw new NotFoundError("page", pageId); +} + +/** Replace the set of page-component associations for a maintenance. */ +export async function updatePageComponentAssociations(args: { + tx: DB; + maintenanceId: number; + componentIds: ReadonlyArray; +}): Promise { + const { tx, maintenanceId, componentIds } = args; + + await tx + .delete(maintenancesToPageComponents) + .where(eq(maintenancesToPageComponents.maintenanceId, maintenanceId)); + + if (componentIds.length > 0) { + await tx.insert(maintenancesToPageComponents).values( + componentIds.map((pageComponentId) => ({ + maintenanceId, + pageComponentId, + })), + ); + } +} + +/** Load a maintenance row by id, scoped to the workspace. Throws on miss. */ +export async function getMaintenanceInWorkspace(args: { + tx: DB; + id: number; + workspaceId: number; +}) { + const { tx, id, workspaceId } = args; + const row = await tx + .select() + .from(maintenance) + .where( + and(eq(maintenance.id, id), eq(maintenance.workspaceId, workspaceId)), + ) + .get(); + if (!row) throw new NotFoundError("maintenance", id); + return row; +} diff --git a/packages/services/src/maintenance/list.ts b/packages/services/src/maintenance/list.ts new file mode 100644 index 000000000..aab0231c0 --- /dev/null +++ b/packages/services/src/maintenance/list.ts @@ -0,0 +1,153 @@ +import { + type SQL, + and, + asc, + db as defaultDb, + desc, + eq, + gte, + inArray, + sql, +} from "@openstatus/db"; +import { + maintenance, + maintenancesToPageComponents, + pageComponent, +} from "@openstatus/db/src/schema"; + +import type { DB, ServiceContext } from "../context"; +import type { Maintenance, PageComponent } from "../types"; +import { getMaintenanceInWorkspace } from "./internal"; +import { + GetMaintenanceInput, + ListMaintenancesInput, + type MaintenanceListPeriod, +} from "./schemas"; + +function periodToSince(period: MaintenanceListPeriod): Date { + const day = 24 * 60 * 60 * 1000; + const now = Date.now(); + switch (period) { + case "1d": + return new Date(now - 1 * day); + case "7d": + return new Date(now - 7 * day); + case "14d": + return new Date(now - 14 * day); + } +} + +export type MaintenanceWithRelations = Maintenance & { + pageComponents: PageComponent[]; + pageComponentIds: number[]; +}; + +export type ListMaintenancesResult = { + items: MaintenanceWithRelations[]; + totalSize: number; +}; + +/** + * Load component associations for a set of maintenances in a single IN + * query, regardless of list size. Avoids the 2-queries-per-row pattern + * that pairs badly with the dashboard's effectively-unlimited list. + */ +async function enrichMaintenancesBatch( + db: DB, + rows: Maintenance[], +): Promise { + if (rows.length === 0) return []; + const ids = rows.map((r) => r.id); + + const assocRows = await db + .select() + .from(pageComponent) + .innerJoin( + maintenancesToPageComponents, + eq(maintenancesToPageComponents.pageComponentId, pageComponent.id), + ) + .where(inArray(maintenancesToPageComponents.maintenanceId, ids)) + .all(); + + const componentsByMaintenance = new Map(); + for (const row of assocRows) { + const mId = (row.maintenance_to_page_component as { maintenanceId: number }) + .maintenanceId; + const component = row.page_component as unknown as PageComponent; + const arr = componentsByMaintenance.get(mId); + if (arr) arr.push(component); + else componentsByMaintenance.set(mId, [component]); + } + + return rows.map((r) => { + const components = componentsByMaintenance.get(r.id) ?? []; + return { + ...r, + pageComponents: components, + pageComponentIds: components.map((c) => c.id), + }; + }); +} + +export async function listMaintenances(args: { + ctx: ServiceContext; + input: ListMaintenancesInput; +}): Promise { + const { ctx } = args; + const input = ListMaintenancesInput.parse(args.input); + const db = ctx.db ?? defaultDb; + + const conditions: SQL[] = [eq(maintenance.workspaceId, ctx.workspace.id)]; + if (input.pageId !== undefined) { + conditions.push(eq(maintenance.pageId, input.pageId)); + } + if (input.period !== undefined) { + conditions.push(gte(maintenance.createdAt, periodToSince(input.period))); + } + const whereClause = and(...conditions); + + // Count and page queries run in parallel outside a transaction — a + // concurrent insert between them can leave `totalSize` one off from the + // returned page. Best-effort is fine for list pagination (Connect clients + // re-fetch; tRPC callers use a 10k sentinel and ignore totalSize). + const [countRow, rows] = await Promise.all([ + db + .select({ count: sql`count(*)` }) + .from(maintenance) + .where(whereClause) + .get(), + db + .select() + .from(maintenance) + .where(whereClause) + .orderBy( + input.order === "asc" + ? asc(maintenance.createdAt) + : desc(maintenance.createdAt), + ) + .limit(input.limit) + .offset(input.offset) + .all(), + ]); + + const totalSize = countRow?.count ?? 0; + const items = await enrichMaintenancesBatch(db, rows); + return { items, totalSize }; +} + +export async function getMaintenance(args: { + ctx: ServiceContext; + input: GetMaintenanceInput; +}): Promise { + const { ctx } = args; + const input = GetMaintenanceInput.parse(args.input); + const db = ctx.db ?? defaultDb; + const record = await getMaintenanceInWorkspace({ + tx: db, + id: input.id, + workspaceId: ctx.workspace.id, + }); + const [enriched] = await enrichMaintenancesBatch(db, [record]); + // biome-ignore lint/style/noNonNullAssertion: always defined for len === 1 + return enriched!; +} diff --git a/packages/services/src/maintenance/notify.ts b/packages/services/src/maintenance/notify.ts new file mode 100644 index 000000000..f088cde04 --- /dev/null +++ b/packages/services/src/maintenance/notify.ts @@ -0,0 +1,51 @@ +import { db as defaultDb, eq } from "@openstatus/db"; +import { maintenance } from "@openstatus/db/src/schema"; +import { dispatchMaintenanceUpdate } from "@openstatus/subscriptions"; + +import { emitAudit } from "../audit"; +import type { ServiceContext } from "../context"; +import { ForbiddenError, NotFoundError } from "../errors"; +import { NotifyMaintenanceInput } from "./schemas"; + +/** + * Dispatch subscriber notifications for a maintenance. Separate from the + * create/update mutations because the dashboard runs on Edge and cannot + * fire-and-forget — callers invoke this as a second awaited call. + * + * Enforces: + * - Workspace owns the target maintenance. + * - Plan has `status-subscribers` enabled — otherwise no-op. + */ +export async function notifyMaintenance(args: { + ctx: ServiceContext; + input: NotifyMaintenanceInput; +}): Promise { + const { ctx } = args; + const input = NotifyMaintenanceInput.parse(args.input); + const db = ctx.db ?? defaultDb; + + const row = await db + .select({ id: maintenance.id, workspaceId: maintenance.workspaceId }) + .from(maintenance) + .where(eq(maintenance.id, input.maintenanceId)) + .get(); + + if (!row) { + throw new NotFoundError("maintenance", input.maintenanceId); + } + if (row.workspaceId !== ctx.workspace.id) { + throw new ForbiddenError("Maintenance does not belong to this workspace."); + } + + if (!ctx.workspace.limits["status-subscribers"]) { + return; + } + + await dispatchMaintenanceUpdate(input.maintenanceId); + + await emitAudit(db, ctx, { + action: "maintenance.notify", + entityType: "maintenance", + entityId: input.maintenanceId, + }); +} diff --git a/packages/services/src/maintenance/schemas.ts b/packages/services/src/maintenance/schemas.ts new file mode 100644 index 000000000..31172b46f --- /dev/null +++ b/packages/services/src/maintenance/schemas.ts @@ -0,0 +1,59 @@ +import { z } from "zod"; + +/** + * `ListMaintenancesInput.period` mirrors the periods supported by the + * existing tRPC router. Duplicated from status-report/schemas — a single + * shared `periods` constant is a worthwhile follow-up once a third domain + * needs it. + */ +export const maintenanceListPeriods = ["1d", "7d", "14d"] as const; +export type MaintenanceListPeriod = (typeof maintenanceListPeriods)[number]; +export const maintenanceListPeriodSchema = z.enum(maintenanceListPeriods); + +export const CreateMaintenanceInput = z + .object({ + title: z.string().min(1).max(256), + message: z.string().min(1), + from: z.coerce.date(), + to: z.coerce.date(), + pageId: z.number().int(), + pageComponentIds: z.array(z.number().int()).default([]), + }) + .refine((v) => v.from < v.to, { + path: ["to"], + error: "End date must be after start date.", + }); +export type CreateMaintenanceInput = z.infer; + +export const UpdateMaintenanceInput = z.object({ + id: z.number().int(), + title: z.string().min(1).max(256).optional(), + message: z.string().min(1).optional(), + from: z.coerce.date().optional(), + to: z.coerce.date().optional(), + /** When provided, replaces the full association set (empty array clears). */ + pageComponentIds: z.array(z.number().int()).optional(), +}); +export type UpdateMaintenanceInput = z.infer; + +export const DeleteMaintenanceInput = z.object({ id: z.number().int() }); +export type DeleteMaintenanceInput = z.infer; + +export const GetMaintenanceInput = z.object({ id: z.number().int() }); +export type GetMaintenanceInput = z.infer; + +// `limit` is intentionally unbounded at the schema level — matches the +// status-report convention. Connect caps externally; tRPC passes a sentinel. +export const ListMaintenancesInput = z.object({ + limit: z.number().int().min(1).default(50), + offset: z.number().int().min(0).default(0), + pageId: z.number().int().optional(), + period: maintenanceListPeriodSchema.optional(), + order: z.enum(["asc", "desc"]).default("desc"), +}); +export type ListMaintenancesInput = z.infer; + +export const NotifyMaintenanceInput = z.object({ + maintenanceId: z.number().int(), +}); +export type NotifyMaintenanceInput = z.infer; diff --git a/packages/services/src/maintenance/update.ts b/packages/services/src/maintenance/update.ts new file mode 100644 index 000000000..f43db060c --- /dev/null +++ b/packages/services/src/maintenance/update.ts @@ -0,0 +1,88 @@ +import { eq } from "@openstatus/db"; +import { maintenance } from "@openstatus/db/src/schema"; + +import { emitAudit } from "../audit"; +import { type ServiceContext, withTransaction } from "../context"; +import { ConflictError, InternalServiceError } from "../errors"; +import type { Maintenance } from "../types"; +import { + getMaintenanceInWorkspace, + updatePageComponentAssociations, + validatePageComponentIds, +} from "./internal"; +import { UpdateMaintenanceInput } from "./schemas"; + +export async function updateMaintenance(args: { + ctx: ServiceContext; + input: UpdateMaintenanceInput; +}): Promise { + const { ctx } = args; + const input = UpdateMaintenanceInput.parse(args.input); + + return withTransaction(ctx, async (tx) => { + const existing = await getMaintenanceInWorkspace({ + tx, + id: input.id, + workspaceId: ctx.workspace.id, + }); + + // Effective from/to for the range check — either the incoming value or + // whatever was persisted before. + const effectiveFrom = input.from ?? existing.from; + const effectiveTo = input.to ?? existing.to; + if (effectiveFrom >= effectiveTo) { + throw new ConflictError("End date must be after start date."); + } + + const updateValues: Record = { updatedAt: new Date() }; + if (input.title !== undefined) updateValues.title = input.title; + if (input.message !== undefined) updateValues.message = input.message; + if (input.from !== undefined) updateValues.from = input.from; + if (input.to !== undefined) updateValues.to = input.to; + + if (input.pageComponentIds !== undefined) { + const validated = await validatePageComponentIds({ + tx, + workspaceId: ctx.workspace.id, + pageComponentIds: input.pageComponentIds, + }); + + // `pageId` follows the association set: a new non-empty set moves + // the maintenance to that page; an empty set nulls it. Matches the + // pattern established on status-report update and what the Connect + // `UpdateMaintenance` tests have encoded since the original handler. + // Mixed-page inputs are rejected upstream by + // `validatePageComponentIds` (all ids must share a page). + updateValues.pageId = validated.pageId; + + await updatePageComponentAssociations({ + tx, + maintenanceId: existing.id, + componentIds: validated.componentIds, + }); + } + + const updated = await tx + .update(maintenance) + .set(updateValues) + .where(eq(maintenance.id, existing.id)) + .returning() + .get(); + + if (!updated) { + throw new InternalServiceError( + `failed to update maintenance ${existing.id}`, + ); + } + + await emitAudit(tx, ctx, { + action: "maintenance.update", + entityType: "maintenance", + entityId: updated.id, + before: existing, + after: updated, + }); + + return updated; + }); +} diff --git a/packages/services/src/monitor/__tests__/monitor.test.ts b/packages/services/src/monitor/__tests__/monitor.test.ts new file mode 100644 index 000000000..9a1c797a8 --- /dev/null +++ b/packages/services/src/monitor/__tests__/monitor.test.ts @@ -0,0 +1,557 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + test, +} from "bun:test"; +import { db, eq, inArray } from "@openstatus/db"; +import { + monitor, + monitorTag, + monitorTagsToMonitors, + notification, + notificationsToMonitors, +} from "@openstatus/db/src/schema"; + +import { + SEEDED_WORKSPACE_FREE_ID, + SEEDED_WORKSPACE_TEAM_ID, +} from "../../../test/fixtures"; +import { + expectAuditRow, + loadSeededWorkspace, + makeUserCtx, + withAuditBuffer, +} from "../../../test/helpers"; +import type { AuditLogRecord } from "../../audit"; +import type { ServiceContext } from "../../context"; +import { ForbiddenError, NotFoundError } from "../../errors"; +import { cloneMonitor } from "../clone"; +import { createMonitor } from "../create"; +import { deleteMonitor, deleteMonitors } from "../delete"; +import { getMonitor, listMonitors } from "../list"; +import { updateMonitorNotifiers, updateMonitorTags } from "../relations"; +import { updateMonitorGeneral } from "../update"; + +const TEST_PREFIX = "svc-monitor-test"; + +let teamCtx: ServiceContext; +let freeCtx: ServiceContext; +let testTagId: number; +let testNotificationId: number; +let auditBuffer: AuditLogRecord[]; +let auditReset: () => void; +const createdMonitorIds: number[] = []; + +beforeAll(async () => { + const team = await loadSeededWorkspace(SEEDED_WORKSPACE_TEAM_ID); + const free = await loadSeededWorkspace(SEEDED_WORKSPACE_FREE_ID); + teamCtx = makeUserCtx(team, { userId: 1 }); + freeCtx = makeUserCtx(free, { userId: 2 }); + + const tagRow = await db + .insert(monitorTag) + .values({ + workspaceId: team.id, + name: `${TEST_PREFIX}-tag`, + color: "#000000", + }) + .returning() + .get(); + testTagId = tagRow.id; + + const notifRow = await db + .insert(notification) + .values({ + workspaceId: team.id, + name: `${TEST_PREFIX}-notif`, + provider: "email", + data: JSON.stringify({ email: "test@example.com" }), + }) + .returning() + .get(); + testNotificationId = notifRow.id; +}); + +afterAll(async () => { + if (createdMonitorIds.length > 0) { + await db + .delete(monitor) + .where(inArray(monitor.id, createdMonitorIds)) + .catch(() => undefined); + } + await db + .delete(monitorTag) + .where(eq(monitorTag.id, testTagId)) + .catch(() => undefined); + await db + .delete(notification) + .where(eq(notification.id, testNotificationId)) + .catch(() => undefined); +}); + +beforeEach(() => { + const hooks = withAuditBuffer(); + auditBuffer = hooks.buffer; + auditReset = hooks.reset; +}); + +afterEach(() => { + auditReset(); +}); + +function track(id: number) { + createdMonitorIds.push(id); + return id; +} + +describe("createMonitor", () => { + test("http happy path — stores defaults + emits audit", async () => { + const row = await createMonitor({ + ctx: teamCtx, + input: { + name: `${TEST_PREFIX}-http`, + jobType: "http", + url: "https://example.com", + method: "GET", + headers: [], + assertions: [], + active: false, + }, + }); + track(row.id); + + expect(row.jobType).toBe("http"); + expect(row.url).toBe("https://example.com"); + expect(row.regions.length).toBeGreaterThan(0); + + await expectAuditRow(auditBuffer, { + action: "monitor.create", + entityType: "monitor", + entityId: row.id, + }); + }); + + test("tcp happy path", async () => { + const row = await createMonitor({ + ctx: teamCtx, + input: { + name: `${TEST_PREFIX}-tcp`, + jobType: "tcp", + url: "example.com:443", + method: "GET", + headers: [], + assertions: [], + active: false, + }, + }); + track(row.id); + expect(row.jobType).toBe("tcp"); + }); + + test("dns happy path", async () => { + const row = await createMonitor({ + ctx: teamCtx, + input: { + name: `${TEST_PREFIX}-dns`, + jobType: "dns", + url: "example.com", + method: "GET", + headers: [], + assertions: [ + { + version: "v1", + type: "dnsRecord", + key: "A", + compare: "eq", + target: "1.1.1.1", + }, + ], + active: false, + }, + }); + track(row.id); + expect(row.jobType).toBe("dns"); + }); +}); + +describe("deleteMonitor", () => { + test("soft-deletes + removes tag / notifier associations", async () => { + const row = await createMonitor({ + ctx: teamCtx, + input: { + name: `${TEST_PREFIX}-delete`, + jobType: "http", + url: "https://example.com", + method: "GET", + headers: [], + assertions: [], + active: false, + }, + }); + track(row.id); + // Attach relations. + await db + .insert(monitorTagsToMonitors) + .values({ monitorId: row.id, monitorTagId: testTagId }); + await db.insert(notificationsToMonitors).values({ + monitorId: row.id, + notificationId: testNotificationId, + }); + + await deleteMonitor({ ctx: teamCtx, input: { id: row.id } }); + + const after = await db + .select() + .from(monitor) + .where(eq(monitor.id, row.id)) + .get(); + expect(after?.deletedAt).toBeInstanceOf(Date); + expect(after?.active).toBe(false); + + const tagAssoc = await db + .select() + .from(monitorTagsToMonitors) + .where(eq(monitorTagsToMonitors.monitorId, row.id)) + .all(); + const notifAssoc = await db + .select() + .from(notificationsToMonitors) + .where(eq(notificationsToMonitors.monitorId, row.id)) + .all(); + expect(tagAssoc).toHaveLength(0); + expect(notifAssoc).toHaveLength(0); + }); + + test("throws NotFoundError for cross-workspace delete", async () => { + const row = await createMonitor({ + ctx: teamCtx, + input: { + name: `${TEST_PREFIX}-cross-ws-delete`, + jobType: "http", + url: "https://example.com", + method: "GET", + headers: [], + assertions: [], + active: false, + }, + }); + track(row.id); + await expect( + deleteMonitor({ ctx: freeCtx, input: { id: row.id } }), + ).rejects.toBeInstanceOf(NotFoundError); + }); + + test("second delete returns NotFoundError (idempotency guard)", async () => { + // Regression for the Cubic P2 fix: `getMonitorInWorkspace` filters + // by `isNull(deletedAt)`, so a second `deleteMonitor` on the same + // id should throw `NotFoundError` rather than silently re-running + // the soft-delete / cascade / audit sequence on a tombstoned row. + const row = await createMonitor({ + ctx: teamCtx, + input: { + name: `${TEST_PREFIX}-double-delete`, + jobType: "http", + url: "https://example.com", + method: "GET", + headers: [], + assertions: [], + active: false, + }, + }); + track(row.id); + await deleteMonitor({ ctx: teamCtx, input: { id: row.id } }); + await expect( + deleteMonitor({ ctx: teamCtx, input: { id: row.id } }), + ).rejects.toBeInstanceOf(NotFoundError); + }); +}); + +describe("deleteMonitors (bulk)", () => { + test("soft-deletes all ids and strips associations", async () => { + const a = await createMonitor({ + ctx: teamCtx, + input: { + name: `${TEST_PREFIX}-bulk-a`, + jobType: "http", + url: "https://a.example.com", + method: "GET", + headers: [], + assertions: [], + active: false, + }, + }); + const b = await createMonitor({ + ctx: teamCtx, + input: { + name: `${TEST_PREFIX}-bulk-b`, + jobType: "http", + url: "https://b.example.com", + method: "GET", + headers: [], + assertions: [], + active: false, + }, + }); + track(a.id); + track(b.id); + + await deleteMonitors({ + ctx: teamCtx, + input: { ids: [a.id, b.id] }, + }); + + const rows = await db + .select({ id: monitor.id, deletedAt: monitor.deletedAt }) + .from(monitor) + .where(inArray(monitor.id, [a.id, b.id])) + .all(); + expect(rows).toHaveLength(2); + for (const r of rows) expect(r.deletedAt).toBeInstanceOf(Date); + }); +}); + +describe("cloneMonitor", () => { + test("duplicates the row with a `(Copy)` name", async () => { + const source = await createMonitor({ + ctx: teamCtx, + input: { + name: `${TEST_PREFIX}-source`, + jobType: "http", + url: "https://example.com", + method: "GET", + headers: [], + assertions: [], + active: false, + }, + }); + track(source.id); + + const clone = await cloneMonitor({ + ctx: teamCtx, + input: { id: source.id }, + }); + track(clone.id); + + expect(clone.id).not.toBe(source.id); + expect(clone.name).toBe(`${source.name} (Copy)`); + expect(clone.url).toBe(source.url); + }); + + test("resets status to active even when source is degraded/error", async () => { + // Regression for the Cubic P2 fix: `clone.ts` destructures + // `status` out of the source row before spreading, so a clone + // always starts in `"active"` state rather than inheriting the + // source's stale health. Flipping the source's status via a + // direct db update — `createMonitor` doesn't accept `status` + // input, and `"active"` is the insert default. + const source = await createMonitor({ + ctx: teamCtx, + input: { + name: `${TEST_PREFIX}-clone-status-reset`, + jobType: "http", + url: "https://example.com", + method: "GET", + headers: [], + assertions: [], + active: false, + }, + }); + track(source.id); + await db + .update(monitor) + .set({ status: "error" }) + .where(eq(monitor.id, source.id)); + + const clone = await cloneMonitor({ + ctx: teamCtx, + input: { id: source.id }, + }); + track(clone.id); + + expect(clone.status).toBe("active"); + }); +}); + +describe("updateMonitorTags / updateMonitorNotifiers", () => { + test("replaces the full tag set with deduped ids", async () => { + const row = await createMonitor({ + ctx: teamCtx, + input: { + name: `${TEST_PREFIX}-tags`, + jobType: "http", + url: "https://example.com", + method: "GET", + headers: [], + assertions: [], + active: false, + }, + }); + track(row.id); + + await updateMonitorTags({ + ctx: teamCtx, + input: { id: row.id, tags: [testTagId, testTagId] }, + }); + const assoc = await db + .select() + .from(monitorTagsToMonitors) + .where(eq(monitorTagsToMonitors.monitorId, row.id)) + .all(); + expect(assoc).toHaveLength(1); + }); + + test("throws ForbiddenError when tag is in another workspace", async () => { + const row = await createMonitor({ + ctx: teamCtx, + input: { + name: `${TEST_PREFIX}-tag-forbidden`, + jobType: "http", + url: "https://example.com", + method: "GET", + headers: [], + assertions: [], + active: false, + }, + }); + track(row.id); + await expect( + updateMonitorTags({ + ctx: freeCtx, + input: { id: row.id, tags: [testTagId] }, + }), + ).rejects.toBeInstanceOf(NotFoundError); // monitor is in another ws + }); + + test("replaces the full notifier set", async () => { + const row = await createMonitor({ + ctx: teamCtx, + input: { + name: `${TEST_PREFIX}-notif`, + jobType: "http", + url: "https://example.com", + method: "GET", + headers: [], + assertions: [], + active: false, + }, + }); + track(row.id); + await updateMonitorNotifiers({ + ctx: teamCtx, + input: { id: row.id, notifiers: [testNotificationId] }, + }); + const assoc = await db + .select() + .from(notificationsToMonitors) + .where(eq(notificationsToMonitors.monitorId, row.id)) + .all(); + expect(assoc).toHaveLength(1); + }); + + test("throws ForbiddenError when notifier is in another workspace", async () => { + // free ctx owns its own monitor; pass team's notificationId (which is in + // team's workspace) into the update under free ctx → the monitor lookup + // would fail first (NotFoundError), so we build an in-free monitor and + // try to attach team's notifier. + const freeMonitor = await createMonitor({ + ctx: freeCtx, + input: { + name: `${TEST_PREFIX}-free-notif`, + jobType: "http", + url: "https://example.com", + method: "GET", + headers: [], + assertions: [], + active: false, + }, + }); + track(freeMonitor.id); + await expect( + updateMonitorNotifiers({ + ctx: freeCtx, + input: { id: freeMonitor.id, notifiers: [testNotificationId] }, + }), + ).rejects.toBeInstanceOf(ForbiddenError); + }); +}); + +describe("list / get", () => { + test("respects workspace isolation + soft-delete", async () => { + const row = await createMonitor({ + ctx: teamCtx, + input: { + name: `${TEST_PREFIX}-list`, + jobType: "http", + url: "https://example.com", + method: "GET", + headers: [], + assertions: [], + active: true, + }, + }); + track(row.id); + + await expect( + getMonitor({ ctx: freeCtx, input: { id: row.id } }), + ).rejects.toBeInstanceOf(NotFoundError); + + const { items } = await listMonitors({ + ctx: freeCtx, + input: { limit: 100, offset: 0, order: "desc" }, + }); + expect(items.find((m) => m.id === row.id)).toBeUndefined(); + + // Soft-delete, then verify it disappears from the team list too. + await deleteMonitor({ ctx: teamCtx, input: { id: row.id } }); + const { items: teamItems } = await listMonitors({ + ctx: teamCtx, + input: { limit: 1000, offset: 0, order: "desc" }, + }); + expect(teamItems.find((m) => m.id === row.id)).toBeUndefined(); + }); +}); + +describe("updateMonitorGeneral", () => { + test("updates name / url / assertions and audits", async () => { + const row = await createMonitor({ + ctx: teamCtx, + input: { + name: `${TEST_PREFIX}-gen`, + jobType: "http", + url: "https://example.com", + method: "GET", + headers: [], + assertions: [], + active: false, + }, + }); + track(row.id); + + const updated = await updateMonitorGeneral({ + ctx: teamCtx, + input: { + id: row.id, + name: `${TEST_PREFIX}-gen-renamed`, + jobType: "http", + url: "https://example.org", + method: "POST", + headers: [{ key: "X-Test", value: "yes" }], + assertions: [], + active: true, + }, + }); + + expect(updated.name).toBe(`${TEST_PREFIX}-gen-renamed`); + expect(updated.url).toBe("https://example.org"); + expect(updated.method).toBe("POST"); + await expectAuditRow(auditBuffer, { + action: "monitor.update_general", + entityType: "monitor", + entityId: row.id, + }); + }); +}); diff --git a/packages/services/src/monitor/clone.ts b/packages/services/src/monitor/clone.ts new file mode 100644 index 000000000..7b59ab445 --- /dev/null +++ b/packages/services/src/monitor/clone.ts @@ -0,0 +1,60 @@ +import { monitor, selectMonitorSchema } from "@openstatus/db/src/schema"; + +import { emitAudit } from "../audit"; +import { type ServiceContext, withTransaction } from "../context"; +import { InternalServiceError, LimitExceededError } from "../errors"; +import type { Monitor } from "../types"; +import { countMonitorsInWorkspace, getMonitorInWorkspace } from "./internal"; +import { CloneMonitorInput } from "./schemas"; + +/** + * Duplicate a monitor. Fails with `LimitExceededError` when cloning would + * push the workspace past its monitor quota. + */ +export async function cloneMonitor(args: { + ctx: ServiceContext; + input: CloneMonitorInput; +}): Promise { + const { ctx } = args; + const input = CloneMonitorInput.parse(args.input); + + return withTransaction(ctx, async (tx) => { + const current = await countMonitorsInWorkspace(tx, ctx.workspace.id); + if (current >= ctx.workspace.limits.monitors) { + throw new LimitExceededError("monitors", ctx.workspace.limits.monitors); + } + + const source = await getMonitorInWorkspace({ + tx, + id: input.id, + workspaceId: ctx.workspace.id, + }); + + const { id: _id, status: _status, ...rest } = source; + const row = await tx + .insert(monitor) + .values({ + ...rest, + // Don't inherit the source's current health — a freshly cloned + // monitor hasn't been checked yet; let the next check settle it. + status: "active", + name: `${source.name} (Copy)`, + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning() + .get(); + if (!row) { + throw new InternalServiceError(`failed to clone monitor ${source.id}`); + } + + await emitAudit(tx, ctx, { + action: "monitor.clone", + entityType: "monitor", + entityId: row.id, + metadata: { sourceMonitorId: source.id }, + }); + + return selectMonitorSchema.parse(row); + }); +} diff --git a/packages/services/src/monitor/create.ts b/packages/services/src/monitor/create.ts new file mode 100644 index 000000000..c8e46dfde --- /dev/null +++ b/packages/services/src/monitor/create.ts @@ -0,0 +1,61 @@ +import { monitor, selectMonitorSchema } from "@openstatus/db/src/schema"; + +import { emitAudit } from "../audit"; +import { type ServiceContext, withTransaction } from "../context"; +import { LimitExceededError } from "../errors"; +import type { Monitor } from "../types"; +import { + countMonitorsInWorkspace, + headersToDbJson, + pickDefaultRegions, + serialiseAssertions, +} from "./internal"; +import { CreateMonitorInput } from "./schemas"; + +export async function createMonitor(args: { + ctx: ServiceContext; + input: CreateMonitorInput; +}): Promise { + const { ctx } = args; + const input = CreateMonitorInput.parse(args.input); + + return withTransaction(ctx, async (tx) => { + const existing = await countMonitorsInWorkspace(tx, ctx.workspace.id); + if (existing >= ctx.workspace.limits.monitors) { + throw new LimitExceededError("monitors", ctx.workspace.limits.monitors); + } + + const defaults = pickDefaultRegions(ctx.workspace); + const regions = input.regions ?? defaults.regions; + const periodicity = input.periodicity ?? defaults.periodicity; + + const row = await tx + .insert(monitor) + .values({ + name: input.name, + jobType: input.jobType, + url: input.url, + method: input.method, + headers: headersToDbJson(input.headers), + body: input.body, + active: input.active, + workspaceId: ctx.workspace.id, + periodicity, + regions: regions.join(","), + assertions: serialiseAssertions(input.assertions), + updatedAt: new Date(), + }) + .returning() + .get(); + + await emitAudit(tx, ctx, { + action: "monitor.create", + entityType: "monitor", + entityId: row.id, + after: row, + metadata: { jobType: row.jobType, url: row.url }, + }); + + return selectMonitorSchema.parse(row); + }); +} diff --git a/packages/services/src/monitor/delete.ts b/packages/services/src/monitor/delete.ts new file mode 100644 index 000000000..25adf404c --- /dev/null +++ b/packages/services/src/monitor/delete.ts @@ -0,0 +1,156 @@ +import { and, eq, inArray, isNull } from "@openstatus/db"; +import { + monitor, + monitorTagsToMonitors, + notificationsToMonitors, + pageComponent, + privateLocationToMonitors, +} from "@openstatus/db/src/schema"; + +import { emitAudit } from "../audit"; +import { type ServiceContext, withTransaction } from "../context"; +import { NotFoundError } from "../errors"; +import { DeleteMonitorInput, DeleteMonitorsInput } from "./schemas"; + +/** + * Soft-delete a single monitor. Sets `deleted_at` + `active = false` and + * tears down the tag / notification / page-component associations in the + * same transaction. + */ +export async function deleteMonitor(args: { + ctx: ServiceContext; + input: DeleteMonitorInput; +}): Promise { + const { ctx } = args; + const input = DeleteMonitorInput.parse(args.input); + + await withTransaction(ctx, async (tx) => { + // Filter out already soft-deleted rows — otherwise a second call would + // re-run the side effects and emit a duplicate audit. + const existing = await tx + .select() + .from(monitor) + .where( + and( + eq(monitor.id, input.id), + eq(monitor.workspaceId, ctx.workspace.id), + isNull(monitor.deletedAt), + ), + ) + .get(); + if (!existing) throw new NotFoundError("monitor", input.id); + + await tx + .update(monitor) + .set({ deletedAt: new Date(), active: false }) + .where(eq(monitor.id, existing.id)); + await tx + .delete(monitorTagsToMonitors) + .where(eq(monitorTagsToMonitors.monitorId, existing.id)); + await tx + .delete(notificationsToMonitors) + .where(eq(notificationsToMonitors.monitorId, existing.id)); + await tx + .delete(pageComponent) + .where(eq(pageComponent.monitorId, existing.id)); + // Also tear down `privateLocationToMonitors` — this join table was + // previously left behind on soft-delete, leaving orphaned + // `(privateLocationId, monitorId)` rows pointing at a tombstoned + // monitor. Scheduling queries that read this join can then try to + // route probes to a deleted monitor. + await tx + .delete(privateLocationToMonitors) + .where(eq(privateLocationToMonitors.monitorId, existing.id)); + + await emitAudit(tx, ctx, { + action: "monitor.delete", + entityType: "monitor", + entityId: existing.id, + before: existing, + }); + }); +} + +/** + * Bulk soft-delete. Requires all ids resolve to monitors in the caller's + * workspace (cross-workspace or already-deleted ids fail the pre-check). + */ +export async function deleteMonitors(args: { + ctx: ServiceContext; + input: DeleteMonitorsInput; +}): Promise { + const { ctx } = args; + const input = DeleteMonitorsInput.parse(args.input); + + await withTransaction(ctx, async (tx) => { + const ids = Array.from(new Set(input.ids)); + const existing = await tx + .select() + .from(monitor) + .where( + and( + inArray(monitor.id, ids), + eq(monitor.workspaceId, ctx.workspace.id), + isNull(monitor.deletedAt), + ), + ) + .all(); + if (existing.length !== ids.length) { + // Report the first *actually missing* id rather than always + // blaming `ids[0]`, which misled callers whenever a mix of valid + // and invalid ids was passed (valid `ids[0]` still appeared in + // the error even though a later id was the real miss). + const foundIds = new Set(existing.map((r) => r.id)); + const missingId = ids.find((id) => !foundIds.has(id)) ?? -1; + throw new NotFoundError("monitor", missingId); + } + + // Layered defense: repeat `workspaceId` on every mutation that has + // the column, even though the pre-check above already validated + // all ids belong to the caller's workspace (same transaction, + // deduped ids). The belt-and-braces `AND workspace_id = ?` closes + // the copy-paste footgun if this block ever gets reused in a + // context without the pre-check. `monitorTagsToMonitors` and + // `notificationsToMonitors` don't carry `workspaceId` — they're + // scoped through the `monitor` FK's cascade, so the pre-check is + // the only practical guard there. + await tx + .update(monitor) + .set({ deletedAt: new Date(), active: false }) + .where( + and( + inArray(monitor.id, ids), + eq(monitor.workspaceId, ctx.workspace.id), + ), + ); + await tx + .delete(monitorTagsToMonitors) + .where(inArray(monitorTagsToMonitors.monitorId, ids)); + await tx + .delete(notificationsToMonitors) + .where(inArray(notificationsToMonitors.monitorId, ids)); + await tx + .delete(pageComponent) + .where( + and( + inArray(pageComponent.monitorId, ids), + eq(pageComponent.workspaceId, ctx.workspace.id), + ), + ); + // Match the single-delete cleanup — bulk soft-delete also has to + // strip `privateLocationToMonitors` so scheduling queries don't + // keep routing probes to tombstoned monitors. + await tx + .delete(privateLocationToMonitors) + .where(inArray(privateLocationToMonitors.monitorId, ids)); + + for (const row of existing) { + await emitAudit(tx, ctx, { + action: "monitor.delete", + entityType: "monitor", + entityId: row.id, + before: row, + }); + } + }); +} diff --git a/packages/services/src/monitor/index.ts b/packages/services/src/monitor/index.ts new file mode 100644 index 000000000..b507890e4 --- /dev/null +++ b/packages/services/src/monitor/index.ts @@ -0,0 +1,46 @@ +export { cloneMonitor } from "./clone"; +export { createMonitor } from "./create"; +export { deleteMonitor, deleteMonitors } from "./delete"; +export { + getMonitor, + type ListMonitorsResult, + listMonitors, + type MonitorListItem, + type MonitorWithRelations, +} from "./list"; +export { + updateMonitorNotifiers, + updateMonitorSchedulingRegions, + updateMonitorTags, +} from "./relations"; +export { + bulkUpdateMonitors, + updateMonitorFollowRedirects, + updateMonitorGeneral, + updateMonitorOtel, + updateMonitorPublic, + updateMonitorResponseTime, + updateMonitorRetry, +} from "./update"; + +export { + BulkUpdateMonitorsInput, + CloneMonitorInput, + CreateMonitorInput, + DeleteMonitorInput, + DeleteMonitorsInput, + GetMonitorInput, + ListMonitorsInput, + monitorJobTypes, + monitorMethods, + monitorPeriodicity, + UpdateMonitorFollowRedirectsInput, + UpdateMonitorGeneralInput, + UpdateMonitorNotifiersInput, + UpdateMonitorOtelInput, + UpdateMonitorPublicInput, + UpdateMonitorResponseTimeInput, + UpdateMonitorRetryInput, + UpdateMonitorSchedulingRegionsInput, + UpdateMonitorTagsInput, +} from "./schemas"; diff --git a/packages/services/src/monitor/internal.ts b/packages/services/src/monitor/internal.ts new file mode 100644 index 000000000..6c7b069e2 --- /dev/null +++ b/packages/services/src/monitor/internal.ts @@ -0,0 +1,195 @@ +import { + type Assertion, + DnsRecordAssertion, + HeaderAssertion, + JsonBodyAssertion, + StatusAssertion, + TextBodyAssertion, + serialize, +} from "@openstatus/assertions"; +import { and, count, eq, inArray, isNull } from "@openstatus/db"; +import { + monitor, + monitorTag, + notification, + privateLocation, +} from "@openstatus/db/src/schema"; +import { + freeFlyRegions, + monitorRegions, +} from "@openstatus/db/src/schema/constants"; +import { regionDict } from "@openstatus/regions"; + +import type { DB } from "../context"; +import { ForbiddenError, NotFoundError } from "../errors"; +import type { Workspace } from "../types"; + +/** Load a monitor by id, scoped to the workspace and excluding soft-deleted. */ +export async function getMonitorInWorkspace(args: { + tx: DB; + id: number; + workspaceId: number; +}) { + const { tx, id, workspaceId } = args; + const row = await tx + .select() + .from(monitor) + .where( + and( + eq(monitor.id, id), + eq(monitor.workspaceId, workspaceId), + isNull(monitor.deletedAt), + ), + ) + .get(); + if (!row) throw new NotFoundError("monitor", id); + return row; +} + +/** Count active (not soft-deleted) monitors in the workspace. */ +export async function countMonitorsInWorkspace( + tx: DB, + workspaceId: number, +): Promise { + const res = await tx + .select({ count: count() }) + .from(monitor) + .where(and(eq(monitor.workspaceId, workspaceId), isNull(monitor.deletedAt))) + .get(); + return res?.count ?? 0; +} + +/** Validate that tag ids exist and belong to the workspace. */ +export async function validateTagIds(args: { + tx: DB; + workspaceId: number; + tagIds: ReadonlyArray; +}): Promise { + const { tx, workspaceId, tagIds } = args; + if (tagIds.length === 0) return []; + const ids = Array.from(new Set(tagIds)); + const rows = await tx + .select({ id: monitorTag.id }) + .from(monitorTag) + .where( + and(inArray(monitorTag.id, ids), eq(monitorTag.workspaceId, workspaceId)), + ) + .all(); + const valid = new Set(rows.map((r) => r.id)); + for (const id of ids) { + if (!valid.has(id)) + throw new ForbiddenError(`Tag ${id} is not accessible.`); + } + return ids; +} + +/** Validate that notification ids exist and belong to the workspace. */ +export async function validateNotificationIds(args: { + tx: DB; + workspaceId: number; + notificationIds: ReadonlyArray; +}): Promise { + const { tx, workspaceId, notificationIds } = args; + if (notificationIds.length === 0) return []; + const ids = Array.from(new Set(notificationIds)); + const rows = await tx + .select({ id: notification.id }) + .from(notification) + .where( + and( + inArray(notification.id, ids), + eq(notification.workspaceId, workspaceId), + ), + ) + .all(); + const valid = new Set(rows.map((r) => r.id)); + for (const id of ids) { + if (!valid.has(id)) { + throw new ForbiddenError(`Notification ${id} is not accessible.`); + } + } + return ids; +} + +/** Validate that private location ids exist and belong to the workspace. */ +export async function validatePrivateLocationIds(args: { + tx: DB; + workspaceId: number; + privateLocationIds: ReadonlyArray; +}): Promise { + const { tx, workspaceId, privateLocationIds } = args; + if (privateLocationIds.length === 0) return []; + const ids = Array.from(new Set(privateLocationIds)); + const rows = await tx + .select({ id: privateLocation.id }) + .from(privateLocation) + .where( + and( + inArray(privateLocation.id, ids), + eq(privateLocation.workspaceId, workspaceId), + ), + ) + .all(); + const valid = new Set(rows.map((r) => r.id)); + for (const id of ids) { + if (!valid.has(id)) { + throw new ForbiddenError(`Private location ${id} is not accessible.`); + } + } + return ids; +} + +/** Translate UI `{key,value}[]` headers into the stored JSON string, or `null`. */ +export function headersToDbJson( + headers: ReadonlyArray<{ key: string; value: string }> | undefined, +): string | undefined { + if (headers === undefined) return undefined; + return JSON.stringify(headers); +} + +/** + * Build an `Assertion[]` from a UI discriminated-union list and serialise to + * the stored JSON string. `null` means "no assertions configured". + */ +export function serialiseAssertions( + list: ReadonlyArray<{ type: string } & Record> | undefined, +): string { + const assertions: Assertion[] = []; + for (const a of list ?? []) { + if (a.type === "status") assertions.push(new StatusAssertion(a as never)); + else if (a.type === "header") + assertions.push(new HeaderAssertion(a as never)); + else if (a.type === "textBody") + assertions.push(new TextBodyAssertion(a as never)); + else if (a.type === "jsonBody") + assertions.push(new JsonBodyAssertion(a as never)); + else if (a.type === "dnsRecord") + assertions.push(new DnsRecordAssertion(a as never)); + } + return serialize(assertions); +} + +/** + * Pick the default regions for a new monitor based on the workspace's plan. + * Matches the old tRPC "randomly choose 4 free regions / 6 paid regions, + * excluding deprecated" logic. + */ +export function pickDefaultRegions(workspace: Workspace): { + regions: string[]; + periodicity: "30m" | "1m"; +} { + const selectable = + workspace.plan === "free" ? freeFlyRegions : monitorRegions; + const count = workspace.plan === "free" ? 4 : 6; + const regions = [...selectable] + .filter((r) => { + const deprecated = regionDict[r].deprecated; + return !deprecated; + }) + .sort(() => 0.5 - Math.random()) + .slice(0, count); + return { + regions, + periodicity: workspace.plan === "free" ? "30m" : "1m", + }; +} diff --git a/packages/services/src/monitor/list.ts b/packages/services/src/monitor/list.ts new file mode 100644 index 000000000..49ac53ed5 --- /dev/null +++ b/packages/services/src/monitor/list.ts @@ -0,0 +1,241 @@ +import { + type SQL, + and, + asc, + db as defaultDb, + desc, + eq, + inArray, + isNull, + sql, +} from "@openstatus/db"; +import { + incidentTable, + monitor, + monitorTag, + monitorTagsToMonitors, + notification, + notificationsToMonitors, + privateLocation, + privateLocationToMonitors, + selectMonitorSchema, +} from "@openstatus/db/src/schema"; + +import type { DB, ServiceContext } from "../context"; +import type { + Incident, + Monitor, + MonitorTag, + Notification, + PrivateLocation, +} from "../types"; +import { getMonitorInWorkspace } from "./internal"; +import { GetMonitorInput, ListMonitorsInput } from "./schemas"; + +export type MonitorListItem = Monitor & { + tags: MonitorTag[]; + incidents: Incident[]; +}; + +export type MonitorWithRelations = Monitor & { + tags: MonitorTag[]; + incidents: Incident[]; + notifications: Notification[]; + privateLocations: PrivateLocation[]; +}; + +export type ListMonitorsResult = { + items: MonitorListItem[]; + totalSize: number; +}; + +/** + * Batched enrichment for a list of monitors — loads tags + incidents in + * two IN queries (three if notifications / privateLocations are also + * requested). Avoids the per-row pattern that pairs badly with dashboards + * that don't paginate. + */ +async function enrichMonitorsBatch( + db: DB, + rows: Array, + workspaceId: number, + include: { notifications?: boolean; privateLocations?: boolean } = {}, +): Promise { + if (rows.length === 0) return []; + const ids = rows.map((r) => r.id); + + const [tagRows, incidentRows, notifRows, locRows] = await Promise.all([ + db + .select() + .from(monitorTag) + .innerJoin( + monitorTagsToMonitors, + eq(monitorTagsToMonitors.monitorTagId, monitorTag.id), + ) + .where( + and( + inArray(monitorTagsToMonitors.monitorId, ids), + eq(monitorTag.workspaceId, workspaceId), + ), + ) + .all(), + db + .select() + .from(incidentTable) + .where( + and( + inArray(incidentTable.monitorId, ids), + // Scope to caller's workspace — defence-in-depth in case an + // incident.monitorId somehow points cross-workspace. The + // `incident.monitorId` FK doesn't enforce workspace ownership. + eq(incidentTable.workspaceId, workspaceId), + ), + ) + .all(), + include.notifications + ? db + .select() + .from(notification) + .innerJoin( + notificationsToMonitors, + eq(notificationsToMonitors.notificationId, notification.id), + ) + .where( + and( + inArray(notificationsToMonitors.monitorId, ids), + eq(notification.workspaceId, workspaceId), + ), + ) + .all() + : Promise.resolve([] as never[]), + include.privateLocations + ? db + .select() + .from(privateLocation) + .innerJoin( + privateLocationToMonitors, + eq(privateLocationToMonitors.privateLocationId, privateLocation.id), + ) + .where( + and( + inArray(privateLocationToMonitors.monitorId, ids), + eq(privateLocation.workspaceId, workspaceId), + ), + ) + .all() + : Promise.resolve([] as never[]), + ]); + + const tagsByMonitor = new Map(); + for (const row of tagRows as Array<{ + monitor_tag: MonitorTag; + monitor_tag_to_monitor: { monitorId: number }; + }>) { + const mId = row.monitor_tag_to_monitor.monitorId; + const arr = tagsByMonitor.get(mId); + if (arr) arr.push(row.monitor_tag); + else tagsByMonitor.set(mId, [row.monitor_tag]); + } + + const incidentsByMonitor = new Map(); + for (const row of incidentRows as Incident[]) { + if (row.monitorId == null) continue; + const arr = incidentsByMonitor.get(row.monitorId); + if (arr) arr.push(row); + else incidentsByMonitor.set(row.monitorId, [row]); + } + + const notifsByMonitor = new Map(); + for (const row of notifRows as Array<{ + notification: Notification; + notifications_to_monitors: { monitorId: number }; + }>) { + const mId = row.notifications_to_monitors.monitorId; + const arr = notifsByMonitor.get(mId); + if (arr) arr.push(row.notification); + else notifsByMonitor.set(mId, [row.notification]); + } + + const locsByMonitor = new Map(); + for (const row of locRows as Array<{ + private_location: PrivateLocation; + private_location_to_monitor: { monitorId: number }; + }>) { + const mId = row.private_location_to_monitor.monitorId; + const arr = locsByMonitor.get(mId); + if (arr) arr.push(row.private_location); + else locsByMonitor.set(mId, [row.private_location]); + } + + return rows.map((r) => ({ + ...selectMonitorSchema.parse(r), + tags: tagsByMonitor.get(r.id) ?? [], + incidents: incidentsByMonitor.get(r.id) ?? [], + notifications: notifsByMonitor.get(r.id) ?? [], + privateLocations: locsByMonitor.get(r.id) ?? [], + })); +} + +export async function listMonitors(args: { + ctx: ServiceContext; + input: ListMonitorsInput; +}): Promise { + const { ctx } = args; + const input = ListMonitorsInput.parse(args.input); + const db = ctx.db ?? defaultDb; + + const conditions: SQL[] = [ + eq(monitor.workspaceId, ctx.workspace.id), + isNull(monitor.deletedAt), + ]; + const whereClause = and(...conditions); + + const [countRow, rows] = await Promise.all([ + db + .select({ count: sql`count(*)` }) + .from(monitor) + .where(whereClause) + .get(), + db + .select() + .from(monitor) + .where(whereClause) + .orderBy( + input.order === "asc" ? asc(monitor.active) : desc(monitor.active), + input.order === "asc" + ? asc(monitor.createdAt) + : desc(monitor.createdAt), + ) + .limit(input.limit) + .offset(input.offset) + .all(), + ]); + + const totalSize = countRow?.count ?? 0; + const enriched = await enrichMonitorsBatch(db, rows, ctx.workspace.id); + // `list` only exposes tags + incidents to match the tRPC `list` shape. + const items: MonitorListItem[] = enriched.map( + ({ notifications: _n, privateLocations: _p, ...rest }) => rest, + ); + return { items, totalSize }; +} + +export async function getMonitor(args: { + ctx: ServiceContext; + input: GetMonitorInput; +}): Promise { + const { ctx } = args; + const input = GetMonitorInput.parse(args.input); + const db = ctx.db ?? defaultDb; + const record = await getMonitorInWorkspace({ + tx: db, + id: input.id, + workspaceId: ctx.workspace.id, + }); + const [enriched] = await enrichMonitorsBatch(db, [record], ctx.workspace.id, { + notifications: true, + privateLocations: true, + }); + // biome-ignore lint/style/noNonNullAssertion: always defined for len === 1 + return enriched!; +} diff --git a/packages/services/src/monitor/relations.ts b/packages/services/src/monitor/relations.ts new file mode 100644 index 000000000..ebfb565e7 --- /dev/null +++ b/packages/services/src/monitor/relations.ts @@ -0,0 +1,179 @@ +import { eq } from "@openstatus/db"; +import { + monitor, + monitorTagsToMonitors, + notificationsToMonitors, + privateLocationToMonitors, +} from "@openstatus/db/src/schema"; + +import { emitAudit } from "../audit"; +import { type ServiceContext, withTransaction } from "../context"; +import { LimitExceededError } from "../errors"; +import { + getMonitorInWorkspace, + validateNotificationIds, + validatePrivateLocationIds, + validateTagIds, +} from "./internal"; +import { + UpdateMonitorNotifiersInput, + UpdateMonitorSchedulingRegionsInput, + UpdateMonitorTagsInput, +} from "./schemas"; + +/** + * Update the monitor's `regions` / `periodicity` + private-location set in + * a single transaction. Enforces plan limits on periodicity, region count, + * and individual region access. + */ +export async function updateMonitorSchedulingRegions(args: { + ctx: ServiceContext; + input: UpdateMonitorSchedulingRegionsInput; +}): Promise { + const { ctx } = args; + const input = UpdateMonitorSchedulingRegionsInput.parse(args.input); + const limits = ctx.workspace.limits; + + if (!limits.periodicity.includes(input.periodicity)) { + throw new LimitExceededError("periodicity", limits.periodicity.length); + } + if (limits["max-regions"] < input.regions.length) { + throw new LimitExceededError("max-regions", limits["max-regions"]); + } + if ( + input.regions.length > 0 && + !input.regions.every((r) => + limits.regions.includes(r as (typeof limits)["regions"][number]), + ) + ) { + throw new LimitExceededError("regions", limits.regions.length); + } + + await withTransaction(ctx, async (tx) => { + const existing = await getMonitorInWorkspace({ + tx, + id: input.id, + workspaceId: ctx.workspace.id, + }); + + const validatedLocations = await validatePrivateLocationIds({ + tx, + workspaceId: ctx.workspace.id, + privateLocationIds: input.privateLocations, + }); + + await tx + .update(monitor) + .set({ + regions: input.regions.join(","), + periodicity: input.periodicity, + updatedAt: new Date(), + }) + .where(eq(monitor.id, existing.id)); + + await tx + .delete(privateLocationToMonitors) + .where(eq(privateLocationToMonitors.monitorId, existing.id)); + if (validatedLocations.length > 0) { + await tx.insert(privateLocationToMonitors).values( + validatedLocations.map((privateLocationId) => ({ + monitorId: existing.id, + privateLocationId, + })), + ); + } + + await emitAudit(tx, ctx, { + action: "monitor.update_scheduling_regions", + entityType: "monitor", + entityId: existing.id, + metadata: { + regions: input.regions, + periodicity: input.periodicity, + privateLocationIds: validatedLocations, + }, + }); + }); +} + +/** Replace the full monitor-tag association set. */ +export async function updateMonitorTags(args: { + ctx: ServiceContext; + input: UpdateMonitorTagsInput; +}): Promise { + const { ctx } = args; + const input = UpdateMonitorTagsInput.parse(args.input); + + await withTransaction(ctx, async (tx) => { + const existing = await getMonitorInWorkspace({ + tx, + id: input.id, + workspaceId: ctx.workspace.id, + }); + const validatedTags = await validateTagIds({ + tx, + workspaceId: ctx.workspace.id, + tagIds: input.tags, + }); + + await tx + .delete(monitorTagsToMonitors) + .where(eq(monitorTagsToMonitors.monitorId, existing.id)); + if (validatedTags.length > 0) { + await tx.insert(monitorTagsToMonitors).values( + validatedTags.map((tagId) => ({ + monitorId: existing.id, + monitorTagId: tagId, + })), + ); + } + + await emitAudit(tx, ctx, { + action: "monitor.update_tags", + entityType: "monitor", + entityId: existing.id, + metadata: { tagIds: validatedTags }, + }); + }); +} + +/** Replace the full monitor-to-notification association set. */ +export async function updateMonitorNotifiers(args: { + ctx: ServiceContext; + input: UpdateMonitorNotifiersInput; +}): Promise { + const { ctx } = args; + const input = UpdateMonitorNotifiersInput.parse(args.input); + + await withTransaction(ctx, async (tx) => { + const existing = await getMonitorInWorkspace({ + tx, + id: input.id, + workspaceId: ctx.workspace.id, + }); + const validatedNotifiers = await validateNotificationIds({ + tx, + workspaceId: ctx.workspace.id, + notificationIds: input.notifiers, + }); + + await tx + .delete(notificationsToMonitors) + .where(eq(notificationsToMonitors.monitorId, existing.id)); + if (validatedNotifiers.length > 0) { + await tx.insert(notificationsToMonitors).values( + validatedNotifiers.map((notificationId) => ({ + monitorId: existing.id, + notificationId, + })), + ); + } + + await emitAudit(tx, ctx, { + action: "monitor.update_notifiers", + entityType: "monitor", + entityId: existing.id, + metadata: { notificationIds: validatedNotifiers }, + }); + }); +} diff --git a/packages/services/src/monitor/schemas.ts b/packages/services/src/monitor/schemas.ts new file mode 100644 index 000000000..7fb76d394 --- /dev/null +++ b/packages/services/src/monitor/schemas.ts @@ -0,0 +1,150 @@ +import { + headerAssertion, + jsonBodyAssertion, + recordAssertion, + statusAssertion, + textBodyAssertion, +} from "@openstatus/assertions"; +import { monitorPeriodicity } from "@openstatus/db/src/schema/constants"; +import { + monitorJobTypes, + monitorMethods, +} from "@openstatus/db/src/schema/monitors/constants"; +import { z } from "zod"; + +export { monitorJobTypes, monitorMethods, monitorPeriodicity }; + +const headerPair = z.object({ key: z.string(), value: z.string() }); +const assertion = z.discriminatedUnion("type", [ + statusAssertion, + headerAssertion, + textBodyAssertion, + jsonBodyAssertion, + recordAssertion, +]); + +/** + * Create a new monitor. Regions and periodicity are optional — when unset, + * the service picks sensible plan-based defaults (4 free regions / 6 paid + * + `30m`/`1m` respectively). + */ +export const CreateMonitorInput = z.object({ + name: z.string().min(1), + jobType: z.enum(monitorJobTypes), + url: z.string(), + method: z.enum(monitorMethods), + headers: z.array(headerPair).default([]), + body: z.string().optional(), + assertions: z.array(assertion).default([]), + active: z.boolean().default(false), + periodicity: z.enum(monitorPeriodicity).optional(), + regions: z.array(z.string()).optional(), +}); +export type CreateMonitorInput = z.infer; + +/** Update the "general" monitor payload — name / endpoint / headers / assertions. */ +export const UpdateMonitorGeneralInput = z.object({ + id: z.number().int(), + name: z.string().min(1), + jobType: z.enum(monitorJobTypes), + url: z.string(), + method: z.enum(monitorMethods), + headers: z.array(headerPair).default([]), + body: z.string().optional(), + assertions: z.array(assertion).default([]), + active: z.boolean().default(true), +}); +export type UpdateMonitorGeneralInput = z.infer< + typeof UpdateMonitorGeneralInput +>; + +export const UpdateMonitorRetryInput = z.object({ + id: z.number().int(), + retry: z.number().int(), +}); +export type UpdateMonitorRetryInput = z.infer; + +export const UpdateMonitorFollowRedirectsInput = z.object({ + id: z.number().int(), + followRedirects: z.boolean(), +}); +export type UpdateMonitorFollowRedirectsInput = z.infer< + typeof UpdateMonitorFollowRedirectsInput +>; + +export const UpdateMonitorOtelInput = z.object({ + id: z.number().int(), + otelEndpoint: z.string(), + otelHeaders: z.array(headerPair).optional(), +}); +export type UpdateMonitorOtelInput = z.infer; + +export const UpdateMonitorPublicInput = z.object({ + id: z.number().int(), + public: z.boolean(), +}); +export type UpdateMonitorPublicInput = z.infer; + +// Bounds mirror `insertMonitorSchema` (0–60_000 ms) — the persisted checker +// timeout is hard-capped at 60 s and rejects negatives. +export const UpdateMonitorResponseTimeInput = z.object({ + id: z.number().int(), + timeout: z.coerce.number().gte(0).lte(60_000), + degradedAfter: z.coerce.number().gte(0).lte(60_000).nullish(), +}); +export type UpdateMonitorResponseTimeInput = z.infer< + typeof UpdateMonitorResponseTimeInput +>; + +export const UpdateMonitorSchedulingRegionsInput = z.object({ + id: z.number().int(), + regions: z.array(z.string()), + periodicity: z.enum(monitorPeriodicity), + privateLocations: z.array(z.number().int()).default([]), +}); +export type UpdateMonitorSchedulingRegionsInput = z.infer< + typeof UpdateMonitorSchedulingRegionsInput +>; + +export const UpdateMonitorTagsInput = z.object({ + id: z.number().int(), + tags: z.array(z.number().int()), +}); +export type UpdateMonitorTagsInput = z.infer; + +export const UpdateMonitorNotifiersInput = z.object({ + id: z.number().int(), + notifiers: z.array(z.number().int()), +}); +export type UpdateMonitorNotifiersInput = z.infer< + typeof UpdateMonitorNotifiersInput +>; + +/** Batched toggle of `public` / `active` across multiple monitors. */ +export const BulkUpdateMonitorsInput = z.object({ + ids: z.array(z.number().int()).min(1), + public: z.boolean().optional(), + active: z.boolean().optional(), +}); +export type BulkUpdateMonitorsInput = z.infer; + +export const DeleteMonitorInput = z.object({ id: z.number().int() }); +export type DeleteMonitorInput = z.infer; + +export const DeleteMonitorsInput = z.object({ + ids: z.array(z.number().int()).min(1), +}); +export type DeleteMonitorsInput = z.infer; + +export const CloneMonitorInput = z.object({ id: z.number().int() }); +export type CloneMonitorInput = z.infer; + +export const GetMonitorInput = z.object({ id: z.number().int() }); +export type GetMonitorInput = z.infer; + +export const ListMonitorsInput = z.object({ + limit: z.number().int().min(1).default(50), + offset: z.number().int().min(0).default(0), + order: z.enum(["asc", "desc"]).default("desc"), +}); +export type ListMonitorsInput = z.infer; diff --git a/packages/services/src/monitor/update.ts b/packages/services/src/monitor/update.ts new file mode 100644 index 000000000..018ce2237 --- /dev/null +++ b/packages/services/src/monitor/update.ts @@ -0,0 +1,257 @@ +import { and, eq, inArray, isNull } from "@openstatus/db"; +import { monitor, selectMonitorSchema } from "@openstatus/db/src/schema"; + +import { emitAudit } from "../audit"; +import { type ServiceContext, withTransaction } from "../context"; +import type { Monitor } from "../types"; +import { + getMonitorInWorkspace, + headersToDbJson, + serialiseAssertions, +} from "./internal"; +import { + BulkUpdateMonitorsInput, + UpdateMonitorFollowRedirectsInput, + UpdateMonitorGeneralInput, + UpdateMonitorOtelInput, + UpdateMonitorPublicInput, + UpdateMonitorResponseTimeInput, + UpdateMonitorRetryInput, +} from "./schemas"; + +/** + * Update a monitor's "general" fields — name / endpoint / method / headers / + * body / assertions / active. Mirrors the tRPC `updateGeneral` surface and + * intentionally allows jobType switching (e.g. HTTP → TCP) to preserve the + * existing dashboard flow. + */ +export async function updateMonitorGeneral(args: { + ctx: ServiceContext; + input: UpdateMonitorGeneralInput; +}): Promise { + const { ctx } = args; + const input = UpdateMonitorGeneralInput.parse(args.input); + + return withTransaction(ctx, async (tx) => { + const existing = await getMonitorInWorkspace({ + tx, + id: input.id, + workspaceId: ctx.workspace.id, + }); + + const updated = await tx + .update(monitor) + .set({ + name: input.name, + jobType: input.jobType, + url: input.url, + method: input.method, + headers: headersToDbJson(input.headers), + body: input.body, + active: input.active, + assertions: serialiseAssertions(input.assertions), + updatedAt: new Date(), + }) + .where(eq(monitor.id, existing.id)) + .returning() + .get(); + + await emitAudit(tx, ctx, { + action: "monitor.update_general", + entityType: "monitor", + entityId: existing.id, + before: existing, + after: updated, + }); + + return selectMonitorSchema.parse(updated); + }); +} + +export async function updateMonitorRetry(args: { + ctx: ServiceContext; + input: UpdateMonitorRetryInput; +}): Promise { + const { ctx } = args; + const input = UpdateMonitorRetryInput.parse(args.input); + await withTransaction(ctx, async (tx) => { + const existing = await getMonitorInWorkspace({ + tx, + id: input.id, + workspaceId: ctx.workspace.id, + }); + await tx + .update(monitor) + .set({ retry: input.retry, updatedAt: new Date() }) + .where(eq(monitor.id, existing.id)) + .run(); + await emitAudit(tx, ctx, { + action: "monitor.update_retry", + entityType: "monitor", + entityId: existing.id, + metadata: { retry: input.retry }, + }); + }); +} + +export async function updateMonitorFollowRedirects(args: { + ctx: ServiceContext; + input: UpdateMonitorFollowRedirectsInput; +}): Promise { + const { ctx } = args; + const input = UpdateMonitorFollowRedirectsInput.parse(args.input); + await withTransaction(ctx, async (tx) => { + const existing = await getMonitorInWorkspace({ + tx, + id: input.id, + workspaceId: ctx.workspace.id, + }); + await tx + .update(monitor) + .set({ followRedirects: input.followRedirects, updatedAt: new Date() }) + .where(eq(monitor.id, existing.id)) + .run(); + await emitAudit(tx, ctx, { + action: "monitor.update_follow_redirects", + entityType: "monitor", + entityId: existing.id, + metadata: { followRedirects: input.followRedirects }, + }); + }); +} + +export async function updateMonitorOtel(args: { + ctx: ServiceContext; + input: UpdateMonitorOtelInput; +}): Promise { + const { ctx } = args; + const input = UpdateMonitorOtelInput.parse(args.input); + await withTransaction(ctx, async (tx) => { + const existing = await getMonitorInWorkspace({ + tx, + id: input.id, + workspaceId: ctx.workspace.id, + }); + await tx + .update(monitor) + .set({ + otelEndpoint: input.otelEndpoint, + otelHeaders: headersToDbJson(input.otelHeaders), + updatedAt: new Date(), + }) + .where(eq(monitor.id, existing.id)) + .run(); + await emitAudit(tx, ctx, { + action: "monitor.update_otel", + entityType: "monitor", + entityId: existing.id, + }); + }); +} + +export async function updateMonitorPublic(args: { + ctx: ServiceContext; + input: UpdateMonitorPublicInput; +}): Promise { + const { ctx } = args; + const input = UpdateMonitorPublicInput.parse(args.input); + await withTransaction(ctx, async (tx) => { + const existing = await getMonitorInWorkspace({ + tx, + id: input.id, + workspaceId: ctx.workspace.id, + }); + await tx + .update(monitor) + .set({ public: input.public, updatedAt: new Date() }) + .where(eq(monitor.id, existing.id)) + .run(); + await emitAudit(tx, ctx, { + action: "monitor.update_public", + entityType: "monitor", + entityId: existing.id, + metadata: { public: input.public }, + }); + }); +} + +export async function updateMonitorResponseTime(args: { + ctx: ServiceContext; + input: UpdateMonitorResponseTimeInput; +}): Promise { + const { ctx } = args; + const input = UpdateMonitorResponseTimeInput.parse(args.input); + await withTransaction(ctx, async (tx) => { + const existing = await getMonitorInWorkspace({ + tx, + id: input.id, + workspaceId: ctx.workspace.id, + }); + await tx + .update(monitor) + .set({ + timeout: input.timeout, + degradedAfter: input.degradedAfter, + updatedAt: new Date(), + }) + .where(eq(monitor.id, existing.id)) + .run(); + await emitAudit(tx, ctx, { + action: "monitor.update_response_time", + entityType: "monitor", + entityId: existing.id, + }); + }); +} + +/** + * Batched update of `public` / `active` across multiple monitors. All ids + * must be in the caller's workspace and not soft-deleted; no per-row + * not-found check (matches the pre-migration behaviour — missing ids are + * silently ignored). + */ +export async function bulkUpdateMonitors(args: { + ctx: ServiceContext; + input: BulkUpdateMonitorsInput; +}): Promise { + const { ctx } = args; + const input = BulkUpdateMonitorsInput.parse(args.input); + if (input.public === undefined && input.active === undefined) return; + + await withTransaction(ctx, async (tx) => { + const set: Record = { updatedAt: new Date() }; + if (input.public !== undefined) set.public = input.public; + if (input.active !== undefined) set.active = input.active; + + // `.returning()` the ids that actually matched so the audit loop + // below can attribute only what we wrote. Looping over + // `input.ids` directly emitted `monitor.bulk_update` rows for ids + // the WHERE clause silently dropped (wrong workspace, already + // soft-deleted) — that's a new audit-log pollution, not a + // preserved legacy behaviour (the pre-migration code had no audit + // trail at all). + const updated = await tx + .update(monitor) + .set(set) + .where( + and( + inArray(monitor.id, input.ids), + eq(monitor.workspaceId, ctx.workspace.id), + isNull(monitor.deletedAt), + ), + ) + .returning({ id: monitor.id }); + + for (const { id } of updated) { + await emitAudit(tx, ctx, { + action: "monitor.bulk_update", + entityType: "monitor", + entityId: id, + metadata: { + public: input.public, + active: input.active, + }, + }); + } + }); +} diff --git a/packages/services/src/notification/__tests__/notification.test.ts b/packages/services/src/notification/__tests__/notification.test.ts new file mode 100644 index 000000000..cc65cc32a --- /dev/null +++ b/packages/services/src/notification/__tests__/notification.test.ts @@ -0,0 +1,402 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + test, +} from "bun:test"; +import { db, eq, inArray } from "@openstatus/db"; +import { + monitor, + notification, + notificationsToMonitors, +} from "@openstatus/db/src/schema"; + +import { + SEEDED_WORKSPACE_FREE_ID, + SEEDED_WORKSPACE_TEAM_ID, +} from "../../../test/fixtures"; +import { + cleanQuotaGatedTables, + expectAuditRow, + loadSeededWorkspace, + makeUserCtx, + withAuditBuffer, +} from "../../../test/helpers"; +import type { AuditLogRecord } from "../../audit"; +import type { ServiceContext } from "../../context"; +import { + ForbiddenError, + LimitExceededError, + NotFoundError, + ValidationError, +} from "../../errors"; +import { createNotification } from "../create"; +import { deleteNotification } from "../delete"; +import { getNotification, listNotifications } from "../list"; +import { updateNotification } from "../update"; + +const TEST_PREFIX = "svc-notification-test"; + +let teamCtx: ServiceContext; +let freeCtx: ServiceContext; +let teamMonitorId: number; +let auditBuffer: AuditLogRecord[]; +let auditReset: () => void; +const createdNotificationIds: number[] = []; + +beforeAll(async () => { + const team = await loadSeededWorkspace(SEEDED_WORKSPACE_TEAM_ID); + const free = await loadSeededWorkspace(SEEDED_WORKSPACE_FREE_ID); + teamCtx = makeUserCtx(team, { userId: 1 }); + freeCtx = makeUserCtx(free, { userId: 2 }); + + // Clear quota-gated rows on the free workspace so + // `notification-channels: 1` (free plan) can actually be exercised + // by negative-path tests — any leftover row from prior runs tripped + // `LimitExceededError` before the intended assertion fired. + await cleanQuotaGatedTables(SEEDED_WORKSPACE_FREE_ID); + + const monitorRow = await db + .insert(monitor) + .values({ + workspaceId: team.id, + active: true, + url: "https://example.com", + name: `${TEST_PREFIX}-monitor`, + method: "GET", + periodicity: "10m", + regions: "ams", + }) + .returning() + .get(); + teamMonitorId = monitorRow.id; +}); + +afterAll(async () => { + if (createdNotificationIds.length > 0) { + await db + .delete(notification) + .where(inArray(notification.id, createdNotificationIds)) + .catch(() => undefined); + } + await db + .delete(monitor) + .where(eq(monitor.id, teamMonitorId)) + .catch(() => undefined); +}); + +beforeEach(() => { + const hooks = withAuditBuffer(); + auditBuffer = hooks.buffer; + auditReset = hooks.reset; +}); + +afterEach(() => { + auditReset(); +}); + +function track(id: number) { + createdNotificationIds.push(id); + return id; +} + +describe("createNotification", () => { + test("creates a discord channel with monitors + audits", async () => { + const row = await createNotification({ + ctx: teamCtx, + input: { + name: `${TEST_PREFIX}-discord`, + provider: "discord", + data: { discord: "https://discord.com/api/webhooks/1/abc" }, + monitors: [teamMonitorId], + }, + }); + track(row.id); + expect(row.provider).toBe("discord"); + + const assoc = await db + .select() + .from(notificationsToMonitors) + .where(eq(notificationsToMonitors.notificationId, row.id)) + .all(); + expect(assoc.map((a) => a.monitorId)).toEqual([teamMonitorId]); + + await expectAuditRow(auditBuffer, { + action: "notification.create", + entityType: "notification", + entityId: row.id, + }); + }); + + test("throws ValidationError for malformed data", async () => { + await expect( + createNotification({ + ctx: teamCtx, + input: { + name: `${TEST_PREFIX}-bad`, + provider: "discord", + // missing the required `discord` key + data: {}, + monitors: [], + }, + }), + ).rejects.toBeInstanceOf(ValidationError); + }); + + test("throws ValidationError when data payload key doesn't match provider", async () => { + await expect( + createNotification({ + ctx: teamCtx, + input: { + name: `${TEST_PREFIX}-mismatch`, + provider: "discord", + // Valid slack payload, but provider is discord → rejected. + data: { slack: "https://hooks.slack.com/services/x/y/z" }, + monitors: [], + }, + }), + ).rejects.toBeInstanceOf(ValidationError); + }); + + test("throws ValidationError when provider payload is malformed but another provider's is valid", async () => { + // The canonical data schema for the selected provider must match. + // A plain key-presence check would have missed this: `discord: "not-a-url"` + // fails `urlSchema`, but a valid `slack` field could've hidden it. + await expect( + createNotification({ + ctx: teamCtx, + input: { + name: `${TEST_PREFIX}-invalid-payload`, + provider: "discord", + data: { + discord: "not-a-url", + slack: "https://hooks.slack.com/services/x/y/z", + }, + monitors: [], + }, + }), + ).rejects.toBeInstanceOf(ValidationError); + }); + + test("throws LimitExceededError when plan blocks the provider", async () => { + // free plan has `pagerduty: false`. + await expect( + createNotification({ + ctx: freeCtx, + input: { + name: `${TEST_PREFIX}-gated`, + provider: "pagerduty", + data: { + pagerduty: JSON.stringify({ + integration_keys: [{ id: "k1", integration_key: "x" }], + }), + }, + monitors: [], + }, + }), + ).rejects.toBeInstanceOf(LimitExceededError); + }); + + test("throws ForbiddenError for cross-workspace monitor", async () => { + await expect( + createNotification({ + ctx: freeCtx, + input: { + name: `${TEST_PREFIX}-cross-ws`, + provider: "discord", + data: { discord: "https://discord.com/api/webhooks/1/abc" }, + monitors: [teamMonitorId], // team's monitor + }, + }), + ).rejects.toBeInstanceOf(ForbiddenError); + }); +}); + +describe("updateNotification", () => { + test("replaces name / data / monitor associations", async () => { + const row = await createNotification({ + ctx: teamCtx, + input: { + name: `${TEST_PREFIX}-update`, + provider: "discord", + data: { discord: "https://discord.com/api/webhooks/1/abc" }, + monitors: [teamMonitorId], + }, + }); + track(row.id); + + const updated = await updateNotification({ + ctx: teamCtx, + input: { + id: row.id, + name: `${TEST_PREFIX}-update-renamed`, + data: { discord: "https://discord.com/api/webhooks/2/def" }, + monitors: [], + }, + }); + expect(updated.name).toBe(`${TEST_PREFIX}-update-renamed`); + + const assoc = await db + .select() + .from(notificationsToMonitors) + .where(eq(notificationsToMonitors.notificationId, row.id)) + .all(); + expect(assoc).toHaveLength(0); + }); + + test("throws NotFoundError for cross-workspace update", async () => { + const row = await createNotification({ + ctx: teamCtx, + input: { + name: `${TEST_PREFIX}-cross-ws-update`, + provider: "discord", + data: { discord: "https://discord.com/api/webhooks/1/abc" }, + monitors: [], + }, + }); + track(row.id); + + await expect( + updateNotification({ + ctx: freeCtx, + input: { + id: row.id, + name: "blocked", + data: { discord: "https://discord.com/api/webhooks/x/y" }, + monitors: [], + }, + }), + ).rejects.toBeInstanceOf(NotFoundError); + }); + + test("emits a notification.update audit row", async () => { + const row = await createNotification({ + ctx: teamCtx, + input: { + name: `${TEST_PREFIX}-update-audit`, + provider: "discord", + data: { discord: "https://discord.com/api/webhooks/1/abc" }, + monitors: [], + }, + }); + track(row.id); + + await updateNotification({ + ctx: teamCtx, + input: { + id: row.id, + name: `${TEST_PREFIX}-update-audit-renamed`, + data: { discord: "https://discord.com/api/webhooks/2/def" }, + monitors: [], + }, + }); + + // `provider` is attached to the audit entry's `metadata` but the v1 + // buffered `AuditLogRecord` shape drops it (logs, not PII-bearing + // structured data). Assert the action/entity signals fire; metadata + // coverage lands with the v2 audit-table move. + await expectAuditRow(auditBuffer, { + action: "notification.update", + entityType: "notification", + entityId: row.id, + }); + }); + + test("throws LimitExceededError when plan gate blocks update", async () => { + // Regression for the post-downgrade case: the row was created on a + // plan that allowed pagerduty, but the current workspace no longer + // does. The pre-fix update flow never re-checked the gate, so an + // editable form field remained open to channels the plan had since + // revoked. Simulate it by bypassing the create-time gate with a + // direct db insert bound to the free workspace. + const [inserted] = await db + .insert(notification) + .values({ + workspaceId: SEEDED_WORKSPACE_FREE_ID, + name: `${TEST_PREFIX}-downgrade-gate`, + provider: "pagerduty", + data: JSON.stringify({ + pagerduty: JSON.stringify({ + integration_keys: [{ id: "k1", integration_key: "x" }], + }), + }), + }) + .returning(); + if (!inserted) throw new Error("direct insert failed"); + track(inserted.id); + + await expect( + updateNotification({ + ctx: freeCtx, + input: { + id: inserted.id, + name: "would-be-rename", + data: { + pagerduty: JSON.stringify({ + integration_keys: [{ id: "k1", integration_key: "y" }], + }), + }, + monitors: [], + }, + }), + ).rejects.toBeInstanceOf(LimitExceededError); + }); +}); + +describe("deleteNotification", () => { + test("removes the row", async () => { + const row = await createNotification({ + ctx: teamCtx, + input: { + name: `${TEST_PREFIX}-delete`, + provider: "discord", + data: { discord: "https://discord.com/api/webhooks/1/abc" }, + monitors: [], + }, + }); + track(row.id); + + await deleteNotification({ ctx: teamCtx, input: { id: row.id } }); + + const remaining = await db + .select() + .from(notification) + .where(eq(notification.id, row.id)) + .all(); + expect(remaining).toHaveLength(0); + }); +}); + +describe("list / get", () => { + test("list returns enriched monitors scoped to the workspace", async () => { + const row = await createNotification({ + ctx: teamCtx, + input: { + name: `${TEST_PREFIX}-enrich`, + provider: "discord", + data: { discord: "https://discord.com/api/webhooks/1/abc" }, + monitors: [teamMonitorId], + }, + }); + track(row.id); + + const full = await getNotification({ + ctx: teamCtx, + input: { id: row.id }, + }); + expect(full.monitors.map((m) => m.id)).toEqual([teamMonitorId]); + + await expect( + getNotification({ ctx: freeCtx, input: { id: row.id } }), + ).rejects.toBeInstanceOf(NotFoundError); + + const { items: freeItems } = await listNotifications({ + ctx: freeCtx, + input: { limit: 100, offset: 0, order: "desc" }, + }); + expect(freeItems.find((n) => n.id === row.id)).toBeUndefined(); + }); +}); diff --git a/packages/services/src/notification/create.ts b/packages/services/src/notification/create.ts new file mode 100644 index 000000000..d15524ea9 --- /dev/null +++ b/packages/services/src/notification/create.ts @@ -0,0 +1,84 @@ +import { count, eq } from "@openstatus/db"; +import { + notification, + notificationsToMonitors, + selectNotificationSchema, +} from "@openstatus/db/src/schema"; + +import { emitAudit } from "../audit"; +import { type ServiceContext, withTransaction } from "../context"; +import { LimitExceededError } from "../errors"; +import type { Notification } from "../types"; +import { + assertProviderAllowed, + validateMonitorIds, + validateNotificationData, +} from "./internal"; +import { CreateNotificationInput } from "./schemas"; + +export async function createNotification(args: { + ctx: ServiceContext; + input: CreateNotificationInput; +}): Promise { + const { ctx } = args; + const input = CreateNotificationInput.parse(args.input); + + return withTransaction(ctx, async (tx) => { + // Plan gate on notification count. + const existing = await tx + .select({ count: count() }) + .from(notification) + .where(eq(notification.workspaceId, ctx.workspace.id)) + .get(); + if ( + existing && + existing.count >= ctx.workspace.limits["notification-channels"] + ) { + throw new LimitExceededError( + "notification-channels", + ctx.workspace.limits["notification-channels"], + ); + } + + // Plan gate on provider (sms / pagerduty / opsgenie / …). + assertProviderAllowed(ctx.workspace, input.provider); + + validateNotificationData(input.provider, input.data); + + const validatedMonitors = await validateMonitorIds({ + tx, + workspaceId: ctx.workspace.id, + monitorIds: input.monitors, + }); + + const row = await tx + .insert(notification) + .values({ + name: input.name, + provider: input.provider, + data: JSON.stringify(input.data), + workspaceId: ctx.workspace.id, + }) + .returning() + .get(); + + if (validatedMonitors.length > 0) { + await tx.insert(notificationsToMonitors).values( + validatedMonitors.map((monitorId) => ({ + notificationId: row.id, + monitorId, + })), + ); + } + + await emitAudit(tx, ctx, { + action: "notification.create", + entityType: "notification", + entityId: row.id, + after: row, + metadata: { provider: input.provider }, + }); + + return selectNotificationSchema.parse(row); + }); +} diff --git a/packages/services/src/notification/delete.ts b/packages/services/src/notification/delete.ts new file mode 100644 index 000000000..9e664188a --- /dev/null +++ b/packages/services/src/notification/delete.ts @@ -0,0 +1,36 @@ +import { eq } from "@openstatus/db"; +import { notification } from "@openstatus/db/src/schema"; + +import { emitAudit } from "../audit"; +import { type ServiceContext, withTransaction } from "../context"; +import { getNotificationInWorkspace } from "./internal"; +import { DeleteNotificationInput } from "./schemas"; + +/** + * Hard-delete a notification row. Cascade clears + * `notifications_to_monitors` associations (FK cascade delete on the table). + */ +export async function deleteNotification(args: { + ctx: ServiceContext; + input: DeleteNotificationInput; +}): Promise { + const { ctx } = args; + const input = DeleteNotificationInput.parse(args.input); + + await withTransaction(ctx, async (tx) => { + const existing = await getNotificationInWorkspace({ + tx, + id: input.id, + workspaceId: ctx.workspace.id, + }); + + await tx.delete(notification).where(eq(notification.id, existing.id)); + + await emitAudit(tx, ctx, { + action: "notification.delete", + entityType: "notification", + entityId: existing.id, + before: existing, + }); + }); +} diff --git a/packages/services/src/notification/index.ts b/packages/services/src/notification/index.ts new file mode 100644 index 000000000..0b0bd55f9 --- /dev/null +++ b/packages/services/src/notification/index.ts @@ -0,0 +1,21 @@ +export { createNotification } from "./create"; +export { deleteNotification } from "./delete"; +export { + getNotification, + type ListNotificationsResult, + listNotifications, + type NotificationWithRelations, +} from "./list"; +export { updateNotification } from "./update"; + +export { + CreateNotificationInput, + DeleteNotificationInput, + GetNotificationInput, + ListNotificationsInput, + type NotificationDataInput, + NotificationDataInputSchema, + notificationProvider, + notificationProviderSchema, + UpdateNotificationInput, +} from "./schemas"; diff --git a/packages/services/src/notification/internal.ts b/packages/services/src/notification/internal.ts new file mode 100644 index 000000000..8b27d3e75 --- /dev/null +++ b/packages/services/src/notification/internal.ts @@ -0,0 +1,146 @@ +import { and, eq, inArray, isNull } from "@openstatus/db"; +import { monitor, notification } from "@openstatus/db/src/schema"; +import { + type NotificationProvider, + discordDataSchema, + emailDataSchema, + googleChatDataSchema, + grafanaOncallDataSchema, + ntfyDataSchema, + opsgenieDataSchema, + pagerdutyDataSchema, + phoneDataSchema, + slackDataSchema, + telegramDataSchema, + webhookDataSchema, + whatsappDataSchema, +} from "@openstatus/db/src/schema"; +import type { ZodType } from "zod"; + +import type { DB } from "../context"; +import { + ForbiddenError, + LimitExceededError, + NotFoundError, + ValidationError, +} from "../errors"; +import type { Workspace } from "../types"; +import type { NotificationDataInput } from "./schemas"; + +/** Load a notification by id, scoped to the workspace. Throws on miss. */ +export async function getNotificationInWorkspace(args: { + tx: DB; + id: number; + workspaceId: number; +}) { + const { tx, id, workspaceId } = args; + const row = await tx + .select() + .from(notification) + .where( + and(eq(notification.id, id), eq(notification.workspaceId, workspaceId)), + ) + .get(); + if (!row) throw new NotFoundError("notification", id); + return row; +} + +/** Validate that monitor ids exist and belong to the workspace (not soft-deleted). */ +export async function validateMonitorIds(args: { + tx: DB; + workspaceId: number; + monitorIds: ReadonlyArray; +}): Promise { + const { tx, workspaceId, monitorIds } = args; + if (monitorIds.length === 0) return []; + const ids = Array.from(new Set(monitorIds)); + const rows = await tx + .select({ id: monitor.id }) + .from(monitor) + .where( + and( + inArray(monitor.id, ids), + eq(monitor.workspaceId, workspaceId), + isNull(monitor.deletedAt), + ), + ) + .all(); + const valid = new Set(rows.map((r) => r.id)); + for (const id of ids) { + if (!valid.has(id)) { + throw new ForbiddenError(`Monitor ${id} is not accessible.`); + } + } + return ids; +} + +/** + * Providers gated behind plan flags. The workspace plan must have the flag + * enabled for the service to accept a `new`/`update` referencing that + * provider. `email`, `slack`, `discord`, `webhook`, `telegram`, `ntfy`, + * `google-chat` are always allowed by the existing UI. + */ +const PLAN_GATED_PROVIDERS = [ + "sms", + "pagerduty", + "opsgenie", + "grafana-oncall", + "whatsapp", +] as const satisfies ReadonlyArray; + +type PlanGatedProvider = (typeof PLAN_GATED_PROVIDERS)[number]; + +function isPlanGated(provider: string): provider is PlanGatedProvider { + return (PLAN_GATED_PROVIDERS as ReadonlyArray).includes(provider); +} + +export function assertProviderAllowed( + workspace: Workspace, + provider: NotificationProvider, +): void { + if (!isPlanGated(provider)) return; + const allowed = workspace.limits[provider]; + if (!allowed) { + throw new LimitExceededError(provider, 0); + } +} + +// Provider → canonical data schema. Each provider-specific schema is +// keyed by the provider name itself, so validating against the exact +// schema guarantees both (a) the key is present, and (b) its payload has +// the right shape. Just asserting `provider in data` would let a case +// like `{ discord: "invalid-url", slack: "valid-url" }` slip through — +// the union parse picks the slack variant and the key check sees discord. +const providerDataSchemas = { + discord: discordDataSchema, + email: emailDataSchema, + "google-chat": googleChatDataSchema, + "grafana-oncall": grafanaOncallDataSchema, + ntfy: ntfyDataSchema, + opsgenie: opsgenieDataSchema, + pagerduty: pagerdutyDataSchema, + slack: slackDataSchema, + sms: phoneDataSchema, + telegram: telegramDataSchema, + webhook: webhookDataSchema, + whatsapp: whatsappDataSchema, +} as const satisfies Record; + +/** + * Validate that `data` is the canonical payload for the given `provider`. + * Runs the provider-specific Zod schema — this checks both key presence + * and the value's shape/content in one pass. + */ +export function validateNotificationData( + provider: NotificationProvider, + data: NotificationDataInput, +): void { + const schema = providerDataSchemas[provider]; + const parsed = schema.safeParse(data); + if (!parsed.success) { + throw new ValidationError( + `Invalid data for provider "${provider}".`, + parsed.error, + ); + } +} diff --git a/packages/services/src/notification/list.ts b/packages/services/src/notification/list.ts new file mode 100644 index 000000000..081cd3629 --- /dev/null +++ b/packages/services/src/notification/list.ts @@ -0,0 +1,138 @@ +import { + and, + asc, + db as defaultDb, + desc, + eq, + inArray, + isNull, + sql, +} from "@openstatus/db"; +import { + monitor, + notification, + notificationsToMonitors, + selectMonitorSchema, + selectNotificationSchema, +} from "@openstatus/db/src/schema"; + +import type { DB, ServiceContext } from "../context"; +import type { Monitor, Notification } from "../types"; +import { getNotificationInWorkspace } from "./internal"; +import { GetNotificationInput, ListNotificationsInput } from "./schemas"; + +export type NotificationWithRelations = Notification & { + monitors: Monitor[]; +}; + +export type ListNotificationsResult = { + items: NotificationWithRelations[]; + totalSize: number; +}; + +/** + * Load monitors for a set of notifications in a single IN query. Scoped to + * the caller's workspace and excluding soft-deleted monitors for + * defence-in-depth. + */ +async function enrichNotificationsBatch( + db: DB, + rows: Array, + workspaceId: number, +): Promise { + if (rows.length === 0) return []; + const ids = rows.map((r) => r.id); + + const assocRows = await db + .select() + .from(monitor) + .innerJoin( + notificationsToMonitors, + eq(notificationsToMonitors.monitorId, monitor.id), + ) + .where( + and( + inArray(notificationsToMonitors.notificationId, ids), + eq(monitor.workspaceId, workspaceId), + isNull(monitor.deletedAt), + ), + ) + .all(); + + const monitorsByNotification = new Map(); + for (const row of assocRows as Array<{ + monitor: typeof monitor.$inferSelect; + notifications_to_monitors: { notificationId: number }; + }>) { + const nId = row.notifications_to_monitors.notificationId; + const parsed = selectMonitorSchema.parse(row.monitor); + const arr = monitorsByNotification.get(nId); + if (arr) arr.push(parsed); + else monitorsByNotification.set(nId, [parsed]); + } + + return rows.map((r) => ({ + ...selectNotificationSchema.parse(r), + monitors: monitorsByNotification.get(r.id) ?? [], + })); +} + +export async function listNotifications(args: { + ctx: ServiceContext; + input: ListNotificationsInput; +}): Promise { + const { ctx } = args; + const input = ListNotificationsInput.parse(args.input); + const db = ctx.db ?? defaultDb; + + const whereClause = eq(notification.workspaceId, ctx.workspace.id); + + const [countRow, rows] = await Promise.all([ + db + .select({ count: sql`count(*)` }) + .from(notification) + .where(whereClause) + .get(), + db + .select() + .from(notification) + .where(whereClause) + // Secondary sort by `id` (matching the primary direction) so + // pages are stable when two rows share a `createdAt` — pure + // `createdAt ORDER BY` can shuffle ties across requests, which + // drops or duplicates rows at page boundaries. + .orderBy( + ...(input.order === "asc" + ? [asc(notification.createdAt), asc(notification.id)] + : [desc(notification.createdAt), desc(notification.id)]), + ) + .limit(input.limit) + .offset(input.offset) + .all(), + ]); + + const totalSize = countRow?.count ?? 0; + const items = await enrichNotificationsBatch(db, rows, ctx.workspace.id); + return { items, totalSize }; +} + +export async function getNotification(args: { + ctx: ServiceContext; + input: GetNotificationInput; +}): Promise { + const { ctx } = args; + const input = GetNotificationInput.parse(args.input); + const db = ctx.db ?? defaultDb; + const record = await getNotificationInWorkspace({ + tx: db, + id: input.id, + workspaceId: ctx.workspace.id, + }); + const [enriched] = await enrichNotificationsBatch( + db, + [record], + ctx.workspace.id, + ); + // biome-ignore lint/style/noNonNullAssertion: always defined for len === 1 + return enriched!; +} diff --git a/packages/services/src/notification/schemas.ts b/packages/services/src/notification/schemas.ts new file mode 100644 index 000000000..1f51759ef --- /dev/null +++ b/packages/services/src/notification/schemas.ts @@ -0,0 +1,56 @@ +import { notificationProvider } from "@openstatus/db/src/schema/notifications/constants"; +import { z } from "zod"; + +export { notificationProvider }; +export const notificationProviderSchema = z.enum(notificationProvider); + +// `data` mirrors the existing tRPC input shape: a partial record keyed by +// provider where each value is either a simple string (urls, phone numbers, +// tokens) or a nested record for structured configs (webhook headers, etc.). +// Persisted as a single JSON string on `notification.data`. Exported so the +// tRPC router reuses the same shape — otherwise the router's hand-rolled +// copy can silently drift from the service and accept inputs the service +// will reject. +export const NotificationDataInputSchema = z.partialRecord( + notificationProviderSchema, + z.union([ + z.string(), + z.record( + z.string(), + z.union([ + z.string(), + z.array(z.object({ key: z.string(), value: z.string() })), + ]), + ), + ]), +); +export type NotificationDataInput = z.infer; + +export const CreateNotificationInput = z.object({ + name: z.string().min(1), + provider: notificationProviderSchema, + data: NotificationDataInputSchema, + monitors: z.array(z.number().int()).default([]), +}); +export type CreateNotificationInput = z.infer; + +export const UpdateNotificationInput = z.object({ + id: z.number().int(), + name: z.string().min(1), + data: NotificationDataInputSchema, + monitors: z.array(z.number().int()).default([]), +}); +export type UpdateNotificationInput = z.infer; + +export const DeleteNotificationInput = z.object({ id: z.number().int() }); +export type DeleteNotificationInput = z.infer; + +export const GetNotificationInput = z.object({ id: z.number().int() }); +export type GetNotificationInput = z.infer; + +export const ListNotificationsInput = z.object({ + limit: z.number().int().min(1).default(50), + offset: z.number().int().min(0).default(0), + order: z.enum(["asc", "desc"]).default("desc"), +}); +export type ListNotificationsInput = z.infer; diff --git a/packages/services/src/notification/update.ts b/packages/services/src/notification/update.ts new file mode 100644 index 000000000..4f06491b8 --- /dev/null +++ b/packages/services/src/notification/update.ts @@ -0,0 +1,80 @@ +import { eq } from "@openstatus/db"; +import { + notification, + notificationsToMonitors, + selectNotificationSchema, +} from "@openstatus/db/src/schema"; + +import { emitAudit } from "../audit"; +import { type ServiceContext, withTransaction } from "../context"; +import type { Notification } from "../types"; +import { + assertProviderAllowed, + getNotificationInWorkspace, + validateMonitorIds, + validateNotificationData, +} from "./internal"; +import { UpdateNotificationInput } from "./schemas"; + +export async function updateNotification(args: { + ctx: ServiceContext; + input: UpdateNotificationInput; +}): Promise { + const { ctx } = args; + const input = UpdateNotificationInput.parse(args.input); + + return withTransaction(ctx, async (tx) => { + const existing = await getNotificationInWorkspace({ + tx, + id: input.id, + workspaceId: ctx.workspace.id, + }); + + // Re-check the plan gate against the stored provider. After a plan + // downgrade a previously allowed channel (e.g. pagerduty) should no + // longer be editable — matches the create-time gate. + assertProviderAllowed(ctx.workspace, existing.provider); + + validateNotificationData(existing.provider, input.data); + + const validatedMonitors = await validateMonitorIds({ + tx, + workspaceId: ctx.workspace.id, + monitorIds: input.monitors, + }); + + const updated = await tx + .update(notification) + .set({ + name: input.name, + data: JSON.stringify(input.data), + updatedAt: new Date(), + }) + .where(eq(notification.id, existing.id)) + .returning() + .get(); + + await tx + .delete(notificationsToMonitors) + .where(eq(notificationsToMonitors.notificationId, existing.id)); + if (validatedMonitors.length > 0) { + await tx.insert(notificationsToMonitors).values( + validatedMonitors.map((monitorId) => ({ + notificationId: existing.id, + monitorId, + })), + ); + } + + await emitAudit(tx, ctx, { + action: "notification.update", + entityType: "notification", + entityId: existing.id, + before: existing, + after: updated, + metadata: { provider: existing.provider }, + }); + + return selectNotificationSchema.parse(updated); + }); +} diff --git a/packages/services/src/page-component/__tests__/page-component.test.ts b/packages/services/src/page-component/__tests__/page-component.test.ts new file mode 100644 index 000000000..7354a4dd5 --- /dev/null +++ b/packages/services/src/page-component/__tests__/page-component.test.ts @@ -0,0 +1,310 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + test, +} from "bun:test"; +import { and, db, eq, inArray } from "@openstatus/db"; +import { + monitor, + page, + pageComponent, + pageComponentGroup, +} from "@openstatus/db/src/schema"; + +import { + SEEDED_WORKSPACE_FREE_ID, + SEEDED_WORKSPACE_TEAM_ID, +} from "../../../test/fixtures"; +import { + expectAuditRow, + loadSeededWorkspace, + makeUserCtx, + withAuditBuffer, +} from "../../../test/helpers"; +import type { AuditLogRecord } from "../../audit"; +import type { ServiceContext } from "../../context"; +import { ForbiddenError, NotFoundError } from "../../errors"; +import { deletePageComponent } from "../delete"; +import { listPageComponents } from "../list"; +import { updatePageComponentOrder } from "../update-order"; + +const TEST_PREFIX = "svc-page-component-test"; + +let teamCtx: ServiceContext; +let freeCtx: ServiceContext; +let testPageId: number; +let teamMonitorId: number; +let freeMonitorId: number; +let auditBuffer: AuditLogRecord[]; +let auditReset: () => void; +const createdComponentIds: number[] = []; + +beforeAll(async () => { + const team = await loadSeededWorkspace(SEEDED_WORKSPACE_TEAM_ID); + const free = await loadSeededWorkspace(SEEDED_WORKSPACE_FREE_ID); + teamCtx = makeUserCtx(team, { userId: 1 }); + freeCtx = makeUserCtx(free, { userId: 2 }); + + const pageRow = await db + .insert(page) + .values({ + workspaceId: team.id, + title: `${TEST_PREFIX}-page`, + description: "test", + slug: `${TEST_PREFIX}-slug`, + customDomain: "", + }) + .returning() + .get(); + testPageId = pageRow.id; + + const teamMonitor = await db + .insert(monitor) + .values({ + workspaceId: team.id, + active: true, + url: "https://example.com", + name: `${TEST_PREFIX}-team-monitor`, + method: "GET", + periodicity: "10m", + regions: "ams", + }) + .returning() + .get(); + teamMonitorId = teamMonitor.id; + + const freeMonitor = await db + .insert(monitor) + .values({ + workspaceId: free.id, + active: true, + url: "https://example.com", + name: `${TEST_PREFIX}-free-monitor`, + method: "GET", + periodicity: "10m", + regions: "ams", + }) + .returning() + .get(); + freeMonitorId = freeMonitor.id; +}); + +afterAll(async () => { + if (createdComponentIds.length > 0) { + await db + .delete(pageComponent) + .where(inArray(pageComponent.id, createdComponentIds)) + .catch(() => undefined); + } + await db + .delete(pageComponent) + .where(eq(pageComponent.pageId, testPageId)) + .catch(() => undefined); + await db + .delete(pageComponentGroup) + .where(eq(pageComponentGroup.pageId, testPageId)) + .catch(() => undefined); + await db + .delete(monitor) + .where(inArray(monitor.id, [teamMonitorId, freeMonitorId])) + .catch(() => undefined); + await db + .delete(page) + .where(eq(page.id, testPageId)) + .catch(() => undefined); +}); + +beforeEach(() => { + const hooks = withAuditBuffer(); + auditBuffer = hooks.buffer; + auditReset = hooks.reset; +}); + +afterEach(() => { + auditReset(); +}); + +describe("updatePageComponentOrder", () => { + test("creates monitor + static components and a group", async () => { + await updatePageComponentOrder({ + ctx: teamCtx, + input: { + pageId: testPageId, + components: [ + { + order: 0, + name: `${TEST_PREFIX}-monitor-cmp`, + type: "monitor", + monitorId: teamMonitorId, + }, + { + order: 1, + name: `${TEST_PREFIX}-static-cmp`, + type: "static", + }, + ], + groups: [ + { + order: 2, + name: `${TEST_PREFIX}-group`, + defaultOpen: false, + components: [ + { + order: 0, + name: `${TEST_PREFIX}-grouped-static`, + type: "static", + }, + ], + }, + ], + }, + }); + + const components = await db + .select() + .from(pageComponent) + .where(eq(pageComponent.pageId, testPageId)) + .all(); + for (const c of components) createdComponentIds.push(c.id); + const groups = await db + .select() + .from(pageComponentGroup) + .where(eq(pageComponentGroup.pageId, testPageId)) + .all(); + expect(components).toHaveLength(3); + expect(groups).toHaveLength(1); + + await expectAuditRow(auditBuffer, { + action: "page_component.update_order", + entityType: "page", + entityId: testPageId, + }); + }); + + test("rejects cross-workspace monitorId with ForbiddenError", async () => { + await expect( + updatePageComponentOrder({ + ctx: teamCtx, + input: { + pageId: testPageId, + components: [ + { + order: 0, + name: `${TEST_PREFIX}-cross-ws`, + type: "monitor", + monitorId: freeMonitorId, + }, + ], + groups: [], + }, + }), + ).rejects.toBeInstanceOf(ForbiddenError); + }); + + test("rejects cross-workspace pageId with ForbiddenError", async () => { + await expect( + updatePageComponentOrder({ + ctx: freeCtx, + input: { + pageId: testPageId, // team's page + components: [], + groups: [], + }, + }), + ).rejects.toBeInstanceOf(ForbiddenError); + }); + + test("upserts monitor components without creating duplicates", async () => { + // The `(pageId, monitorId)` unique constraint + `onConflictDoUpdate` + // is the riskiest path in this service: a regression here would + // silently insert duplicate rows on every re-invocation. Run the + // service twice with the same `monitorId` — expect exactly one + // row, with the second call's values winning the update. + await updatePageComponentOrder({ + ctx: teamCtx, + input: { + pageId: testPageId, + components: [ + { + order: 0, + name: `${TEST_PREFIX}-upsert-initial`, + type: "monitor", + monitorId: teamMonitorId, + }, + ], + groups: [], + }, + }); + await updatePageComponentOrder({ + ctx: teamCtx, + input: { + pageId: testPageId, + components: [ + { + order: 5, + name: `${TEST_PREFIX}-upsert-renamed`, + type: "monitor", + monitorId: teamMonitorId, + }, + ], + groups: [], + }, + }); + + const rows = await db + .select() + .from(pageComponent) + .where( + and( + eq(pageComponent.pageId, testPageId), + eq(pageComponent.type, "monitor"), + eq(pageComponent.monitorId, teamMonitorId), + ), + ) + .all(); + for (const r of rows) createdComponentIds.push(r.id); + expect(rows).toHaveLength(1); + expect(rows[0]?.name).toBe(`${TEST_PREFIX}-upsert-renamed`); + expect(rows[0]?.order).toBe(5); + }); +}); + +describe("listPageComponents", () => { + test("respects workspace isolation", async () => { + const teamResult = await listPageComponents({ + ctx: teamCtx, + input: { pageId: testPageId, order: "asc" }, + }); + expect(teamResult.length).toBeGreaterThan(0); + + const freeResult = await listPageComponents({ + ctx: freeCtx, + input: { pageId: testPageId, order: "asc" }, + }); + expect(freeResult).toHaveLength(0); + }); +}); + +describe("deletePageComponent", () => { + test("throws NotFoundError for cross-workspace id", async () => { + const [anyComponent] = await db + .select() + .from(pageComponent) + .where(eq(pageComponent.pageId, testPageId)) + .all(); + if (!anyComponent) { + throw new Error("test setup broken: no components present"); + } + + await expect( + deletePageComponent({ + ctx: freeCtx, + input: { id: anyComponent.id }, + }), + ).rejects.toBeInstanceOf(NotFoundError); + }); +}); diff --git a/packages/services/src/page-component/delete.ts b/packages/services/src/page-component/delete.ts new file mode 100644 index 000000000..46ec66308 --- /dev/null +++ b/packages/services/src/page-component/delete.ts @@ -0,0 +1,43 @@ +import { and, eq } from "@openstatus/db"; +import { pageComponent } from "@openstatus/db/src/schema"; + +import { emitAudit } from "../audit"; +import { type ServiceContext, withTransaction } from "../context"; +import { NotFoundError } from "../errors"; +import { DeletePageComponentInput } from "./schemas"; + +/** + * Hard-delete a page component. Cascade clears + * `status_report_to_page_component` / `maintenance_to_page_component` + * (FK cascade delete on the join tables). + */ +export async function deletePageComponent(args: { + ctx: ServiceContext; + input: DeletePageComponentInput; +}): Promise { + const { ctx } = args; + const input = DeletePageComponentInput.parse(args.input); + + await withTransaction(ctx, async (tx) => { + const existing = await tx + .select() + .from(pageComponent) + .where( + and( + eq(pageComponent.id, input.id), + eq(pageComponent.workspaceId, ctx.workspace.id), + ), + ) + .get(); + if (!existing) throw new NotFoundError("page_component", input.id); + + await tx.delete(pageComponent).where(eq(pageComponent.id, existing.id)); + + await emitAudit(tx, ctx, { + action: "page_component.delete", + entityType: "page_component", + entityId: existing.id, + before: existing, + }); + }); +} diff --git a/packages/services/src/page-component/index.ts b/packages/services/src/page-component/index.ts new file mode 100644 index 000000000..1e4166a55 --- /dev/null +++ b/packages/services/src/page-component/index.ts @@ -0,0 +1,12 @@ +export { deletePageComponent } from "./delete"; +export { + listPageComponents, + type PageComponentWithRelations, +} from "./list"; +export { updatePageComponentOrder } from "./update-order"; + +export { + DeletePageComponentInput, + ListPageComponentsInput, + UpdatePageComponentOrderInput, +} from "./schemas"; diff --git a/packages/services/src/page-component/internal.ts b/packages/services/src/page-component/internal.ts new file mode 100644 index 000000000..fa05e7986 --- /dev/null +++ b/packages/services/src/page-component/internal.ts @@ -0,0 +1,53 @@ +import { and, eq, inArray, isNull } from "@openstatus/db"; +import { monitor, page } from "@openstatus/db/src/schema"; + +import type { DB } from "../context"; +import { ForbiddenError } from "../errors"; + +/** Assert a page is in the workspace. Throws `ForbiddenError` otherwise. */ +export async function assertPageInWorkspace(args: { + tx: DB; + pageId: number; + workspaceId: number; +}): Promise { + const { tx, pageId, workspaceId } = args; + const row = await tx + .select({ id: page.id }) + .from(page) + .where(and(eq(page.id, pageId), eq(page.workspaceId, workspaceId))) + .get(); + if (!row) throw new ForbiddenError("You don't have access to this page."); +} + +/** + * Verify the supplied monitor ids all belong to the workspace. Duplicated + * from `monitor/internal.ts` deliberately; a shared `packages/services/src/ + * internal/` extraction is a natural follow-up once a third consumer + * appears (tags-to-workspace may be a candidate). + */ +export async function validateMonitorIds(args: { + tx: DB; + workspaceId: number; + monitorIds: ReadonlyArray; +}): Promise { + const { tx, workspaceId, monitorIds } = args; + if (monitorIds.length === 0) return; + const ids = Array.from(new Set(monitorIds)); + // Exclude soft-deleted monitors — a deleted monitor's id shouldn't be + // attachable to a fresh page component. Without this filter the row + // count matches on ids pointing at rows that are already tombstoned. + const rows = await tx + .select({ id: monitor.id }) + .from(monitor) + .where( + and( + inArray(monitor.id, ids), + eq(monitor.workspaceId, workspaceId), + isNull(monitor.deletedAt), + ), + ) + .all(); + if (rows.length !== ids.length) { + throw new ForbiddenError("Invalid monitor IDs."); + } +} diff --git a/packages/services/src/page-component/list.ts b/packages/services/src/page-component/list.ts new file mode 100644 index 000000000..1987577e2 --- /dev/null +++ b/packages/services/src/page-component/list.ts @@ -0,0 +1,196 @@ +import { + type SQL, + and, + asc, + db as defaultDb, + desc, + eq, + inArray, + isNull, +} from "@openstatus/db"; +import { + maintenance, + maintenancesToPageComponents, + monitor, + pageComponent, + pageComponentGroup, + selectMaintenanceSchema, + selectMonitorSchema, + selectPageComponentGroupSchema, + selectPageComponentSchema, + selectStatusReportSchema, + statusReport, + statusReportsToPageComponents, +} from "@openstatus/db/src/schema"; + +import type { DB, ServiceContext } from "../context"; +import type { + Maintenance, + Monitor, + PageComponent, + StatusReport, +} from "../types"; +import { ListPageComponentsInput } from "./schemas"; + +type PageComponentGroupRow = typeof pageComponentGroup.$inferSelect; + +export type PageComponentWithRelations = PageComponent & { + monitor?: Monitor | null; + group?: PageComponentGroupRow | null; + statusReports: StatusReport[]; + maintenances: Maintenance[]; +}; + +/** + * Batched enrichment for a list of page components. Four IN queries total + * (monitors / groups / status reports via join / maintenances via join); + * no per-row fan-out. + */ +async function enrichPageComponentsBatch( + db: DB, + rows: Array, + workspaceId: number, +): Promise { + if (rows.length === 0) return []; + const ids = rows.map((r) => r.id); + const monitorIds = Array.from( + new Set(rows.map((r) => r.monitorId).filter((m): m is number => m != null)), + ); + const groupIds = Array.from( + new Set(rows.map((r) => r.groupId).filter((g): g is number => g != null)), + ); + + const [monitorRows, groupRows, statusReportRows, maintenanceRows] = + await Promise.all([ + monitorIds.length > 0 + ? db + .select() + .from(monitor) + .where( + and( + inArray(monitor.id, monitorIds), + eq(monitor.workspaceId, workspaceId), + // Skip soft-deleted monitors so the enrichment map + // doesn't resurrect tombstoned rows as `component.monitor`. + isNull(monitor.deletedAt), + ), + ) + .all() + : Promise.resolve([] as never[]), + groupIds.length > 0 + ? db + .select() + .from(pageComponentGroup) + .where( + and( + inArray(pageComponentGroup.id, groupIds), + eq(pageComponentGroup.workspaceId, workspaceId), + ), + ) + .all() + : Promise.resolve([] as never[]), + db + .select() + .from(statusReport) + .innerJoin( + statusReportsToPageComponents, + eq(statusReportsToPageComponents.statusReportId, statusReport.id), + ) + .where( + and( + inArray(statusReportsToPageComponents.pageComponentId, ids), + eq(statusReport.workspaceId, workspaceId), + ), + ) + .all(), + db + .select() + .from(maintenance) + .innerJoin( + maintenancesToPageComponents, + eq(maintenancesToPageComponents.maintenanceId, maintenance.id), + ) + .where( + and( + inArray(maintenancesToPageComponents.pageComponentId, ids), + eq(maintenance.workspaceId, workspaceId), + ), + ) + .all(), + ]); + + const monitorById = new Map(); + for (const m of monitorRows as Array) { + monitorById.set(m.id, selectMonitorSchema.parse(m)); + } + + const groupById = new Map(); + for (const g of groupRows as PageComponentGroupRow[]) { + groupById.set(g.id, selectPageComponentGroupSchema.parse(g)); + } + + const statusReportsByComponent = new Map(); + for (const row of statusReportRows as Array<{ + status_report: typeof statusReport.$inferSelect; + status_report_to_page_component: { + pageComponentId: number; + createdAt: Date | null; + }; + }>) { + const cId = row.status_report_to_page_component.pageComponentId; + const parsed = selectStatusReportSchema.parse(row.status_report); + const arr = statusReportsByComponent.get(cId); + if (arr) arr.push(parsed); + else statusReportsByComponent.set(cId, [parsed]); + } + + const maintenancesByComponent = new Map(); + for (const row of maintenanceRows as Array<{ + maintenance: typeof maintenance.$inferSelect; + maintenance_to_page_component: { + pageComponentId: number; + createdAt: Date | null; + }; + }>) { + const cId = row.maintenance_to_page_component.pageComponentId; + const parsed = selectMaintenanceSchema.parse(row.maintenance); + const arr = maintenancesByComponent.get(cId); + if (arr) arr.push(parsed); + else maintenancesByComponent.set(cId, [parsed]); + } + + return rows.map((r) => ({ + ...selectPageComponentSchema.parse(r), + monitor: r.monitorId != null ? monitorById.get(r.monitorId) ?? null : null, + group: r.groupId != null ? groupById.get(r.groupId) ?? null : null, + statusReports: statusReportsByComponent.get(r.id) ?? [], + maintenances: maintenancesByComponent.get(r.id) ?? [], + })); +} + +export async function listPageComponents(args: { + ctx: ServiceContext; + input: ListPageComponentsInput; +}): Promise { + const { ctx } = args; + const input = ListPageComponentsInput.parse(args.input); + const db = ctx.db ?? defaultDb; + + const conditions: SQL[] = [eq(pageComponent.workspaceId, ctx.workspace.id)]; + if (input.pageId !== undefined) { + conditions.push(eq(pageComponent.pageId, input.pageId)); + } + + const rows = await db + .select() + .from(pageComponent) + .where(and(...conditions)) + .orderBy( + input.order === "desc" + ? desc(pageComponent.order) + : asc(pageComponent.order), + ) + .all(); + + return enrichPageComponentsBatch(db, rows, ctx.workspace.id); +} diff --git a/packages/services/src/page-component/schemas.ts b/packages/services/src/page-component/schemas.ts new file mode 100644 index 000000000..aedb60376 --- /dev/null +++ b/packages/services/src/page-component/schemas.ts @@ -0,0 +1,58 @@ +import { z } from "zod"; + +// Flat shape + cross-field `.refine` for the monitor/static invariant. +// The DB has a matching CHECK constraint +// (`type='monitor' AND monitor_id IS NOT NULL OR type='static' AND +// monitor_id IS NULL`); catching it at parse time produces a clean +// `ZodError` instead of letting it surface as an opaque SQLite CHECK +// failure. A `z.discriminatedUnion` would express the same invariant +// more precisely in the type system, but react-hook-form (used by the +// dashboard forms that submit this shape) doesn't model discriminated +// unions cleanly — every caller would need a flat→union adapter. +// `.refine` keeps the inferred TS shape flat so callers can use their +// existing RHF schemas unchanged and we still get the service-level +// parse error when the invariant is violated. +const componentInput = z + .object({ + id: z.number().int().optional(), + monitorId: z.number().int().nullish(), + order: z.number().int(), + name: z.string(), + description: z.string().nullish(), + type: z.enum(["monitor", "static"]), + }) + .refine( + (c) => (c.type === "monitor" ? c.monitorId != null : c.monitorId == null), + { + path: ["monitorId"], + message: + "Monitor components require a monitorId; static components must not set one.", + }, + ); + +const groupInput = z.object({ + order: z.number().int(), + name: z.string(), + defaultOpen: z.boolean().optional().default(false), + components: z.array(componentInput), +}); + +export const ListPageComponentsInput = z.object({ + pageId: z.number().int().optional(), + order: z.enum(["asc", "desc"]).default("asc"), +}); +export type ListPageComponentsInput = z.infer; + +export const DeletePageComponentInput = z.object({ + id: z.number().int(), +}); +export type DeletePageComponentInput = z.infer; + +export const UpdatePageComponentOrderInput = z.object({ + pageId: z.number().int(), + components: z.array(componentInput), + groups: z.array(groupInput), +}); +export type UpdatePageComponentOrderInput = z.infer< + typeof UpdatePageComponentOrderInput +>; diff --git a/packages/services/src/page-component/update-order.ts b/packages/services/src/page-component/update-order.ts new file mode 100644 index 000000000..826220886 --- /dev/null +++ b/packages/services/src/page-component/update-order.ts @@ -0,0 +1,334 @@ +import { and, eq, inArray, ne, sql } from "@openstatus/db"; +import { pageComponent, pageComponentGroup } from "@openstatus/db/src/schema"; + +import { emitAudit } from "../audit"; +import { type ServiceContext, withTransaction } from "../context"; +import { LimitExceededError } from "../errors"; +import { assertPageInWorkspace, validateMonitorIds } from "./internal"; +import { UpdatePageComponentOrderInput } from "./schemas"; + +/** + * Replace the full order/layout of a page's components and groups in one + * transaction. Mirrors the pre-migration tRPC behaviour exactly — the + * update-order flow is a diff-and-reconcile pass: + * + * 1. Validate the page is in the workspace. + * 2. Enforce the `page-components` plan limit across the workspace. + * 3. Validate every monitor id on the input set belongs to the workspace. + * 4. Delete removed monitor and static components. + * 5. Clear `groupId` on all components (prevents FK errors before the + * next step), then delete existing groups and recreate them. + * 6. Upsert monitor components via the `(pageId, monitorId)` unique + * constraint (preserves existing ids). + * 7. Update existing static components by id; insert new ones. + */ +export async function updatePageComponentOrder(args: { + ctx: ServiceContext; + input: UpdatePageComponentOrderInput; +}): Promise { + const { ctx } = args; + const input = UpdatePageComponentOrderInput.parse(args.input); + + await withTransaction(ctx, async (tx) => { + await assertPageInWorkspace({ + tx, + pageId: input.pageId, + workspaceId: ctx.workspace.id, + }); + + const pageComponentLimit = ctx.workspace.limits["page-components"]; + + const existingComponents = await tx + .select() + .from(pageComponent) + .where( + and( + eq(pageComponent.pageId, input.pageId), + eq(pageComponent.workspaceId, ctx.workspace.id), + ), + ) + .all(); + + // Count components on OTHER pages so we can reject requests that push + // the workspace past the plan cap. + const otherPagesComponentCount = await tx + .select({ id: pageComponent.id }) + .from(pageComponent) + .where( + and( + eq(pageComponent.workspaceId, ctx.workspace.id), + ne(pageComponent.pageId, input.pageId), + ), + ) + .all(); + + const inputComponentCount = + input.components.length + + input.groups.reduce((sum, g) => sum + g.components.length, 0); + + const totalAfterUpdate = + otherPagesComponentCount.length + inputComponentCount; + + if (totalAfterUpdate > pageComponentLimit) { + throw new LimitExceededError("page-components", pageComponentLimit); + } + + const existingGroups = await tx + .select() + .from(pageComponentGroup) + .where( + and( + eq(pageComponentGroup.pageId, input.pageId), + eq(pageComponentGroup.workspaceId, ctx.workspace.id), + ), + ) + .all(); + + const existingGroupIds = existingGroups.map((g) => g.id); + + // `schemas.ts` enforces the `type === "monitor" → monitorId != null` + // invariant via `.refine`, but the refined type is still flat so we + // keep the defensive `&& c.monitorId` guard to narrow + // `monitorId: number | null | undefined` to `number` for TS. The + // refine parse would have already rejected any violating input. + const inputMonitorIds = [ + ...input.components + .filter((c) => c.type === "monitor" && c.monitorId) + .map((c) => c.monitorId), + ...input.groups.flatMap((g) => + g.components + .filter((c) => c.type === "monitor" && c.monitorId) + .map((c) => c.monitorId), + ), + ] as number[]; + + await validateMonitorIds({ + tx, + workspaceId: ctx.workspace.id, + monitorIds: inputMonitorIds, + }); + + const inputStaticComponentIds = [ + ...input.components + .filter((c) => c.type === "static" && c.id) + .map((c) => c.id), + ...input.groups.flatMap((g) => + g.components + .filter((c) => c.type === "static" && c.id) + .map((c) => c.id), + ), + ] as number[]; + + // Remove monitor components whose monitorId isn't in the new input. + const removedMonitorComponents = existingComponents.filter( + (c) => + c.type === "monitor" && + c.monitorId && + !inputMonitorIds.includes(c.monitorId), + ); + + // Static component removal: if any input static components carry ids + // we keep those and drop the rest; otherwise drop all existing static + // components. Matches the pre-migration semantics for the "recreate + // from scratch" flow the dashboard uses. + // + // Simplified from a previous two-step guard that branched on + // `hasStaticComponentsInInput` first — both "input has static with + // no ids" and "input has no static at all" collapsed to the same + // "drop all" action, so the outer check was dead weight. + const removedStaticComponents = existingComponents.filter((c) => { + if (c.type !== "static") return false; + if (inputStaticComponentIds.length > 0) { + return !inputStaticComponentIds.includes(c.id); + } + return true; + }); + + const removedComponentIds = [ + ...removedMonitorComponents.map((c) => c.id), + ...removedStaticComponents.map((c) => c.id), + ]; + + if (removedComponentIds.length > 0) { + await tx + .delete(pageComponent) + .where( + and( + eq(pageComponent.pageId, input.pageId), + eq(pageComponent.workspaceId, ctx.workspace.id), + inArray(pageComponent.id, removedComponentIds), + ), + ); + } + + // Clear `groupId` before deleting groups — otherwise the FK blocks us. + if (existingGroupIds.length > 0) { + await tx + .update(pageComponent) + .set({ groupId: null }) + .where( + and( + eq(pageComponent.pageId, input.pageId), + eq(pageComponent.workspaceId, ctx.workspace.id), + inArray(pageComponent.groupId, existingGroupIds), + ), + ); + } + + if (existingGroupIds.length > 0) { + await tx + .delete(pageComponentGroup) + .where( + and( + eq(pageComponentGroup.pageId, input.pageId), + eq(pageComponentGroup.workspaceId, ctx.workspace.id), + ), + ); + } + + // Sequential inserts instead of a bulk `.values([...]).returning()`. + // The previous bulk call relied on Drizzle/SQLite returning rows in + // the same order as they were inserted to line up `newGroups[i]` + // with `input.groups[i]` when the component mapping below runs. + // Turso/libSQL happens to preserve that order today, but it's an + // implicit coupling — any future driver change, batch split, or + // sort side-effect would silently land components in the wrong + // group with no error signal. Inserting one at a time is trivial + // cost for a small set (groups on a status page are capped) and + // makes the index alignment a guarantee rather than an assumption. + const newGroups: Array<{ id: number; name: string }> = []; + for (const g of input.groups) { + const [created] = await tx + .insert(pageComponentGroup) + .values({ + pageId: input.pageId, + workspaceId: ctx.workspace.id, + name: g.name, + defaultOpen: g.defaultOpen, + }) + .returning(); + if (!created) { + throw new Error("Failed to insert page component group"); + } + newGroups.push(created); + } + + const groupComponentValues = input.groups.flatMap((g, i) => + g.components.map((c) => ({ + id: c.id, + pageId: input.pageId, + workspaceId: ctx.workspace.id, + name: c.name, + description: c.description, + type: c.type, + monitorId: c.monitorId, + order: g.order, + groupId: newGroups[i]?.id, + groupOrder: c.order, + })), + ); + + const standaloneComponentValues = input.components.map((c) => ({ + id: c.id, + pageId: input.pageId, + workspaceId: ctx.workspace.id, + name: c.name, + description: c.description, + type: c.type, + monitorId: c.monitorId, + order: c.order, + groupId: null as number | null, + groupOrder: null as number | null, + })); + + const allComponentValues = [ + ...groupComponentValues, + ...standaloneComponentValues, + ]; + + const monitorComponents = allComponentValues.filter( + (c) => c.type === "monitor" && c.monitorId, + ); + const staticComponents = allComponentValues.filter( + (c) => c.type === "static", + ); + + // Use the `(pageId, monitorId)` unique constraint to preserve ids. + if (monitorComponents.length > 0) { + await tx + .insert(pageComponent) + .values(monitorComponents) + .onConflictDoUpdate({ + target: [pageComponent.pageId, pageComponent.monitorId], + set: { + name: sql.raw("excluded.`name`"), + description: sql.raw("excluded.`description`"), + order: sql.raw("excluded.`order`"), + groupId: sql.raw("excluded.`group_id`"), + groupOrder: sql.raw("excluded.`group_order`"), + updatedAt: sql`(strftime('%s', 'now'))`, + }, + }); + } + + // Restrict the "take update path" set to ids that (a) still exist + // after the delete pass above and (b) correspond to *static* rows. + // Without both filters, an input static carrying an id that just + // got deleted (e.g. because the row was a monitor component whose + // monitorId is no longer in the input set) would match `has(id)` + // on the stale pre-delete snapshot, take the UPDATE branch, and + // silently no-op — the new static never gets inserted. + const removedIdSet = new Set(removedComponentIds); + const existingStaticComponentIds = new Set( + existingComponents + .filter((c) => c.type === "static" && !removedIdSet.has(c.id)) + .map((c) => c.id), + ); + + for (const c of staticComponents) { + if (c.id && existingStaticComponentIds.has(c.id)) { + await tx + .update(pageComponent) + .set({ + name: c.name, + description: c.description, + type: c.type, + monitorId: c.monitorId, + order: c.order, + groupId: c.groupId, + groupOrder: c.groupOrder, + updatedAt: new Date(), + }) + .where( + and( + eq(pageComponent.id, c.id), + eq(pageComponent.pageId, input.pageId), + eq(pageComponent.workspaceId, ctx.workspace.id), + ), + ); + } else { + await tx.insert(pageComponent).values({ + pageId: c.pageId, + workspaceId: c.workspaceId, + name: c.name, + description: c.description, + type: c.type, + monitorId: c.monitorId, + order: c.order, + groupId: c.groupId, + groupOrder: c.groupOrder, + }); + } + } + + await emitAudit(tx, ctx, { + action: "page_component.update_order", + entityType: "page", + entityId: input.pageId, + metadata: { + componentCount: inputComponentCount, + groupCount: input.groups.length, + }, + }); + }); +} diff --git a/packages/services/src/page/__tests__/page.test.ts b/packages/services/src/page/__tests__/page.test.ts new file mode 100644 index 000000000..bb1c4f68c --- /dev/null +++ b/packages/services/src/page/__tests__/page.test.ts @@ -0,0 +1,320 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + test, +} from "bun:test"; +import { db, eq, inArray } from "@openstatus/db"; +import { monitor, page, pageComponent } from "@openstatus/db/src/schema"; + +import { + SEEDED_WORKSPACE_FREE_ID, + SEEDED_WORKSPACE_TEAM_ID, +} from "../../../test/fixtures"; +import { + cleanQuotaGatedTables, + expectAuditRow, + loadSeededWorkspace, + makeUserCtx, + withAuditBuffer, +} from "../../../test/helpers"; +import type { AuditLogRecord } from "../../audit"; +import type { ServiceContext } from "../../context"; +import { + ConflictError, + ForbiddenError, + LimitExceededError, + NotFoundError, +} from "../../errors"; +import { createPage, newPage } from "../create"; +import { deletePage } from "../delete"; +import { getPage, getSlugAvailable, listPages } from "../list"; +import { updatePageGeneral, updatePageLocales } from "../update"; + +const TEST_PREFIX = "svc-page-test"; + +let teamCtx: ServiceContext; +let freeCtx: ServiceContext; +let teamMonitorId: number; +let auditBuffer: AuditLogRecord[]; +let auditReset: () => void; +const createdPageIds: number[] = []; + +beforeAll(async () => { + const team = await loadSeededWorkspace(SEEDED_WORKSPACE_TEAM_ID); + const free = await loadSeededWorkspace(SEEDED_WORKSPACE_FREE_ID); + teamCtx = makeUserCtx(team, { userId: 1 }); + freeCtx = makeUserCtx(free, { userId: 2 }); + + // Clear leftover quota-gated rows on the free workspace so + // negative-path tests hit their intended assertion (e.g. + // `assertStatusPageQuota` on free = 1 page) regardless of what + // prior runs left behind. + await cleanQuotaGatedTables(SEEDED_WORKSPACE_FREE_ID); + + const teamMonitor = await db + .insert(monitor) + .values({ + workspaceId: team.id, + active: true, + url: "https://example.com", + name: `${TEST_PREFIX}-monitor`, + method: "GET", + periodicity: "10m", + regions: "ams", + }) + .returning() + .get(); + teamMonitorId = teamMonitor.id; +}); + +afterAll(async () => { + if (createdPageIds.length > 0) { + await db + .delete(pageComponent) + .where(inArray(pageComponent.pageId, createdPageIds)) + .catch(() => undefined); + await db + .delete(page) + .where(inArray(page.id, createdPageIds)) + .catch(() => undefined); + } + await db + .delete(monitor) + .where(eq(monitor.id, teamMonitorId)) + .catch(() => undefined); +}); + +beforeEach(() => { + const hooks = withAuditBuffer(); + auditBuffer = hooks.buffer; + auditReset = hooks.reset; +}); + +afterEach(() => { + auditReset(); +}); + +function track(id: number) { + createdPageIds.push(id); + return id; +} + +let slugCounter = 0; +const uniqueSlug = (tag: string) => `${TEST_PREFIX}-${tag}-${++slugCounter}`; + +describe("newPage", () => { + test("happy path + audit", async () => { + const slug = uniqueSlug("new"); + const row = await newPage({ + ctx: teamCtx, + input: { title: "Test", slug }, + }); + track(row.id); + expect(row.slug).toBe(slug); + await expectAuditRow(auditBuffer, { + action: "page.create", + entityType: "page", + entityId: row.id, + }); + }); + + test("rejects reserved subdomain", async () => { + await expect( + newPage({ + ctx: teamCtx, + input: { title: "Test", slug: "api" }, + }), + ).rejects.toBeInstanceOf(ConflictError); + }); + + test("rejects duplicate slug", async () => { + const slug = uniqueSlug("dup"); + const first = await newPage({ + ctx: teamCtx, + input: { title: "First", slug }, + }); + track(first.id); + await expect( + newPage({ ctx: teamCtx, input: { title: "Second", slug } }), + ).rejects.toBeInstanceOf(ConflictError); + }); +}); + +describe("createPage (full form)", () => { + // `CreatePageInput` re-exports the drizzle `insertPageSchema`, which + // requires `workspaceId` at parse time. The service strips the input + // value and uses `ctx.workspace.id` when persisting (so the input + // value is informational only), but parse-time validation still + // fails without the field. Passing `ctx.workspace.id` satisfies the + // schema and matches what the router does. + test("attaches monitors as pageComponents", async () => { + const slug = uniqueSlug("full"); + const row = await createPage({ + ctx: teamCtx, + input: { + title: "Full Create", + slug, + description: "desc", + customDomain: "", + workspaceId: SEEDED_WORKSPACE_TEAM_ID, + monitors: [{ monitorId: teamMonitorId }], + }, + }); + track(row.id); + const components = await db + .select() + .from(pageComponent) + .where(eq(pageComponent.pageId, row.id)) + .all(); + expect(components.map((c) => c.monitorId)).toEqual([teamMonitorId]); + }); + + test("rejects cross-workspace monitor", async () => { + const slug = uniqueSlug("cross-ws"); + await expect( + createPage({ + ctx: freeCtx, + input: { + title: "Cross", + slug, + description: "", + customDomain: "", + workspaceId: SEEDED_WORKSPACE_FREE_ID, + monitors: [{ monitorId: teamMonitorId }], + }, + }), + ).rejects.toBeInstanceOf(ForbiddenError); + }); +}); + +describe("updatePageGeneral", () => { + test("updates title + slug; rejects duplicate slug", async () => { + const a = await newPage({ + ctx: teamCtx, + input: { title: "A", slug: uniqueSlug("a") }, + }); + const b = await newPage({ + ctx: teamCtx, + input: { title: "B", slug: uniqueSlug("b") }, + }); + track(a.id); + track(b.id); + + // Rename A to a new slug — fine. + const newSlug = uniqueSlug("a-renamed"); + await updatePageGeneral({ + ctx: teamCtx, + input: { id: a.id, title: "A Renamed", slug: newSlug }, + }); + + // Rename A to B's slug — conflict. + await expect( + updatePageGeneral({ + ctx: teamCtx, + input: { id: a.id, title: "A", slug: b.slug }, + }), + ).rejects.toBeInstanceOf(ConflictError); + }); + + test("cross-workspace → NotFoundError", async () => { + const p = await newPage({ + ctx: teamCtx, + input: { title: "Team", slug: uniqueSlug("ws-iso") }, + }); + track(p.id); + await expect( + updatePageGeneral({ + ctx: freeCtx, + input: { id: p.id, title: "Hacked", slug: uniqueSlug("hack") }, + }), + ).rejects.toBeInstanceOf(NotFoundError); + }); +}); + +describe("updatePageLocales", () => { + test("rejects when plan lacks i18n", async () => { + // free plan has i18n: false + const p = await newPage({ + ctx: freeCtx, + input: { title: "Free", slug: uniqueSlug("free") }, + }); + track(p.id); + await expect( + updatePageLocales({ + ctx: freeCtx, + input: { id: p.id, defaultLocale: "en", locales: ["en"] }, + }), + ).rejects.toBeInstanceOf(LimitExceededError); + }); +}); + +describe("list / get / getSlugAvailable", () => { + test("list respects workspace isolation", async () => { + const p = await newPage({ + ctx: teamCtx, + input: { title: "List Test", slug: uniqueSlug("list") }, + }); + track(p.id); + + const teamItems = await listPages({ + ctx: teamCtx, + input: { order: "desc" }, + }); + expect(teamItems.find((x) => x.id === p.id)).toBeDefined(); + + const freeItems = await listPages({ + ctx: freeCtx, + input: { order: "desc" }, + }); + expect(freeItems.find((x) => x.id === p.id)).toBeUndefined(); + }); + + test("get cross-workspace → NotFoundError", async () => { + const p = await newPage({ + ctx: teamCtx, + input: { title: "Get", slug: uniqueSlug("get") }, + }); + track(p.id); + await expect( + getPage({ ctx: freeCtx, input: { id: p.id } }), + ).rejects.toBeInstanceOf(NotFoundError); + }); + + test("getSlugAvailable handles reserved + taken", async () => { + const p = await newPage({ + ctx: teamCtx, + input: { title: "Slug", slug: uniqueSlug("slug") }, + }); + track(p.id); + + expect( + await getSlugAvailable({ ctx: teamCtx, input: { slug: p.slug } }), + ).toBe(false); + expect( + await getSlugAvailable({ ctx: teamCtx, input: { slug: "api" } }), + ).toBe(false); + expect( + await getSlugAvailable({ + ctx: teamCtx, + input: { slug: uniqueSlug("free-slug") }, + }), + ).toBe(true); + }); +}); + +describe("deletePage", () => { + test("cross-workspace → NotFoundError", async () => { + const p = await newPage({ + ctx: teamCtx, + input: { title: "Delete", slug: uniqueSlug("del") }, + }); + track(p.id); + await expect( + deletePage({ ctx: freeCtx, input: { id: p.id } }), + ).rejects.toBeInstanceOf(NotFoundError); + }); +}); diff --git a/packages/services/src/page/create.ts b/packages/services/src/page/create.ts new file mode 100644 index 000000000..1d6e3c9a9 --- /dev/null +++ b/packages/services/src/page/create.ts @@ -0,0 +1,156 @@ +import { + page, + pageComponent, + selectPageSchema, +} from "@openstatus/db/src/schema"; + +import { emitAudit } from "../audit"; +import { type ServiceContext, withTransaction } from "../context"; +import type { Page } from "../types"; +import { + assertAccessTypeAllowed, + assertSlugAvailable, + assertStatusPageQuota, + validateMonitorIdsActive, +} from "./internal"; +import { CreatePageInput, NewPageInput } from "./schemas"; + +/** Full create — mirrors legacy `pageRouter.create` (insertPageSchema input). */ +export async function createPage(args: { + ctx: ServiceContext; + input: CreatePageInput; +}): Promise { + const { ctx } = args; + const input = CreatePageInput.parse(args.input); + + return withTransaction(ctx, async (tx) => { + await assertStatusPageQuota(tx, ctx.workspace); + await assertSlugAvailable({ tx, slug: input.slug }); + assertAccessTypeAllowed(ctx.workspace, { + accessType: input.accessType ?? "public", + passwordProtected: input.passwordProtected ?? null, + allowedIpRanges: input.allowedIpRanges ?? null, + allowIndex: input.allowIndex, + }); + + const { + monitors, + workspaceId: _ws, + id: _id, + configuration, + ...pageProps + } = input; + const monitorIds = monitors?.map((m) => m.monitorId) ?? []; + + const row = await tx + .insert(page) + .values({ + workspaceId: ctx.workspace.id, + // `page.configuration` is a drizzle `text("…", { mode: "json" })` + // column — drizzle serialises objects automatically. Calling + // `JSON.stringify` first would double-encode and persist a raw + // JSON string, breaking downstream reads that expect an object. + configuration, + ...pageProps, + authEmailDomains: pageProps.authEmailDomains?.join(","), + allowedIpRanges: pageProps.allowedIpRanges?.join(","), + }) + .returning() + .get(); + + if (monitorIds.length > 0) { + const validMonitors = await validateMonitorIdsActive({ + tx, + workspaceId: ctx.workspace.id, + monitorIds, + }); + const monitorMap = new Map(validMonitors.map((m) => [m.id, m])); + const pageComponentValues = (monitors ?? []) + .map(({ monitorId }, index) => { + const m = monitorMap.get(monitorId); + if (!m || !m.workspaceId) return null; + return { + workspaceId: m.workspaceId, + pageId: row.id, + type: "monitor" as const, + monitorId, + name: m.externalName || m.name, + order: index, + groupId: null, + groupOrder: 0, + }; + }) + .filter((v): v is NonNullable => v !== null); + if (pageComponentValues.length > 0) { + await tx.insert(pageComponent).values(pageComponentValues).run(); + } + } + + await emitAudit(tx, ctx, { + action: "page.create", + entityType: "page", + entityId: row.id, + after: row, + metadata: { slug: row.slug }, + }); + + // `selectPageSchema.parse` normalises the drizzle row into the + // `Page` shape callers expect: `authEmailDomains` / `allowedIpRanges` + // go from the raw comma-joined string (drizzle-inferred) to the + // `string[]` the `selectPageSchema` defines. Previously this used + // `row as unknown as Page`, which hid the drift. + return selectPageSchema.parse(row); + }); +} + +/** Minimal create — matches the dashboard onboarding `new` shape. */ +export async function newPage(args: { + ctx: ServiceContext; + input: NewPageInput; +}): Promise { + const { ctx } = args; + const input = NewPageInput.parse(args.input); + + return withTransaction(ctx, async (tx) => { + await assertStatusPageQuota(tx, ctx.workspace); + await assertSlugAvailable({ tx, slug: input.slug }); + + const defaultConfiguration = { + type: "absolute", + value: "requests", + uptime: true, + theme: "default-rounded", + }; + + const row = await tx + .insert(page) + .values({ + workspaceId: ctx.workspace.id, + title: input.title, + slug: input.slug, + description: input.description ?? "", + icon: input.icon ?? "", + legacyPage: false, + configuration: defaultConfiguration, + customDomain: "", + allowIndex: true, + }) + .returning() + .get(); + + await emitAudit(tx, ctx, { + action: "page.create", + entityType: "page", + entityId: row.id, + after: row, + metadata: { slug: row.slug, source: "new" }, + }); + + // `selectPageSchema.parse` normalises the drizzle row into the + // `Page` shape callers expect: `authEmailDomains` / `allowedIpRanges` + // go from the raw comma-joined string (drizzle-inferred) to the + // `string[]` the `selectPageSchema` defines. Previously this used + // `row as unknown as Page`, which hid the drift. + return selectPageSchema.parse(row); + }); +} diff --git a/packages/services/src/page/delete.ts b/packages/services/src/page/delete.ts new file mode 100644 index 000000000..58a9de72e --- /dev/null +++ b/packages/services/src/page/delete.ts @@ -0,0 +1,33 @@ +import { eq } from "@openstatus/db"; +import { page } from "@openstatus/db/src/schema"; + +import { emitAudit } from "../audit"; +import { type ServiceContext, withTransaction } from "../context"; +import { getPageInWorkspace } from "./internal"; +import { DeletePageInput } from "./schemas"; + +/** Delete a page. FK cascade clears pageComponents / statusReports / … */ +export async function deletePage(args: { + ctx: ServiceContext; + input: DeletePageInput; +}): Promise { + const { ctx } = args; + const input = DeletePageInput.parse(args.input); + + await withTransaction(ctx, async (tx) => { + const existing = await getPageInWorkspace({ + tx, + id: input.id, + workspaceId: ctx.workspace.id, + }); + + await tx.delete(page).where(eq(page.id, existing.id)); + + await emitAudit(tx, ctx, { + action: "page.delete", + entityType: "page", + entityId: existing.id, + before: existing, + }); + }); +} diff --git a/packages/services/src/page/index.ts b/packages/services/src/page/index.ts new file mode 100644 index 000000000..3adbe75b6 --- /dev/null +++ b/packages/services/src/page/index.ts @@ -0,0 +1,36 @@ +export { createPage, newPage } from "./create"; +export { deletePage } from "./delete"; +export { + getPage, + getPageCustomDomain, + getSlugAvailable, + listPages, + type PageListItem, + type PageWithRelations, +} from "./list"; +export { + updatePageAppearance, + updatePageConfiguration, + updatePageCustomDomain, + updatePageGeneral, + updatePageLinks, + updatePageLocales, + updatePagePasswordProtection, +} from "./update"; + +export { + CreatePageInput, + DeletePageInput, + GetPageInput, + GetSlugAvailableInput, + ListPagesInput, + NewPageInput, + pageAccessTypes, + UpdatePageAppearanceInput, + UpdatePageConfigurationInput, + UpdatePageCustomDomainInput, + UpdatePageGeneralInput, + UpdatePageLinksInput, + UpdatePageLocalesInput, + UpdatePagePasswordProtectionInput, +} from "./schemas"; diff --git a/packages/services/src/page/internal.ts b/packages/services/src/page/internal.ts new file mode 100644 index 000000000..d3aba7bc1 --- /dev/null +++ b/packages/services/src/page/internal.ts @@ -0,0 +1,177 @@ +import { and, count, eq, inArray, isNull, sql } from "@openstatus/db"; +import { monitor, page } from "@openstatus/db/src/schema"; +import { + type pageAccessTypes, + subdomainSafeList, +} from "@openstatus/db/src/schema/pages/constants"; + +import type { DB } from "../context"; +import { + ConflictError, + ForbiddenError, + LimitExceededError, + NotFoundError, + ValidationError, +} from "../errors"; +import type { Workspace } from "../types"; + +/** Load a page by id, scoped to the workspace. Throws on miss. */ +export async function getPageInWorkspace(args: { + tx: DB; + id: number; + workspaceId: number; +}) { + const { tx, id, workspaceId } = args; + const row = await tx + .select() + .from(page) + .where(and(eq(page.id, id), eq(page.workspaceId, workspaceId))) + .get(); + if (!row) throw new NotFoundError("page", id); + return row; +} + +/** Count the workspace's pages. */ +export async function countPagesInWorkspace( + tx: DB, + workspaceId: number, +): Promise { + const res = await tx + .select({ count: count() }) + .from(page) + .where(eq(page.workspaceId, workspaceId)) + .get(); + return res?.count ?? 0; +} + +/** + * Assert a slug is free — rejects reserved subdomains and already-taken + * slugs. Optionally exempts a page id (for updates where the current slug + * shouldn't trip the check). + */ +export async function assertSlugAvailable(args: { + tx: DB; + slug: string; + excludePageId?: number; +}): Promise { + const { tx, slug, excludePageId } = args; + if (subdomainSafeList.includes(slug)) { + throw new ConflictError( + "This slug is already taken. Please choose another one.", + ); + } + const rows = await tx + .select({ id: page.id }) + .from(page) + .where(sql`lower(${page.slug}) = ${slug}`) + .all(); + const conflicts = excludePageId + ? rows.filter((r) => r.id !== excludePageId) + : rows; + if (conflicts.length > 0) { + throw new ConflictError( + "This slug is already taken. Please choose another one.", + ); + } +} + +/** Validate monitor ids against the workspace's active (non-soft-deleted) set. */ +export async function validateMonitorIdsActive(args: { + tx: DB; + workspaceId: number; + monitorIds: ReadonlyArray; +}): Promise< + Array<{ + id: number; + name: string; + externalName: string | null; + workspaceId: number | null; + }> +> { + const { tx, workspaceId, monitorIds } = args; + if (monitorIds.length === 0) return []; + const ids = Array.from(new Set(monitorIds)); + const rows = await tx + .select({ + id: monitor.id, + name: monitor.name, + externalName: monitor.externalName, + workspaceId: monitor.workspaceId, + }) + .from(monitor) + .where( + and( + inArray(monitor.id, ids), + eq(monitor.workspaceId, workspaceId), + eq(monitor.active, true), + isNull(monitor.deletedAt), + ), + ) + .all(); + if (rows.length !== ids.length) { + throw new ForbiddenError( + "You don't have access to all the monitors or some monitors are inactive.", + ); + } + return rows; +} + +/** + * Plan gate for the access-type options on create / update. Mirrors the + * existing tRPC gates. + */ +export function assertAccessTypeAllowed( + workspace: Workspace, + args: { + accessType: (typeof pageAccessTypes)[number]; + passwordProtected?: boolean | null; + allowedIpRanges?: ReadonlyArray | null; + allowIndex?: boolean; + }, +): void { + const limits = workspace.limits; + + if ( + limits["password-protection"] === false && + (args.accessType === "password" || args.passwordProtected === true) + ) { + throw new LimitExceededError("password-protection", 0); + } + if ( + limits["email-domain-protection"] === false && + args.accessType === "email-domain" + ) { + throw new LimitExceededError("email-domain-protection", 0); + } + if ( + limits["ip-restriction"] === false && + args.accessType === "ip-restriction" + ) { + throw new LimitExceededError("ip-restriction", 0); + } + if ( + args.accessType === "ip-restriction" && + (!args.allowedIpRanges || args.allowedIpRanges.length === 0) + ) { + throw new ValidationError( + "At least one IP range is required for IP restriction.", + ); + } + if (args.allowIndex === false && limits["no-index"] === false) { + throw new LimitExceededError("no-index", 0); + } +} + +/** Plan gate on the workspace's `status-pages` cap. */ +export async function assertStatusPageQuota( + tx: DB, + workspace: Workspace, +): Promise { + const current = await countPagesInWorkspace(tx, workspace.id); + if (current >= workspace.limits["status-pages"]) { + throw new LimitExceededError( + "status-pages", + workspace.limits["status-pages"], + ); + } +} diff --git a/packages/services/src/page/list.ts b/packages/services/src/page/list.ts new file mode 100644 index 000000000..42e7c729f --- /dev/null +++ b/packages/services/src/page/list.ts @@ -0,0 +1,190 @@ +import { + and, + asc, + db as defaultDb, + desc, + eq, + inArray, + sql, +} from "@openstatus/db"; +import { + maintenance, + page, + pageComponent, + pageComponentGroup, + selectMaintenanceSchema, + selectPageComponentGroupSchema, + selectPageComponentSchema, + selectPageSchema, + selectStatusReportSchema, + statusReport, +} from "@openstatus/db/src/schema"; + +import { subdomainSafeList } from "@openstatus/db/src/schema/pages/constants"; +import type { ServiceContext } from "../context"; +import { NotFoundError } from "../errors"; +import type { Maintenance, Page, PageComponent, StatusReport } from "../types"; +import { getPageInWorkspace } from "./internal"; +import { GetPageInput, GetSlugAvailableInput, ListPagesInput } from "./schemas"; + +type PageComponentGroupRow = typeof pageComponentGroup.$inferSelect; + +export type PageListItem = Page & { + statusReports: StatusReport[]; +}; + +export type PageWithRelations = Page & { + maintenances: Maintenance[]; + pageComponents: PageComponent[]; + pageComponentGroups: PageComponentGroupRow[]; +}; + +export async function listPages(args: { + ctx: ServiceContext; + input: ListPagesInput; +}): Promise { + const { ctx } = args; + const input = ListPagesInput.parse(args.input); + const db = ctx.db ?? defaultDb; + + const pageRows = await db + .select() + .from(page) + .where(eq(page.workspaceId, ctx.workspace.id)) + .orderBy(input.order === "asc" ? asc(page.createdAt) : desc(page.createdAt)) + .all(); + if (pageRows.length === 0) return []; + + const pageIds = pageRows.map((p) => p.id); + + const reportRows = await db + .select() + .from(statusReport) + .where( + and( + eq(statusReport.workspaceId, ctx.workspace.id), + inArray(statusReport.pageId, pageIds), + ), + ) + .all(); + + const reportsByPage = new Map(); + for (const r of reportRows) { + if (r.pageId == null) continue; + const arr = reportsByPage.get(r.pageId); + const parsed = selectStatusReportSchema.parse(r); + if (arr) arr.push(parsed); + else reportsByPage.set(r.pageId, [parsed]); + } + + return pageRows.map((p) => ({ + ...selectPageSchema.parse(p), + statusReports: reportsByPage.get(p.id) ?? [], + })); +} + +export async function getPage(args: { + ctx: ServiceContext; + input: GetPageInput; +}): Promise { + const { ctx } = args; + const input = GetPageInput.parse(args.input); + const db = ctx.db ?? defaultDb; + + const record = await getPageInWorkspace({ + tx: db, + id: input.id, + workspaceId: ctx.workspace.id, + }); + + const [maintenanceRows, componentRows, groupRows] = await Promise.all([ + db + .select() + .from(maintenance) + .where( + and( + eq(maintenance.pageId, record.id), + eq(maintenance.workspaceId, ctx.workspace.id), + ), + ) + .all(), + db + .select() + .from(pageComponent) + .where( + and( + eq(pageComponent.pageId, record.id), + eq(pageComponent.workspaceId, ctx.workspace.id), + ), + ) + .all(), + db + .select() + .from(pageComponentGroup) + .where( + and( + eq(pageComponentGroup.pageId, record.id), + eq(pageComponentGroup.workspaceId, ctx.workspace.id), + ), + ) + .all(), + ]); + + return { + ...selectPageSchema.parse(record), + maintenances: maintenanceRows.map((m) => selectMaintenanceSchema.parse(m)), + pageComponents: componentRows.map((c) => + selectPageComponentSchema.parse(c), + ), + pageComponentGroups: groupRows.map((g) => + selectPageComponentGroupSchema.parse(g), + ), + }; +} + +/** + * Narrow "just the customDomain" read, scoped to the caller's workspace. + * + * The tRPC `updateCustomDomain` procedure needs the *pre-update* domain + * so it can call Vercel add/remove before the db write — it can't use + * the `existingDomain` returned from `updatePageCustomDomain` because + * Vercel needs the old value up front. Using `getPage` (the full- + * relations read) here would fire 3 extra batched queries for + * maintenances / pageComponents / pageComponentGroups we don't need. + * This one-column select replaces that with a single indexed lookup. + */ +export async function getPageCustomDomain(args: { + ctx: ServiceContext; + input: GetPageInput; +}): Promise { + const { ctx } = args; + const input = GetPageInput.parse(args.input); + const db = ctx.db ?? defaultDb; + + const row = await db + .select({ customDomain: page.customDomain }) + .from(page) + .where(and(eq(page.id, input.id), eq(page.workspaceId, ctx.workspace.id))) + .get(); + + if (!row) throw new NotFoundError("page", input.id); + return row.customDomain; +} + +/** Returns `true` when the slug is free (not reserved, not taken). */ +export async function getSlugAvailable(args: { + ctx: ServiceContext; + input: GetSlugAvailableInput; +}): Promise { + const { ctx } = args; + const input = GetSlugAvailableInput.parse(args.input); + const db = ctx.db ?? defaultDb; + + if (subdomainSafeList.includes(input.slug)) return false; + const rows = await db + .select({ id: page.id }) + .from(page) + .where(sql`lower(${page.slug}) = ${input.slug}`) + .all(); + return rows.length === 0; +} diff --git a/packages/services/src/page/schemas.ts b/packages/services/src/page/schemas.ts new file mode 100644 index 000000000..67dddc6db --- /dev/null +++ b/packages/services/src/page/schemas.ts @@ -0,0 +1,178 @@ +import { insertPageSchema } from "@openstatus/db/src/schema"; +import { pageAccessTypes } from "@openstatus/db/src/schema/pages/constants"; +import { + customDomainSchema, + pageConfigurationSchema, + slugSchema, +} from "@openstatus/db/src/schema/pages/validation"; +import { locales } from "@openstatus/locales"; +import { THEME_KEYS, type ThemeKey } from "@openstatus/theme-store"; +import { z } from "zod"; + +export { pageAccessTypes }; + +/** + * Full-form create — matches the legacy `pageRouter.create` input shape + * (the drizzle insert schema). We type it explicitly because the inferred + * `z.infer` references drizzle-zod internals that + * aren't cleanly portable across the workspace boundary. + */ +export const CreatePageInput = insertPageSchema; +export type CreatePageInput = { + id?: number; + workspaceId?: number; + title: string; + description?: string; + slug: string; + customDomain?: string; + icon?: string | null; + legacyPage?: boolean; + passwordProtected?: boolean | null; + password?: string | null; + accessType?: (typeof pageAccessTypes)[number]; + authEmailDomains?: string[]; + allowedIpRanges?: string[]; + allowIndex?: boolean; + forceTheme?: "light" | "dark" | "system"; + defaultLocale?: (typeof locales)[number]; + locales?: (typeof locales)[number][] | null; + homepageUrl?: string | null; + contactUrl?: string | null; + configuration?: Record | null; + monitors?: Array<{ monitorId: number }>; +}; + +/** Minimal create — the onboarding / `new` path with no monitors. */ +export const NewPageInput = z.object({ + title: z.string(), + // Canonical `slugSchema` from db validation — regex + min(3). + // Plain `z.string().toLowerCase()` here let malformed slugs through + // that `insertPageSchema` would reject, so `create` and `new` had + // diverging contracts. + slug: slugSchema, + icon: z.string().nullish(), + description: z.string().nullish(), +}); +export type NewPageInput = z.infer; + +export const DeletePageInput = z.object({ id: z.number().int() }); +export type DeletePageInput = z.infer; + +export const GetPageInput = z.object({ id: z.number().int() }); +export type GetPageInput = z.infer; + +export const ListPagesInput = z.object({ + order: z.enum(["asc", "desc"]).default("desc"), +}); +export type ListPagesInput = z.infer; + +export const GetSlugAvailableInput = z.object({ + // Same canonical `slugSchema` — don't want `getSlugAvailable` to + // return a confident "available" answer for strings that `createPage` + // would reject at insert time. + slug: slugSchema, +}); +export type GetSlugAvailableInput = z.infer; + +export const UpdatePageGeneralInput = z.object({ + id: z.number().int(), + title: z.string(), + slug: slugSchema, + description: z.string().nullish(), + icon: z.string().nullish(), +}); +export type UpdatePageGeneralInput = z.infer; + +/** + * Only persists the `customDomain` change to the DB. Vercel domain + * operations live at the transport layer — services don't touch external + * integrations. + */ +export const UpdatePageCustomDomainInput = z.object({ + id: z.number().int(), + // Canonical `customDomainSchema` enforces the same `no http://`, + // `no https://`, `no www.` rules as `insertPageSchema`; empty string + // ("" branch of the `.or(...)`) is still allowed so callers can + // clear the domain. + customDomain: customDomainSchema, +}); +export type UpdatePageCustomDomainInput = z.infer< + typeof UpdatePageCustomDomainInput +>; + +export const UpdatePagePasswordProtectionInput = z.object({ + id: z.number().int(), + accessType: z.enum(pageAccessTypes), + authEmailDomains: z.array(z.string()).nullish(), + password: z.string().nullish(), + // Mirror the CIDR validation applied on insert (`insertPageSchema`): bare + // IPs get `/32` appended, everything must be a valid IPv4 CIDR. Without + // this the update path would happily persist malformed ranges. + allowedIpRanges: z + .array( + z + .string() + .transform((s) => { + const trimmed = s.trim(); + return trimmed.includes("/") ? trimmed : `${trimmed}/32`; + }) + .pipe(z.cidrv4()), + ) + .nullish(), + allowIndex: z.boolean().optional(), +}); +export type UpdatePagePasswordProtectionInput = z.infer< + typeof UpdatePagePasswordProtectionInput +>; + +export const UpdatePageAppearanceInput = z.object({ + id: z.number().int(), + forceTheme: z.enum(["light", "dark", "system"]), + // `theme` must be a known THEME_KEYS member — the status-page read + // path parses `configuration` through `pageConfigurationSchema` which + // uses the same enum. An arbitrary string here persists fine but + // fails the read-side parse with an opaque zod error. + configuration: z.object({ + theme: z.enum(THEME_KEYS as [ThemeKey, ...ThemeKey[]]), + }), +}); +export type UpdatePageAppearanceInput = z.infer< + typeof UpdatePageAppearanceInput +>; + +export const UpdatePageLinksInput = z.object({ + id: z.number().int(), + homepageUrl: z.string().nullish(), + contactUrl: z.string().nullish(), +}); +export type UpdatePageLinksInput = z.infer; + +export const UpdatePageLocalesInput = z + .object({ + id: z.number().int(), + defaultLocale: z.enum(locales), + locales: z.array(z.enum(locales)).nullable(), + }) + .refine( + (data) => (data.locales ? data.locales.includes(data.defaultLocale) : true), + { + message: "Default locale must be included in the locales list", + path: ["defaultLocale"], + }, + ); +export type UpdatePageLocalesInput = z.infer; + +// Reuse the canonical `pageConfigurationSchema` — the read path runs +// stored configuration through it, so anything the update accepts that +// doesn't round-trip here would surface as an opaque parse error at +// status-page render time rather than at write time. The prior +// `z.record(z.string(), z.union([z.string(), z.boolean()]))` happily +// persisted any key/value and broke the read parser on values outside +// the defined enums. +export const UpdatePageConfigurationInput = z.object({ + id: z.number().int(), + configuration: pageConfigurationSchema.nullish(), +}); +export type UpdatePageConfigurationInput = z.infer< + typeof UpdatePageConfigurationInput +>; diff --git a/packages/services/src/page/update.ts b/packages/services/src/page/update.ts new file mode 100644 index 000000000..a9b59c439 --- /dev/null +++ b/packages/services/src/page/update.ts @@ -0,0 +1,307 @@ +import { eq } from "@openstatus/db"; +import { page } from "@openstatus/db/src/schema"; + +import { emitAudit } from "../audit"; +import { type ServiceContext, withTransaction } from "../context"; +import { LimitExceededError } from "../errors"; +import { + assertAccessTypeAllowed, + assertSlugAvailable, + getPageInWorkspace, +} from "./internal"; +import { + UpdatePageAppearanceInput, + UpdatePageConfigurationInput, + UpdatePageCustomDomainInput, + UpdatePageGeneralInput, + UpdatePageLinksInput, + UpdatePageLocalesInput, + UpdatePagePasswordProtectionInput, +} from "./schemas"; + +export async function updatePageGeneral(args: { + ctx: ServiceContext; + input: UpdatePageGeneralInput; +}): Promise { + const { ctx } = args; + const input = UpdatePageGeneralInput.parse(args.input); + + await withTransaction(ctx, async (tx) => { + const existing = await getPageInWorkspace({ + tx, + id: input.id, + workspaceId: ctx.workspace.id, + }); + + if (input.slug !== existing.slug) { + await assertSlugAvailable({ + tx, + slug: input.slug, + excludePageId: existing.id, + }); + } + + await tx + .update(page) + .set({ + title: input.title, + slug: input.slug, + description: input.description ?? "", + icon: input.icon ?? "", + updatedAt: new Date(), + }) + .where(eq(page.id, existing.id)); + + await emitAudit(tx, ctx, { + action: "page.update_general", + entityType: "page", + entityId: existing.id, + before: existing, + }); + }); +} + +/** + * Only persists the `customDomain` change. External integration (Vercel + * add/remove) is the caller's responsibility — services don't reach out + * to third-party APIs. The tRPC / Connect adapter calls the Vercel API + * around the service call. + * + * Returns `{ existingDomain }` so the caller can diff without another + * read. + */ +export async function updatePageCustomDomain(args: { + ctx: ServiceContext; + input: UpdatePageCustomDomainInput; +}): Promise<{ existingDomain: string }> { + const { ctx } = args; + const input = UpdatePageCustomDomainInput.parse(args.input); + + return withTransaction(ctx, async (tx) => { + const existing = await getPageInWorkspace({ + tx, + id: input.id, + workspaceId: ctx.workspace.id, + }); + + await tx + .update(page) + .set({ customDomain: input.customDomain, updatedAt: new Date() }) + .where(eq(page.id, existing.id)); + + await emitAudit(tx, ctx, { + action: "page.update_custom_domain", + entityType: "page", + entityId: existing.id, + metadata: { + from: existing.customDomain, + to: input.customDomain, + }, + }); + + return { existingDomain: existing.customDomain }; + }); +} + +/** + * Update the page's access-type + all the fields scoped to it + * (password / authEmailDomains / allowedIpRanges / allowIndex). + * + * The legacy boolean `passwordProtected` column isn't touched here — + * it's schema-deprecated and the v1 REST read path derives it from + * `accessType` via `normalizePasswordProtected` (see + * `apps/server/src/routes/v1/pages/schema.ts`). Deliberately omitted + * so we're not writing two sources of truth for the same signal. + */ +export async function updatePagePasswordProtection(args: { + ctx: ServiceContext; + input: UpdatePagePasswordProtectionInput; +}): Promise { + const { ctx } = args; + const input = UpdatePagePasswordProtectionInput.parse(args.input); + + assertAccessTypeAllowed(ctx.workspace, { + accessType: input.accessType, + allowedIpRanges: input.allowedIpRanges ?? null, + allowIndex: input.allowIndex, + }); + + await withTransaction(ctx, async (tx) => { + const existing = await getPageInWorkspace({ + tx, + id: input.id, + workspaceId: ctx.workspace.id, + }); + + await tx + .update(page) + .set({ + accessType: input.accessType, + // `|| null` (not `??`) on both array columns so three inputs + // all clear the DB value: `undefined`, `null`, and `[]`. An + // empty array joins to `""`, which `??` wouldn't coerce — we'd + // persist the empty string instead of nulling the column, + // leaving a misleading "present but blank" state. `|| null` + // treats `""` as falsy and maps it to `null`, while real + // non-empty joins (e.g. `"a@b.com"` / `"10.0.0.0/24"`) pass + // through unchanged. + authEmailDomains: input.authEmailDomains?.join(",") || null, + password: input.password, + allowedIpRanges: input.allowedIpRanges?.join(",") || null, + ...(input.allowIndex !== undefined && { allowIndex: input.allowIndex }), + updatedAt: new Date(), + }) + .where(eq(page.id, existing.id)); + + await emitAudit(tx, ctx, { + action: "page.update_password_protection", + entityType: "page", + entityId: existing.id, + metadata: { accessType: input.accessType }, + }); + }); +} + +export async function updatePageAppearance(args: { + ctx: ServiceContext; + input: UpdatePageAppearanceInput; +}): Promise { + const { ctx } = args; + const input = UpdatePageAppearanceInput.parse(args.input); + + await withTransaction(ctx, async (tx) => { + const existing = await getPageInWorkspace({ + tx, + id: input.id, + workspaceId: ctx.workspace.id, + }); + + const currentConfiguration = + typeof existing.configuration === "object" && + existing.configuration !== null + ? (existing.configuration as Record) + : {}; + + await tx + .update(page) + .set({ + forceTheme: input.forceTheme, + configuration: { + ...currentConfiguration, + theme: input.configuration.theme, + }, + updatedAt: new Date(), + }) + .where(eq(page.id, existing.id)); + + await emitAudit(tx, ctx, { + action: "page.update_appearance", + entityType: "page", + entityId: existing.id, + }); + }); +} + +export async function updatePageLinks(args: { + ctx: ServiceContext; + input: UpdatePageLinksInput; +}): Promise { + const { ctx } = args; + const input = UpdatePageLinksInput.parse(args.input); + + await withTransaction(ctx, async (tx) => { + const existing = await getPageInWorkspace({ + tx, + id: input.id, + workspaceId: ctx.workspace.id, + }); + await tx + .update(page) + .set({ + homepageUrl: input.homepageUrl, + contactUrl: input.contactUrl, + updatedAt: new Date(), + }) + .where(eq(page.id, existing.id)); + + await emitAudit(tx, ctx, { + action: "page.update_links", + entityType: "page", + entityId: existing.id, + }); + }); +} + +export async function updatePageLocales(args: { + ctx: ServiceContext; + input: UpdatePageLocalesInput; +}): Promise { + const { ctx } = args; + const input = UpdatePageLocalesInput.parse(args.input); + + if (!ctx.workspace.limits.i18n) { + throw new LimitExceededError("i18n", 0); + } + + await withTransaction(ctx, async (tx) => { + const existing = await getPageInWorkspace({ + tx, + id: input.id, + workspaceId: ctx.workspace.id, + }); + + await tx + .update(page) + .set({ + defaultLocale: input.defaultLocale, + locales: input.locales, + updatedAt: new Date(), + }) + .where(eq(page.id, existing.id)); + + await emitAudit(tx, ctx, { + action: "page.update_locales", + entityType: "page", + entityId: existing.id, + }); + }); +} + +export async function updatePageConfiguration(args: { + ctx: ServiceContext; + input: UpdatePageConfigurationInput; +}): Promise { + const { ctx } = args; + const input = UpdatePageConfigurationInput.parse(args.input); + + await withTransaction(ctx, async (tx) => { + const existing = await getPageInWorkspace({ + tx, + id: input.id, + workspaceId: ctx.workspace.id, + }); + + const currentConfiguration = + typeof existing.configuration === "object" && + existing.configuration !== null + ? (existing.configuration as Record) + : {}; + + await tx + .update(page) + .set({ + configuration: { + ...currentConfiguration, + ...input.configuration, + }, + updatedAt: new Date(), + }) + .where(eq(page.id, existing.id)); + + await emitAudit(tx, ctx, { + action: "page.update_configuration", + entityType: "page", + entityId: existing.id, + }); + }); +} diff --git a/packages/services/src/types.ts b/packages/services/src/types.ts index 08ea58063..b88b434b8 100644 --- a/packages/services/src/types.ts +++ b/packages/services/src/types.ts @@ -21,3 +21,15 @@ export type { } from "@openstatus/db/src/schema"; export type { Page, PageComponent } from "@openstatus/db/src/schema"; + +export type { Maintenance } from "@openstatus/db/src/schema"; + +export type { Incident, Monitor } from "@openstatus/db/src/schema"; + +export type { + MonitorTag, + Notification, + PrivateLocation, +} from "@openstatus/db/src/schema"; + +export type { ApiKey, Invitation, User } from "@openstatus/db/src/schema"; diff --git a/packages/services/src/user/delete.ts b/packages/services/src/user/delete.ts new file mode 100644 index 000000000..66a1caae1 --- /dev/null +++ b/packages/services/src/user/delete.ts @@ -0,0 +1,114 @@ +import { and, eq, ne } from "@openstatus/db"; +import { + account, + session, + user, + usersToWorkspaces, +} from "@openstatus/db/src/schema"; + +import { emitAudit } from "../audit"; +import { + type ServiceContext, + tryGetActorUserId, + withTransaction, +} from "../context"; +import { PreconditionFailedError, UnauthorizedError } from "../errors"; +import { DeleteAccountInput } from "./schemas"; + +/** + * Soft-delete a user account: + * 1. Refuses to proceed if the user owns a workspace on a paid plan — they + * must cancel the subscription first. (Legacy behavior; preserves the + * revenue guardrail.) + * 2. Removes their membership from every workspace they don't own. + * 3. Deletes their sessions and OAuth accounts. + * 4. Blanks out PII on the user row and stamps `deletedAt`. + * + * All four writes run in a single transaction so a partial failure never + * leaves the account half-deleted. + * + * **Scope note — owned workspaces are not cleaned up here.** The user's + * `usersToWorkspaces` rows where `role === "owner"` survive, along with + * every workspace / monitor / page they own. Since only free-plan users + * reach this path (the paid-plan guard above), the outcome is an + * orphaned free workspace with no active owner, which matches the + * legacy router behavior. Workspace-level cleanup (reclaim slots, tombstone + * unowned free workspaces) is explicitly out of scope for this service — + * if/when it lands, it'll be a separate admin / scheduled job rather + * than inline here. + */ +export async function deleteAccount(args: { + ctx: ServiceContext; + input?: DeleteAccountInput; +}): Promise { + const { ctx } = args; + if (args.input !== undefined) DeleteAccountInput.parse(args.input); + + // `userId` is derived from `ctx.actor`, not input — account deletion + // is strictly self-service and must target the authenticated user, + // never an arbitrary id supplied by the caller. Matches the same + // pattern established by `acceptInvitation` / `createApiKey`. + const userId = tryGetActorUserId(ctx.actor); + if (userId == null) { + throw new UnauthorizedError( + "Account deletion requires a known user actor.", + ); + } + + await withTransaction(ctx, async (tx) => { + const ownedRows = await tx.query.usersToWorkspaces.findMany({ + where: and( + eq(usersToWorkspaces.userId, userId), + eq(usersToWorkspaces.role, "owner"), + ), + with: { workspace: true }, + }); + + const hasPaidWorkspace = ownedRows.some( + ({ workspace }) => workspace.plan && workspace.plan !== "free", + ); + + if (hasPaidWorkspace) { + // `PreconditionFailedError` (maps to tRPC `PRECONDITION_FAILED`) + // rather than `ForbiddenError` — the pre-migration tRPC handler + // used `PRECONDITION_FAILED` for this case so the UI could + // distinguish "missing permissions" from "missing prerequisite + // state" and render a different copy ("cancel your subscription + // first"). Collapsing both to `FORBIDDEN` via generic service- + // error mapping regressed the distinction. + throw new PreconditionFailedError( + "You must cancel your subscription before deleting your account.", + ); + } + + await tx + .delete(usersToWorkspaces) + .where( + and( + eq(usersToWorkspaces.userId, userId), + ne(usersToWorkspaces.role, "owner"), + ), + ); + + await tx.delete(session).where(eq(session.userId, userId)); + await tx.delete(account).where(eq(account.userId, userId)); + + await tx + .update(user) + .set({ + deletedAt: new Date(), + email: "", + firstName: "", + lastName: "", + photoUrl: "", + name: "", + }) + .where(eq(user.id, userId)); + + await emitAudit(tx, ctx, { + action: "user.delete_account", + entityType: "user", + entityId: userId, + }); + }); +} diff --git a/packages/services/src/user/get.ts b/packages/services/src/user/get.ts new file mode 100644 index 000000000..b56e3e7c9 --- /dev/null +++ b/packages/services/src/user/get.ts @@ -0,0 +1,28 @@ +import { and, db as defaultDb, eq, isNull } from "@openstatus/db"; +import { user } from "@openstatus/db/src/schema"; + +import type { ServiceContext } from "../context"; +import { GetUserInput } from "./schemas"; + +export type UserRecord = typeof user.$inferSelect; + +/** + * Load an active (non-soft-deleted) user by id. Returns `undefined` when + * the user doesn't exist or is soft-deleted — matches the legacy router + * which surfaced the raw lookup result to the client. + */ +export async function getUser(args: { + ctx: ServiceContext; + input: GetUserInput; +}): Promise { + const input = GetUserInput.parse(args.input); + const db = args.ctx.db ?? defaultDb; + + const row = await db + .select() + .from(user) + .where(and(eq(user.id, input.userId), isNull(user.deletedAt))) + .get(); + + return row; +} diff --git a/packages/services/src/user/index.ts b/packages/services/src/user/index.ts new file mode 100644 index 000000000..3c1bff6a7 --- /dev/null +++ b/packages/services/src/user/index.ts @@ -0,0 +1,3 @@ +export { getUser, type UserRecord } from "./get"; +export { deleteAccount } from "./delete"; +export { DeleteAccountInput, GetUserInput } from "./schemas"; diff --git a/packages/services/src/user/schemas.ts b/packages/services/src/user/schemas.ts new file mode 100644 index 000000000..0d9015847 --- /dev/null +++ b/packages/services/src/user/schemas.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; + +export const GetUserInput = z.object({ userId: z.number().int() }); +export type GetUserInput = z.infer; + +// `userId` is intentionally absent — the service derives it from +// `ctx.actor` so the caller can't target a different user's account. +// The schema is empty today and kept as a forward-compat object for +// future fields (e.g., reason / confirmation token). +export const DeleteAccountInput = z.object({}).strict(); +export type DeleteAccountInput = z.infer; diff --git a/packages/services/src/workspace/__tests__/workspace.test.ts b/packages/services/src/workspace/__tests__/workspace.test.ts new file mode 100644 index 000000000..ca4ed3051 --- /dev/null +++ b/packages/services/src/workspace/__tests__/workspace.test.ts @@ -0,0 +1,134 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + test, +} from "bun:test"; +import { db, eq } from "@openstatus/db"; +import { workspace } from "@openstatus/db/src/schema"; + +import { + getWorkspace, + getWorkspaceWithUsage, + listWorkspaces, + updateWorkspaceName, +} from ".."; +import { SEEDED_WORKSPACE_TEAM_ID } from "../../../test/fixtures"; +import { + expectAuditRow, + loadSeededWorkspace, + makeUserCtx, + withAuditBuffer, +} from "../../../test/helpers"; +import type { AuditLogRecord } from "../../audit"; +import type { ServiceContext } from "../../context"; + +let teamCtx: ServiceContext; +let originalName: string | null = null; +let auditBuffer: AuditLogRecord[]; +let auditReset: () => void; + +beforeAll(async () => { + const team = await loadSeededWorkspace(SEEDED_WORKSPACE_TEAM_ID); + teamCtx = makeUserCtx(team, { userId: 1 }); + originalName = team.name; +}); + +afterAll(async () => { + if (originalName !== null) { + await db + .update(workspace) + .set({ name: originalName }) + .where(eq(workspace.id, SEEDED_WORKSPACE_TEAM_ID)); + } +}); + +beforeEach(() => { + const session = withAuditBuffer(); + auditBuffer = session.buffer; + auditReset = session.reset; +}); +afterEach(() => auditReset()); + +describe("getWorkspace", () => { + test("returns the caller's workspace", async () => { + const result = await getWorkspace({ ctx: teamCtx }); + expect(result.id).toBe(SEEDED_WORKSPACE_TEAM_ID); + expect(typeof result.limits).toBe("object"); + }); +}); + +describe("getWorkspaceWithUsage", () => { + test("attaches a zero-or-positive usage block", async () => { + const result = await getWorkspaceWithUsage({ ctx: teamCtx }); + expect(result.id).toBe(SEEDED_WORKSPACE_TEAM_ID); + + // Iterate the usage object *before* any `toMatchObject` call — + // bun:test's `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 + // matcher object (typeof "object"), not the original count. Do + // the value-shape + non-negative check first. + for (const key of [ + "monitors", + "notifications", + "pages", + "pageComponents", + "checks", + ] as const) { + const value = result.usage[key]; + expect(typeof value).toBe("number"); + if (typeof value === "number") { + expect(value).toBeGreaterThanOrEqual(0); + } + } + expect(result.usage.checks).toBe(0); + }); +}); + +describe("listWorkspaces", () => { + test("returns every workspace a user belongs to", async () => { + const rows = await listWorkspaces({ + ctx: teamCtx, + input: { userId: 1 }, + }); + const ids = rows.map((r) => r.id); + expect(ids).toContain(SEEDED_WORKSPACE_TEAM_ID); + }); + + test("returns an empty list for a user with no memberships", async () => { + const rows = await listWorkspaces({ + ctx: teamCtx, + input: { userId: 999_999 }, + }); + expect(rows).toEqual([]); + }); +}); + +describe("updateWorkspaceName", () => { + test("renames the workspace and emits an audit row", async () => { + const nextName = `svc-ws-test-${Date.now()}`; + await updateWorkspaceName({ + ctx: teamCtx, + input: { name: nextName }, + }); + + const row = await db + .select() + .from(workspace) + .where(eq(workspace.id, SEEDED_WORKSPACE_TEAM_ID)) + .get(); + expect(row?.name).toBe(nextName); + + await expectAuditRow(auditBuffer, { + action: "workspace.update_name", + entityType: "workspace", + entityId: SEEDED_WORKSPACE_TEAM_ID, + actorType: "user", + }); + }); +}); diff --git a/packages/services/src/workspace/index.ts b/packages/services/src/workspace/index.ts new file mode 100644 index 000000000..ea6109cff --- /dev/null +++ b/packages/services/src/workspace/index.ts @@ -0,0 +1,14 @@ +export { + getWorkspace, + getWorkspaceWithUsage, + listWorkspaces, + type WorkspaceUsage, + type WorkspaceWithUsage, +} from "./list"; +export { updateWorkspaceName } from "./update"; +export { + GetWorkspaceInput, + GetWorkspaceWithUsageInput, + ListWorkspacesInput, + UpdateWorkspaceNameInput, +} from "./schemas"; diff --git a/packages/services/src/workspace/list.ts b/packages/services/src/workspace/list.ts new file mode 100644 index 000000000..6c82a085f --- /dev/null +++ b/packages/services/src/workspace/list.ts @@ -0,0 +1,117 @@ +import { db as defaultDb, eq, isNull } from "@openstatus/db"; +import { + monitor, + selectWorkspaceSchema, + usersToWorkspaces, + workspace, +} from "@openstatus/db/src/schema"; + +import type { ServiceContext } from "../context"; +import { NotFoundError } from "../errors"; +import type { Workspace } from "../types"; +import { + type GetWorkspaceWithUsageInput, + ListWorkspacesInput, +} from "./schemas"; + +/** + * Usage snapshot shown on the workspace settings page — counts the workspace's + * active (non-soft-deleted) monitors, notifications and pages, plus the total + * page-component count across all pages. + */ +export type WorkspaceUsage = { + monitors: number; + notifications: number; + pages: number; + pageComponents: number; + checks: number; +}; + +export type WorkspaceWithUsage = Workspace & { usage: WorkspaceUsage }; + +/** Load the workspace the caller is scoped to. */ +export async function getWorkspace(args: { + ctx: ServiceContext; +}): Promise { + const { ctx } = args; + const db = ctx.db ?? defaultDb; + + const result = await db.query.workspace.findFirst({ + where: eq(workspace.id, ctx.workspace.id), + }); + + // Shouldn't be reachable in practice — `ctx.workspace` was already + // resolved upstream — but guard explicitly so callers see the same + // `NotFoundError` shape every other service throws when a row is + // missing, rather than a `ZodError` from `parse(undefined)`. + if (!result) throw new NotFoundError("workspace", ctx.workspace.id); + + 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 result = await db.query.workspace.findFirst({ + where: eq(workspace.id, ctx.workspace.id), + with: { + pages: { + with: { pageComponents: true }, + }, + monitors: { + where: isNull(monitor.deletedAt), + }, + notifications: true, + }, + }); + + // 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", ctx.workspace.id); + + const usage: WorkspaceUsage = { + monitors: result.monitors?.length ?? 0, + notifications: result.notifications?.length ?? 0, + pages: result.pages?.length ?? 0, + pageComponents: + result.pages?.flatMap((page) => page.pageComponents)?.length ?? 0, + // Parity with the legacy router — checks usage was previously commented + // out pending a real source and left as 0. Preserved here. + checks: 0, + }; + + return { ...selectWorkspaceSchema.parse(result), usage }; +} + +/** + * Workspaces the given user belongs to. Called before `ctx.workspace` is + * meaningful (list runs across every workspace the user has access to), so + * the user id is passed explicitly in the input rather than read from ctx. + */ +export async function listWorkspaces(args: { + ctx: ServiceContext; + input: ListWorkspacesInput; +}): Promise { + const input = ListWorkspacesInput.parse(args.input); + const db = args.ctx.db ?? defaultDb; + + const rows = await db.query.usersToWorkspaces.findMany({ + where: eq(usersToWorkspaces.userId, input.userId), + with: { workspace: true }, + }); + + return selectWorkspaceSchema + .array() + .parse(rows.map(({ workspace }) => workspace)); +} diff --git a/packages/services/src/workspace/schemas.ts b/packages/services/src/workspace/schemas.ts new file mode 100644 index 000000000..300c48107 --- /dev/null +++ b/packages/services/src/workspace/schemas.ts @@ -0,0 +1,17 @@ +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 ListWorkspacesInput = z.object({ userId: z.number().int() }); +export type ListWorkspacesInput = z.infer; + +export const UpdateWorkspaceNameInput = z.object({ + name: z.string().min(1), +}); +export type UpdateWorkspaceNameInput = z.infer; diff --git a/packages/services/src/workspace/update.ts b/packages/services/src/workspace/update.ts new file mode 100644 index 000000000..858fa581e --- /dev/null +++ b/packages/services/src/workspace/update.ts @@ -0,0 +1,32 @@ +import { eq } from "@openstatus/db"; +import { workspace } from "@openstatus/db/src/schema"; + +import { emitAudit } from "../audit"; +import { type ServiceContext, withTransaction } from "../context"; +import { UpdateWorkspaceNameInput } from "./schemas"; + +/** + * Rename the caller's workspace. No conflict check — workspace names are + * not globally unique today; slugs are. Preserves legacy parity. + */ +export async function updateWorkspaceName(args: { + ctx: ServiceContext; + input: UpdateWorkspaceNameInput; +}): Promise { + const { ctx } = args; + const input = UpdateWorkspaceNameInput.parse(args.input); + + await withTransaction(ctx, async (tx) => { + await tx + .update(workspace) + .set({ name: input.name, updatedAt: new Date() }) + .where(eq(workspace.id, ctx.workspace.id)); + + await emitAudit(tx, ctx, { + action: "workspace.update_name", + entityType: "workspace", + entityId: ctx.workspace.id, + metadata: { name: input.name }, + }); + }); +} diff --git a/packages/services/test/helpers.ts b/packages/services/test/helpers.ts index 262d4b782..bae584635 100644 --- a/packages/services/test/helpers.ts +++ b/packages/services/test/helpers.ts @@ -1,6 +1,9 @@ import { expect } from "bun:test"; -import { db, eq } from "@openstatus/db"; +import { db, eq, inArray } from "@openstatus/db"; import { + notification, + page, + pageComponent, selectWorkspaceSchema, workspace as workspaceTable, } from "@openstatus/db/src/schema"; @@ -10,6 +13,44 @@ import { installTestAuditBuffer, uninstallTestAuditBuffer } from "../src/audit"; import type { Actor, ServiceContext } from "../src/context"; import type { Workspace } from "../src/types"; +/** + * Clear leftover quota-gated rows on a workspace so tests that rely on + * a specific cap state (e.g. `free` plan has `status-pages: 1`, + * `notification-channels: 1`) can run regardless of what prior tests + * or aborted runs left behind. + * + * Intended for `beforeAll` of suites that exercise the `free` + * workspace — the tight-plan negative tests break randomly otherwise + * because cumulative state trips a quota check before the test can + * hit its intended assertion. Scoped to the two tables that have bit + * us repeatedly in CI; extend when a new quota-gated table surfaces. + */ +export async function cleanQuotaGatedTables( + workspaceId: number, +): Promise { + // Pages → delete dependent pageComponents first to satisfy FKs. + const pages = await db + .select({ id: page.id }) + .from(page) + .where(eq(page.workspaceId, workspaceId)) + .all(); + const pageIds = pages.map((p) => p.id); + if (pageIds.length > 0) { + await db + .delete(pageComponent) + .where(inArray(pageComponent.pageId, pageIds)) + .catch(() => undefined); + await db + .delete(page) + .where(inArray(page.id, pageIds)) + .catch(() => undefined); + } + await db + .delete(notification) + .where(eq(notification.workspaceId, workspaceId)) + .catch(() => undefined); +} + /** * Load a seeded workspace by id (defaults to id=1, the `team` plan fixture). * Tests that need a fresh workspace should insert one explicitly and clean up; diff --git a/packages/services/tsconfig.json b/packages/services/tsconfig.json index 4f1fc5013..54a1ac325 100644 --- a/packages/services/tsconfig.json +++ b/packages/services/tsconfig.json @@ -1,5 +1,8 @@ { "extends": "@openstatus/tsconfig/react-library.json", "include": ["src", "test"], - "exclude": ["dist", "build", "node_modules"] + "exclude": ["dist", "build", "node_modules"], + "compilerOptions": { + "moduleResolution": "bundler" + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 19e3539dd..3ef2d421b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -73,7 +73,7 @@ importers: version: 0.15.15 '@openpanel/nextjs': specifier: 1.2.0 - version: 1.2.0(next@16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 1.2.0(next@16.1.6(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@openstatus/analytics': specifier: workspace:* version: link:../../packages/analytics @@ -187,7 +187,7 @@ importers: version: 1.2.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@sentry/nextjs': specifier: 10.31.0 - version: 10.31.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(next@16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(webpack@5.103.0(@swc/core@1.15.18(@swc/helpers@0.5.17))) + version: 10.31.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(next@16.1.6(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(webpack@5.103.0(@swc/core@1.15.18(@swc/helpers@0.5.17))) '@stripe/stripe-js': specifier: 2.1.6 version: 2.1.6 @@ -202,7 +202,7 @@ importers: version: 11.4.4(@trpc/server@11.4.4(typescript@5.9.3))(typescript@5.9.3) '@trpc/next': specifier: 11.4.4 - version: 11.4.4(@tanstack/react-query@5.81.5(react@19.2.3))(@trpc/client@11.4.4(@trpc/server@11.4.4(typescript@5.9.3))(typescript@5.9.3))(@trpc/react-query@11.4.4(@tanstack/react-query@5.81.5(react@19.2.3))(@trpc/client@11.4.4(@trpc/server@11.4.4(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.4.4(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(@trpc/server@11.4.4(typescript@5.9.3))(next@16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3) + version: 11.4.4(@tanstack/react-query@5.81.5(react@19.2.3))(@trpc/client@11.4.4(@trpc/server@11.4.4(typescript@5.9.3))(typescript@5.9.3))(@trpc/react-query@11.4.4(@tanstack/react-query@5.81.5(react@19.2.3))(@trpc/client@11.4.4(@trpc/server@11.4.4(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.4.4(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(@trpc/server@11.4.4(typescript@5.9.3))(next@16.1.6(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3) '@trpc/react-query': specifier: 11.4.4 version: 11.4.4(@tanstack/react-query@5.81.5(react@19.2.3))(@trpc/client@11.4.4(@trpc/server@11.4.4(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.4.4(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3) @@ -235,13 +235,13 @@ importers: version: 16.1.6(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) next-auth: specifier: 5.0.0-beta.29 - version: 5.0.0-beta.29(next@16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) + version: 5.0.0-beta.29(next@16.1.6(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) next-themes: specifier: 0.4.6 version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) nuqs: specifier: 2.8.5 - version: 2.8.5(next@16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) + version: 2.8.5(next@16.1.6(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) random-word-slugs: specifier: 0.1.7 version: 0.1.7 @@ -518,6 +518,9 @@ importers: '@openstatus/subscriptions': specifier: workspace:* version: link:../../packages/subscriptions + '@openstatus/theme-store': + specifier: workspace:* + version: link:../../packages/theme-store '@openstatus/tinybird': specifier: workspace:* version: link:../../packages/tinybird @@ -623,7 +626,7 @@ importers: version: 0.15.15 '@openpanel/nextjs': specifier: 1.2.0 - version: 1.2.0(next@16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 1.2.0(next@16.1.6(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@openstatus/analytics': specifier: workspace:* version: link:../../packages/analytics @@ -668,7 +671,7 @@ importers: version: 1.1.14(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@sentry/nextjs': specifier: 10.31.0 - version: 10.31.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(next@16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(webpack@5.103.0(@swc/core@1.15.18(@swc/helpers@0.5.17))) + version: 10.31.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(next@16.1.6(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(webpack@5.103.0(@swc/core@1.15.18(@swc/helpers@0.5.17))) '@stripe/stripe-js': specifier: 2.1.6 version: 2.1.6 @@ -683,7 +686,7 @@ importers: version: 11.4.4(@trpc/server@11.4.4(typescript@5.9.3))(typescript@5.9.3) '@trpc/next': specifier: 11.4.4 - version: 11.4.4(@tanstack/react-query@5.81.5(react@19.2.3))(@trpc/client@11.4.4(@trpc/server@11.4.4(typescript@5.9.3))(typescript@5.9.3))(@trpc/react-query@11.4.4(@tanstack/react-query@5.81.5(react@19.2.3))(@trpc/client@11.4.4(@trpc/server@11.4.4(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.4.4(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(@trpc/server@11.4.4(typescript@5.9.3))(next@16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3) + version: 11.4.4(@tanstack/react-query@5.81.5(react@19.2.3))(@trpc/client@11.4.4(@trpc/server@11.4.4(typescript@5.9.3))(typescript@5.9.3))(@trpc/react-query@11.4.4(@tanstack/react-query@5.81.5(react@19.2.3))(@trpc/client@11.4.4(@trpc/server@11.4.4(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.4.4(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(@trpc/server@11.4.4(typescript@5.9.3))(next@16.1.6(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3) '@trpc/react-query': specifier: 11.4.4 version: 11.4.4(@tanstack/react-query@5.81.5(react@19.2.3))(@trpc/client@11.4.4(@trpc/server@11.4.4(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.4.4(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3) @@ -719,19 +722,19 @@ importers: version: 16.1.6(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) next-auth: specifier: 5.0.0-beta.29 - version: 5.0.0-beta.29(next@16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) + version: 5.0.0-beta.29(next@16.1.6(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) next-intl: specifier: ^4.8.3 version: 4.8.3(@swc/helpers@0.5.17)(next@16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(typescript@5.9.3) next-plausible: specifier: 3.12.5 - version: 3.12.5(next@16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 3.12.5(next@16.1.6(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) next-themes: specifier: 0.4.6 version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) nuqs: specifier: 2.8.5 - version: 2.8.5(next@16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) + version: 2.8.5(next@16.1.6(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) react: specifier: 19.2.3 version: 19.2.3 @@ -828,7 +831,7 @@ importers: version: 0.15.15 '@openpanel/nextjs': specifier: 1.2.0 - version: 1.2.0(next@16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 1.2.0(next@16.1.6(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@openstatus/analytics': specifier: workspace:* version: link:../../packages/analytics @@ -900,7 +903,7 @@ importers: version: link:../../packages/utils '@sentry/nextjs': specifier: 10.31.0 - version: 10.31.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(next@16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(webpack@5.103.0(@swc/core@1.15.18(@swc/helpers@0.5.17))) + version: 10.31.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(next@16.1.6(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(webpack@5.103.0(@swc/core@1.15.18(@swc/helpers@0.5.17))) '@stripe/stripe-js': specifier: 2.1.6 version: 2.1.6 @@ -927,7 +930,7 @@ importers: version: 11.4.4(@trpc/server@11.4.4(typescript@5.9.3))(typescript@5.9.3) '@trpc/next': specifier: 11.4.4 - version: 11.4.4(@tanstack/react-query@5.81.5(react@19.2.3))(@trpc/client@11.4.4(@trpc/server@11.4.4(typescript@5.9.3))(typescript@5.9.3))(@trpc/react-query@11.4.4(@tanstack/react-query@5.81.5(react@19.2.3))(@trpc/client@11.4.4(@trpc/server@11.4.4(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.4.4(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(@trpc/server@11.4.4(typescript@5.9.3))(next@16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3) + version: 11.4.4(@tanstack/react-query@5.81.5(react@19.2.3))(@trpc/client@11.4.4(@trpc/server@11.4.4(typescript@5.9.3))(typescript@5.9.3))(@trpc/react-query@11.4.4(@tanstack/react-query@5.81.5(react@19.2.3))(@trpc/client@11.4.4(@trpc/server@11.4.4(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.4.4(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(@trpc/server@11.4.4(typescript@5.9.3))(next@16.1.6(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3) '@trpc/react-query': specifier: 11.4.4 version: 11.4.4(@tanstack/react-query@5.81.5(react@19.2.3))(@trpc/client@11.4.4(@trpc/server@11.4.4(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.4.4(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3) @@ -978,19 +981,19 @@ importers: version: 16.1.6(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) next-auth: specifier: 5.0.0-beta.29 - version: 5.0.0-beta.29(next@16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) + version: 5.0.0-beta.29(next@16.1.6(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) next-mdx-remote: specifier: 6.0.0 version: 6.0.0(@types/react@19.2.2)(react@19.2.3) next-plausible: specifier: 3.12.5 - version: 3.12.5(next@16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 3.12.5(next@16.1.6(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) next-themes: specifier: 0.4.6 version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) nuqs: specifier: 2.8.5 - version: 2.8.5(next@16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) + version: 2.8.5(next@16.1.6(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) random-word-slugs: specifier: 0.1.7 version: 0.1.7 @@ -1419,7 +1422,7 @@ importers: version: 0.31.4 next-auth: specifier: 5.0.0-beta.29 - version: 5.0.0-beta.29(next@16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) + version: 5.0.0-beta.29(next@16.1.6(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) typescript: specifier: 5.9.3 version: 5.9.3 @@ -2026,12 +2029,27 @@ importers: '@logtape/logtape': specifier: 2.0.1 version: 2.0.1 + '@openstatus/assertions': + specifier: workspace:* + version: link:../assertions '@openstatus/db': specifier: workspace:* version: link:../db + '@openstatus/importers': + specifier: workspace:* + version: link:../importers + '@openstatus/locales': + specifier: workspace:* + version: link:../locales + '@openstatus/regions': + specifier: workspace:* + version: link:../regions '@openstatus/subscriptions': specifier: workspace:* version: link:../subscriptions + '@openstatus/theme-store': + specifier: workspace:* + version: link:../theme-store zod: specifier: 4.1.13 version: 4.1.13 @@ -14869,7 +14887,7 @@ snapshots: '@openpanel/web': 1.0.1 astro: 5.16.6(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.1)(rollup@4.53.3)(terser@5.44.1)(typescript@5.9.3)(yaml@2.8.1) - '@openpanel/nextjs@1.2.0(next@16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@openpanel/nextjs@1.2.0(next@16.1.6(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@openpanel/web': 1.1.0 next: 16.1.6(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -17206,7 +17224,7 @@ snapshots: '@sentry/types': 8.9.2 '@sentry/utils': 8.9.2 - '@sentry/nextjs@10.31.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(next@16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(webpack@5.103.0(@swc/core@1.15.18(@swc/helpers@0.5.17)))': + '@sentry/nextjs@10.31.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(next@16.1.6(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(webpack@5.103.0(@swc/core@1.15.18(@swc/helpers@0.5.17)))': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/semantic-conventions': 1.38.0 @@ -17986,7 +18004,7 @@ snapshots: '@trpc/server': 11.4.4(typescript@5.9.3) typescript: 5.9.3 - '@trpc/next@11.4.4(@tanstack/react-query@5.81.5(react@19.2.3))(@trpc/client@11.4.4(@trpc/server@11.4.4(typescript@5.9.3))(typescript@5.9.3))(@trpc/react-query@11.4.4(@tanstack/react-query@5.81.5(react@19.2.3))(@trpc/client@11.4.4(@trpc/server@11.4.4(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.4.4(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(@trpc/server@11.4.4(typescript@5.9.3))(next@16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)': + '@trpc/next@11.4.4(@tanstack/react-query@5.81.5(react@19.2.3))(@trpc/client@11.4.4(@trpc/server@11.4.4(typescript@5.9.3))(typescript@5.9.3))(@trpc/react-query@11.4.4(@tanstack/react-query@5.81.5(react@19.2.3))(@trpc/client@11.4.4(@trpc/server@11.4.4(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.4.4(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(@trpc/server@11.4.4(typescript@5.9.3))(next@16.1.6(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)': dependencies: '@trpc/client': 11.4.4(@trpc/server@11.4.4(typescript@5.9.3))(typescript@5.9.3) '@trpc/server': 11.4.4(typescript@5.9.3) @@ -21659,7 +21677,7 @@ snapshots: netmask@2.0.2: {} - next-auth@5.0.0-beta.29(next@16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3): + next-auth@5.0.0-beta.29(next@16.1.6(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3): dependencies: '@auth/core': 0.40.0 next: 16.1.6(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -21698,7 +21716,7 @@ snapshots: - '@types/react' - supports-color - next-plausible@3.12.5(next@16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + next-plausible@3.12.5(next@16.1.6(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: next: 16.1.6(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react: 19.2.3 @@ -21820,7 +21838,7 @@ snapshots: dependencies: boolbase: 1.0.0 - nuqs@2.8.5(next@16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3): + nuqs@2.8.5(next@16.1.6(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3): dependencies: '@standard-schema/spec': 1.0.0 react: 19.2.3