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