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.
3.6 kB ยท 107 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108import { and, eq, gte, isNull } from "@openstatus/db";import { invitation, selectInvitationSchema, usersToWorkspaces,} from "@openstatus/db/src/schema";
import { emitAudit } from "../audit";import { requireScope } from "../auth";import { type ServiceContext, withTransaction } from "../context";import { env } from "../env";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<Invitation> { const { ctx } = args; requireScope(ctx, "write"); 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 the test runner (which sets // `NODE_ENV=test`) and CI (typically `undefined` or `"test"`) don't // leak tokens into logs. if (env.NODE_ENV === "development") { console.log( `>>>> Invitation token: http://localhost:3000/invite?token=${token} <<<< `, ); }
// Strip `token` โ it's the email-link capability, never audit it. // Everything else on the row is fine as an `after` snapshot. const { token: _token, ...safe } = row; await emitAudit(tx, ctx, { action: "invitation.create", entityType: "invitation", entityId: row.id, after: safe, });
return selectInvitationSchema.parse(row); });}