BangSo/packages/db/src/bootstrap-user.ts

140 lines
4.2 KiB
TypeScript
Raw Normal View History

2026-09-01 16:51:19 +00:00
import { randomBytes } from "node:crypto";
import { signupPolicyFromEnv } from "@rakazo/core";
import type { PrismaClient } from "./client.js";
export interface SignupPolicyEnv {
signupsEnabled: string | undefined;
signupAllowlist: string | undefined;
}
function newId(): string {
return randomBytes(16).toString("hex");
}
/** Prisma unique-constraint violation; anything else must still throw. */
function isUniqueViolation(error: unknown): boolean {
return Boolean(error && typeof error === "object" && "code" in error && error.code === "P2002");
}
/**
* Everything a brand-new user needs around their account row: a personal
* organization, its default space, owner memberships for both boundaries,
* deployment-owner claim, user memory, and notification preferences. Shared by
* the Better Auth `user.create.after` hook and phone-identity provisioning so
* both paths stay in lockstep.
*
* `claimDeploymentOwner: false` is for identities that did not sign up
* through the app (phone provisioning): a first texter must never become
* the deployment owner.
*/
export async function bootstrapUserSpace(
prisma: PrismaClient,
user: { id: string },
env: SignupPolicyEnv,
options: { claimDeploymentOwner?: boolean } = {},
): Promise<{ spaceId: string }> {
const claimDeploymentOwner = options.claimDeploymentOwner ?? true;
// Concurrent bootstraps for the same user (e.g. overlapping first phone
// inbounds) race on every unique key below; each step either wins or
// joins the winner's state instead of failing.
const slug = `user-${user.id.slice(0, 12)}`;
let orgId = newId();
try {
await prisma.organization.create({
data: { id: orgId, name: "Personal", slug, createdAt: new Date() },
});
} catch (error) {
if (!isUniqueViolation(error)) throw error;
orgId = (await prisma.organization.findUniqueOrThrow({ where: { slug } })).id;
}
await prisma.member
.create({
data: {
id: newId(),
organizationId: orgId,
userId: user.id,
role: "owner",
createdAt: new Date(),
},
})
.catch((error: unknown) => {
if (!isUniqueViolation(error)) throw error;
});
await prisma.space
.create({
data: {
id: orgId,
organizationId: orgId,
name: "Personal",
isDefault: true,
createdByUserId: user.id,
createdAt: new Date(),
},
})
.catch((error: unknown) => {
if (!isUniqueViolation(error)) throw error;
});
await prisma.spaceMember
.create({
data: {
id: newId(),
spaceId: orgId,
organizationId: orgId,
userId: user.id,
role: "owner",
createdAt: new Date(),
},
})
.catch((error: unknown) => {
if (!isUniqueViolation(error)) throw error;
});
const policy = signupPolicyFromEnv(env);
await prisma.deploymentSettings.upsert({
where: { id: "default" },
create: {
id: "default",
ownerUserId: claimDeploymentOwner ? user.id : null,
signupsEnabled: policy.enabled,
signupAllowlist: policy.allowlist.join(","),
signupPolicyInitialized: true,
},
update: {},
});
if (claimDeploymentOwner) {
// Conditional claim: only the first concurrent claimant wins the seat.
await prisma.deploymentSettings.updateMany({
where: { id: "default", ownerUserId: null },
data: { ownerUserId: user.id },
});
}
const hasMemory = await prisma.memoryDocument.findFirst({
where: { spaceId: orgId, userId: user.id, scope: "user", path: "MEMORY.md" },
});
if (!hasMemory) {
await prisma.memoryDocument
.create({
data: {
spaceId: orgId,
userId: user.id,
scope: "user",
path: "MEMORY.md",
content: "# Space memory\n\nPreferences and context kept within this space live here.\n",
},
})
.catch((error: unknown) => {
if (!isUniqueViolation(error)) throw error;
});
}
await prisma.notificationPreference
.create({
data: {
spaceId: orgId,
userId: user.id,
},
})
.catch((error: unknown) => {
if (!isUniqueViolation(error)) throw error;
});
return { spaceId: orgId };
}