Docker-Stack, README und Korrekturen aus dem ersten Durchlauf

- Dockerfile (4 Stufen) und docker-compose.yml mit Postgres-Healthcheck,
  Volume und direktem Port-Mapping 3000:3000, ohne Reverse Proxy.
- Entrypoint wendet beim Start "prisma migrate deploy" an; die Prisma-CLI
  bekommt eine eigene Build-Stufe, weil sie mehr als nur @prisma/* braucht.
- prisma.config.ts durch prisma.config.js ersetzt: laedt ohne TypeScript und
  ohne dotenv, damit sie im Laufzeit-Image funktioniert.
- Prisma-Client wird erst beim ersten Zugriff erzeugt. Vorher scheiterte
  "next build" beim Einsammeln der Routen-Konfiguration ohne DATABASE_URL.
- Postgres-18-Volume auf /var/lib/postgresql gemountet (neues Datenlayout).
- npm 11 blockiert Installationsskripte: erlaubte Pakete stehen jetzt im
  Feld "allowScripts" der package.json.
- Lokale Variable "module" ueberall in "qrType" umbenannt (verdeckte sonst
  die CommonJS-Variable) und Vorschau-Effekt ohne synchrones setState.
- README.md auf Deutsch: Setup, Konfiguration, Bedienung, Aufbau, API und
  Ausblick auf Version 2.
This commit is contained in:
Florian Wilde
2026-08-13 16:48:28 +02:00
parent ad4cd92f8e
commit 5806dc4ce0
26 changed files with 751 additions and 129 deletions
+4 -4
View File
@@ -28,14 +28,14 @@ function asRecord(value: unknown): Record<string, unknown> {
export function toQrCodeDto(code: QrCode): QrCodeDto {
const data = asRecord(code.data);
const module = getQrType(code.type);
const qrType = getQrType(code.type);
let target = "";
let summary = "";
if (module) {
if (qrType) {
try {
target = module.redirectTarget(data);
summary = module.summary(data);
target = qrType.redirectTarget(data);
summary = qrType.summary(data);
} catch {
// Datensatz passt nicht mehr zum Typ-Schema (z. B. nach einem
// Schema-Wechsel). Die Liste soll trotzdem anzeigbar bleiben.
+32 -12
View File
@@ -5,20 +5,40 @@ import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "@/generated/prisma/client";
import { getDatabaseUrl } from "@/lib/env";
type Client = InstanceType<typeof PrismaClient>;
// Im Entwicklungsmodus wird das Modul bei jedem Hot-Reload neu ausgewertet.
// Ohne diesen Cache entstünde pro Reload ein neuer Verbindungspool.
const globalForPrisma = globalThis as unknown as {
prisma?: ReturnType<typeof createPrismaClient>;
};
const globalForPrisma = globalThis as unknown as { prisma?: Client };
function createPrismaClient() {
return new PrismaClient({
adapter: new PrismaPg({ connectionString: getDatabaseUrl() }),
});
let client: Client | undefined;
function getClient(): Client {
if (!client) {
client =
globalForPrisma.prisma ??
new PrismaClient({
adapter: new PrismaPg({ connectionString: getDatabaseUrl() }),
});
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = client;
}
}
return client;
}
export const prisma = globalForPrisma.prisma ?? createPrismaClient();
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}
/**
* Der Client wird erst beim ersten Zugriff erzeugt.
*
* Beim Produktionsbuild lädt Next.js die Routen-Module, um ihre Konfiguration
* einzusammeln. Zu diesem Zeitpunkt gibt es weder eine Datenbank noch eine
* DATABASE_URL — ein sofort verbindender Client würde den Build scheitern
* lassen.
*/
export const prisma: Client = new Proxy({} as Client, {
get(_target, property) {
const instance = getClient() as unknown as Record<string | symbol, unknown>;
const value = instance[property];
return typeof value === "function" ? value.bind(instance) : value;
},
});
+1 -1
View File
@@ -3,7 +3,7 @@
*
* Scanner erkennen einen Code über den Helligkeitsunterschied zwischen
* Modul- und Hintergrundfarbe. Zu geringer Kontrast — und ebenso ein heller
* Vordergrund auf dunklem Hintergrund („invertierter" Code) — führt bei
* Vordergrund auf dunklem Hintergrund („invertierter“ Code) — führt bei
* vielen Kameras zu Lesefehlern.
*/
+3 -3
View File
@@ -105,7 +105,7 @@ const MODULES: readonly QrTypeModule[] = [
];
export const qrTypes: Record<QrTypeId, QrTypeModule> = Object.fromEntries(
MODULES.map((module) => [module.id, module]),
MODULES.map((qrType) => [qrType.id, qrType]),
) as Record<QrTypeId, QrTypeModule>;
const missing = QR_TYPE_IDS.filter((id) => !qrTypes[id]);
@@ -122,8 +122,8 @@ export function getQrType(id: string): QrTypeModule | undefined {
/** Liefert den Typ nur, wenn er in dieser Version nutzbar ist. */
export function getActiveQrType(id: string): QrTypeModule | undefined {
const module = getQrType(id);
return module?.active ? module : undefined;
const qrType = getQrType(id);
return qrType?.active ? qrType : undefined;
}
export { QR_TYPE_IDS, isQrTypeId };
+11 -11
View File
@@ -41,7 +41,7 @@ export class QrRequestError extends Error {
}
export type ResolvedQrRequest = {
module: QrTypeModule;
qrType: QrTypeModule;
data: Record<string, unknown>;
/** Der Text, der tatsächlich in den QR-Code kodiert wird. */
content: string;
@@ -52,23 +52,23 @@ export type ResolvedQrRequest = {
* bei statischen Codes die Nutzdaten selbst, bei dynamischen die Kurz-URL.
*/
export function resolveQrRequest(request: QrRequest): ResolvedQrRequest {
const module = getActiveQrType(request.type);
if (!module) {
const qrType = getActiveQrType(request.type);
if (!qrType) {
throw new QrRequestError(
"Dieser QR-Typ ist in dieser Version noch nicht verfügbar.",
404,
);
}
const validated = module.validate(request.data);
const validated = qrType.validate(request.data);
if (!validated.ok) {
throw new QrRequestError(validated.message);
}
if (request.mode === "dynamic") {
if (!module.supportsDynamic) {
if (!qrType.supportsDynamic) {
throw new QrRequestError(
`Der QR-Typ „${module.title}" unterstützt keine dynamischen Codes.`,
`Der QR-Typ „${qrType.title}“ unterstützt keine dynamischen Codes.`,
);
}
const slug = request.slug;
@@ -76,28 +76,28 @@ export function resolveQrRequest(request: QrRequest): ResolvedQrRequest {
throw new QrRequestError("Ungültige Kurz-URL.");
}
return {
module,
qrType,
data: validated.data,
content: shortUrl(slug ?? PREVIEW_SLUG),
};
}
return {
module,
qrType,
data: validated.data,
content: module.encode(validated.data),
content: qrType.encode(validated.data),
};
}
/** Dateiname für den Download, abgeleitet aus dem Inhalt des Codes. */
export function downloadFilename(
module: QrTypeModule,
qrType: QrTypeModule,
data: unknown,
extension: string,
): string {
let base = "qr-code";
try {
const summary = module
const summary = qrType
.summary(data)
.toLowerCase()
.replaceAll("ä", "ae")
+3 -3
View File
@@ -5,7 +5,7 @@ import type { IconName } from "@/components/icons";
/**
* Alle QR-Typen, die die Anwendung kennt. V1 implementiert davon nur
* "website"; die übrigen Einträge sind als Platzhalter registriert und
* erscheinen in der Oberfläche mit dem Badge „Bald verfügbar".
* erscheinen in der Oberfläche mit dem Badge „Bald verfügbar“.
*/
export const QR_TYPE_IDS = [
"website",
@@ -148,7 +148,7 @@ export function defineComingSoonType(spec: {
}): QrTypeModule {
const notAvailable = (): never => {
throw new QrTypeError(
`Der QR-Typ „${spec.title}" ist in dieser Version noch nicht verfügbar.`,
`Der QR-Typ „${spec.title}“ ist in dieser Version noch nicht verfügbar.`,
);
};
@@ -162,7 +162,7 @@ export function defineComingSoonType(spec: {
defaultData: {},
validate: () => ({
ok: false,
message: `Der QR-Typ „${spec.title}" ist in dieser Version noch nicht verfügbar.`,
message: `Der QR-Typ „${spec.title}“ ist in dieser Version noch nicht verfügbar.`,
}),
encode: notAvailable,
redirectTarget: notAvailable,