CI / pruefen (pull_request) Canceled after 0s
Ein Universum kann mehrere Reihenfolgen haben, aber welche beim Oeffnen
erschien, liess sich nicht bestimmen — es war immer die
Erscheinungsreihenfolge. Das Feld isDefault gab es schon und wurde beim
Oeffnen auch ausgewertet; es fehlte nur der Weg, es zu setzen.
Der Standard gehoert dem Universum, nicht der einzelnen Person: wer
Schreibrecht hat, legt ihn fuer alle fest — so wie die Reihenfolgen selbst
auch geteilt sind. Die Rangfolge bleibt dadurch die schlichte: die Adresse
schlaegt den Standard schlaegt die erste.
setDefaultOrder() setzt zurueck und neu in einer Transaktion, damit nie zwei
Standards nebeneinander stehen. deleteOrder() faengt jetzt den Fall ab, dass
die Standardreihenfolge geloescht wird — dann uebernimmt die
Erscheinungsreihenfolge, statt dass die Auswahl auf "die erste" faellt.
Ueber die Schnittstelle geht es per PATCH /api/v1/universen/{id}. Der
Endpunkt prueft, dass die Reihenfolge zu genau diesem Universum gehoert:
ohne das setzte ein PATCH auf A den Standard in B, weil die Domaenenschicht
das Universum aus der Reihenfolge ableitet.
Vier Tests dazu: Umstellen samt Eindeutigkeit, Vorrang der Adresse,
Rueckfall beim Loeschen, Rechtepruefung. Klickweg und Endpunkt gegen einen
Produktionsbau geprueft.
Refs #4
674 lines
22 KiB
TypeScript
674 lines
22 KiB
TypeScript
import "server-only";
|
|
import { prisma } from "./db";
|
|
import { SPECIALS_SEASON } from "./catalog";
|
|
import type { OrderKind, SharePermission, TitleType } from "@/generated/prisma/client";
|
|
|
|
/**
|
|
* Universen: eigene Sammlungen aus Filmen **und** Serien.
|
|
*
|
|
* Zwei Dinge unterscheiden ein Universum von einer Liste:
|
|
*
|
|
* 1. Es hat **mehrere Reihenfolgen**. Die Erscheinungsreihenfolge wird aus den
|
|
* Daten berechnet und nie gespeichert — sie stimmt damit immer. Alles
|
|
* andere (Story-Chronologie, Einsteiger-Route) ist frei sortierbar.
|
|
* 2. Der **Fortschritt** ist der des jeweiligen Betrachters. Ein geteiltes
|
|
* Universum zeigt jeder Person ihren eigenen Stand.
|
|
*/
|
|
|
|
export class UniverseError extends Error {}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Zugriff
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export type UniverseAccess = { canRead: boolean; canWrite: boolean; isOwner: boolean };
|
|
|
|
export async function getAccess(userId: string, universeId: string): Promise<UniverseAccess> {
|
|
const universe = await prisma.universe.findFirst({
|
|
where: { id: universeId, deletedAt: null },
|
|
select: {
|
|
ownerId: true,
|
|
members: { where: { userId }, select: { permission: true } },
|
|
},
|
|
});
|
|
|
|
if (!universe) return { canRead: false, canWrite: false, isOwner: false };
|
|
|
|
const isOwner = universe.ownerId === userId;
|
|
const membership = universe.members[0];
|
|
|
|
return {
|
|
isOwner,
|
|
canRead: isOwner || Boolean(membership),
|
|
canWrite: isOwner || membership?.permission === "WRITE",
|
|
};
|
|
}
|
|
|
|
async function requireWrite(userId: string, universeId: string) {
|
|
const access = await getAccess(userId, universeId);
|
|
if (!access.canRead) throw new UniverseError("Dieses Universum gibt es nicht.");
|
|
if (!access.canWrite) throw new UniverseError("Du darfst dieses Universum nur ansehen.");
|
|
return access;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Anlegen und pflegen
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function slugify(value: string): string {
|
|
return value
|
|
.toLocaleLowerCase("de-DE")
|
|
.replace(/ä/g, "ae")
|
|
.replace(/ö/g, "oe")
|
|
.replace(/ü/g, "ue")
|
|
.replace(/ß/g, "ss")
|
|
.replace(/[^a-z0-9]+/g, "-")
|
|
.replace(/^-+|-+$/g, "")
|
|
.slice(0, 60) || "universum";
|
|
}
|
|
|
|
export async function createUniverse(userId: string, name: string, description?: string) {
|
|
const base = slugify(name);
|
|
let slug = base;
|
|
|
|
// Der Slug ist je Besitzer eindeutig — bei Dubletten durchzählen.
|
|
for (let suffix = 2; suffix < 100; suffix += 1) {
|
|
const taken = await prisma.universe.findUnique({
|
|
where: { ownerId_slug: { ownerId: userId, slug } },
|
|
select: { id: true },
|
|
});
|
|
if (!taken) break;
|
|
slug = `${base}-${suffix}`;
|
|
}
|
|
|
|
const universe = await prisma.universe.create({
|
|
data: {
|
|
ownerId: userId,
|
|
name: name.trim(),
|
|
slug,
|
|
description: description?.trim() || null,
|
|
orders: {
|
|
create: [
|
|
// Die Erscheinungsreihenfolge braucht keine Einträge — sie wird
|
|
// aus den Erscheinungsdaten berechnet.
|
|
{ name: "Erscheinungsreihenfolge", kind: "RELEASE", isDefault: true },
|
|
],
|
|
},
|
|
},
|
|
});
|
|
|
|
return universe;
|
|
}
|
|
|
|
export async function renameUniverse(
|
|
userId: string,
|
|
universeId: string,
|
|
data: { name?: string; description?: string | null },
|
|
) {
|
|
await requireWrite(userId, universeId);
|
|
return prisma.universe.update({
|
|
where: { id: universeId },
|
|
data: {
|
|
...(data.name ? { name: data.name.trim() } : {}),
|
|
...(data.description !== undefined ? { description: data.description?.trim() || null } : {}),
|
|
},
|
|
});
|
|
}
|
|
|
|
export async function trashUniverse(userId: string, universeId: string) {
|
|
const access = await getAccess(userId, universeId);
|
|
if (!access.isOwner) throw new UniverseError("Nur wer das Universum angelegt hat, darf es löschen.");
|
|
return prisma.universe.update({
|
|
where: { id: universeId },
|
|
data: { deletedAt: new Date() },
|
|
});
|
|
}
|
|
|
|
/** Nimmt einen Titel ins Universum auf und hängt ihn an alle Reihenfolgen an. */
|
|
export async function addTitle(userId: string, universeId: string, titleId: string) {
|
|
await requireWrite(userId, universeId);
|
|
|
|
const existing = await prisma.universeItem.findUnique({
|
|
where: { universeId_titleId: { universeId, titleId } },
|
|
select: { id: true },
|
|
});
|
|
if (existing) return existing;
|
|
|
|
const item = await prisma.universeItem.create({
|
|
data: { universeId, titleId },
|
|
select: { id: true },
|
|
});
|
|
|
|
await appendToCustomOrders(universeId, item.id);
|
|
return item;
|
|
}
|
|
|
|
/** Neue Einträge landen ans Ende jeder frei sortierten Reihenfolge. */
|
|
async function appendToCustomOrders(universeId: string, itemId: string) {
|
|
const orders = await prisma.universeOrder.findMany({
|
|
where: { universeId, kind: "CUSTOM" },
|
|
select: { id: true },
|
|
});
|
|
|
|
for (const order of orders) {
|
|
const last = await prisma.universeOrderItem.findFirst({
|
|
where: { orderId: order.id },
|
|
orderBy: { position: "desc" },
|
|
select: { position: true },
|
|
});
|
|
await prisma.universeOrderItem.create({
|
|
data: { orderId: order.id, itemId, position: (last?.position ?? -1) + 1 },
|
|
});
|
|
}
|
|
}
|
|
|
|
export async function removeTitle(userId: string, universeId: string, itemId: string) {
|
|
await requireWrite(userId, universeId);
|
|
await prisma.universeItem.delete({ where: { id: itemId } });
|
|
}
|
|
|
|
/**
|
|
* Übernimmt alle Filme einer TMDB-Collection, die bereits im Katalog liegen.
|
|
* Der eigentliche Abruf bei TMDB passiert vorher in der Katalogschicht.
|
|
*/
|
|
export async function addCollection(
|
|
userId: string,
|
|
universeId: string,
|
|
tmdbCollectionId: number,
|
|
): Promise<number> {
|
|
await requireWrite(userId, universeId);
|
|
|
|
const titles = await prisma.title.findMany({
|
|
where: { tmdbCollectionId },
|
|
orderBy: { releaseDate: "asc" },
|
|
select: { id: true },
|
|
});
|
|
|
|
let added = 0;
|
|
for (const title of titles) {
|
|
const existing = await prisma.universeItem.findUnique({
|
|
where: { universeId_titleId: { universeId, titleId: title.id } },
|
|
select: { id: true },
|
|
});
|
|
if (existing) continue;
|
|
const item = await prisma.universeItem.create({
|
|
data: { universeId, titleId: title.id },
|
|
select: { id: true },
|
|
});
|
|
await appendToCustomOrders(universeId, item.id);
|
|
added += 1;
|
|
}
|
|
|
|
return added;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Reihenfolgen
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function createOrder(userId: string, universeId: string, name: string) {
|
|
await requireWrite(userId, universeId);
|
|
|
|
const order = await prisma.universeOrder.create({
|
|
data: { universeId, name: name.trim(), kind: "CUSTOM" },
|
|
select: { id: true },
|
|
});
|
|
|
|
// Startbelegung: Erscheinungsreihenfolge — von dort aus umsortiert es sich
|
|
// leichter als aus einer zufälligen Anordnung.
|
|
const items = await prisma.universeItem.findMany({
|
|
where: { universeId },
|
|
orderBy: [{ title: { releaseDate: "asc" } }, { title: { title: "asc" } }],
|
|
select: { id: true },
|
|
});
|
|
|
|
await prisma.universeOrderItem.createMany({
|
|
data: items.map((item, index) => ({ orderId: order.id, itemId: item.id, position: index })),
|
|
});
|
|
|
|
return order;
|
|
}
|
|
|
|
export async function renameOrder(userId: string, orderId: string, name: string) {
|
|
const order = await prisma.universeOrder.findUniqueOrThrow({
|
|
where: { id: orderId },
|
|
select: { universeId: true, kind: true },
|
|
});
|
|
await requireWrite(userId, order.universeId);
|
|
if (order.kind === "RELEASE") {
|
|
throw new UniverseError("Die Erscheinungsreihenfolge lässt sich nicht umbenennen.");
|
|
}
|
|
return prisma.universeOrder.update({ where: { id: orderId }, data: { name: name.trim() } });
|
|
}
|
|
|
|
export async function deleteOrder(userId: string, orderId: string) {
|
|
const order = await prisma.universeOrder.findUniqueOrThrow({
|
|
where: { id: orderId },
|
|
select: { universeId: true, kind: true, isDefault: true },
|
|
});
|
|
await requireWrite(userId, order.universeId);
|
|
if (order.kind === "RELEASE") {
|
|
throw new UniverseError("Die Erscheinungsreihenfolge gehört fest dazu.");
|
|
}
|
|
|
|
await prisma.$transaction(async (tx) => {
|
|
await tx.universeOrder.delete({ where: { id: orderId } });
|
|
|
|
// War die gelöschte der Standard, stünde das Universum ohne einen da und
|
|
// die Auswahl fiele auf „die erste" — Zufall statt Absicht. Die
|
|
// Erscheinungsreihenfolge gehört immer dazu, sie übernimmt.
|
|
if (order.isDefault) {
|
|
await tx.universeOrder.updateMany({
|
|
where: { universeId: order.universeId, kind: "RELEASE" },
|
|
data: { isDefault: true },
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Legt fest, welche Reihenfolge beim Öffnen des Universums erscheint.
|
|
*
|
|
* Der Standard gehört dem Universum, nicht der einzelnen Person: wer
|
|
* Schreibrecht hat, setzt ihn für alle, die das Universum sehen — so wie die
|
|
* Reihenfolgen selbst auch geteilt sind.
|
|
*
|
|
* Zurücksetzen und Setzen laufen gemeinsam in einer Transaktion. Sonst stünden
|
|
* dazwischen zwei Standards nebeneinander, und die Auswahl in `getUniverse()`
|
|
* griffe sich nach Sortierzufall einen davon.
|
|
*/
|
|
export async function setDefaultOrder(userId: string, orderId: string) {
|
|
const order = await prisma.universeOrder.findUniqueOrThrow({
|
|
where: { id: orderId },
|
|
select: { universeId: true, isDefault: true },
|
|
});
|
|
await requireWrite(userId, order.universeId);
|
|
if (order.isDefault) return;
|
|
|
|
await prisma.$transaction([
|
|
prisma.universeOrder.updateMany({
|
|
where: { universeId: order.universeId, isDefault: true },
|
|
data: { isDefault: false },
|
|
}),
|
|
prisma.universeOrder.update({ where: { id: orderId }, data: { isDefault: true } }),
|
|
]);
|
|
}
|
|
|
|
/** Schreibt eine neue Anordnung — die Liste enthält alle Einträge in Wunschfolge. */
|
|
export async function setOrderPositions(userId: string, orderId: string, itemIds: string[]) {
|
|
const order = await prisma.universeOrder.findUniqueOrThrow({
|
|
where: { id: orderId },
|
|
select: { universeId: true, kind: true },
|
|
});
|
|
await requireWrite(userId, order.universeId);
|
|
if (order.kind === "RELEASE") {
|
|
throw new UniverseError("Die Erscheinungsreihenfolge ergibt sich aus den Daten.");
|
|
}
|
|
|
|
// Erst alle auf negative Positionen schieben: die Eindeutigkeit von
|
|
// (orderId, itemId) bleibt, aber wir vermeiden Kollisionen beim Umnummerieren.
|
|
await prisma.$transaction(
|
|
itemIds.map((itemId, index) =>
|
|
prisma.universeOrderItem.updateMany({
|
|
where: { orderId, itemId },
|
|
data: { position: index },
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Teilen
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function shareUniverse(
|
|
userId: string,
|
|
universeId: string,
|
|
targetEmail: string,
|
|
permission: SharePermission,
|
|
) {
|
|
const access = await getAccess(userId, universeId);
|
|
if (!access.isOwner) throw new UniverseError("Nur wer das Universum angelegt hat, darf es teilen.");
|
|
|
|
const target = await prisma.user.findUnique({
|
|
where: { email: targetEmail.trim().toLowerCase() },
|
|
select: { id: true, name: true },
|
|
});
|
|
if (!target) throw new UniverseError("Zu dieser E-Mail-Adresse gibt es auf dieser Instanz kein Konto.");
|
|
if (target.id === userId) throw new UniverseError("Mit dir selbst musst du nicht teilen.");
|
|
|
|
await prisma.universeMember.upsert({
|
|
where: { universeId_userId: { universeId, userId: target.id } },
|
|
create: { universeId, userId: target.id, permission },
|
|
update: { permission },
|
|
});
|
|
|
|
return target;
|
|
}
|
|
|
|
export async function unshareUniverse(userId: string, universeId: string, memberId: string) {
|
|
const access = await getAccess(userId, universeId);
|
|
if (!access.isOwner) throw new UniverseError("Nur wer das Universum angelegt hat, darf das ändern.");
|
|
await prisma.universeMember.delete({ where: { id: memberId } });
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Lesen und Fortschritt
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export type UniverseEntry = {
|
|
itemId: string;
|
|
titleId: string;
|
|
title: string;
|
|
type: TitleType;
|
|
posterPath: string | null;
|
|
year: number | null;
|
|
note: string | null;
|
|
/** Gesamtlaufzeit des Titels in Minuten. */
|
|
totalMinutes: number;
|
|
/** Noch offene Laufzeit für diesen Betrachter. */
|
|
remainingMinutes: number;
|
|
watchedMinutes: number;
|
|
inLibrary: boolean;
|
|
completed: boolean;
|
|
status: string | null;
|
|
watchedEpisodes: number;
|
|
totalEpisodes: number;
|
|
};
|
|
|
|
export type UniverseView = {
|
|
id: string;
|
|
name: string;
|
|
description: string | null;
|
|
isOwner: boolean;
|
|
canWrite: boolean;
|
|
ownerName: string;
|
|
orders: { id: string; name: string; kind: OrderKind; isDefault: boolean }[];
|
|
activeOrderId: string;
|
|
entries: UniverseEntry[];
|
|
progress: {
|
|
completed: number;
|
|
total: number;
|
|
percent: number;
|
|
watchedMinutes: number;
|
|
remainingMinutes: number;
|
|
totalMinutes: number;
|
|
};
|
|
/** Der erste noch nicht abgeschlossene Eintrag in der gewählten Reihenfolge. */
|
|
nextUp: UniverseEntry | null;
|
|
members: { id: string; name: string; email: string; permission: SharePermission }[];
|
|
};
|
|
|
|
export async function getUniverse(
|
|
userId: string,
|
|
universeId: string,
|
|
orderId?: string,
|
|
): Promise<UniverseView | null> {
|
|
const access = await getAccess(userId, universeId);
|
|
if (!access.canRead) return null;
|
|
|
|
const universe = await prisma.universe.findUnique({
|
|
where: { id: universeId },
|
|
include: {
|
|
owner: { select: { name: true } },
|
|
orders: { orderBy: [{ kind: "asc" }, { createdAt: "asc" }] },
|
|
members: {
|
|
include: { user: { select: { name: true, email: true } } },
|
|
orderBy: { createdAt: "asc" },
|
|
},
|
|
items: {
|
|
include: {
|
|
title: {
|
|
select: {
|
|
id: true,
|
|
title: true,
|
|
type: true,
|
|
posterPath: true,
|
|
releaseDate: true,
|
|
runtime: true,
|
|
episodeCount: true,
|
|
},
|
|
},
|
|
positions: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!universe) return null;
|
|
|
|
const activeOrder =
|
|
universe.orders.find((order) => order.id === orderId) ??
|
|
universe.orders.find((order) => order.isDefault) ??
|
|
universe.orders[0];
|
|
if (!activeOrder) return null;
|
|
|
|
const titleIds = universe.items.map((item) => item.titleId);
|
|
|
|
const [entries, seriesRuntimes, watchedByTitle, watchedEpisodes] = await Promise.all([
|
|
prisma.libraryEntry.findMany({
|
|
where: { userId, titleId: { in: titleIds }, deletedAt: null },
|
|
select: { titleId: true, status: true, currentRun: true },
|
|
}),
|
|
// Gesamtlaufzeit je Serie aus den Episoden (ohne Specials).
|
|
prisma.episode.groupBy({
|
|
by: ["titleId"],
|
|
where: { titleId: { in: titleIds }, seasonNumber: { gt: SPECIALS_SEASON } },
|
|
_sum: { runtime: true },
|
|
_count: { _all: true },
|
|
}),
|
|
prisma.watch.groupBy({
|
|
by: ["titleId"],
|
|
where: { userId, titleId: { in: titleIds } },
|
|
_sum: { minutes: true },
|
|
}),
|
|
// Gesehene reguläre Episoden im jeweils aktuellen Durchlauf.
|
|
prisma.watch.groupBy({
|
|
by: ["titleId", "run"],
|
|
where: {
|
|
userId,
|
|
titleId: { in: titleIds },
|
|
episode: { seasonNumber: { gt: SPECIALS_SEASON } },
|
|
},
|
|
_count: { _all: true },
|
|
}),
|
|
]);
|
|
|
|
const entryByTitle = new Map(entries.map((entry) => [entry.titleId, entry]));
|
|
const runtimeByTitle = new Map(
|
|
seriesRuntimes.map((row) => [row.titleId, { minutes: row._sum.runtime ?? 0, episodes: row._count._all }]),
|
|
);
|
|
const minutesByTitle = new Map(watchedByTitle.map((row) => [row.titleId, row._sum.minutes ?? 0]));
|
|
|
|
const seenByTitle = new Map<string, number>();
|
|
for (const row of watchedEpisodes) {
|
|
const entry = entryByTitle.get(row.titleId);
|
|
if (!entry || entry.currentRun !== row.run) continue;
|
|
seenByTitle.set(row.titleId, row._count._all);
|
|
}
|
|
|
|
const built: UniverseEntry[] = universe.items.map((item) => {
|
|
const title = item.title;
|
|
const isSeries = title.type === "SERIES";
|
|
const runtimeInfo = runtimeByTitle.get(title.id);
|
|
|
|
const totalEpisodes = isSeries ? (runtimeInfo?.episodes ?? title.episodeCount) : 1;
|
|
const totalMinutes = isSeries
|
|
? (runtimeInfo?.minutes ?? (title.runtime ?? 0) * title.episodeCount)
|
|
: (title.runtime ?? 0);
|
|
|
|
const entry = entryByTitle.get(title.id);
|
|
const watchedEpisodeCount = isSeries
|
|
? Math.min(seenByTitle.get(title.id) ?? 0, totalEpisodes)
|
|
: entry?.status === "COMPLETED"
|
|
? 1
|
|
: 0;
|
|
|
|
const completed =
|
|
entry?.status === "COMPLETED" ||
|
|
(totalEpisodes > 0 && watchedEpisodeCount >= totalEpisodes);
|
|
|
|
// Verbleibende Zeit: bei Serien anteilig über die offenen Episoden,
|
|
// bei Filmen alles oder nichts.
|
|
const remainingMinutes = completed
|
|
? 0
|
|
: isSeries && totalEpisodes > 0
|
|
? Math.round((totalMinutes * (totalEpisodes - watchedEpisodeCount)) / totalEpisodes)
|
|
: totalMinutes;
|
|
|
|
return {
|
|
itemId: item.id,
|
|
titleId: title.id,
|
|
title: title.title,
|
|
type: title.type,
|
|
posterPath: title.posterPath,
|
|
year: title.releaseDate?.getFullYear() ?? null,
|
|
note: item.note,
|
|
totalMinutes,
|
|
remainingMinutes,
|
|
watchedMinutes: minutesByTitle.get(title.id) ?? 0,
|
|
inLibrary: Boolean(entry),
|
|
completed,
|
|
status: entry?.status ?? null,
|
|
watchedEpisodes: watchedEpisodeCount,
|
|
totalEpisodes,
|
|
};
|
|
});
|
|
|
|
// Sortieren: Erscheinungsreihenfolge rechnerisch, sonst nach den Positionen.
|
|
const positionByItem = new Map<string, number>();
|
|
for (const item of universe.items) {
|
|
const position = item.positions.find((row) => row.orderId === activeOrder.id);
|
|
if (position) positionByItem.set(item.id, position.position);
|
|
}
|
|
|
|
const releaseYear = new Map(
|
|
universe.items.map((item) => [item.id, item.title.releaseDate?.getTime() ?? Number.MAX_SAFE_INTEGER]),
|
|
);
|
|
|
|
const sorted = [...built].sort((a, b) => {
|
|
if (activeOrder.kind === "RELEASE") {
|
|
const diff = (releaseYear.get(a.itemId) ?? 0) - (releaseYear.get(b.itemId) ?? 0);
|
|
return diff !== 0 ? diff : a.title.localeCompare(b.title, "de");
|
|
}
|
|
const positionA = positionByItem.get(a.itemId) ?? Number.MAX_SAFE_INTEGER;
|
|
const positionB = positionByItem.get(b.itemId) ?? Number.MAX_SAFE_INTEGER;
|
|
return positionA !== positionB
|
|
? positionA - positionB
|
|
: a.title.localeCompare(b.title, "de");
|
|
});
|
|
|
|
const completed = sorted.filter((entry) => entry.completed).length;
|
|
const watchedMinutes = sorted.reduce((sum, entry) => sum + entry.watchedMinutes, 0);
|
|
const remainingMinutes = sorted.reduce((sum, entry) => sum + entry.remainingMinutes, 0);
|
|
const totalMinutes = sorted.reduce((sum, entry) => sum + entry.totalMinutes, 0);
|
|
|
|
return {
|
|
id: universe.id,
|
|
name: universe.name,
|
|
description: universe.description,
|
|
isOwner: access.isOwner,
|
|
canWrite: access.canWrite,
|
|
ownerName: universe.owner.name,
|
|
orders: universe.orders.map((order) => ({
|
|
id: order.id,
|
|
name: order.name,
|
|
kind: order.kind,
|
|
isDefault: order.isDefault,
|
|
})),
|
|
activeOrderId: activeOrder.id,
|
|
entries: sorted,
|
|
progress: {
|
|
completed,
|
|
total: sorted.length,
|
|
percent: sorted.length > 0 ? Math.round((completed / sorted.length) * 100) : 0,
|
|
watchedMinutes,
|
|
remainingMinutes,
|
|
totalMinutes,
|
|
},
|
|
nextUp: sorted.find((entry) => !entry.completed) ?? null,
|
|
members: universe.members.map((member) => ({
|
|
id: member.id,
|
|
name: member.user.name,
|
|
email: member.user.email,
|
|
permission: member.permission,
|
|
})),
|
|
};
|
|
}
|
|
|
|
export type UniverseSummary = {
|
|
id: string;
|
|
name: string;
|
|
description: string | null;
|
|
isOwner: boolean;
|
|
ownerName: string;
|
|
itemCount: number;
|
|
completed: number;
|
|
percent: number;
|
|
posters: string[];
|
|
};
|
|
|
|
/** Eigene und mit einem geteilte Universen. */
|
|
export async function listUniverses(userId: string): Promise<UniverseSummary[]> {
|
|
const universes = await prisma.universe.findMany({
|
|
where: {
|
|
deletedAt: null,
|
|
OR: [{ ownerId: userId }, { members: { some: { userId } } }],
|
|
},
|
|
orderBy: { updatedAt: "desc" },
|
|
include: {
|
|
owner: { select: { name: true } },
|
|
items: {
|
|
include: {
|
|
title: { select: { id: true, posterPath: true, releaseDate: true, type: true } },
|
|
},
|
|
orderBy: { title: { releaseDate: "asc" } },
|
|
},
|
|
},
|
|
});
|
|
|
|
if (universes.length === 0) return [];
|
|
|
|
const allTitleIds = universes.flatMap((universe) => universe.items.map((item) => item.titleId));
|
|
const completedEntries = await prisma.libraryEntry.findMany({
|
|
where: { userId, titleId: { in: allTitleIds }, status: "COMPLETED", deletedAt: null },
|
|
select: { titleId: true },
|
|
});
|
|
const completedTitles = new Set(completedEntries.map((entry) => entry.titleId));
|
|
|
|
return universes.map((universe) => {
|
|
const completed = universe.items.filter((item) => completedTitles.has(item.titleId)).length;
|
|
return {
|
|
id: universe.id,
|
|
name: universe.name,
|
|
description: universe.description,
|
|
isOwner: universe.ownerId === userId,
|
|
ownerName: universe.owner.name,
|
|
itemCount: universe.items.length,
|
|
completed,
|
|
percent: universe.items.length > 0 ? Math.round((completed / universe.items.length) * 100) : 0,
|
|
posters: universe.items
|
|
.map((item) => item.title.posterPath)
|
|
.filter((path): path is string => Boolean(path))
|
|
.slice(0, 5),
|
|
};
|
|
});
|
|
}
|
|
|
|
/** TMDB-Collections, die in der Bibliothek des Nutzers vorkommen. */
|
|
export async function suggestCollections(userId: string) {
|
|
const rows = await prisma.title.findMany({
|
|
where: {
|
|
tmdbCollectionId: { not: null },
|
|
entries: { some: { userId, deletedAt: null } },
|
|
},
|
|
select: { tmdbCollectionId: true, tmdbCollectionName: true },
|
|
distinct: ["tmdbCollectionId"],
|
|
orderBy: { tmdbCollectionName: "asc" },
|
|
});
|
|
|
|
return rows
|
|
.filter((row) => row.tmdbCollectionId && row.tmdbCollectionName)
|
|
.map((row) => ({ id: row.tmdbCollectionId!, name: row.tmdbCollectionName! }));
|
|
}
|