Vectrum
Sign In
236 lines · 10.5 KB · TypeScript
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;
}