import { randomBytes } from "node:crypto";
import { count, db, filter, findById, findFirst, isUniqueViolation, withLock, type RecordModel } from "./db";
import { isValidOwner } from "./git";
import { ORG_ROLES, roleAtLeast, type OrgRole } from "./org-roles";
import { readStoredImage, uploadedImage } from "./uploaded-images";
import { getUserByUsername, toUser, type User } from "./users";
export * from "./org-roles";
// Organizations are accounts that own repositories for a group of people (see
// pb_migrations/1791300000_orgs.js). Their members' roles are their access to every repository the
// organization owns; people get one by accepting an invitation from an owner.
export type OrgMember = { id: string; username: string; name: string; role: OrgRole; joinedAt: number };
export type OrgInvitation = { id: string; org: User; user: User; role: OrgRole; invitedBy: string | null; createdAt: number };
export class OrgError extends Error {}
const members = async () => (await db()).collection("org_members");
const invitations = async () => (await db()).collection("org_invitations");
/** Membership changes are read-modify-write (an organization always keeps an owner). */
const lockOrg = <T>(orgId: string, fn: () => Promise<T>) => withLock(`org:${orgId}`, fn);
export async function getOrg(name: string) {
const user = await getUserByUsername(name);
return user?.isOrg ? user : null;
}
export async function createOrg(creator: User, input: { name: string; displayName: string; description: string }) {
const name = input.name.trim();
if (!isValidOwner(name)) {
throw new OrgError("Organization names may only contain letters, numbers and hyphens, and can't be a reserved word.");
}
if (await getUserByUsername(name)) throw new OrgError(`The name ${name} is already taken.`);
// Nobody signs in as an organization; PocketBase still wants a password for the record.
const password = randomBytes(36).toString("base64url");
let record: RecordModel;
try {
record = await (await db()).collection("users").create({
username: name,
kind: "org",
name: input.displayName.trim().slice(0, 100),
bio: input.description.trim().slice(0, 300),
password,
passwordConfirm: password,
});
} catch (e) {
if (isUniqueViolation(e)) throw new OrgError(`The name ${name} was just taken.`);
throw e;
}
await (await members()).create({ org: record.id, user: creator.id, role: "owner", created_at: Date.now() });
return toUser(record);
}
export async function updateOrgProfile(orgId: string, data: { displayName: string; description: string }) {
await (await db()).collection("users").update(orgId, { name: data.displayName.trim().slice(0, 100), bio: data.description.trim().slice(0, 300) });
}
export async function setOrgAvatar(orgId: string, data: Uint8Array) {
const image = uploadedImage(data, "avatar");
if ("error" in image) throw new OrgError(image.error);
await (await db()).collection("users").update(orgId, { avatar: image.file, avatar_type: image.type, avatar_updated_at: Date.now() });
}
export async function removeOrgAvatar(orgId: string) {
await (await db()).collection("users").update(orgId, { avatar: null, avatar_type: "", avatar_updated_at: 0 });
}
export async function getOrgAvatar(orgId: string) {
const row = await findById("users", orgId);
return row?.kind === "org" && row.avatar ? readStoredImage(row, row.avatar, row.avatar_type) : null;
}
/** Deletes an organization without repositories, and its memberships and invitations with it. */
export async function deleteOrg(org: User) {
const repos = await count("repos", filter("owner = {:id}", { id: org.id }));
if (repos) throw new OrgError(`${org.username} still owns ${repos === 1 ? "a repository. Delete it" : `${repos} repositories. Delete them`} first.`);
await (await db()).collection("users").delete(org.id);
}
// ---------------------------------------------------------------------------
// Members
/** The person's role in the organization, or null (also when `orgId` is a person's). */
export async function orgRole(orgId: string, userId: string | null | undefined): Promise<OrgRole | null> {
if (!userId || orgId === userId) return null;
const row = await findFirst("org_members", filter("org = {:orgId} && user = {:userId}", { orgId, userId }), { fields: "role" });
return (row?.role as OrgRole) ?? null;
}
export async function listOrgMembers(orgId: string): Promise<OrgMember[]> {
const rows = await (await members()).getFullList({ filter: filter("org = {:orgId}", { orgId }), expand: "user" });
return rows
.map((r) => {
const user = r.expand?.user as RecordModel;
return { id: user.id, username: user.username as string, name: user.name as string, role: r.role as OrgRole, joinedAt: r.created_at as number };
})
.sort((a, b) => ORG_ROLES.indexOf(b.role) - ORG_ROLES.indexOf(a.role) || a.username.localeCompare(b.username));
}
/** The ids of members with at least `role`. */
export async function orgMemberIds(orgId: string, role: OrgRole) {
const roles = ORG_ROLES.slice(ORG_ROLES.indexOf(role));
const rows = await (await members()).getFullList({
filter: `${filter("org = {:orgId}", { orgId })} && (${roles.map((r) => filter("role = {:r}", { r })).join(" || ")})`,
fields: "user",
});
return rows.map((r) => r.user as string);
}
/**
* Matches `field` (a user id) against the organization's members who can push to its repositories,
* for filters; "" if there are none (or `orgId` is a person's).
*/
export async function orgPushersFilter(field: string, orgId: string) {
return (await orgMemberIds(orgId, "write")).map((id) => filter(`${field} = {:id}`, { id })).join(" || ");
}
/** The organizations a person belongs to, by name. */
export async function listOrgsOf(userId: string) {
const rows = await (await members()).getFullList({ filter: filter("user = {:userId}", { userId }), expand: "org" });
return rows
.map((r) => ({ org: toUser(r.expand?.org as RecordModel), role: r.role as OrgRole, joinedAt: r.created_at as number }))
.sort((a, b) => a.org.username.localeCompare(b.org.username));
}
/** Where a person may create repositories: their own account, and organizations they're an admin or owner of. */
export async function repoOwnersFor(user: User) {
const orgs = (await listOrgsOf(user.id)).filter((m) => roleAtLeast(m.role, "admin")).map((m) => m.org);
return [user, ...orgs];
}
export async function mayCreateRepos(owner: User, user: User) {
return owner.id === user.id || (owner.isOrg && roleAtLeast(await orgRole(owner.id, user.id), "admin"));
}
async function membership(orgId: string, userId: string) {
return findFirst("org_members", filter("org = {:orgId} && user = {:userId}", { orgId, userId }));
}
/** Refuses to leave the organization without an owner. */
async function keepAnOwner(orgId: string, userId: string) {
const owners = await orgMemberIds(orgId, "owner");
if (owners.length === 1 && owners[0] === userId) {
throw new OrgError("An organization needs at least one owner. Make someone else an owner first.");
}
}
export async function setMemberRole(orgId: string, userId: string, role: OrgRole) {
return lockOrg(orgId, async () => {
const row = await membership(orgId, userId);
if (!row) throw new OrgError("They're not a member of this organization.");
if (row.role === role) return;
if (row.role === "owner") await keepAnOwner(orgId, userId);
await (await members()).update(row.id, { role });
});
}
/** Removes a member, or lets one leave. */
export async function removeMember(orgId: string, userId: string) {
return lockOrg(orgId, async () => {
const row = await membership(orgId, userId);
if (!row) return;
if (row.role === "owner") await keepAnOwner(orgId, userId);
await (await members()).delete(row.id);
});
}
// ---------------------------------------------------------------------------
// Invitations
function toInvitation(r: RecordModel): OrgInvitation {
const invitedBy = r.expand?.invited_by as RecordModel | undefined;
return {
id: r.id,
org: toUser(r.expand?.org as RecordModel),
user: toUser(r.expand?.user as RecordModel),
role: r.role as OrgRole,
invitedBy: (invitedBy?.username as string) ?? null,
createdAt: r.created_at,
};
}
const INVITATION_EXPAND = "org,user,invited_by";
export async function inviteMember(org: User, inviter: User, username: string, role: OrgRole) {
const user = await getUserByUsername(username.trim());
if (!user || user.isOrg) throw new OrgError(`No user named ${username.trim()}.`);
return lockOrg(org.id, async () => {
if (await membership(org.id, user.id)) throw new OrgError(`${user.username} is already a member.`);
try {
await (await invitations()).create({ org: org.id, user: user.id, role, invited_by: inviter.id, created_at: Date.now() });
} catch (e) {
if (isUniqueViolation(e)) throw new OrgError(`${user.username} has already been invited.`);
throw e;
}
return user;
});
}
/** Invitations an organization sent that haven't been answered. */
export async function listOrgInvitations(orgId: string) {
const rows = await (await invitations()).getFullList({ filter: filter("org = {:orgId}", { orgId }), sort: "-created_at", expand: INVITATION_EXPAND });
return rows.map(toInvitation);
}
/** Invitations a person hasn't answered yet. */
export async function listInvitationsFor(userId: string) {
const rows = await (await invitations()).getFullList({ filter: filter("user = {:userId}", { userId }), sort: "-created_at", expand: INVITATION_EXPAND });
return rows.map(toInvitation);
}
export async function cancelInvitation(orgId: string, invitationId: string) {
const row = await findById("org_invitations", invitationId);
if (row?.org === orgId) await (await invitations()).delete(row.id);
}
/** Accepts (the person joins with the role they were invited with) or declines an invitation addressed to `userId`. */
export async function answerInvitation(userId: string, invitationId: string, accept: boolean) {
const row = await findById("org_invitations", invitationId, { expand: INVITATION_EXPAND });
if (!row || row.user !== userId) throw new OrgError("That invitation doesn't exist anymore.");
const invitation = toInvitation(row);
if (accept) {
await lockOrg(row.org, async () => {
try {
await (await members()).create({ org: row.org, user: userId, role: row.role, created_at: Date.now() });
} catch (e) {
// Already a member.
if (!isUniqueViolation(e)) throw e;
}
});
}
await (await invitations()).delete(row.id);
return invitation;
}