Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/openstatusHQ/openstatus. ๐ซ Status page with uptime monitoring & API monitoring as code ๐ซ openstatus.dev
bun drizzle-orm monitoring monitoring-as-code nextjs observability on-call open-source shadcn-ui status-page statuspage synthetic-monitoring tinybird turso uptime uptime-checker uptime-monitor
Something went wrong. Try again.
6.4 kB ยท 212 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213import { and, eq, isNull } from "@openstatus/db";import { workspace } from "@openstatus/db/src/schema";import { limitsSchema } from "@openstatus/db/src/schema/plan/schema";import { updateAddonInLimits } from "@openstatus/db/src/schema/plan/utils";
import { emitAudit } from "../audit";import { requireScope } from "../auth";import { type ServiceContext, withTransaction } from "../context";import { ConflictError, NotFoundError } from "../errors";import { UpdateWorkspaceLimitsInput, UpdateWorkspaceNameInput, UpdateWorkspacePlanInput, UpdateWorkspaceStripeIdInput,} 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<void> { const { ctx } = args; requireScope(ctx, "write"); const input = UpdateWorkspaceNameInput.parse(args.input);
await withTransaction(ctx, async (tx) => { const existing = await tx .select() .from(workspace) .where(eq(workspace.id, ctx.workspace.id)) .get(); // Workspace is derived from `ctx.workspace`, so absence here is a // state anomaly (concurrent delete?). Fail closed rather than emit // an audit row without a `before` snapshot. if (!existing) throw new NotFoundError("workspace", ctx.workspace.id);
const updated = await tx .update(workspace) .set({ name: input.name, updatedAt: new Date() }) .where(eq(workspace.id, ctx.workspace.id)) .returning() .get();
await emitAudit(tx, ctx, { action: "workspace.update", entityType: "workspace", entityId: ctx.workspace.id, before: existing, after: updated, }); });}
/** * Set the workspace's plan and the billing columns that move with it. * The single audit `workspace.update` row carries the plan flip in * `changed_fields`; a `reason` (e.g. `"subscription_deleted"`) is stamped * into `metadata` so an involuntary downgrade reads differently from a * checkout upgrade. */export async function updateWorkspacePlan(args: { ctx: ServiceContext; input: UpdateWorkspacePlanInput;}): Promise<void> { const { ctx } = args; requireScope(ctx, "write"); const input = UpdateWorkspacePlanInput.parse(args.input);
await withTransaction(ctx, async (tx) => { const existing = await tx .select() .from(workspace) .where(eq(workspace.id, ctx.workspace.id)) .get(); if (!existing) throw new NotFoundError("workspace", ctx.workspace.id);
const updated = await tx .update(workspace) .set({ plan: input.plan, subscriptionId: input.subscriptionId, paidUntil: input.paidUntil, endsAt: input.endsAt, ...(input.trialEndsAt !== undefined && { trialEndsAt: input.trialEndsAt, }), limits: JSON.stringify(input.limits), updatedAt: new Date(), }) .where(eq(workspace.id, ctx.workspace.id)) .returning() .get();
const metadata = input.reason ? { reason: input.reason, from: existing.plan ?? "free", to: input.plan, } : undefined;
await emitAudit(tx, ctx, { action: "workspace.update", entityType: "workspace", entityId: ctx.workspace.id, before: existing, after: updated, metadata, }); });}
/** * Link the workspace to its Stripe customer. Set once; never cleared. The * write is gated on `stripe_id IS NULL`, so two requests that both saw no * customer cannot both link: the loser gets a `ConflictError` and must * discard the customer it created. Re-linking the same id is a no-op. */export async function updateWorkspaceStripeId(args: { ctx: ServiceContext; input: UpdateWorkspaceStripeIdInput;}): Promise<void> { const { ctx } = args; requireScope(ctx, "write"); const input = UpdateWorkspaceStripeIdInput.parse(args.input);
await withTransaction(ctx, async (tx) => { const existing = await tx .select() .from(workspace) .where(eq(workspace.id, ctx.workspace.id)) .get(); if (!existing) throw new NotFoundError("workspace", ctx.workspace.id); if (existing.stripeId === input.stripeId) return;
const updated = await tx .update(workspace) .set({ stripeId: input.stripeId, updatedAt: new Date() }) .where( and(eq(workspace.id, ctx.workspace.id), isNull(workspace.stripeId)), ) .returning() .get(); if (!updated) { throw new ConflictError( `Workspace ${ctx.workspace.id} is already linked to a Stripe customer`, ); }
await emitAudit(tx, ctx, { action: "workspace.update", entityType: "workspace", entityId: ctx.workspace.id, before: existing, after: updated, }); });}
/** * Apply one addon change to the workspace's limits. The addon is merged into * the limits read inside the transaction, not into the caller's snapshot, * so two concurrent addon changes cannot overwrite each other. */export async function updateWorkspaceLimits(args: { ctx: ServiceContext; input: UpdateWorkspaceLimitsInput;}): Promise<void> { const { ctx } = args; requireScope(ctx, "write"); const input = UpdateWorkspaceLimitsInput.parse(args.input);
await withTransaction(ctx, async (tx) => { const existing = await tx .select() .from(workspace) .where(eq(workspace.id, ctx.workspace.id)) .get(); if (!existing) throw new NotFoundError("workspace", ctx.workspace.id);
const limits = updateAddonInLimits( limitsSchema.parse(JSON.parse(existing.limits)), input.addon, input.value, );
const updated = await tx .update(workspace) .set({ limits: JSON.stringify(limits), ...(input.trialEndsAt !== undefined && { trialEndsAt: input.trialEndsAt, }), updatedAt: new Date(), }) .where(eq(workspace.id, ctx.workspace.id)) .returning() .get();
await emitAudit(tx, ctx, { action: "workspace.update", entityType: "workspace", entityId: ctx.workspace.id, before: existing, after: updated, ...(input.reason ? { metadata: { reason: input.reason } } : {}), }); });}