feat(tags): QR-Aufkleber, Geraeteseite, Etikettendruck und Scanner
Der Einstieg, fuer den die Anwendung gebaut ist: scannen, sehen was zu tun ist,
loslegen. `/t/<tagCode>` kennt drei Faelle — registriertes Geraet, vorgedruckter
Tag, unbekannter Code —, die Geraeteseite zeigt Zuweisungen, Schnellstart und
Historie, und beides ist am Handy bedienbar.
Im QR steht ausschliesslich `{APP_URL}/t/{tagCode}`: kein Name, keine
Seriennummer, kein Besitzer. Ein Aufkleber haengt jahrelang sichtbar am Geraet
und wird von jedem Telefon gelesen; was darauf steht, ist veroeffentlicht. Die
Masse sind gemessen, nicht behauptet: ein echtes PDF liefert 62 x 29 mm und A4,
und ein bei 300 dpi gerastertes Etikett wird mit demselben Decoder
zurueckgelesen, den auch der Scanner benutzt.
Drei Defekte zeigte erst der gebaute Stand, alle an den Naehten zwischen den
parallel gebauten Teilen: „Starten“ setzte IN_ARBEIT und sprang nicht, „Vorlage
zuweisen“ bot Vorlagen an, die der Dienst danach ablehnte, und die Geraeteliste
verlinkte nichts. Der unabhaengige Review fand drei Blocker — darunter, dass der
Scanner nie die native BarcodeDetector-API benutzte und dass ein Unterpfad in
APP_URL aus dem gedruckten QR herausfiel.
Closes #14
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit was merged in pull request #28.
This commit is contained in:
@@ -21,3 +21,31 @@ export function oeffentlicheAdresse(pfad: string, ersatzHerkunft: string): URL {
|
||||
}
|
||||
return new URL(pfad, ersatzHerkunft);
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Basisadresse der Anwendung – **mit** ihrem Pfad (#014).
|
||||
*
|
||||
* `oeffentlicheAdresse` loest einen absoluten Pfad gegen die Basis auf, und
|
||||
* dabei faellt ein Unterpfad heraus: aus `https://firma.de/laufzettel` und
|
||||
* `/t/LZ-…` wird `https://firma.de/t/LZ-…`. Fuer eine Umleitung im laufenden
|
||||
* Betrieb faellt das niemandem auf, weil der Reverse Proxy den Pfad ohnehin
|
||||
* wieder anhaengt. Auf einem **gedruckten Aufkleber** ist es das Ende: er zeigt
|
||||
* fuer immer ins Leere.
|
||||
*
|
||||
* Wer eine Adresse baut, die jemand behaelt, nimmt deshalb diese Basis und
|
||||
* haengt relativ an. Der Schraegstrich am Ende entscheidet darueber, ob das
|
||||
* letzte Pfadstueck ersetzt oder ergaenzt wird — er wird hier gesetzt, statt
|
||||
* darauf zu hoffen.
|
||||
*/
|
||||
export function basisAdresse(ersatzHerkunft: string): URL {
|
||||
const roh = process.env["APP_URL"]?.trim();
|
||||
let basis: URL;
|
||||
try {
|
||||
basis = new URL(roh || ersatzHerkunft);
|
||||
} catch {
|
||||
// Eine unbrauchbare APP_URL soll den Druck nicht blockieren.
|
||||
basis = new URL(ersatzHerkunft);
|
||||
}
|
||||
if (!basis.pathname.endsWith("/")) basis.pathname += "/";
|
||||
return basis;
|
||||
}
|
||||
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
import QRCode from "qrcode";
|
||||
import { basisAdresse } from "@/lib/adressen";
|
||||
import { istTagCode } from "@/lib/tagcode";
|
||||
|
||||
/**
|
||||
* QR-Codes fuer die Aufkleber (#014).
|
||||
*
|
||||
* Framework- und datenbankfrei, wie jedes Modul in `src/lib`: hier stehen nur
|
||||
* die Rechnung von `tagCode` zu Adresse und die Erzeugung des SVG. Wer den Code
|
||||
* kennen darf, entscheiden Route Handler und Seiten.
|
||||
*
|
||||
* **Im QR steht ausschliesslich die Adresse `{APP_URL}/t/{tagCode}`.** Kein
|
||||
* Name, keine Seriennummer, kein Besitzer. Ein Aufkleber haengt jahrelang
|
||||
* sichtbar am Geraet und wird von jedem Telefon gelesen, auch von einem fremden;
|
||||
* was darauf steht, ist damit veroeffentlicht. Die Adresse selbst verraet
|
||||
* nichts — dahinter verlangt `/t/[tagCode]` eine Anmeldung.
|
||||
*/
|
||||
|
||||
export class QrFehler extends Error {
|
||||
constructor(nachricht: string) {
|
||||
super(nachricht);
|
||||
this.name = "QrFehler";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fehlerkorrektur M wie im Auftrag: rund 15 % der Module duerfen fehlen.
|
||||
*
|
||||
* Das passt zu einem Aufkleber, der verkratzt und verstaubt, ohne den Code so
|
||||
* gross zu machen, dass er auf ein 62 × 29 mm grosses Etikett nicht mehr passt.
|
||||
*/
|
||||
export const QR_FEHLERKORREKTUR = "M" as const;
|
||||
|
||||
/**
|
||||
* Ruhezone in Modulen.
|
||||
*
|
||||
* Vier ist das Mass aus ISO/IEC 18004 und zugleich der Standard von `qrcode`.
|
||||
* Die Zone wird hier ausdruecklich gesetzt und nicht dem Paket ueberlassen,
|
||||
* damit niemand sie am Etikett „einspart“: ohne weissen Rand findet der Decoder
|
||||
* das Suchmuster nicht mehr, und das faellt erst am gedruckten Bogen auf.
|
||||
*/
|
||||
export const QR_RUHEZONE = 4;
|
||||
|
||||
/**
|
||||
* Basisadresse, wenn weder `APP_URL` noch eine Anfrage eine liefert.
|
||||
*
|
||||
* `oeffentlicheAdresse` verlangt eine Ersatzherkunft, weil im Betrieb die
|
||||
* laufende Anfrage sie stellt. Beim Etikettendruck gibt es keine — eine
|
||||
* Server-Komponente hat keine Anfrageadresse zur Hand, und ein Etikett wird
|
||||
* gedruckt, nicht angeklickt. Ohne gesetzte `APP_URL` waere jeder Aufkleber
|
||||
* ohnehin falsch, egal welche Adresse hier stuende; der Entwicklungsstand ist
|
||||
* dann die Adresse, unter der die Anwendung ueberhaupt laeuft.
|
||||
*/
|
||||
export const QR_ERSATZ_HERKUNFT = "http://localhost:3000";
|
||||
|
||||
/**
|
||||
* Die Adresse, die im QR steht.
|
||||
*
|
||||
* Die Basis kommt aus `APP_URL` ueber `oeffentlicheAdresse` (#007) und nicht aus
|
||||
* der eingehenden Anfrage: hinter einem Reverse Proxy traegt die Anfrage den
|
||||
* internen Host, und ein Aufkleber mit `http://laufzettel-app:3000/t/…` ist
|
||||
* wertlos — er klebt Jahre am Geraet und laesst sich nicht nachtraeglich
|
||||
* korrigieren. Wer eine Ersatzherkunft hat (ein Route Handler etwa), gibt sie
|
||||
* mit; sie greift nur, wenn `APP_URL` fehlt.
|
||||
*/
|
||||
export function tagAdresse(tagCode: string, ersatzHerkunft: string = QR_ERSATZ_HERKUNFT): string {
|
||||
if (!istTagCode(tagCode)) {
|
||||
// Der Code wird in die Adresse eingesetzt. Ein nicht geprueftes Zeichen
|
||||
// stuende damit im QR jedes gedruckten Aufklebers.
|
||||
throw new QrFehler(`„${tagCode}“ hat nicht die Form eines Tag-Codes.`);
|
||||
}
|
||||
|
||||
/*
|
||||
* **Relativ angehaengt, nicht als absoluter Pfad.** Liegt die Anwendung unter
|
||||
* einem Unterpfad (`https://firma.de/laufzettel`), verschluckt `new
|
||||
* URL("/t/…", basis)` genau diesen Unterpfad — der Aufkleber zeigte dann auf
|
||||
* `https://firma.de/t/…`, und dort steht nichts. Auf Papier ist das nicht
|
||||
* mehr zu korrigieren (Reviewbefund zu #014). `basisAdresse` haelt den Pfad
|
||||
* fest, und hier wird relativ angehaengt.
|
||||
*/
|
||||
return new URL(`t/${tagCode}`, basisAdresse(ersatzHerkunft)).toString();
|
||||
}
|
||||
|
||||
export type QrOptionen = {
|
||||
/** Greift nur ohne gesetzte `APP_URL`; siehe `tagAdresse`. */
|
||||
readonly ersatzHerkunft?: string;
|
||||
/**
|
||||
* Feste Kantenlaenge in Pixeln fuer das `svg`-Element. Ohne Angabe traegt das
|
||||
* SVG nur eine `viewBox` und fuellt seinen Platz — genau das, was ein Etikett
|
||||
* in Millimetern braucht.
|
||||
*/
|
||||
readonly breite?: number;
|
||||
/** Ruhezone in Modulen. Standard `QR_RUHEZONE`. */
|
||||
readonly rand?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Das QR-SVG zu einem `tagCode`.
|
||||
*
|
||||
* SVG und nicht PNG: es wird in einen Druck mit Millimeterangaben gesetzt, und
|
||||
* nur ein Vektor kommt auf jedem Drucker scharf heraus. Erzeugt wird es lokal
|
||||
* mit `qrcode`; eine externe API kaeme nicht in Frage, sie bekaeme mit jedem
|
||||
* Aufruf den Bestand an Tag-Codes geliefert.
|
||||
*/
|
||||
export async function qrSvg(tagCode: string, optionen: QrOptionen = {}): Promise<string> {
|
||||
const adresse = tagAdresse(tagCode, optionen.ersatzHerkunft ?? QR_ERSATZ_HERKUNFT);
|
||||
return QRCode.toString(adresse, {
|
||||
type: "svg",
|
||||
errorCorrectionLevel: QR_FEHLERKORREKTUR,
|
||||
margin: optionen.rand ?? QR_RUHEZONE,
|
||||
...(optionen.breite === undefined ? {} : { width: optionen.breite }),
|
||||
// Schwarz auf Weiss, feste Werte: ein Etikett ist immer hell, und der
|
||||
// Decoder braucht den Kontrast. Die Token der Oberflaeche gelten hier nicht.
|
||||
color: { dark: "#000000ff", light: "#ffffffff" },
|
||||
});
|
||||
}
|
||||
@@ -77,3 +77,45 @@ export async function tagCodeEindeutigErzeugen(
|
||||
"Das deutet auf eine defekte Vergabepruefung hin, nicht auf einen erschoepften Vorrat.",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest den Tag-Code aus dem, was der Scanner gefunden hat (#014).
|
||||
*
|
||||
* Auf dem Aufkleber steht ein QR mit `{APP_URL}/t/{tagCode}`. Gescannt wird aber
|
||||
* nicht nur der eigene Aufkleber: in der Kamera landen auch fremde QR-Codes,
|
||||
* alte Etiketten einer anderen Anwendung oder ein abgetippter Code aus dem
|
||||
* Ersatzfeld. Deshalb wird hier entschieden, **was** ein Fund bedeutet, und
|
||||
* nicht in der Kamerakomponente.
|
||||
*
|
||||
* Angenommen wird dreierlei, und zwar bewusst grosszuegig:
|
||||
*
|
||||
* 1. eine Adresse, deren Pfad auf `/t/<code>` endet — **egal von welchem Host**.
|
||||
* Ein Haus zieht um, ein zweiter Name zeigt auf dieselbe Anwendung, ein
|
||||
* Reverse Proxy schreibt um: an all dem soll der Aufkleber im Regal nicht
|
||||
* scheitern. Der Code selbst ist die Auskunft, nicht der Host.
|
||||
* 2. der blanke Code `LZ-XXXXXX`
|
||||
* 3. derselbe in Kleinbuchstaben — abgetippt wird klein, und das Alphabet des
|
||||
* Codes ist ohnehin nur gross.
|
||||
*
|
||||
* Alles andere ergibt `null`; der Scanner sagt dann „kein Laufzettel-Tag“ und
|
||||
* sucht weiter, statt auf eine Fehlerseite zu springen.
|
||||
*/
|
||||
export function tagCodeAusFund(fund: string): string | null {
|
||||
const roh = fund.trim();
|
||||
if (!roh) return null;
|
||||
|
||||
const alsCode = roh.toUpperCase();
|
||||
if (istTagCode(alsCode)) return alsCode;
|
||||
|
||||
try {
|
||||
// `new URL` scheitert bei allem, was keine absolute Adresse ist – genau die
|
||||
// Unterscheidung, die hier gebraucht wird.
|
||||
const adresse = new URL(roh);
|
||||
const teile = adresse.pathname.split("/").filter(Boolean);
|
||||
if (teile.length !== 2 || teile[0] !== "t") return null;
|
||||
const code = decodeURIComponent(teile[1] ?? "").toUpperCase();
|
||||
return istTagCode(code) ? code : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user