Export fuer LOLYO, und die Zielsystem-Exporte in einem eigenen Abschnitt
Some checks failed
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
CI / Lint, Typen, Tests, Build (push) Has been cancelled

LOLYO ist die dritte Datei, die in ein fremdes System geladen wird. Spalten,
Trennzeichen und Schreibweise kommen aus der Vorlage des Anbieters, Zeichen fuer
Zeichen -- einschliesslich der gemischten Kopfzeile ("Titel prefix" deutsch,
"Title Suffix" englisch). Semikolon und kein BOM: das BOM haenge unsichtbar am
Namen der ersten Spalte, und "Benutzer/Code" kaeme in keiner Zuordnung mehr vor.

Der Benutzercode ist die Alpenwerk-UUID. Anders als bei Cornerstone ist das
hier richtig: LOLYO kennt die Person noch nicht und vergibt keine eigene
Kennung, die wir treffen muessten.

Diese Datei legt Konten an, samt Passwort in der Zeile. Das Startpasswort steht
deshalb in der Route und nicht in lib/lolyo.ts: eine Route ist serverseitig, eine
lib-Datei waere nur so lange sicher, wie niemand sie aus einem Client-Bauteil
heraus einbindet -- und das saehe man dem Buendel nicht an. Dieselbe Ueberlegung
steht in lib/passwort.ts.

Die drei Zielsystem-Exporte stehen jetzt unter einer gemeinsamen Ueberschrift,
getrennt vom Datenexport darueber. Der gibt Zahlen heraus, die jemand ansieht;
diese legen im Zielsystem Datensaetze an. Nebeneinander in einer Reihe
gleichartiger Karten war das nicht zu sehen. Die Auswahl steht damit einmal
statt dreimal da.

Der erklaerende Text unter dem Cornerstone-Export entfaellt auf Wunsch des
Kunden.
This commit is contained in:
2026-09-28 13:45:38 +02:00
parent af2ad53ef9
commit 676cecbf15
4 changed files with 329 additions and 33 deletions

View File

@@ -0,0 +1,45 @@
import { NextResponse, type NextRequest } from "next/server";
import { exportFilename, exportResponseHeaders, toCsv, toXlsx } from "@/lib/export";
import { exportParameter, ladeExportMitarbeiter } from "@/lib/export-auswahl";
import { baueLolyoZeile, LOLYO_CSV, lolyoSpalten } from "@/lib/lolyo";
import { requireHrUser } from "@/lib/auth/require-hr";
import { withUser } from "@/lib/db";
// Der Export für die Mitarbeiter-App LOLYO — Spalten und feste Werte stehen in
// lib/lolyo.ts.
//
// Dieselben Personen wie im vollständigen Export daneben, mit denselben Filtern
// (lib/export-auswahl.ts). Wer nur einen Bereich oder nur die Aktiven anlegen
// will, stellt das auf der Berichtsseite ein, bevor er exportiert.
/**
* Das gemeinsame Startpasswort, mit dem die Konten in LOLYO angelegt werden.
*
* Steht bewusst **hier** und nicht in lib/lolyo.ts: eine Route ist serverseitig
* und gelangt nie in ein Bündel, das der Browser lädt. Eine Konstante in einer
* lib-Datei wäre nur so lange sicher, wie niemand sie aus einem Client-Bauteil
* heraus einbindet — und das sähe man dem Bündel nicht an. Dieselbe Überlegung
* steht in lib/passwort.ts.
*
* Es ist dasselbe Passwort wie in Alpenwerk, so bestellt. Damit gilt hier auch
* dasselbe: es ist allen gemeinsam und damit bekannt, und es trägt so lange,
* bis die Person es wechselt. Die erzeugte Datei ist deshalb kein Anhang, den
* man weiterleitet.
*/
const STARTPASSWORT = "Initialpasswort2026!";
export async function GET(request: NextRequest) {
const gate = await requireHrUser();
if ("denied" in gate) return gate.denied;
const { format, asOf, filters } = exportParameter(request.nextUrl.searchParams);
const { rows } = await withUser(gate.userId, (tx) => ladeExportMitarbeiter(tx, { asOf, filters }));
const zeilen = rows.map((r) => baueLolyoZeile(r, STARTPASSWORT));
const spalten = lolyoSpalten();
const filename = exportFilename("lolyo-export", format);
const body = format === "xlsx" ? await toXlsx(zeilen, spalten, "LOLYO") : toCsv(zeilen, spalten, LOLYO_CSV);
// Derselbe Umweg über Blob wie im vollständigen Export (TS#59417).
return new NextResponse(new Blob([body as BlobPart]), { headers: exportResponseHeaders(filename, format) });
}

View File

@@ -49,6 +49,21 @@ const EVENT_TYPES = EREIGNIS_AUSWAHL;
/** Wie viele Namen eine aufgeklappte Zeile zeigt, bevor der Rest auf Klick kommt. */
const VORSCHAU_NAMEN = 12;
/**
* Die Exporte, die in ein anderes System geladen werden.
*
* Sie stehen zusammen und getrennt vom Datenexport darüber, weil sie etwas
* anderes sind: der Datenexport gibt Zahlen heraus, die jemand ansieht, diese
* hier legen im Zielsystem Datensätze an. Nebeneinander in einer Reihe
* gleichartiger Karten war das nicht zu sehen — und eine versehentlich
* eingeschränkte Auswahl wirkt sich hier ganz anders aus.
*/
const ZIELSYSTEME: readonly { name: string; pfad: string }[] = [
{ name: "Honestly — Mitarbeiterbefragung", pfad: "/api/export/honestly" },
{ name: "Cornerstone — Lernsystem", pfad: "/api/export/cornerstone" },
{ name: "LOLYO — Mitarbeiter-App", pfad: "/api/export/lolyo" },
];
type SavedReport = { id: string; name: string; config: Record<string, unknown> };
type OrgOption = { id: string; name: string };
@@ -674,44 +689,31 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
</div>
</div>
{/* Nur im Bestand: eine Befragung richtet sich an Personen, nicht an
Ereignisse. Dieselben Filter wie der vollständige Export darüber
— was dort steht, steht auch hier. */}
{/* Nur im Bestand: ein Zielsystem führt Personen, keine Ereignisse.
Dieselben Filter wie der vollständige Export darüber. */}
{mode === "snapshot" && (
<div className="rounded border border-border bg-white p-4">
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-ink-muted">Honestly-Report</h3>
<AuswahlHinweis texte={auswahlTexte} anzahl={recordCount} />
<div className="flex gap-2">
<a href={exportHref("/api/export/honestly", "csv")} className={LINK_BUTTON_CLASS}>
<FileText className="h-4 w-4" />
CSV
</a>
<a href={exportHref("/api/export/honestly", "xlsx")} className={LINK_BUTTON_CLASS}>
<FileSpreadsheet className="h-4 w-4" />
Excel
</a>
</div>
</div>
)}
{mode === "snapshot" && (
<div className="rounded border border-border bg-white p-4">
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-ink-muted">Cornerstone Report</h3>
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-ink-muted">Exporte für Zielsysteme</h3>
<p className="mb-1 text-xs text-ink-muted">
Load-File für Cornerstone: 57 Spalten in der Reihenfolge der Vorlage, kommagetrennt, Datum
TT.MM.JJJJ, Systemwerte statt Anzeigenamen (Active, Working, Employee). Ohne Location ID und
Position ID — dafür fehlen die Kennungen des Zielsystems.
Load-Dateien für Fremdsysteme, jede in deren eigenem Format. Für alle gilt dieselbe Auswahl.
</p>
<AuswahlHinweis texte={auswahlTexte} anzahl={recordCount} />
<div className="flex gap-2">
<a href={exportHref("/api/export/cornerstone", "csv")} className={LINK_BUTTON_CLASS}>
<FileText className="h-4 w-4" />
CSV
</a>
<a href={exportHref("/api/export/cornerstone", "xlsx")} className={LINK_BUTTON_CLASS}>
<FileSpreadsheet className="h-4 w-4" />
Excel
</a>
<div className="flex flex-col divide-y divide-border-subtle">
{ZIELSYSTEME.map((z) => (
<div key={z.pfad} className="py-2 first:pt-0 last:pb-0">
<p className="mb-1.5 text-sm font-semibold text-ink">{z.name}</p>
<div className="flex gap-2">
<a href={exportHref(z.pfad, "csv")} className={LINK_BUTTON_CLASS}>
<FileText className="h-4 w-4" />
CSV
</a>
<a href={exportHref(z.pfad, "xlsx")} className={LINK_BUTTON_CLASS}>
<FileSpreadsheet className="h-4 w-4" />
Excel
</a>
</div>
</div>
))}
</div>
</div>
)}

119
lib/lolyo.ts Normal file
View File

@@ -0,0 +1,119 @@
import type { CsvForm, ExportColumn } from "./export";
// Der Export für LOLYO, die Mitarbeiter-App: eine Zeile je Person, die dort
// einen Zugang bekommen soll.
//
// Diese Datei tut etwas anderes als die beiden Exporte daneben. Honestly lädt
// zu einer Befragung ein, Cornerstone führt Stammdaten nach — hier entstehen
// **Konten**, samt Passwort in der Zeile. Wer die Datei hat, hat damit einen
// Zugang zu jedem Konto darin, solange niemand sein Passwort gewechselt hat.
// Deshalb steht das Passwort nicht in dieser Datei: die Route reicht es herein
// (app/api/export/lolyo/route.ts), und es gelangt damit nie in ein Bündel, das
// der Browser lädt. Dieselbe Überlegung steht in lib/passwort.ts.
//
// Kopfzeile und Trennzeichen kommen aus der Vorlage des Anbieters, Zeichen für
// Zeichen. Die gemischte Schreibweise — „Titel prefix" deutsch, „Title Suffix"
// englisch — steht so darin; sie hier stillschweigend zu vereinheitlichen
// hiesse, eine Spalte zu liefern, die LOLYO nicht zuordnet.
/**
* Semikolon, kein BOM — so sieht die Vorlage aus.
*
* Die Vorgabe von toCsv ist für Excel gemacht und setzt ein BOM voran; das
* hinge unsichtbar am Namen der ersten Spalte, und „Benutzer/Code" käme beim
* Einlesen in keiner Zuordnung mehr vor.
*/
export const LOLYO_CSV: CsvForm = { trenner: ";", bom: false };
/** Für alle dieselbe. Wer eine andere braucht, stellt sie in LOLYO um, nicht hier. */
export const LOLYO_SPRACHE = "DE";
/**
* Die Gruppe, in die alle kommen.
*
* Fest vorgegeben, nicht aus der Organisationseinheit abgeleitet: LOLYO-Gruppen
* sind Verteiler in der App und nicht die Aufbauorganisation. Beides
* gleichzusetzen erzeugte 78 Gruppen, die dort niemand bestellt hat.
*/
export const LOLYO_GRUPPE = "Demo-Gruppe";
/** LOLYO holt die Bestätigung selbst ein; aus Alpenwerk kommt sie nie bestätigt. */
export const LOLYO_EMAIL_BESTAETIGT = "0";
/**
* Die Anrede aus dem Geschlecht.
*
* Alpenwerk kennt genau zwei Werte (gender_type), und die Vorgabe nennt genau
* zwei Anreden. Ein dritter Wert bliebe hier leer statt geraten — eine falsche
* Anrede steht in jeder Nachricht, die die App verschickt.
*/
export function anrede(gender: string): string {
if (gender === "m") return "Herr";
if (gender === "w") return "Frau";
return "";
}
/** Die Person, so wie der Export sie braucht. */
export type LolyoQuelle = {
id: string;
company_email: string | null;
title_prefix: string[];
title_suffix: string[];
first_name: string;
last_name: string;
job_title: string;
phone: string | null;
gender: string;
};
export type LolyoZeile = Record<string, string>;
/**
* Eine Zeile.
*
* Der Benutzercode ist die Alpenwerk-UUID. Anders als bei Cornerstone ist das
* hier richtig: LOLYO kennt die Person noch nicht und vergibt keine eigene
* Kennung, die wir treffen müssten — die UUID ist der Schlüssel, über den sich
* ein späterer Abgleich wieder auf dieselbe Person bezieht.
*
* Die E-Mail-Spalte trägt die **Firmenadresse** und niemals die private aus
* employees.email: die Datei legt Konten bei einem fremden Anbieter an.
*/
export function baueLolyoZeile(p: LolyoQuelle, passwort: string): LolyoZeile {
return {
"Benutzer/Code": p.id,
Email: p.company_email ?? "",
Passwort: passwort,
"Titel prefix": p.title_prefix.join(" "),
Vorname: p.first_name,
Nachname: p.last_name,
"Title Suffix": p.title_suffix.join(" "),
Position: p.job_title,
Gruppen: LOLYO_GRUPPE,
Telefon: p.phone ?? "",
Anrede: anrede(p.gender),
Sprache: LOLYO_SPRACHE,
"E-Mail bestätigt": LOLYO_EMAIL_BESTAETIGT,
};
}
/** Die Spalten in der Reihenfolge der Vorlage. */
export const LOLYO_SPALTEN: readonly string[] = [
"Benutzer/Code",
"Email",
"Passwort",
"Titel prefix",
"Vorname",
"Nachname",
"Title Suffix",
"Position",
"Gruppen",
"Telefon",
"Anrede",
"Sprache",
"E-Mail bestätigt",
];
export function lolyoSpalten(): ExportColumn<LolyoZeile>[] {
return LOLYO_SPALTEN.map((header) => ({ header, get: (z) => z[header] ?? "" }));
}

130
tests/unit/lolyo.test.ts Normal file
View File

@@ -0,0 +1,130 @@
import { describe, expect, it } from "vitest";
import { toCsv } from "@/lib/export";
import {
anrede,
baueLolyoZeile,
LOLYO_CSV,
LOLYO_EMAIL_BESTAETIGT,
LOLYO_GRUPPE,
LOLYO_SPALTEN,
LOLYO_SPRACHE,
lolyoSpalten,
type LolyoQuelle,
} from "@/lib/lolyo";
// Die Datei legt in LOLYO Konten an. Eine falsche Spalte fällt hier nicht auf,
// sondern dort — als Konto, das niemandem gehört, oder als Zeile, die der
// Import abweist.
// Die Kopfzeile aus der Vorlage des Anbieters, Zeichen für Zeichen.
const VORGABE = "Benutzer/Code;Email;Passwort;Titel prefix;Vorname;Nachname;Title Suffix;Position;Gruppen;Telefon;Anrede;Sprache;E-Mail bestätigt";
const UUID = "8f2b1c40-9d3e-4a77-b2c1-5e6f70a81234";
const PASSWORT = "Startpasswort1!";
function person(teil: Partial<LolyoQuelle> = {}): LolyoQuelle {
return {
id: UUID,
company_email: "max.alpentest@alpenwerk-firmentest.com",
title_prefix: ["Mag."],
title_suffix: ["MBA"],
first_name: "Max",
last_name: "Alpentest",
job_title: "HR-Manager",
phone: "+4366412345678",
gender: "m",
...teil,
};
}
const zeile = (teil: Partial<LolyoQuelle> = {}) => baueLolyoZeile(person(teil), PASSWORT);
describe("Spalten", () => {
it("stehen genau so und in genau der Reihenfolge wie in der Vorlage", () => {
expect(LOLYO_SPALTEN.join(";")).toBe(VORGABE);
});
it("sind 13", () => {
expect(LOLYO_SPALTEN).toHaveLength(13);
});
it("werden alle gefüllt — keine Spalte ohne Zuordnung", () => {
const z = zeile();
for (const spalte of LOLYO_SPALTEN) {
expect(z, `Spalte "${spalte}" fehlt in der Zeile`).toHaveProperty(spalte);
}
expect(Object.keys(z).sort()).toEqual([...LOLYO_SPALTEN].sort());
});
});
describe("Die Zuordnung", () => {
it("nimmt als Benutzercode die Alpenwerk-UUID", () => {
expect(zeile()["Benutzer/Code"]).toBe(UUID);
});
it("nimmt die Firmenadresse und nie die private", () => {
// Die Datei legt Konten bei einem fremden Anbieter an. Eine Privatadresse
// hat dort nichts verloren; ohne Firmenadresse bleibt die Zelle leer.
expect(zeile().Email).toBe("max.alpentest@alpenwerk-firmentest.com");
expect(zeile({ company_email: null }).Email).toBe("");
});
it("trägt das Passwort ein, das die Route hereinreicht", () => {
expect(zeile().Passwort).toBe(PASSWORT);
});
it("setzt Titel, Name und Position aus Alpenwerk", () => {
const z = zeile();
expect(z["Titel prefix"]).toBe("Mag.");
expect(z.Vorname).toBe("Max");
expect(z.Nachname).toBe("Alpentest");
expect(z["Title Suffix"]).toBe("MBA");
expect(z.Position).toBe("HR-Manager");
});
it("fügt mehrere Titel zusammen und lässt keine zu einer leeren Zelle werden", () => {
expect(zeile({ title_prefix: ["Dr.", "Mag."] })["Titel prefix"]).toBe("Dr. Mag.");
expect(zeile({ title_prefix: [], title_suffix: [] })["Titel prefix"]).toBe("");
});
it("setzt Gruppe, Sprache und Bestätigung fest", () => {
const z = zeile();
expect(z.Gruppen).toBe(LOLYO_GRUPPE);
expect(z.Sprache).toBe(LOLYO_SPRACHE);
expect(z["E-Mail bestätigt"]).toBe(LOLYO_EMAIL_BESTAETIGT);
});
it("leitet die Anrede aus dem Geschlecht ab", () => {
expect(anrede("m")).toBe("Herr");
expect(anrede("w")).toBe("Frau");
// Lieber leer als geraten: eine falsche Anrede steht in jeder Nachricht,
// die die App verschickt.
expect(anrede("")).toBe("");
});
it("lässt eine fehlende Telefonnummer leer", () => {
expect(zeile({ phone: null }).Telefon).toBe("");
});
});
describe("Die Datei", () => {
it("ist semikolongetrennt und ohne BOM", () => {
// Das BOM hinge unsichtbar am Namen der ersten Spalte, und
// „Benutzer/Code" käme beim Einlesen in keiner Zuordnung mehr vor.
const csv = toCsv([zeile()], lolyoSpalten(), LOLYO_CSV);
expect(csv.startsWith("Benutzer/Code;")).toBe(true);
expect(csv.charCodeAt(0)).not.toBe(0xfeff);
});
it("beginnt mit der Kopfzeile der Vorlage", () => {
const csv = toCsv([], lolyoSpalten(), LOLYO_CSV);
expect(csv.split("\r\n")[0]).toBe(VORGABE);
});
it("maskiert eine Telefonnummer, die Excel als Formel läse", () => {
// „+43…" beginnt mit einem Pluszeichen — ohne Schutz macht Excel daraus
// beim Öffnen eine Formel und zeigt einen Fehler statt der Nummer.
const csv = toCsv([zeile()], lolyoSpalten(), LOLYO_CSV);
expect(csv).toContain("'+4366412345678");
});
});