Restore the shapes the application was written against
The app runs against a real database for the first time since the port, and two things were broken. Both were invisible to typecheck, lint, 192 tests and the build. Sign-in looped. Auth.js created the session and app_upsert_user() adopted the existing profiles id correctly — the row was in app_users, right id and all — but the proxy builds its own Auth.js instance from lib/auth/config.ts alone, and the session callback that copies token.uid onto session.user.id lived in auth.ts. So the proxy saw a session without an id, treated every signed-in user as signed out, and sent them back to /login. Click, flash, login page: from the outside it looked like the button did nothing. The callback moves to the config both instances share. auth.ts now spreads the base callbacks instead of replacing them, which is the mistake that would reintroduce this. The proxy test did not catch it because its fixture hands the handler a session that already has user.id — it tested the routing, not the shape Auth.js actually produces. Then the dashboard crashed on a.date.localeCompare. PostgREST returned JSON: a `date` arrived as "2026-08-03", a `numeric` as a number, and that is what lib/supabase/types.ts declares and what every sort, every date comparison and every status derivation assumes. The pg driver does the opposite — Date object and string respectively. The declarations stayed true to what the code believes; only the runtime value changed, which is why nothing flagged it. The driver is configured back to the declared shapes in lib/db/pool.ts, rather than rewriting 49 call sites. That also removes a timezone hazard: `date` is a calendar day, and as a Date object it acquires midnight in the server's zone — a birth date would shift by a day in Austria, always. The same class of bug as in the seed. int8 stays a string on purpose: it only comes from count() and is read through Number() everywhere; parsed as a number it would quietly lose precision past 2^53. A missing sign-in error now reaches the server log. Auth.js was failing silently — a 302 back to /login and nothing to read. That was its own defect, and it is the reason the first diagnosis took as long as it did. Verified against the live database: all six pages render, 797 active of 852 records, 744.4 FTE, and a detail page shows birth date 15.08.1968 against SV number 7960 150868 — the digits agree, so no day has shifted. Both new tests were checked by mutation: remove the fix and they fail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
18
auth.ts
18
auth.ts
@@ -31,9 +31,15 @@ async function upsertAppUser(externalId: string, email: string, fullName: string
|
||||
return row.id;
|
||||
}
|
||||
|
||||
export const { handlers, auth, signIn, signOut } = NextAuth(() => ({
|
||||
...authConfig(),
|
||||
export const { handlers, auth, signIn, signOut } = NextAuth(() => {
|
||||
const base = authConfig();
|
||||
return {
|
||||
...base,
|
||||
callbacks: {
|
||||
// Die Rückrufe aus der Basis **behalten**, nicht ersetzen: dort liegt
|
||||
// session(), das die Kennung aus dem Token auf die Sitzung legt. Ein
|
||||
// schlichtes `callbacks: { jwt }` hätte es stillschweigend entfernt.
|
||||
...base.callbacks,
|
||||
async jwt({ token, profile }) {
|
||||
// `profile` liegt nur beim ersten Durchlauf nach der Rückkehr von Entra
|
||||
// vor. Danach wird das Token nur noch weitergereicht — die Datenbank
|
||||
@@ -54,10 +60,6 @@ export const { handlers, auth, signIn, signOut } = NextAuth(() => ({
|
||||
token.uid = await upsertAppUser(externalId, email, typeof profile.name === "string" ? profile.name : null);
|
||||
return token;
|
||||
},
|
||||
|
||||
async session({ session, token }) {
|
||||
if (token.uid) session.user.id = token.uid;
|
||||
return session;
|
||||
},
|
||||
},
|
||||
}));
|
||||
};
|
||||
});
|
||||
|
||||
@@ -26,7 +26,9 @@ const eslintConfig = defineConfig([
|
||||
// Vereinbarung überlebt den nächsten Termindruck nicht.
|
||||
{
|
||||
files: ["**/*.ts", "**/*.tsx"],
|
||||
ignores: ["lib/db/**", "tests/integration/**", "supabase/**", "scripts/**"],
|
||||
// Tests dürfen: sie werden nicht ausgeliefert, und einige prüfen gerade
|
||||
// die Einstellungen des Pools — das geht nicht, ohne ihn anzusehen.
|
||||
ignores: ["lib/db/**", "tests/**", "supabase/**", "scripts/**"],
|
||||
rules: {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
|
||||
@@ -91,9 +91,49 @@ export function authConfig(): NextAuthConfig {
|
||||
|
||||
session: { strategy: "jwt", maxAge: SESSION_MAX_AGE_SECONDS },
|
||||
|
||||
callbacks: {
|
||||
// Muss **hier** stehen und nicht in auth.ts, obwohl es nur eine
|
||||
// Zuweisung ist.
|
||||
//
|
||||
// proxy.ts baut eine eigene Auth.js-Instanz aus genau dieser Datei. Lag
|
||||
// die Zuordnung in auth.ts, bekäme der Proxy die Standard-Sitzung ohne
|
||||
// `id`, hielte jede angemeldete Person für nicht angemeldet und
|
||||
// schickte sie zurück auf /login — obwohl das Cookie längst gesetzt
|
||||
// ist. Genau so ist die erste echte Anmeldung in einer Schleife
|
||||
// gelandet: Konto angelegt, Sitzung gültig, und trotzdem kam man nicht
|
||||
// hinein.
|
||||
//
|
||||
// Datenbank braucht das nicht, es liest nur aus dem entschlüsselten
|
||||
// Token — deshalb darf es am Rand laufen.
|
||||
session({ session, token }) {
|
||||
if (token.uid) session.user.id = token.uid;
|
||||
return session;
|
||||
},
|
||||
},
|
||||
|
||||
// Hinter Reverse Proxy und Container-Netzwerk kommt der Host aus dem
|
||||
// Header. Ohne das verweigert Auth.js in der Produktion den Dienst, weil
|
||||
// es die Herkunft nicht bestätigen kann.
|
||||
trustHost: true,
|
||||
|
||||
// Auf der Anmeldeseite steht bewusst nur eine allgemeine Meldung — der
|
||||
// Grund ist fremdbestimmt und gehört nicht auf eine Seite, die echt
|
||||
// aussieht. Im Serverprotokoll gehört er dagegen hin, und zwar
|
||||
// vollständig: ohne das scheitert die Anmeldung lautlos, der Browser
|
||||
// springt auf /login zurück, und es gibt nichts zu lesen ausser einer
|
||||
// 302. Genau so ist die erste Anmeldung hier fehlgeschlagen.
|
||||
logger: {
|
||||
error(err) {
|
||||
console.error("[auth] Fehler:", err);
|
||||
const cause = (err as { cause?: unknown }).cause;
|
||||
// Der eigentliche Grund steckt oft eine Ebene tiefer — bei einem
|
||||
// Fehler in einem Rückruf ist die äussere Meldung nur „Read more at
|
||||
// …/errors#callback-route-error".
|
||||
if (cause) console.error("[auth] Ursache:", cause);
|
||||
},
|
||||
warn(code) {
|
||||
console.warn("[auth] Warnung:", code);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,40 @@
|
||||
import "server-only";
|
||||
import { Pool } from "pg";
|
||||
import { Pool, types } from "pg";
|
||||
|
||||
// ═══ Wie Werte aus der Datenbank ankommen ════════════════════════
|
||||
//
|
||||
// Der Wechsel des Zugriffswegs hat hier eine Falle hinterlassen, die kein
|
||||
// Typprüfer und keiner der Tests fangen konnte.
|
||||
//
|
||||
// Die alte API-Schicht lieferte JSON: ein `date` kam als "2026-08-03" an,
|
||||
// ein `numeric` als Zahl. Genau so steht es in lib/supabase/types.ts, und
|
||||
// darauf baut die gesamte Anwendung — Sortierungen mit localeCompare,
|
||||
// Vergleiche wie `entry_date <= stichtag`, das Ableiten des Status.
|
||||
//
|
||||
// Der `pg`-Treiber macht es anders herum: aus `date` wird ein Date-Objekt,
|
||||
// aus `numeric` eine Zeichenkette. Die Deklarationen blieben dabei
|
||||
// unverändert gültig — sie beschreiben ja nur, was der Code *glaubt*. Der
|
||||
// Fehler zeigt sich erst zur Laufzeit, und im günstigen Fall als Absturz
|
||||
// („a.date.localeCompare is not a function"). Im ungünstigen Fall gar
|
||||
// nicht: ein Datumsvergleich zwischen Date und Zeichenkette wirft nicht, er
|
||||
// liefert bloss das falsche Ergebnis.
|
||||
//
|
||||
// Deshalb wird der Treiber hier auf die Form zurückgestellt, die die Typen
|
||||
// beschreiben. Das ist die kleinere und ehrlichere Änderung, als 49
|
||||
// Abfragestellen umzuschreiben.
|
||||
//
|
||||
// Nebenbei löst es ein zweites Problem: `date` ist ein Kalendertag ohne
|
||||
// Zeitzone. Als Date-Objekt bekäme er eine — Mitternacht in der Zone des
|
||||
// Servers —, und ein Geburtsdatum verschöbe sich beim Formatieren um einen
|
||||
// Tag. Dieselbe Klasse von Fehler wie im Seed.
|
||||
types.setTypeParser(1082, (v) => v); // date → "YYYY-MM-DD", unverändert
|
||||
types.setTypeParser(1184, (v) => new Date(v).toISOString()); // timestamptz → ISO-8601 mit Z
|
||||
types.setTypeParser(1114, (v) => new Date(v + "Z").toISOString()); // timestamp ohne Zone
|
||||
types.setTypeParser(1700, (v) => Number(v)); // numeric → Zahl
|
||||
|
||||
// Bewusst *nicht* umgestellt: int8 (bigint). Es kommt nur aus count() und
|
||||
// wird überall mit Number() gelesen; als Zahl geparst verlöre es jenseits
|
||||
// von 2^53 stillschweigend an Genauigkeit.
|
||||
|
||||
// Die einzige Stelle im Projekt, die `pg` importieren darf.
|
||||
//
|
||||
|
||||
68
tests/unit/db-type-parsers.test.ts
Normal file
68
tests/unit/db-type-parsers.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { types } from "pg";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Warum es diesen Test gibt.
|
||||
//
|
||||
// lib/supabase/types.ts sagt für 16 Spalten `string` und für zwei `number`.
|
||||
// Der `pg`-Treiber liefert von Haus aus das Gegenteil: aus `date` wird ein
|
||||
// Date-Objekt, aus `numeric` eine Zeichenkette.
|
||||
//
|
||||
// Diese Abweichung ist für Typprüfer und Tests unsichtbar — die
|
||||
// Deklarationen beschreiben, was der Code glaubt, nicht was ankommt. tsc war
|
||||
// sauber, 187 Tests waren grün, und die Anwendung stürzte auf der ersten
|
||||
// Seite ab („a.date.localeCompare is not a function"). Der Absturz war noch
|
||||
// der freundliche Fall; ein Vergleich zwischen Date und Zeichenkette wirft
|
||||
// nicht, er liefert bloss das falsche Ergebnis.
|
||||
//
|
||||
// Geprüft wird deshalb die einzige Stelle, an der beides zusammenkommt: die
|
||||
// Parser, die lib/db/pool.ts beim Import registriert.
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
|
||||
beforeAll(async () => {
|
||||
// Der Import allein registriert die Parser — deshalb reicht er als
|
||||
// Vorbereitung, und deshalb würde ein Entfernen der Zeilen hier auffallen.
|
||||
await import("@/lib/db/pool");
|
||||
});
|
||||
|
||||
const parse = (oid: number, raw: string) => types.getTypeParser(oid)(raw);
|
||||
|
||||
describe("Typumwandlung des Treibers", () => {
|
||||
it("gibt `date` unverändert als Kalendertag zurück", () => {
|
||||
// Nicht `new Date(...)`: ein Kalendertag hat keine Zeitzone. Als
|
||||
// Date-Objekt bekäme er Mitternacht in der Zone des Servers, und ein
|
||||
// Geburtsdatum verschöbe sich beim Formatieren um einen Tag — in
|
||||
// Österreich immer, weil MEZ östlich von UTC liegt.
|
||||
expect(parse(1082, "1968-08-15")).toBe("1968-08-15");
|
||||
});
|
||||
|
||||
it("gibt `numeric` als Zahl zurück", () => {
|
||||
// Die FTE-Kachel rechnet damit. Als Zeichenkette ergäbe die Summe
|
||||
// "744.4" + "0.5" = "744.40.5" statt 744.9 — ohne jede Fehlermeldung.
|
||||
expect(parse(1700, "744.4")).toBe(744.4);
|
||||
expect(parse(1700, "38.5")).toBe(38.5);
|
||||
});
|
||||
|
||||
it("gibt `timestamptz` als ISO-8601 mit Z zurück", () => {
|
||||
const v = parse(1184, "2026-08-03 07:55:46.659+00");
|
||||
expect(v).toBe("2026-08-03T07:55:46.659Z");
|
||||
// Muss für Date verwertbar bleiben — die Protokollansicht formatiert
|
||||
// damit. Die Rohform des Treibers („… +00") ist kein Format, das der
|
||||
// Standard kennt.
|
||||
expect(Number.isNaN(new Date(v as string).getTime())).toBe(false);
|
||||
});
|
||||
|
||||
it("lässt `int8` als Zeichenkette", () => {
|
||||
// Kommt nur aus count() und wird überall mit Number() gelesen. Als Zahl
|
||||
// geparst verlöre es jenseits von 2^53 stillschweigend an Genauigkeit.
|
||||
expect(parse(20, "852")).toBe("852");
|
||||
});
|
||||
|
||||
it("sortiert ISO-Zeitstempel als Text chronologisch", () => {
|
||||
// Darauf beruht jedes orderBy im Anwendungscode, das auf Zeichenketten
|
||||
// arbeitet. Gilt nur, weil das Format feste Breite hat und in UTC steht.
|
||||
const frueher = parse(1184, "2026-08-03 07:55:46.659+00") as string;
|
||||
const spaeter = parse(1184, "2026-08-03 09:12:01.000+00") as string;
|
||||
expect(frueher.localeCompare(spaeter)).toBeLessThan(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user