+14
-1
@@ -149,11 +149,24 @@ 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
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user