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
This commit is contained in:
+14
-1
@@ -149,11 +149,24 @@ GET /api/v1/universen
|
|||||||
POST /api/v1/universen
|
POST /api/v1/universen
|
||||||
GET /api/v1/universen/{id}?reihenfolge={orderId}
|
GET /api/v1/universen/{id}?reihenfolge={orderId}
|
||||||
POST /api/v1/universen/{id}
|
POST /api/v1/universen/{id}
|
||||||
|
PATCH /api/v1/universen/{id}
|
||||||
```
|
```
|
||||||
|
|
||||||
`GET /api/v1/universen/{id}` gibt die Einträge in der gewählten Reihenfolge
|
`GET /api/v1/universen/{id}` gibt die Einträge in der gewählten Reihenfolge
|
||||||
zurück, dazu `progress` und `nextUp`. Ohne `reihenfolge` gilt die
|
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
|
### Export
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
removeTitle,
|
removeTitle,
|
||||||
renameOrder,
|
renameOrder,
|
||||||
renameUniverse,
|
renameUniverse,
|
||||||
|
setDefaultOrder,
|
||||||
setOrderPositions,
|
setOrderPositions,
|
||||||
shareUniverse,
|
shareUniverse,
|
||||||
trashUniverse,
|
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[]) {
|
export async function reorderAction(orderId: string, itemIds: string[]) {
|
||||||
return run(async (userId) => {
|
return run(async (userId) => {
|
||||||
await setOrderPositions(userId, id.parse(orderId), z.array(id).max(1000).parse(itemIds));
|
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" } }],
|
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
|
||||||
get: {
|
get: {
|
||||||
summary: "Universum mit Reihenfolge und Fortschritt",
|
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" } }],
|
parameters: [{ name: "reihenfolge", in: "query", schema: { type: "string" } }],
|
||||||
responses: { "200": { description: "Universum" }, "404": { description: "Unbekannt" } },
|
responses: { "200": { description: "Universum" }, "404": { description: "Unbekannt" } },
|
||||||
},
|
},
|
||||||
@@ -306,6 +309,33 @@ export function GET() {
|
|||||||
},
|
},
|
||||||
responses: { "200": { description: "Aufgenommen" } },
|
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": {
|
"/export": {
|
||||||
get: {
|
get: {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { apiError, readJson, withApi } from "@/lib/api";
|
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. */
|
/** GET /api/v1/universen/{id}?reihenfolge=… — Inhalt und Fortschritt. */
|
||||||
export const GET = withApi(async (request, user, context) => {
|
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);
|
await addTitle(user.id, id, body.titleId);
|
||||||
return { ok: true };
|
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,
|
createOrderAction,
|
||||||
createUniverseAction,
|
createUniverseAction,
|
||||||
deleteOrderAction,
|
deleteOrderAction,
|
||||||
|
setDefaultOrderAction,
|
||||||
shareUniverseAction,
|
shareUniverseAction,
|
||||||
trashUniverseAction,
|
trashUniverseAction,
|
||||||
unshareUniverseAction,
|
unshareUniverseAction,
|
||||||
@@ -109,6 +110,24 @@ export function OrderTabs({
|
|||||||
style={isActive ? { backgroundImage: "var(--fl-gradient)" } : undefined}
|
style={isActive ? { backgroundImage: "var(--fl-gradient)" } : undefined}
|
||||||
>
|
>
|
||||||
{order.name}
|
{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>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -158,6 +177,21 @@ export function OrderTabs({
|
|||||||
</button>
|
</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" && (
|
{active && active.kind === "CUSTOM" && (
|
||||||
<button
|
<button
|
||||||
type="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) {
|
export async function deleteOrder(userId: string, orderId: string) {
|
||||||
const order = await prisma.universeOrder.findUniqueOrThrow({
|
const order = await prisma.universeOrder.findUniqueOrThrow({
|
||||||
where: { id: orderId },
|
where: { id: orderId },
|
||||||
select: { universeId: true, kind: true },
|
select: { universeId: true, kind: true, isDefault: true },
|
||||||
});
|
});
|
||||||
await requireWrite(userId, order.universeId);
|
await requireWrite(userId, order.universeId);
|
||||||
if (order.kind === "RELEASE") {
|
if (order.kind === "RELEASE") {
|
||||||
throw new UniverseError("Die Erscheinungsreihenfolge gehört fest dazu.");
|
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. */
|
/** Schreibt eine neue Anordnung — die Liste enthält alle Einträge in Wunschfolge. */
|
||||||
|
|||||||
@@ -12,9 +12,11 @@ import {
|
|||||||
addTitle,
|
addTitle,
|
||||||
createOrder,
|
createOrder,
|
||||||
createUniverse,
|
createUniverse,
|
||||||
|
deleteOrder,
|
||||||
getAccess,
|
getAccess,
|
||||||
getUniverse,
|
getUniverse,
|
||||||
listUniverses,
|
listUniverses,
|
||||||
|
setDefaultOrder,
|
||||||
setOrderPositions,
|
setOrderPositions,
|
||||||
shareUniverse,
|
shareUniverse,
|
||||||
trashUniverse,
|
trashUniverse,
|
||||||
@@ -280,3 +282,72 @@ test("gelöschte Universen verschwinden aus der Übersicht", async () => {
|
|||||||
const list = await listUniverses(ownerId);
|
const list = await listUniverses(ownerId);
|
||||||
assert.equal(list.find((item) => item.id === universe.id), undefined);
|
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