Files
tessera-ctl/packages/shared/src/index.ts
T
schalli 1f85277a3b feat(kantine-datev): Kantinenabrechnung als Modul in neuer Gruppe Finanzbuchhaltung
- neue Seitenleisten-Kategorie accounting (Finanzbuchhaltung / Financial accounting)
- CSV (UTF-8, UTF-8 mit BOM, Windows-1252) pruefen, Vorschau mit Zeilenfehlern, DATEV-Lohn-ASCII-Export
- Beraternummer, Mandantennummer, Lohnart je Mandant als Admin-Einstellung (KantineDatevConfig mit Zeilenschutz), anfangs leer
- hochgeladene Daten werden nicht gespeichert; Download per Blob (auch im Desktop-Client)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 11:32:55 +02:00

380 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
export const APP_NAME = "Tessera";
export interface HealthResponse {
status: string;
timestamp: string;
}
/**
* Antwort von `GET /health/version` (quick-260914-ku1).
* `channel` ist in der Praxis `beta` | `live` | `dev`; die Wahrheit der
* Version ist der Git-Tag (`git describe`), nicht eine package.json.
*/
export interface VersionResponse {
name: string;
version: string;
channel: string;
commit: string;
buildTime: string;
}
/**
* Desktop-Pakete (Phase 18, D-08): Quelle ist ausschliesslich `manifest.json`,
* geschrieben nur von `.gitea/scripts/desktop-collect.sh` im CI. `platform` ist
* ein geschlossener Wertevorrat -- eine dritte Plattform waere eine bewusste
* Erweiterung hier und an der Konstante `PLATFORMS` im API-Dienst.
*/
export type DesktopPlatform = 'windows' | 'linux';
export interface DesktopManifestFile {
name: string;
size: number;
sha256: string;
/**
* Base64-Inhalt der `.sig`-Datei des Tauri-Bundlers (minisign), geschrieben
* von desktop-collect.sh, gelesen nur von `GET /desktop/update`. Fehlt bei
* Bauten mit `--no-sign` -- dann gibt es kein Update in der App.
*/
signature?: string;
}
export interface DesktopManifest {
version: string;
channel: string;
commit: string;
buildTime: string;
/**
* SemVer-Form, die der Updater im Client vergleicht: `X.Y.Z` bei live,
* `X.Y.Z-beta.g<sha7>` bei beta (Praefix `g` Pflicht -- ein rein numerischer
* SHA mit fuehrender Null waere kein gueltiger SemVer-Identifier).
*/
updateVersion?: string;
files: Partial<Record<DesktopPlatform, DesktopManifestFile>>;
}
/**
* Antwort von `GET /desktop/update` -- das dynamische Antwortformat von
* `tauri-plugin-updater`. Die Feldnamen sind vom Plugin vorgegeben, darum
* snake_case `pub_date`. `url` muss absolut sein, `signature` ist Pflicht.
*/
export interface DesktopUpdateResponse {
version: string;
pub_date?: string;
url: string;
signature: string;
notes?: string;
}
export interface DesktopLatestFile extends DesktopManifestFile {
url: string;
}
export interface DesktopLatestResponse {
version: string;
channel: string;
commit: string;
buildTime: string;
files: Partial<Record<DesktopPlatform, DesktopLatestFile>>;
}
/**
* Dashboard-Kacheln: EINE Typliste fuer Web und API (quick-260922-m1h).
*
* Vorher stand dieselbe Liste an sieben Stellen (Union-Typ, Constraints,
* Registry, Katalog-Liste, `@IsIn`-Whitelist ...). Vergass man eine, fehlte
* die Kachel im Katalog oder die API lehnte sie mit 400 ab. Seit m1h leiten
* beide Seiten von hier ab: `apps/web/src/components/dashboard/
* widget-registry.tsx` (Registry + Katalog) und
* `apps/api/src/dashboard/dto/create-widget.dto.ts` (`@IsIn`).
*
* ACHTUNG: Dies ist der erste LAUFZEIT-Import aus `@tessera/shared` (alle
* uebrigen sind `import type`). `packages/shared` liefert rohes TypeScript
* (`main: src/index.ts`, kein Bauschritt); die API laedt es im Betrieb ueber
* das native Type-Stripping von Node 24. Deshalb darf diese Datei nur
* loeschbare Syntax enthalten — keine `enum`, kein `namespace`, keine
* Parameter-Eigenschaften.
*
* Der zweite Laufzeit-Import sind `parseReleaseVersion`/`compareReleaseVersions`
* weiter unten (quick-260925-bow) — dieselbe Regel gilt dort, und neue
* Laufzeit-Funktionen gehoeren direkt in diese Datei (CJS-`require` findet
* relative Importe ohne `.ts`-Endung nicht).
*/
export const WIDGET_TYPES = [
'clock',
'search',
'calendar',
'note',
'calculator',
'favorites',
'stopwatch',
'picture-frame',
'xframe',
'proxmox',
'reminder',
] as const;
export type WidgetType = (typeof WIDGET_TYPES)[number];
/**
* Kachel → Modul-Slug. Eine Kachel ohne Eintrag ist immer sichtbar; eine
* Kachel MIT Eintrag erscheint nur fuer Benutzer, die das Modul nutzen
* duerfen — im Katalog (Komfort, `visibleWidgetTypes`) und verbindlich
* serverseitig in `DashboardService.getWidgets` (fail-closed).
*
* Die neun Kacheln von vor quick-260924-i8v sind Plattform-Kacheln ohne
* Modulbezug. Proxmox ist die erste modulgebundene Kachel; ihr Slug ist
* derselbe wie `@UseModule('proxmox')` im Controller und `slug: 'proxmox'`
* in `proxmox.seed.ts`.
*/
export const WIDGET_MODULE_SLUGS: Partial<Record<WidgetType, string>> = {
proxmox: 'proxmox',
};
/**
* Freigegebene Versionen (quick-260925-bow, D-02/D-06) — EINE Implementierung
* fuer API und Web ("Was ist neu"-Fenster nach einem Versionswechsel).
*
* Laufzeit-Import aus `@tessera/shared` (siehe Warnkommentar ueber
* `WIDGET_TYPES`): nur loeschbare Syntax, keine relativen Importe.
*
* Angenommen werden genau: optionales `v`, drei Zifferngruppen zu je 1 bis 6
* Ziffern, optional der Describe-Anhang von `git describe`
* (`-<Anzahl>-g<4 bis 40 Hex-Zeichen>`). Alles andere ist KEINE freigegebene
* Version: `dev`, ein blosser Commit-Stempel, Vorabversionen wie `-rc.1`,
* `-dirty`, Leerzeichen am Rand (kein Trimmen). Eingaben ueber 64 Zeichen
* werden vor dem Muster abgewiesen; der Ausdruck ist verankert und hat keine
* verschachtelten Wiederholungen.
*/
const RELEASE_VERSION_PATTERN = /^v?(\d{1,6})\.(\d{1,6})\.(\d{1,6})(?:-\d{1,6}-g[0-9a-f]{4,40})?$/;
const RELEASE_VERSION_MAX_LENGTH = 64;
/**
* Liefert die kanonische Form `X.Y.Z` (ohne `v`, ohne fuehrende Nullen) oder
* `null`, wenn die Eingabe keine freigegebene Version ist.
* Beispiel: `v10.2.3-5-gabc1234` → `10.2.3`, `dev` → `null`.
*/
export function parseReleaseVersion(raw: string): string | null {
if (typeof raw !== 'string' || raw.length > RELEASE_VERSION_MAX_LENGTH) {
return null;
}
const match = RELEASE_VERSION_PATTERN.exec(raw);
if (!match) {
return null;
}
return `${Number(match[1])}.${Number(match[2])}.${Number(match[3])}`;
}
/**
* Numerischer Vergleich zweier freigegebener Versionen: -1, 0 oder 1.
* `1.10.0` liegt ueber `1.9.0`. Wirft, wenn eine Seite nicht parsebar ist —
* Aufrufer pruefen vorher mit `parseReleaseVersion`.
*/
export function compareReleaseVersions(a: string, b: string): number {
const left = parseReleaseVersion(a);
const right = parseReleaseVersion(b);
if (left === null || right === null) {
throw new Error(`Keine freigegebene Version: ${left === null ? a : b}`);
}
const l = left.split('.').map(Number);
const r = right.split('.').map(Number);
for (let i = 0; i < 3; i++) {
if (l[i] > r[i]) return 1;
if (l[i] < r[i]) return -1;
}
return 0;
}
/**
* Antwort von `GET /users/me/release-notice` (quick-260925-bow).
* `currentRelease` ist die laufende freigegebene Version der API
* (`getRunningRelease()`, `null` auf `dev`-Staenden), `lastSeenReleaseVersion`
* der gemerkte Stand des angemeldeten Benutzers (`null` = Bestandsbenutzer).
*/
export interface ReleaseNoticeResponse {
currentRelease: string | null;
lastSeenReleaseVersion: string | null;
}
/**
* Dashboard-Hintergrund pro Benutzer (quick-260928-ujj) — EINE Pruefregel
* fuer API und Web.
*
* Gespeichert in `User.dashboardBackground` (JSONB), geschrieben nur ueber
* `PATCH /users/me/dashboard-background`, gelesen mit der Sitzungsantwort
* (`GET /auth/me`, neben `accentColor`). Das Web rendert die Wahl als
* CSS-Hintergrund (`url("...")`) — deshalb ist die Pruefung streng
* (T-ujj-01): `kind` und Preset-Kennung aus einer festen Liste, `imageId`
* nur als UUID (Kennung eines Bilderrahmen-Bildes, `DashboardImage.id`).
*
* Laufzeit-Import aus `@tessera/shared` (siehe Warnkommentar ueber
* `WIDGET_TYPES`): nur loeschbare Syntax, keine relativen Importe.
*/
export const DASHBOARD_BACKGROUND_PRESET_IDS = [
'mist',
'pebble',
'bloom',
'dunes',
'mosaic',
] as const;
export type DashboardBackgroundPresetId = (typeof DASHBOARD_BACKGROUND_PRESET_IDS)[number];
export type DashboardBackground =
| { kind: 'none' }
| { kind: 'preset'; id: DashboardBackgroundPresetId }
| { kind: 'image'; imageId: string };
const DASHBOARD_BACKGROUND_IMAGE_ID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/**
* Liefert eine gueltige Wahl als FRISCH aufgebautes Objekt (nur die
* erlaubten Felder, Zusatzschluessel fallen weg) oder `null`, wenn der Wert
* keine gueltige Wahl ist. `null` bedeutet beim Lesen „nie gewaehlt“ und
* beim Schreiben „abweisen“.
*/
export function parseDashboardBackground(value: unknown): DashboardBackground | null {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return null;
}
const v = value as Record<string, unknown>;
if (v.kind === 'none') {
return { kind: 'none' };
}
if (v.kind === 'preset') {
const id = v.id;
if (typeof id !== 'string') return null;
const known = DASHBOARD_BACKGROUND_PRESET_IDS.find((p) => p === id);
return known ? { kind: 'preset', id: known } : null;
}
if (v.kind === 'image') {
const imageId = v.imageId;
if (typeof imageId !== 'string' || !DASHBOARD_BACKGROUND_IMAGE_ID_PATTERN.test(imageId)) {
return null;
}
return { kind: 'image', imageId };
}
return null;
}
/**
* Die fuenf Seitenleisten-Kategorien der Module (quick-260929-9wc). Die
* Liste entspricht den Kategorien in den Seeds der eingebauten Module und
* den Schluesseln `moduleCategories` in den Uebersetzungen (de.json/en.json);
* `apps/web/src/messages/module-categories.spec.ts` haelt den Gleichlauf.
* Die API prueft damit die Kategorie eigener Module, das Formular baut die
* Auswahl daraus.
*/
export const MODULE_CATEGORIES = [
"domain-tools",
"security-tools",
"fleet",
"infrastructure",
"procurement",
"accounting",
] as const;
export type ModuleCategory = (typeof MODULE_CATEGORIES)[number];
/**
* Zusaetzliche Kategorie nur fuer eigene Module (Nutzerwunsch 29.09.2026):
* „Eigene Module“ sammelt selbst angelegte Eintraege in einer eigenen Gruppe
* der Seitenleiste. Wie jede Kategorie erscheint sie nur, wenn ein Eintrag
* darin liegt, und steht immer zuletzt.
*/
export const CUSTOM_MODULE_CATEGORY = "custom-modules" as const;
/** Auswahl im Formular „Eigene Module“ und Pruefung in der API. */
export const CUSTOM_MODULE_CATEGORIES = [...MODULE_CATEGORIES, CUSTOM_MODULE_CATEGORY] as const;
export type CustomModuleCategory = (typeof CUSTOM_MODULE_CATEGORIES)[number];
/**
* Eigene Vorlage der Willkommensmail (Administrator → Willkommensmail).
*
* Eine Stelle fuer API und Oberflaeche: welche Platzhalter es gibt, wie lang
* die sechs Textfelder sein duerfen, wie die Standardtexte lauten (Vorbelegung
* im Formular, "Auf Standard zuruecksetzen", Versand ohne eigene Vorlage) und
* wie unbekannte Platzhalter erkannt werden. Die API lehnt eine Vorlage mit
* unbekanntem Platzhalter mit 400 ab; das Formular benennt ihn schon vorher.
*
* Die festen Bausteine der Mail (Kopf, Kasten Adresse/Benutzername, Knopf
* "Passwort festlegen" samt Gueltigkeitshinweis, Knopf "Zu Tessera",
* Fusszeile) sind KEIN Teil der Vorlage — so kann eine eigene Vorlage die
* Anmeldung nicht kaputt machen. Anpassbar ist nur der Anmeldehinweis-TEXT
* davor, getrennt fuer Verzeichniskonten und lokale Konten.
*/
export const WELCOME_MAIL_PLACEHOLDERS = [
'name',
'vorname',
'benutzername',
'email',
'adresse',
'firma',
] as const;
export type WelcomeMailPlaceholder = (typeof WELCOME_MAIL_PLACEHOLDERS)[number];
/** Die sechs bearbeitbaren Texte der Willkommensmail. */
export interface WelcomeMailTexts {
subject: string;
heading: string;
intro: string;
/** Anmeldehinweis fuer verzeichnisgefuehrte Konten (Windows-Passwort). */
loginHintDirectory: string;
/** Anmeldehinweis fuer lokale Konten, steht vor dem Knopf "Passwort festlegen". */
loginHintLocal: string;
closing: string;
}
/** Hoechstlaengen je Feld (Zeichen). */
export const WELCOME_MAIL_LIMITS: Readonly<Record<keyof WelcomeMailTexts, number>> = {
subject: 200,
heading: 200,
intro: 4000,
loginHintDirectory: 1000,
loginHintLocal: 1000,
closing: 4000,
};
/**
* Standardtexte — entsprechen der Willkommensmail vor der eigenen Vorlage.
* Leerzeile = neuer Absatz, einfacher Zeilenumbruch = Umbruch im Absatz.
*/
export const DEFAULT_WELCOME_MAIL_TEXTS: Readonly<WelcomeMailTexts> = {
subject: 'Willkommen bei Tessera',
heading: 'Willkommen bei Tessera, {{name}}!',
intro:
'Für Sie wurde ein Zugang zu Tessera eingerichtet – Ihrer zentralen Plattform für Werkzeuge und Abläufe im Unternehmen. Alles, was Sie für Ihre tägliche Arbeit brauchen, finden Sie dort an einem Ort.',
loginHintDirectory:
'Melden Sie sich mit Ihrem Benutzernamen und Ihrem gewohnten Windows-Passwort an.',
loginHintLocal:
'Bevor Sie sich zum ersten Mal anmelden, legen Sie bitte Ihr persönliches Passwort fest.',
closing:
'Tipp: Tessera gibt es auch als Desktop-App – den Download finden Sie auf der Anmeldeseite.\n\nViel Erfolg mit Tessera!',
};
/** Findet `{{ … }}`; der Name ohne Leerraum steht in Gruppe 1. */
export const WELCOME_MAIL_PLACEHOLDER_RE = /\{\{\s*([^{}]*?)\s*\}\}/g;
/** Ob `name` (Gross-/Kleinschreibung egal) ein bekannter Platzhalter ist. */
export function isWelcomeMailPlaceholder(name: string): name is WelcomeMailPlaceholder {
return (WELCOME_MAIL_PLACEHOLDERS as readonly string[]).includes(name.toLowerCase());
}
/**
* Alle unbekannten Platzhalter eines Textes in der Form `{{xyz}}`, ohne
* Doppelte, in Reihenfolge des ersten Auftretens.
*/
export function findUnknownWelcomeMailPlaceholders(text: string): string[] {
const unknown: string[] = [];
for (const match of text.matchAll(WELCOME_MAIL_PLACEHOLDER_RE)) {
const token = `{{${match[1]}}}`;
if (!isWelcomeMailPlaceholder(match[1]) && !unknown.includes(token)) {
unknown.push(token);
}
}
return unknown;
}