Compare commits
6
Commits
v0.7.0
..
5673055216
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5673055216 | ||
|
|
853d56f764 | ||
|
|
9d4e419cd1 | ||
|
|
a853b066b5 | ||
|
|
4ad5b244bc | ||
|
|
19f8003945 |
@@ -4,6 +4,7 @@ node_modules
|
||||
.env
|
||||
.env.local
|
||||
data
|
||||
backups
|
||||
docs/screenshots
|
||||
*.log
|
||||
test-results
|
||||
|
||||
@@ -27,6 +27,9 @@ AUTH_SECRET=bitte-durch-zufallswert-ersetzen
|
||||
# Ablage für Bild-Cache und sonstige Laufzeitdaten.
|
||||
DATA_DIR=/app/data
|
||||
|
||||
# Port, auf dem Flimmer auf dem Host lauscht.
|
||||
APP_PORT=3000
|
||||
|
||||
# Zeitzone für Kalender, Statistiken und geplante Läufe
|
||||
TZ=Europe/Berlin
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ next-env.d.ts
|
||||
|
||||
# Produktion / Laufzeit
|
||||
data/
|
||||
backups/
|
||||
*.tsbuildinfo
|
||||
|
||||
# Umgebung — niemals committen!
|
||||
|
||||
+28
-1
@@ -7,6 +7,32 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
|
||||
|
||||
## [Unveröffentlicht]
|
||||
|
||||
## [0.7.1] — 2026-09-01
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- **`update.sh`** für den Betrieb unter Linux — ausführbar im Repository
|
||||
hinterlegt, also direkt nach dem Klonen startklar. Der Ablauf: Datenbank
|
||||
sichern, Änderungen holen, Image bauen, neu starten, warten bis die Instanz
|
||||
wieder antwortet.
|
||||
|
||||
Grundhaltung: erst sichern, dann anfassen. Schlägt die Sicherung fehl, bricht
|
||||
das Skript ab, statt weiterzumachen. Geht danach etwas schief, nennt es den
|
||||
**konkreten** Weg zurück — mit dem Commit, zu dem man zurückspringt, und der
|
||||
Datei, die man einspielt, nicht bloß dem Rat, ein Backup zu haben.
|
||||
|
||||
Weitere Eigenheiten:
|
||||
- Lokale Änderungen im Arbeitsverzeichnis werden erkannt; `git pull`
|
||||
unterbleibt dann, statt sie zu überfahren.
|
||||
- Erreichbarkeit wird über den veröffentlichten Port geprüft und, falls der
|
||||
nicht nach außen geht (Reverse Proxy im selben Docker-Netz), über den
|
||||
Healthcheck des Containers.
|
||||
- Alte Sicherungen werden aufgeräumt, die letzten zehn bleiben (`--behalte N`).
|
||||
- Kommt mit `docker compose` und dem alten `docker-compose` zurecht.
|
||||
|
||||
- `APP_PORT` in der `.env.example` — der Port stand bisher nur in der
|
||||
Compose-Datei als Vorgabewert.
|
||||
|
||||
## [0.7.0] — 2026-09-01
|
||||
|
||||
„Hab ich" — Titel, die in der eigenen Sammlung liegen. Dazu die Nacharbeiten
|
||||
@@ -321,7 +347,8 @@ Erste lauffähige Etappe: Gerüst, Marke, Datenmodell und Anmeldung.
|
||||
dass weitere Sprachen als zusätzliche Datei dazukommen können.
|
||||
- **Seed-Skript** mit Demo-Konten sowie README und Design-Dokumentation.
|
||||
|
||||
[Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/flimmer/compare/v0.7.0...HEAD
|
||||
[Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/flimmer/compare/v0.7.1...HEAD
|
||||
[0.7.1]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.7.1
|
||||
[0.7.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.7.0
|
||||
[0.6.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.6.0
|
||||
[0.5.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.5.0
|
||||
|
||||
@@ -114,14 +114,31 @@ flimmer.example.com {
|
||||
|
||||
```bash
|
||||
cd flimmer
|
||||
git pull
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
./update.sh
|
||||
```
|
||||
|
||||
Datenbankmigrationen laufen automatisch beim Start des Containers — ein eigener
|
||||
Handgriff ist nicht nötig. **Vor** einem Update ein Backup zu ziehen ist
|
||||
trotzdem eine gute Angewohnheit.
|
||||
Das Skript sichert zuerst die Datenbank, holt die Änderungen, baut das Image neu
|
||||
und wartet, bis die Instanz wieder antwortet. Schlägt etwas fehl, nennt es den
|
||||
konkreten Weg zurück — mit dem Befehl und der Sicherungsdatei, nicht bloß dem
|
||||
Rat, ein Backup zu haben.
|
||||
|
||||
```
|
||||
./update.sh --ohne-backup ohne Datenbanksicherung
|
||||
./update.sh --ohne-pull nur neu bauen und starten
|
||||
./update.sh --behalte 20 wie viele Sicherungen aufgehoben werden (Standard: 10)
|
||||
./update.sh --hilfe
|
||||
```
|
||||
|
||||
Die Sicherungen landen unter `backups/` und sind gzip-komprimierte `pg_dump`s.
|
||||
|
||||
Von Hand geht es genauso:
|
||||
|
||||
```bash
|
||||
git pull && docker compose build && docker compose up -d
|
||||
```
|
||||
|
||||
Datenbankmigrationen laufen in beiden Fällen automatisch beim Start des
|
||||
Containers — ein eigener Handgriff ist nicht nötig.
|
||||
|
||||
## Backup
|
||||
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Nur die Datenbank — die App läuft bei der Entwicklung direkt über `npm run dev`.
|
||||
# docker compose -f docker-compose.dev.yml up -d
|
||||
#
|
||||
# Eigener Projektname: sonst teilt sich dieser Stack den Namen mit der
|
||||
# Produktiv-Compose (beide leiten ihn sonst aus dem Verzeichnis ab), und ein
|
||||
# `docker compose down -v` dort nimmt die Entwicklungsdatenbank mit.
|
||||
name: flimmer-dev
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:17-alpine
|
||||
|
||||
+18
-5
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "flimmer",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.1",
|
||||
"private": true,
|
||||
"description": "Flimmer — selbst hostbarer Serien- und Film-Tracker",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Flimmer 0.7.1
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- **`update.sh`** für den Betrieb unter Linux — ausführbar im Repository
|
||||
hinterlegt, also direkt nach dem Klonen startklar. Der Ablauf: Datenbank
|
||||
sichern, Änderungen holen, Image bauen, neu starten, warten bis die Instanz
|
||||
wieder antwortet.
|
||||
|
||||
Grundhaltung: erst sichern, dann anfassen. Schlägt die Sicherung fehl, bricht
|
||||
das Skript ab, statt weiterzumachen. Geht danach etwas schief, nennt es den
|
||||
**konkreten** Weg zurück — mit dem Commit, zu dem man zurückspringt, und der
|
||||
Datei, die man einspielt, nicht bloß dem Rat, ein Backup zu haben.
|
||||
|
||||
Weitere Eigenheiten:
|
||||
- Lokale Änderungen im Arbeitsverzeichnis werden erkannt; `git pull`
|
||||
unterbleibt dann, statt sie zu überfahren.
|
||||
- Erreichbarkeit wird über den veröffentlichten Port geprüft und, falls der
|
||||
nicht nach außen geht (Reverse Proxy im selben Docker-Netz), über den
|
||||
Healthcheck des Containers.
|
||||
- Alte Sicherungen werden aufgeräumt, die letzten zehn bleiben (`--behalte N`).
|
||||
- Kommt mit `docker compose` und dem alten `docker-compose` zurecht.
|
||||
|
||||
- `APP_PORT` in der `.env.example` — der Port stand bisher nur in der
|
||||
Compose-Datei als Vorgabewert.
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -119,7 +119,7 @@ async function Results({ query, filter }: { query: string; filter: Filter }) {
|
||||
>
|
||||
<Poster path={item.posterPath} alt={item.title} size="w342" />
|
||||
<div className="mt-2 space-y-0.5">
|
||||
<p className="line-clamp-2 text-sm font-medium leading-snug group-hover:text-[var(--fl-text)]">
|
||||
<p className="line-clamp-2 min-h-[2lh] text-sm font-medium leading-snug group-hover:text-[var(--fl-text)]">
|
||||
{item.title}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--fl-faint)]">
|
||||
|
||||
@@ -63,7 +63,7 @@ export function LibraryCard({ item }: { item: LibraryItem }) {
|
||||
</div>
|
||||
|
||||
<div className="mt-2 space-y-1">
|
||||
<p className="line-clamp-2 text-sm font-medium leading-snug">{item.title}</p>
|
||||
<p className="line-clamp-2 min-h-[2lh] text-sm font-medium leading-snug">{item.title}</p>
|
||||
<p className="text-xs text-[var(--fl-faint)]">
|
||||
{item.type === "SERIES" ? "Serie" : "Film"}
|
||||
{item.year ? ` · ${item.year}` : ""}
|
||||
@@ -74,17 +74,28 @@ export function LibraryCard({ item }: { item: LibraryItem }) {
|
||||
: ""}
|
||||
</p>
|
||||
|
||||
{showProgress && (
|
||||
<div className="pt-0.5">
|
||||
<ProgressBar
|
||||
percent={item.percent}
|
||||
label={`${item.watched} von ${item.total} Episoden gesehen`}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-[var(--fl-faint)]">
|
||||
{item.watched}/{item.total} Episoden
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="pt-0.5">
|
||||
{showProgress ? (
|
||||
<>
|
||||
<ProgressBar
|
||||
percent={item.percent}
|
||||
label={`${item.watched} von ${item.total} Episoden gesehen`}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-[var(--fl-faint)]">
|
||||
{item.watched}/{item.total} Episoden
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
// Filme und Watchlist-Einträge haben keinen Balken. Der Platz wird
|
||||
// trotzdem freigehalten — sonst fallen sie kürzer aus als Serien und
|
||||
// das Raster franst reihenweise aus. Der Nachbau statt einer festen
|
||||
// Pixelhöhe hält sich von selbst nach, wenn sich der Balken ändert.
|
||||
<div className="invisible" aria-hidden>
|
||||
<div className="fl-progress" />
|
||||
<p className="mt-1 text-xs"> </p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Flimmer aktualisieren.
|
||||
#
|
||||
# ./update.sh sichern, holen, bauen, neu starten
|
||||
# ./update.sh --ohne-backup ohne Datenbanksicherung
|
||||
# ./update.sh --ohne-pull nur neu bauen und starten (kein git pull)
|
||||
# ./update.sh --hilfe
|
||||
#
|
||||
# Grundhaltung: Erst sichern, dann anfassen. Bricht etwas ab, steht am Ende,
|
||||
# wie der Weg zurück aussieht — mit dem konkreten Befehl und der konkreten
|
||||
# Sicherungsdatei, nicht bloß dem Rat, ein Backup zu haben.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# --- Ausgabe -----------------------------------------------------------------
|
||||
|
||||
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then
|
||||
AKZENT=$'\033[38;5;214m'; GUT=$'\033[32m'; WARN=$'\033[33m'
|
||||
FEHLER=$'\033[31m'; LEISE=$'\033[2m'; AUS=$'\033[0m'
|
||||
else
|
||||
AKZENT=""; GUT=""; WARN=""; FEHLER=""; LEISE=""; AUS=""
|
||||
fi
|
||||
|
||||
schritt() { printf '\n%s▸ %s%s\n' "$AKZENT" "$1" "$AUS"; }
|
||||
info() { printf ' %s\n' "$1"; }
|
||||
leise() { printf ' %s%s%s\n' "$LEISE" "$1" "$AUS"; }
|
||||
gut() { printf ' %s✓ %s%s\n' "$GUT" "$1" "$AUS"; }
|
||||
warnung() { printf ' %s! %s%s\n' "$WARN" "$1" "$AUS"; }
|
||||
abbruch() { printf '\n%s✗ %s%s\n' "$FEHLER" "$1" "$AUS" >&2; exit 1; }
|
||||
|
||||
hilfe() {
|
||||
cat <<'ENDE'
|
||||
Flimmer aktualisieren
|
||||
|
||||
./update.sh [OPTIONEN]
|
||||
|
||||
Ablauf: Datenbank sichern → Änderungen holen → Image bauen → neu starten →
|
||||
warten, bis die Instanz wieder antwortet. Die Datenbankmigrationen spielt der
|
||||
Container beim Start selbst ein.
|
||||
|
||||
Optionen:
|
||||
--ohne-backup Keine Sicherung anlegen (nicht empfohlen)
|
||||
--ohne-pull Nichts aus dem Repository holen, nur neu bauen und starten
|
||||
--behalte N Wie viele Sicherungen aufgehoben werden (Standard: 10)
|
||||
--hilfe, -h Diese Übersicht
|
||||
|
||||
Die Sicherungen liegen unter backups/ und sind gzip-komprimierte pg_dumps.
|
||||
ENDE
|
||||
}
|
||||
|
||||
# --- Optionen ----------------------------------------------------------------
|
||||
|
||||
MIT_BACKUP=1
|
||||
MIT_PULL=1
|
||||
BEHALTE=10
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--ohne-backup) MIT_BACKUP=0 ;;
|
||||
--ohne-pull) MIT_PULL=0 ;;
|
||||
--behalte) shift; BEHALTE="${1:-10}" ;;
|
||||
--hilfe|-h) hilfe; exit 0 ;;
|
||||
*) abbruch "Unbekannte Option: $1 (./update.sh --hilfe)" ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
# --- Voraussetzungen ---------------------------------------------------------
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
[ -f docker-compose.yml ] || abbruch "Hier liegt keine docker-compose.yml. Das Skript gehört ins Flimmer-Verzeichnis."
|
||||
[ -f .env ] || abbruch "Es fehlt die .env. Lege sie an: cp .env.example .env"
|
||||
|
||||
command -v docker >/dev/null 2>&1 || abbruch "docker ist nicht installiert oder nicht im Pfad."
|
||||
|
||||
if docker compose version >/dev/null 2>&1; then
|
||||
COMPOSE="docker compose"
|
||||
elif command -v docker-compose >/dev/null 2>&1; then
|
||||
COMPOSE="docker-compose"
|
||||
else
|
||||
abbruch "Weder 'docker compose' noch 'docker-compose' gefunden."
|
||||
fi
|
||||
|
||||
docker info >/dev/null 2>&1 || abbruch "Der Docker-Dienst antwortet nicht. Läuft er?"
|
||||
|
||||
# Werte aus der .env lesen, ohne sie auszuführen.
|
||||
env_wert() {
|
||||
local schluessel="$1" standard="${2:-}"
|
||||
local zeile
|
||||
zeile=$(grep -E "^[[:space:]]*${schluessel}=" .env 2>/dev/null | tail -1 || true)
|
||||
if [ -z "$zeile" ]; then printf '%s' "$standard"; return; fi
|
||||
zeile="${zeile#*=}"
|
||||
zeile="${zeile%\"}"; zeile="${zeile#\"}"
|
||||
zeile="${zeile%\'}"; zeile="${zeile#\'}"
|
||||
printf '%s' "$zeile"
|
||||
}
|
||||
|
||||
DB_USER=$(env_wert POSTGRES_USER flimmer)
|
||||
DB_NAME=$(env_wert POSTGRES_DB flimmer)
|
||||
PORT=$(env_wert APP_PORT 3000)
|
||||
|
||||
printf '%s\n' "Flimmer aktualisieren"
|
||||
leise "Verzeichnis: $(pwd)"
|
||||
leise "Compose: $COMPOSE"
|
||||
|
||||
# --- Stand festhalten --------------------------------------------------------
|
||||
|
||||
IST_GIT=0
|
||||
VORHER_REF=""
|
||||
if [ -d .git ] && command -v git >/dev/null 2>&1; then
|
||||
IST_GIT=1
|
||||
VORHER_REF=$(git rev-parse --short HEAD 2>/dev/null || echo "unbekannt")
|
||||
fi
|
||||
|
||||
VORHER_VERSION=$(grep -m1 '"version"' package.json 2>/dev/null | sed 's/.*"version"[^"]*"\([^"]*\)".*/\1/' || echo "?")
|
||||
leise "Aktuell: Version $VORHER_VERSION${VORHER_REF:+ (${VORHER_REF})}"
|
||||
|
||||
# --- Sicherung ---------------------------------------------------------------
|
||||
|
||||
SICHERUNG=""
|
||||
if [ "$MIT_BACKUP" -eq 1 ]; then
|
||||
schritt "Datenbank sichern"
|
||||
|
||||
# -q gibt es in beiden Compose-Fassungen; --status erst ab v2.
|
||||
if [ -z "$($COMPOSE ps -q db 2>/dev/null)" ]; then
|
||||
warnung "Die Datenbank läuft nicht — es gibt also auch nichts zu sichern."
|
||||
else
|
||||
mkdir -p backups
|
||||
SICHERUNG="backups/flimmer-$(date +%Y-%m-%d-%H%M%S).sql.gz"
|
||||
|
||||
if $COMPOSE exec -T db pg_dump -U "$DB_USER" -d "$DB_NAME" --clean --if-exists \
|
||||
| gzip > "$SICHERUNG" 2>/dev/null && [ -s "$SICHERUNG" ]; then
|
||||
gut "$SICHERUNG ($(du -h "$SICHERUNG" | cut -f1))"
|
||||
else
|
||||
rm -f "$SICHERUNG"
|
||||
SICHERUNG=""
|
||||
abbruch "Die Sicherung ist fehlgeschlagen. Abbruch — ohne Sicherung wird hier nichts aktualisiert.
|
||||
Wenn du es trotzdem willst: ./update.sh --ohne-backup"
|
||||
fi
|
||||
|
||||
# Ältere Sicherungen aufräumen, die jüngsten bleiben.
|
||||
if [ "$BEHALTE" -gt 0 ]; then
|
||||
ANZAHL=$(ls -1 backups/flimmer-*.sql.gz 2>/dev/null | wc -l)
|
||||
if [ "$ANZAHL" -gt "$BEHALTE" ]; then
|
||||
ls -1t backups/flimmer-*.sql.gz | tail -n +$((BEHALTE + 1)) | xargs -r rm --
|
||||
leise "Ältere Sicherungen entfernt, die letzten $BEHALTE bleiben."
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
else
|
||||
warnung "Ohne Sicherung — auf eigenes Risiko."
|
||||
fi
|
||||
|
||||
# --- Änderungen holen --------------------------------------------------------
|
||||
|
||||
if [ "$MIT_PULL" -eq 1 ]; then
|
||||
schritt "Änderungen holen"
|
||||
|
||||
if [ "$IST_GIT" -eq 0 ]; then
|
||||
warnung "Kein Git-Repository — übersprungen."
|
||||
elif [ -n "$(git status --porcelain 2>/dev/null)" ]; then
|
||||
warnung "Es gibt lokale Änderungen; git pull wird übersprungen."
|
||||
leise "Ansehen mit: git status"
|
||||
else
|
||||
ZWEIG=$(git rev-parse --abbrev-ref HEAD)
|
||||
if git pull --ff-only 2>&1 | sed 's/^/ /'; then
|
||||
NACHHER_REF=$(git rev-parse --short HEAD)
|
||||
if [ "$NACHHER_REF" = "$VORHER_REF" ]; then
|
||||
gut "Schon auf dem neuesten Stand ($ZWEIG)."
|
||||
else
|
||||
gut "$VORHER_REF → $NACHHER_REF"
|
||||
fi
|
||||
else
|
||||
abbruch "git pull ist fehlgeschlagen. Vermutlich sind Zweig und Gegenstelle auseinandergelaufen."
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
NEUE_VERSION=$(grep -m1 '"version"' package.json 2>/dev/null | sed 's/.*"version"[^"]*"\([^"]*\)".*/\1/' || echo "?")
|
||||
|
||||
# --- Bauen und starten -------------------------------------------------------
|
||||
|
||||
schritt "Image bauen"
|
||||
info "Das dauert beim ersten Mal einige Minuten."
|
||||
$COMPOSE build 2>&1 | sed 's/^/ /' || abbruch "Der Build ist fehlgeschlagen. Die laufende Instanz ist unangetastet."
|
||||
gut "Gebaut."
|
||||
|
||||
schritt "Neu starten"
|
||||
$COMPOSE up -d 2>&1 | sed 's/^/ /' || abbruch "Der Start ist fehlgeschlagen. Log ansehen: $COMPOSE logs -f app"
|
||||
gut "Container laufen."
|
||||
|
||||
# --- Warten, bis die Instanz antwortet ---------------------------------------
|
||||
|
||||
schritt "Auf die Instanz warten"
|
||||
info "Die Datenbankmigrationen spielt der Container beim Start selbst ein."
|
||||
|
||||
GESUND=0
|
||||
for _ in $(seq 1 60); do
|
||||
if curl -fsS "http://127.0.0.1:${PORT}/api/health" >/dev/null 2>&1; then
|
||||
GESUND=1
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if [ "$GESUND" -eq 1 ]; then
|
||||
ANTWORT=$(curl -fsS "http://127.0.0.1:${PORT}/api/health" 2>/dev/null || true)
|
||||
gut "Flimmer antwortet auf Port ${PORT}."
|
||||
leise "$ANTWORT"
|
||||
|
||||
printf '\n%s✓ Fertig — Version %s%s\n' "$GUT" "$NEUE_VERSION" "$AUS"
|
||||
if [ "$VORHER_VERSION" != "$NEUE_VERSION" ]; then
|
||||
info "Was sich geändert hat, steht in CHANGELOG.md."
|
||||
fi
|
||||
[ -n "$SICHERUNG" ] && leise "Sicherung: $SICHERUNG"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- Fehlschlag: konkreter Weg zurück ----------------------------------------
|
||||
|
||||
printf '\n%s✗ Die Instanz antwortet nach zwei Minuten nicht.%s\n\n' "$FEHLER" "$AUS" >&2
|
||||
info "Log ansehen:"
|
||||
printf ' %s logs --tail 50 app\n\n' "$COMPOSE"
|
||||
|
||||
if [ -n "$SICHERUNG" ] || [ -n "$VORHER_REF" ]; then
|
||||
info "Weg zurück:"
|
||||
if [ "$IST_GIT" -eq 1 ] && [ -n "$VORHER_REF" ]; then
|
||||
printf ' git checkout %s && %s up -d --build\n' "$VORHER_REF" "$COMPOSE"
|
||||
fi
|
||||
if [ -n "$SICHERUNG" ]; then
|
||||
printf ' gunzip -c %s | %s exec -T db psql -U %s -d %s\n' \
|
||||
"$SICHERUNG" "$COMPOSE" "$DB_USER" "$DB_NAME"
|
||||
fi
|
||||
printf '\n'
|
||||
fi
|
||||
|
||||
exit 1
|
||||
Reference in New Issue
Block a user