1f85277a3b
- 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>
380 lines
13 KiB
TypeScript
380 lines
13 KiB
TypeScript
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;
|
||
}
|