Merge #8: Standardreihenfolge je Universum festlegen
CI / pruefen (push) Canceled after 0s

Closes #4
This commit was merged in pull request #8.
This commit is contained in:
2026-09-01 21:53:34 +00:00
7 changed files with 228 additions and 8 deletions
+18 -5
View File
@@ -145,15 +145,28 @@ Liefert `totals`, `monthly`, `providers`, `genres` und `yearReview` auf einmal.
### Universen
```http
GET /api/v1/universen
POST /api/v1/universen
GET /api/v1/universen/{id}?reihenfolge={orderId}
POST /api/v1/universen/{id}
GET /api/v1/universen
POST /api/v1/universen
GET /api/v1/universen/{id}?reihenfolge={orderId}
POST /api/v1/universen/{id}
PATCH /api/v1/universen/{id}
```
`GET /api/v1/universen/{id}` gibt die Einträge in der gewählten Reihenfolge
zurück, dazu `progress` und `nextUp`. Ohne `reihenfolge` gilt die
Erscheinungsreihenfolge.
Standardreihenfolge des Universums — in `orders` trägt sie `isDefault`.
`PATCH` legt fest, welche das ist. Die Reihenfolge muss zu diesem Universum
gehören, und es braucht Schreibrecht:
```bash
curl -X PATCH -H "Authorization: Bearer $KEY" -H "content-type: application/json" \
-d '{"defaultOrderId": "…"}' \
https://flimmer.example.com/api/v1/universen/$ID
```
Je Universum trägt immer genau eine Reihenfolge den Standard. Wird die
Standardreihenfolge gelöscht, übernimmt die Erscheinungsreihenfolge.
### Export
+8
View File
@@ -12,6 +12,7 @@ import {
removeTitle,
renameOrder,
renameUniverse,
setDefaultOrder,
setOrderPositions,
shareUniverse,
trashUniverse,
@@ -124,6 +125,13 @@ export async function deleteOrderAction(orderId: string) {
});
}
export async function setDefaultOrderAction(orderId: string) {
return run(async (userId) => {
await setDefaultOrder(userId, id.parse(orderId));
return { message: "Standardreihenfolge gesetzt" };
});
}
export async function reorderAction(orderId: string, itemIds: string[]) {
return run(async (userId) => {
await setOrderPositions(userId, id.parse(orderId), z.array(id).max(1000).parse(itemIds));
+30
View File
@@ -287,6 +287,9 @@ export function GET() {
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
get: {
summary: "Universum mit Reihenfolge und Fortschritt",
description:
"Ohne 'reihenfolge' gilt die Standardreihenfolge des Universums — sie trägt in " +
"'orders' das Kennzeichen 'isDefault'.",
parameters: [{ name: "reihenfolge", in: "query", schema: { type: "string" } }],
responses: { "200": { description: "Universum" }, "404": { description: "Unbekannt" } },
},
@@ -306,6 +309,33 @@ export function GET() {
},
responses: { "200": { description: "Aufgenommen" } },
},
patch: {
summary: "Standardreihenfolge festlegen",
description:
"Bestimmt, welche Reihenfolge beim Öffnen erscheint. Braucht Schreibrecht; je " +
"Universum trägt immer genau eine Reihenfolge den Standard.",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
properties: {
defaultOrderId: {
type: "string",
description: "Muss eine Reihenfolge dieses Universums sein.",
},
},
required: ["defaultOrderId"],
},
},
},
},
responses: {
"200": { description: "Gesetzt" },
"404": { description: "Die Reihenfolge gehört nicht zu diesem Universum" },
},
},
},
"/export": {
get: {
+24 -1
View File
@@ -1,6 +1,7 @@
import { z } from "zod";
import { apiError, readJson, withApi } from "@/lib/api";
import { addTitle, getUniverse } from "@/lib/universes";
import { prisma } from "@/lib/db";
import { addTitle, getUniverse, setDefaultOrder } from "@/lib/universes";
/** GET /api/v1/universen/{id}?reihenfolge=… — Inhalt und Fortschritt. */
export const GET = withApi(async (request, user, context) => {
@@ -25,3 +26,25 @@ export const POST = withApi(async (request, user, context) => {
await addTitle(user.id, id, body.titleId);
return { ok: true };
});
const patchSchema = z.object({ defaultOrderId: z.string().min(1) });
/** PATCH /api/v1/universen/{id} — festlegen, welche Reihenfolge beim Öffnen erscheint. */
export const PATCH = withApi(async (request, user, context) => {
const { id } = await context.params;
if (!id) return apiError("Es fehlt die Kennung des Universums.", 400);
const body = await readJson(request, patchSchema);
// Die Reihenfolge muss zu genau diesem Universum gehören. Ohne diese Prüfung
// setzte ein PATCH auf Universum A den Standard in Universum B — die
// Domänenschicht leitet das Universum ja aus der Reihenfolge ab.
const order = await prisma.universeOrder.findFirst({
where: { id: body.defaultOrderId, universeId: id },
select: { id: true },
});
if (!order) return apiError("Diese Reihenfolge gehört nicht zu diesem Universum.", 404);
await setDefaultOrder(user.id, order.id);
return { ok: true };
});
@@ -9,6 +9,7 @@ import {
createOrderAction,
createUniverseAction,
deleteOrderAction,
setDefaultOrderAction,
shareUniverseAction,
trashUniverseAction,
unshareUniverseAction,
@@ -109,6 +110,24 @@ export function OrderTabs({
style={isActive ? { backgroundImage: "var(--fl-gradient)" } : undefined}
>
{order.name}
{order.isDefault && (
<>
{/* Häkchen an der Reihenfolge, die beim Öffnen erscheint. Das
Wort dahinter steht nur für Screenreader — sichtbar würde
es die Reiter auseinanderziehen. */}
<svg
viewBox="0 0 24 24"
className="ml-1.5 inline h-3.5 w-3.5 align-[-2px]"
fill="none"
stroke="currentColor"
strokeWidth="3"
aria-hidden
>
<path d="m5 13 4.5 4.5L19 7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<span className="sr-only"> (Standard)</span>
</>
)}
</button>
);
})}
@@ -158,6 +177,21 @@ export function OrderTabs({
</button>
)}
{active && !active.isDefault && (
<button
type="button"
onClick={() =>
startTransition(async () => {
report(await setDefaultOrderAction(active.id));
router.refresh();
})
}
className="text-sm text-[var(--fl-faint)] underline-offset-4 hover:text-[var(--fl-text)] hover:underline"
>
Als Standard festlegen
</button>
)}
{active && active.kind === "CUSTOM" && (
<button
type="button"
+43 -2
View File
@@ -244,13 +244,54 @@ export async function renameOrder(userId: string, orderId: string, name: string)
export async function deleteOrder(userId: string, orderId: string) {
const order = await prisma.universeOrder.findUniqueOrThrow({
where: { id: orderId },
select: { universeId: true, kind: true },
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.universeOrder.delete({ where: { id: orderId } });
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. */
+71
View File
@@ -12,9 +12,11 @@ import {
addTitle,
createOrder,
createUniverse,
deleteOrder,
getAccess,
getUniverse,
listUniverses,
setDefaultOrder,
setOrderPositions,
shareUniverse,
trashUniverse,
@@ -280,3 +282,72 @@ test("gelöschte Universen verschwinden aus der Übersicht", async () => {
const list = await listUniverses(ownerId);
assert.equal(list.find((item) => item.id === universe.id), undefined);
});
// --- Standardreihenfolge ------------------------------------------------------
test("die Standardreihenfolge lässt sich umstellen und bleibt eindeutig", async () => {
const universe = await createUniverse(ownerId, "Standardwechsel");
await addCollection(ownerId, universe.id, COLLECTION_ID);
const order = await createOrder(ownerId, universe.id, "Chronologisch");
// Frisch angelegt führt die Erscheinungsreihenfolge.
const vorher = await getUniverse(ownerId, universe.id);
assert.equal(vorher?.orders.find((o) => o.isDefault)?.kind, "RELEASE");
await setDefaultOrder(ownerId, order.id);
// Ohne Angabe in der Adresse erscheint jetzt die eigene Reihenfolge.
const nachher = await getUniverse(ownerId, universe.id);
assert.equal(nachher?.activeOrderId, order.id);
const standards = nachher!.orders.filter((o) => o.isDefault);
assert.equal(standards.length, 1, "es steht immer genau ein Standard");
assert.equal(standards[0]!.id, order.id, "und zwar der zuletzt gesetzte");
});
test("die Adresse schlägt die Standardreihenfolge", async () => {
const universe = await createUniverse(ownerId, "Adressvorrang");
await addCollection(ownerId, universe.id, COLLECTION_ID);
const order = await createOrder(ownerId, universe.id, "Chronologisch");
await setDefaultOrder(ownerId, order.id);
const release = (await getUniverse(ownerId, universe.id))!.orders.find(
(o) => o.kind === "RELEASE",
)!;
const view = await getUniverse(ownerId, universe.id, release.id);
assert.equal(view?.activeOrderId, release.id, "die angeforderte gewinnt gegen den Standard");
});
test("wird die Standardreihenfolge gelöscht, übernimmt die Erscheinungsreihenfolge", async () => {
const universe = await createUniverse(ownerId, "Rueckfall");
await addCollection(ownerId, universe.id, COLLECTION_ID);
const order = await createOrder(ownerId, universe.id, "Chronologisch");
await setDefaultOrder(ownerId, order.id);
await deleteOrder(ownerId, order.id);
const view = await getUniverse(ownerId, universe.id);
const standards = view!.orders.filter((o) => o.isDefault);
assert.equal(standards.length, 1, "das Universum bleibt nicht ohne Standard zurück");
assert.equal(standards[0]!.kind, "RELEASE");
assert.equal(view?.activeOrderId, standards[0]!.id);
});
test("den Standard setzen darf nur, wer schreiben darf", async () => {
const universe = await createUniverse(ownerId, "Rechteprobe");
await addCollection(ownerId, universe.id, COLLECTION_ID);
const order = await createOrder(ownerId, universe.id, "Chronologisch");
await shareUniverse(ownerId, universe.id, GUEST_EMAIL, "READ");
await assert.rejects(() => setDefaultOrder(guestId, order.id), /nur ansehen/);
// Der Standard steht danach unverändert auf der Erscheinungsreihenfolge.
const unveraendert = await getUniverse(ownerId, universe.id);
assert.equal(unveraendert?.orders.find((o) => o.isDefault)?.kind, "RELEASE");
// Mit Schreibrecht geht es dann.
await shareUniverse(ownerId, universe.id, GUEST_EMAIL, "WRITE");
await setDefaultOrder(guestId, order.id);
assert.equal((await getUniverse(guestId, universe.id))?.activeOrderId, order.id);
});