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) => `/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)), }; }