Vectrum
Sign In
249 lines · 10.3 KB · TypeScript
import { count, db, filter, findById, findFirst, isUniqueViolation, type RecordModel } from "./db";
import { listOrgsOf, orgRole, roleAtLeast } from "./orgs";
import { REPO_EXPAND, repoAccess, toRepo, visibleTo, type RepoRecord, type Visibility } from "./repos";
import { toUser, type User } from "./users";

// Likes and lists of repositories (see pb_migrations/1791500000_lists.js). A person likes a
// repository once; people and organizations keep lists of repositories, public or private. Lists
// only ever show a viewer the repositories they may see.

export class ListError extends Error {}

// ---------------------------------------------------------------------------
// Likes

export async function countLikes(repoId: string) {
  return count("repo_likes", filter("repo = {:repoId}", { repoId }));
}

export async function likes(repoId: string, userId: string | null | undefined) {
  if (!userId) return false;
  return !!(await findFirst("repo_likes", filter("repo = {:repoId} && user = {:userId}", { repoId, userId }), { fields: "id" }));
}

/** Likes or unlikes a repository the person can see. */
export async function setLiked(repo: RepoRecord, user: User, liked: boolean) {
  if (user.isOrg || !(await repoAccess(repo, user.id))) throw new ListError("Repository not found.");
  const row = await findFirst("repo_likes", filter("repo = {:repoId} && user = {:userId}", { repoId: repo.id, userId: user.id }), { fields: "id" });
  if (liked && !row) {
    try {
      await (await db()).collection("repo_likes").create({ repo: repo.id, user: user.id, created_at: Date.now() });
    } catch (e) {
      // Liked twice at once.
      if (!isUniqueViolation(e)) throw e;
    }
  } else if (!liked && row) {
    await (await db()).collection("repo_likes").delete(row.id);
  }
}

/** The repositories a person likes that the viewer may see, the latest liked first. */
export async function listLikedRepos(userId: string, viewerId: string | null) {
  const rows = await (await db()).collection("repo_likes").getFullList({
    filter: `${filter("user = {:userId}", { userId })} && ${visibleTo(viewerId, "repo.")}`,
    sort: "-created_at",
    expand: expandVia("repo"),
  });
  return rows.map((r) => toRepo(r.expand?.repo as RecordModel));
}

export async function countLikedRepos(userId: string, viewerId: string | null) {
  return count("repo_likes", `${filter("user = {:userId}", { userId })} && ${visibleTo(viewerId, "repo.")}`);
}

/** The people who like a repository, the latest first. */
export async function listLikers(repoId: string) {
  const rows = await (await db()).collection("repo_likes").getFullList({ filter: filter("repo = {:repoId}", { repoId }), sort: "-created_at", expand: "user" });
  return rows.map((r) => ({ user: toUser(r.expand?.user as RecordModel), likedAt: r.created_at as number }));
}

/** REPO_EXPAND, for a record that points at a repository through `field`. */
const expandVia = (field: string) => [field, ...REPO_EXPAND.split(",").map((e) => `${field}.${e}`)].join(",");

// ---------------------------------------------------------------------------
// Lists

export type RepoList = {
  id: string;
  owner: User;
  name: string;
  slug: string;
  description: string;
  visibility: Visibility;
  createdAt: number;
  updatedAt: number;
};

const lists = async () => (await db()).collection("repo_lists");
const items = async () => (await db()).collection("repo_list_items");

function toList(r: RecordModel): RepoList {
  return {
    id: r.id,
    owner: toUser(r.expand?.owner as RecordModel),
    name: r.name,
    slug: r.slug,
    description: r.description,
    visibility: r.visibility,
    createdAt: r.created_at,
    updatedAt: r.updated_at,
  };
}

/** A list's address: its name in lowercase, words joined by hyphens. */
export function listSlug(name: string) {
  return name
    .toLowerCase()
    .normalize("NFKD")
    .replace(/[̀-ͯ]/g, "")
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/^-+|-+$/g, "")
    .slice(0, 60)
    .replace(/-+$/, "");
}

export const listPath = (list: Pick<RepoList, "owner" | "slug">) => `/lists/${list.owner.username}/${list.slug}`;

/** Whether someone sees an account's private lists: the person themselves, or the organization's members. */
async function seesPrivateLists(owner: User, viewerId: string | null | undefined) {
  if (!viewerId) return false;
  return owner.id === viewerId || (owner.isOrg && !!(await orgRole(owner.id, viewerId)));
}

/** Whether someone may see a list: anyone a public one, and private ones as seesPrivateLists says. */
export async function maySeeList(list: RepoList, viewerId: string | null | undefined) {
  return list.visibility === "public" || seesPrivateLists(list.owner, viewerId);
}

/** Whether someone may change an owner's lists: their own, and those of organizations they can push in. */
export async function mayManageLists(owner: User, viewerId: string | null | undefined) {
  if (!viewerId) return false;
  return owner.id === viewerId || (owner.isOrg && roleAtLeast(await orgRole(owner.id, viewerId), "write"));
}

/** Accounts whose lists a person may change: their own, and organizations they can push in. */
export async function listOwnersFor(user: User) {
  const orgs = (await listOrgsOf(user.id)).filter((m) => roleAtLeast(m.role, "write")).map((m) => m.org);
  return [user, ...orgs];
}

export async function findList(owner: string, slug: string) {
  const row = await findFirst(
    "repo_lists",
    filter("owner.username:lower = {:owner} && slug = {:slug}", { owner: owner.toLowerCase(), slug: slug.toLowerCase() }),
    { expand: "owner" },
  );
  return row ? toList(row) : null;
}

export async function findListById(id: string) {
  const row = await findById("repo_lists", id, { expand: "owner" });
  return row ? toList(row) : null;
}

/** An account's lists the viewer may see, the most recently changed first. */
export async function listListsOf(owner: User, viewerId: string | null) {
  const where = filter("owner = {:ownerId}", { ownerId: owner.id });
  const rows = await (await lists()).getFullList({
    filter: (await seesPrivateLists(owner, viewerId)) ? where : `${where} && visibility = 'public'`,
    sort: "-updated_at",
    expand: "owner",
  });
  return rows.map(toList);
}

/** Lists with how many of their repositories the viewer may see. */
export async function withRepoCounts(lists: RepoList[], viewerId: string | null) {
  return Promise.all(lists.map(async (list) => ({ ...list, repoCount: await countListRepos(list.id, viewerId) })));
}

export async function countListRepos(listId: string, viewerId: string | null) {
  return count("repo_list_items", `${filter("list = {:listId}", { listId })} && ${visibleTo(viewerId, "repo.")}`);
}

/** The repositories in a list that the viewer may see, the latest added first. */
export async function listReposInList(listId: string, viewerId: string | null) {
  const rows = await (await items()).getFullList({
    filter: `${filter("list = {:listId}", { listId })} && ${visibleTo(viewerId, "repo.")}`,
    sort: "-created_at",
    expand: expandVia("repo"),
  });
  return rows.map((r) => toRepo(r.expand?.repo as RecordModel));
}

type ListInput = { name: string; description: string; visibility: Visibility };

function checkInput(input: ListInput) {
  const name = input.name.trim().replace(/\s+/g, " ");
  if (!name) throw new ListError("Give the list a name.");
  if (name.length > 60) throw new ListError("List names can be at most 60 characters.");
  const slug = listSlug(name);
  if (!slug) throw new ListError("List names need at least one letter or digit.");
  if (input.visibility !== "public" && input.visibility !== "private") throw new ListError("Choose who can see the list.");
  return { name, slug, description: input.description.trim().slice(0, 300), visibility: input.visibility };
}

const nameTaken = (owner: User, name: string) =>
  new ListError(`${owner.isOrg ? `${owner.username} already has` : "You already have"} a list named ${name}, or one with the same address.`);

export async function createList(owner: User, input: ListInput) {
  const data = checkInput(input);
  const now = Date.now();
  try {
    const record = await (await lists()).create({ owner: owner.id, ...data, created_at: now, updated_at: now }, { expand: "owner" });
    return toList(record);
  } catch (e) {
    if (isUniqueViolation(e)) throw nameTaken(owner, data.name);
    throw e;
  }
}

/** Changes a list; renaming it changes its address. */
export async function updateList(list: RepoList, input: ListInput) {
  const data = checkInput(input);
  try {
    return toList(await (await lists()).update(list.id, data, { expand: "owner" }));
  } catch (e) {
    if (isUniqueViolation(e)) throw nameTaken(list.owner, data.name);
    throw e;
  }
}

/** Deletes a list; PocketBase removes its items with it. The repositories stay. */
export async function deleteList(list: RepoList) {
  await (await lists()).delete(list.id);
}

/** Puts a repository in a list, or takes it out. */
export async function setInList(list: RepoList, repoId: string, inList: boolean) {
  const row = await findFirst("repo_list_items", filter("list = {:listId} && repo = {:repoId}", { listId: list.id, repoId }), { fields: "id" });
  if (inList && !row) {
    try {
      await (await items()).create({ list: list.id, repo: repoId, created_at: Date.now() });
    } catch (e) {
      // Added twice at once.
      if (!isUniqueViolation(e)) throw e;
    }
    await (await lists()).update(list.id, { updated_at: Date.now() });
  } else if (!inList && row) {
    await (await items()).delete(row.id);
  }
}

/** The lists a person may change, and whether each holds the repository: for the Save menu. */
export async function saveTargets(user: User, repoId: string) {
  const owners = await listOwnersFor(user);
  const ofOwners = (field: string) => `(${owners.map((o) => filter(`${field} = {:id}`, { id: o.id })).join(" || ")})`;
  const [rows, saved] = await Promise.all([
    (await lists()).getFullList({ filter: ofOwners("owner"), expand: "owner" }),
    (await items()).getFullList({ filter: `${filter("repo = {:repoId}", { repoId })} && ${ofOwners("list.owner")}`, fields: "list" }),
  ]);
  const savedIn = new Set(saved.map((r) => r.list as string));
  const order = owners.map((o) => o.id);
  return {
    owners,
    lists: rows
      .map((r) => ({ ...toList(r), saved: savedIn.has(r.id) }))
      .sort((a, b) => order.indexOf(a.owner.id) - order.indexOf(b.owner.id) || a.name.localeCompare(b.name)),
  };
}