Unit Tests
All checks were successful
Vercel Production Deployment / Deploy-Production (push) Successful in 1m1s

This commit is contained in:
Ashikagi
2026-03-28 15:54:02 +01:00
parent 6b2d0024ed
commit 0acece98dc
8 changed files with 810 additions and 33 deletions

1
.env
View File

@@ -1,2 +1,3 @@
VITE_SUPABASE_URL=https://vzudjibddwcvfdnyvhhs.supabase.co
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InZ6dWRqaWJkZHdjdmZkbnl2aGhzIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjUwMjk4MDksImV4cCI6MjA4MDYwNTgwOX0.wE8R1mpd2FY6m6d5-Hf3hlCG8OfeCkra6SjUvbt1mD0
SUPABASE_DB_PASSWORD=xDsHrwqC9VpifPTU

View File

@@ -1,6 +1,10 @@
VITE_SUPABASE_URL=https://your-project.supabase.co
VITE_SUPABASE_ANON_KEY=your-anon-key
SUPABASE_DB_URL=postgresql://postgres:password@db.your-project.supabase.co:5432/postgres
# Für Migrations: Datenbankpasswort aus Supabase → Project Settings → Database → Database password
SUPABASE_DB_PASSWORD=your-db-password
# Alternativ: volle Postgres-URL (überschreibt SUPABASE_DB_PASSWORD)
# SUPABASE_DB_URL=postgresql://postgres:password@db.your-project.supabase.co:5432/postgres
RESEND_API_KEY=your-resend-api-key
REMINDER_FROM_EMAIL=PAT Stats <noreply@example.com>
REMINDER_APP_URL=https://your-app.example.com

285
package-lock.json generated
View File

@@ -25,6 +25,7 @@
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"supabase": "^2.83.0",
"tailwindcss": "^3.4.1",
"vite": "^5.4.2",
"vitest": "^1.6.1"
@@ -725,6 +726,19 @@
"node": ">=12"
}
},
"node_modules/@isaacs/fs-minipass": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
"integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
"dev": true,
"license": "ISC",
"dependencies": {
"minipass": "^7.0.4"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@jest/schemas": {
"version": "29.6.3",
"resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz",
@@ -1482,6 +1496,16 @@
"node": ">=0.8"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/all": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/all/-/all-0.0.0.tgz",
@@ -1597,6 +1621,23 @@
"baseline-browser-mapping": "dist/cli.js"
}
},
"node_modules/bin-links": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/bin-links/-/bin-links-6.0.0.tgz",
"integrity": "sha512-X4CiKlcV2GjnCMwnKAfbVWpHa++65th9TuzAEYtZoATiOE2DQKhSp4CJlyLoTqdhBKlXjpXjCTYPNNFS33Fi6w==",
"dev": true,
"license": "ISC",
"dependencies": {
"cmd-shim": "^8.0.0",
"npm-normalize-package-bin": "^5.0.0",
"proc-log": "^6.0.0",
"read-cmd-shim": "^6.0.0",
"write-file-atomic": "^7.0.0"
},
"engines": {
"node": "^20.17.0 || >=22.9.0"
}
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -1801,6 +1842,26 @@
"node": ">= 6"
}
},
"node_modules/chownr": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
"integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=18"
}
},
"node_modules/cmd-shim": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/cmd-shim/-/cmd-shim-8.0.0.tgz",
"integrity": "sha512-Jk/BK6NCapZ58BKUxlSI+ouKRbjH1NLZCgJkYoab+vEHUY3f6OzpNBN9u7HFSv9J6TRDGs4PLOHezoKGaFRSCA==",
"dev": true,
"license": "ISC",
"engines": {
"node": "^20.17.0 || >=22.9.0"
}
},
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
@@ -1909,6 +1970,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/data-uri-to-buffer": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -2115,6 +2186,30 @@
"reusify": "^1.0.4"
}
},
"node_modules/fetch-blob": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "paypal",
"url": "https://paypal.me/jimmywarting"
}
],
"license": "MIT",
"dependencies": {
"node-domexception": "^1.0.0",
"web-streams-polyfill": "^3.0.3"
},
"engines": {
"node": "^12.20 || >= 14.13"
}
},
"node_modules/fflate": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
@@ -2134,6 +2229,19 @@
"node": ">=8"
}
},
"node_modules/formdata-polyfill": {
"version": "4.0.10",
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
"dev": true,
"license": "MIT",
"dependencies": {
"fetch-blob": "^3.1.2"
},
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
@@ -2255,6 +2363,20 @@
"node": ">=8.0.0"
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/human-signals": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz",
@@ -2562,6 +2684,29 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minipass": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/minizlib": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz",
"integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"minipass": "^7.1.2"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/mlly": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz",
@@ -2620,6 +2765,46 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
"deprecated": "Use your platform's native DOMException instead",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "github",
"url": "https://paypal.me/jimmywarting"
}
],
"license": "MIT",
"engines": {
"node": ">=10.5.0"
}
},
"node_modules/node-fetch": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
"dev": true,
"license": "MIT",
"dependencies": {
"data-uri-to-buffer": "^4.0.0",
"fetch-blob": "^3.1.4",
"formdata-polyfill": "^4.0.10"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/node-fetch"
}
},
"node_modules/node-releases": {
"version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
@@ -2647,6 +2832,16 @@
"node": ">=0.10.0"
}
},
"node_modules/npm-normalize-package-bin": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-5.0.0.tgz",
"integrity": "sha512-CJi3OS4JLsNMmr2u07OJlhcrPxCeOeP/4xq67aWNai6TNWWbTrlNDgl8NcFKVlcBKp18GPj+EzbNIgrBfZhsag==",
"dev": true,
"license": "ISC",
"engines": {
"node": "^20.17.0 || >=22.9.0"
}
},
"node_modules/npm-run-path": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz",
@@ -3140,6 +3335,16 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/proc-log": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz",
"integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==",
"dev": true,
"license": "ISC",
"engines": {
"node": "^20.17.0 || >=22.9.0"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -3223,6 +3428,16 @@
"pify": "^2.3.0"
}
},
"node_modules/read-cmd-shim": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/read-cmd-shim/-/read-cmd-shim-6.0.0.tgz",
"integrity": "sha512-1zM5HuOfagXCBWMN83fuFI/x+T/UhZ7k+KIzhrHXcQoeX5+7gmaDYjELQHmmzIodumBHeByBJT4QYS7ufAgs7A==",
"dev": true,
"license": "ISC",
"engines": {
"node": "^20.17.0 || >=22.9.0"
}
},
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
@@ -3524,6 +3739,26 @@
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/supabase": {
"version": "2.83.0",
"resolved": "https://registry.npmjs.org/supabase/-/supabase-2.83.0.tgz",
"integrity": "sha512-80j5YeYMkJqVc5gZGySMxiUJSUcwES6mNqi1nCa9q40qnPftff+LevcdZGLct4xtpaefkTtgmZArPSdq+H2RZQ==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"bin-links": "^6.0.0",
"https-proxy-agent": "^7.0.2",
"node-fetch": "^3.3.2",
"tar": "7.5.11"
},
"bin": {
"supabase": "bin/supabase"
},
"engines": {
"npm": ">=8"
}
},
"node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
@@ -3585,6 +3820,33 @@
"node": ">=14.0.0"
}
},
"node_modules/tar": {
"version": "7.5.11",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.11.tgz",
"integrity": "sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/fs-minipass": "^4.0.0",
"chownr": "^3.0.0",
"minipass": "^7.1.2",
"minizlib": "^3.1.0",
"yallist": "^5.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/tar/node_modules/yallist": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
"integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=18"
}
},
"node_modules/text-segmentation": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
@@ -3939,6 +4201,16 @@
}
}
},
"node_modules/web-streams-polyfill": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -3990,6 +4262,19 @@
"node": ">=0.8"
}
},
"node_modules/write-file-atomic": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-7.0.1.tgz",
"integrity": "sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg==",
"dev": true,
"license": "ISC",
"dependencies": {
"signal-exit": "^4.0.1"
},
"engines": {
"node": "^20.17.0 || >=22.9.0"
}
},
"node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",

View File

@@ -7,7 +7,9 @@
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"migrate": "node scripts/applyMigrations.js",
"migrate": "node --env-file=.env scripts/applyMigrations.js",
"migrate:status": "node --env-file=.env scripts/applyMigrations.js --status",
"migrate:rollback": "node --env-file=.env scripts/applyMigrations.js --rollback",
"send-finalization-reminders": "node scripts/sendFinalizationReminders.js",
"test": "vitest run"
},
@@ -29,6 +31,7 @@
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"supabase": "^2.83.0",
"tailwindcss": "^3.4.1",
"vite": "^5.4.2",
"vitest": "^1.6.1"

View File

@@ -1,10 +1,15 @@
#!/usr/bin/env node
/**
* Lightweight migration runner for Supabase.
* Reads SQL files in supabase/migrations (sorted) and applies any that have not been recorded.
* Similar to Laravel's `artisan migrate`.
*
* Env:
* - SUPABASE_DB_URL or DATABASE_URL: postgres connection string (service role / connection string)
* Usage:
* npm run migrate - Apply all pending migrations
* npm run migrate:status - Show status of all migrations
* npm run migrate:rollback - Rollback the last applied migration
*
* Env (loaded from .env automatically via --env-file):
* SUPABASE_DB_URL or DATABASE_URL postgres connection string
*/
import fs from 'fs';
import path from 'path';
@@ -15,15 +20,43 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const migrationsDir = path.resolve(__dirname, '..', 'supabase', 'migrations');
const connectionString = process.env.SUPABASE_DB_URL || process.env.DATABASE_URL;
const args = process.argv.slice(2);
const command = args[0]; // --status | --rollback | undefined (= migrate)
function buildConnectionString() {
// Direct override takes precedence
if (process.env.SUPABASE_DB_URL) return process.env.SUPABASE_DB_URL;
if (process.env.DATABASE_URL) return process.env.DATABASE_URL;
// Derive from VITE_SUPABASE_URL + SUPABASE_DB_PASSWORD
const supabaseUrl = process.env.VITE_SUPABASE_URL;
const dbPassword = process.env.SUPABASE_DB_PASSWORD;
if (supabaseUrl && dbPassword) {
// Extract project ref from https://[ref].supabase.co
const match = supabaseUrl.match(/https:\/\/([^.]+)\.supabase\.co/);
if (!match) {
console.error('❌ VITE_SUPABASE_URL hat ein unerwartetes Format.');
process.exit(1);
}
const ref = match[1];
return `postgresql://postgres:${encodeURIComponent(dbPassword)}@db.${ref}.supabase.co:5432/postgres`;
}
return null;
}
const connectionString = buildConnectionString();
if (!connectionString) {
console.error('Missing SUPABASE_DB_URL (or DATABASE_URL) environment variable. Aborting migrations.');
console.error('❌ Keine Datenbankverbindung konfiguriert.');
console.error(' Füge SUPABASE_DB_PASSWORD zu deiner .env hinzu (Passwort aus Supabase → Settings → Database).');
console.error(' Alternativ: SUPABASE_DB_URL=postgresql://... direkt setzen.');
process.exit(1);
}
if (!fs.existsSync(migrationsDir)) {
console.error(`Migrations directory not found: ${migrationsDir}`);
console.error(`Migrations directory not found: ${migrationsDir}`);
process.exit(1);
}
@@ -32,38 +65,127 @@ const pool = new Pool({ connectionString });
async function ensureMigrationsTable(client) {
await client.query(`
create table if not exists public.schema_migrations (
id serial primary key,
filename text not null unique,
id serial primary key,
filename text not null unique,
applied_at timestamptz not null default now()
);
`);
}
async function getApplied(client) {
const { rows } = await client.query('select filename from public.schema_migrations');
return new Set(rows.map((r) => r.filename));
const { rows } = await client.query(
'select filename, applied_at from public.schema_migrations order by filename'
);
return rows;
}
function listMigrations() {
function listMigrationFiles() {
return fs
.readdirSync(migrationsDir)
.filter((file) => file.endsWith('.sql'))
.filter((f) => f.endsWith('.sql'))
.sort();
}
async function applyMigration(client, filename) {
const filePath = path.join(migrationsDir, filename);
const sql = fs.readFileSync(filePath, 'utf8');
console.log(`Applying migration: ${filename}`);
console.log(`Applying: ${filename}`);
await client.query('begin');
try {
await client.query(sql);
await client.query('insert into public.schema_migrations (filename) values ($1)', [filename]);
await client.query(
'insert into public.schema_migrations (filename) values ($1)',
[filename]
);
await client.query('commit');
console.log(`✓ Applied ${filename}`);
console.log(` ✓ Done: ${filename}`);
} catch (err) {
await client.query('rollback');
console.error(`✗ Failed ${filename}:`, err.message);
console.error(` ✗ Failed: ${filename}\n ${err.message}`);
throw err;
}
}
async function cmdMigrate(client) {
await ensureMigrationsTable(client);
const applied = new Set((await getApplied(client)).map((r) => r.filename));
const pending = listMigrationFiles().filter((f) => !applied.has(f));
if (pending.length === 0) {
console.log('✔ Nothing to migrate all migrations are up to date.');
return;
}
console.log(`\nRunning ${pending.length} migration(s)...\n`);
for (const file of pending) {
await applyMigration(client, file);
}
console.log(`\n${pending.length} migration(s) applied successfully.`);
}
async function cmdStatus(client) {
await ensureMigrationsTable(client);
const appliedRows = await getApplied(client);
const appliedMap = new Map(appliedRows.map((r) => [r.filename, r.applied_at]));
const files = listMigrationFiles();
if (files.length === 0) {
console.log('No migration files found.');
return;
}
console.log('\n Status Migration');
console.log(' ───────── ' + '─'.repeat(50));
for (const file of files) {
if (appliedMap.has(file)) {
const ts = new Date(appliedMap.get(file)).toLocaleString('de-AT');
console.log(` ✓ applied ${file} (${ts})`);
} else {
console.log(` ○ pending ${file}`);
}
}
const pendingCount = files.filter((f) => !appliedMap.has(f)).length;
console.log(`\n ${appliedMap.size} applied, ${pendingCount} pending.\n`);
}
async function cmdRollback(client) {
await ensureMigrationsTable(client);
const applied = await getApplied(client);
if (applied.length === 0) {
console.log('Nothing to rollback no migrations have been applied.');
return;
}
const last = applied[applied.length - 1];
console.log(`\nRolling back: ${last.filename}`);
// Check for a corresponding .down.sql file
const downFile = path.join(
migrationsDir,
last.filename.replace('.sql', '.down.sql')
);
await client.query('begin');
try {
if (fs.existsSync(downFile)) {
const sql = fs.readFileSync(downFile, 'utf8');
await client.query(sql);
console.log(` ✓ Ran down-migration: ${path.basename(downFile)}`);
} else {
console.warn(` ⚠ No down-migration file found (${path.basename(downFile)})`);
console.warn(' Only removing the migration record, schema changes are NOT reverted.');
}
await client.query(
'delete from public.schema_migrations where filename = $1',
[last.filename]
);
await client.query('commit');
console.log(`✔ Rolled back: ${last.filename}\n`);
} catch (err) {
await client.query('rollback');
console.error(`✗ Rollback failed: ${err.message}`);
throw err;
}
}
@@ -71,20 +193,13 @@ async function applyMigration(client, filename) {
async function run() {
const client = await pool.connect();
try {
await ensureMigrationsTable(client);
const applied = await getApplied(client);
const migrations = listMigrations();
const pending = migrations.filter((m) => !applied.has(m));
if (pending.length === 0) {
console.log('No pending migrations.');
return;
if (command === '--status') {
await cmdStatus(client);
} else if (command === '--rollback') {
await cmdRollback(client);
} else {
await cmdMigrate(client);
}
for (const migration of pending) {
await applyMigration(client, migration);
}
console.log('All pending migrations applied.');
} finally {
client.release();
await pool.end();
@@ -92,6 +207,6 @@ async function run() {
}
run().catch((err) => {
console.error('Migration run failed:', err);
console.error('\n❌ Migration runner failed:', err.message);
process.exit(1);
});

View File

@@ -0,0 +1,133 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
const importService = async (supabaseMock) => {
vi.resetModules();
vi.doMock('./supabaseClient', () => ({
supabase: supabaseMock
}));
return import('./assessmentShareService');
};
afterEach(() => {
vi.clearAllMocks();
vi.resetModules();
vi.doUnmock('./supabaseClient');
delete globalThis.window;
});
describe('assessmentShareService', () => {
it('creates a share link with the current browser URL', async () => {
globalThis.window = {
location: {
origin: 'https://pat.example.com',
pathname: '/manager'
}
};
const rpc = vi.fn().mockResolvedValue({
data: { share_token: 'token-123' },
error: null
});
const { createAssessmentShareLink } = await importService({ rpc });
const result = await createAssessmentShareLink({ id: 'assessment-1' });
expect(rpc).toHaveBeenCalledWith('create_or_get_assessment_share', {
p_assessment_id: 'assessment-1'
});
expect(result).toEqual({
token: 'token-123',
url: 'https://pat.example.com/manager?share=token-123'
});
});
it('maps profile visibility RPC errors to a user-facing German message', async () => {
const rpc = vi.fn().mockResolvedValue({
data: null,
error: { message: 'Tests are hidden in the profile' }
});
const { createAssessmentShareLink } = await importService({ rpc });
await expect(createAssessmentShareLink({ id: 'assessment-1' })).rejects.toThrow(
'Deine Tests sind im Profil aktuell nicht sichtbar.'
);
});
it('lists existing shares by assessment id', async () => {
const inMock = vi.fn().mockResolvedValue({
data: [
{ assessment_id: 'a1', share_token: 'share-a1' },
{ assessment_id: 'a2', share_token: 'share-a2' }
],
error: null
});
const selectMock = vi.fn(() => ({ in: inMock }));
const fromMock = vi.fn(() => ({ select: selectMock }));
const { listAssessmentShares } = await importService({ from: fromMock });
const result = await listAssessmentShares(['a1', 'a2']);
expect(fromMock).toHaveBeenCalledWith('assessment_shares');
expect(selectMock).toHaveBeenCalledWith('assessment_id, share_token');
expect(inMock).toHaveBeenCalledWith('assessment_id', ['a1', 'a2']);
expect(result).toEqual({
a1: 'share-a1',
a2: 'share-a2'
});
});
it('hydrates shared assessments and recalculates derived exercise values', async () => {
const rpc = vi.fn().mockResolvedValue({
data: [
{
id: 'shared-1',
pat_type: 'PAT 2',
datum: '2026-03-28',
name: 'Max Mustermann',
exercises: [
{
name: 'Gerade Einsteiger',
soll: 6,
faktor: 10,
values: ['2', '4', '']
},
{
name: 'Split Drill',
soll: 4,
faktor: 5,
subA: ['1', '3'],
subB: ['5', ''],
subLabels: ['A', 'B']
}
]
}
],
error: null
});
const { getSharedAssessment } = await importService({ rpc });
const result = await getSharedAssessment('share-token');
expect(rpc).toHaveBeenCalledWith('get_shared_assessment', {
p_share_token: 'share-token'
});
expect(result).toMatchObject({
id: 'shared-1',
patType: 'PAT 2',
datum: '2026-03-28',
name: 'Max Mustermann'
});
expect(result.exercises[0]).toMatchObject({
name: 'Gerade Einsteiger',
durchschnitt: 3,
points: 30
});
expect(result.exercises[1]).toMatchObject({
name: 'Split Drill',
durchschnitt: 3,
points: 15,
subLabels: ['A', 'B']
});
});
});

View File

@@ -0,0 +1,171 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
const importService = async (supabaseMock) => {
vi.resetModules();
vi.doMock('./supabaseClient', () => ({
supabase: supabaseMock
}));
return import('./trainingPlanService');
};
afterEach(() => {
vi.clearAllMocks();
vi.resetModules();
vi.doUnmock('./supabaseClient');
});
describe('trainingPlanService', () => {
it('maps sessions to the RPC shape and normalizes task defaults', async () => {
const { mapSessionToRpc } = await importService(null);
const result = mapSessionToRpc({
week_no: '2',
session_no: '3',
main_exercise: 'Break-Aufbau',
secondary_exercises: ['Safety', 'Pattern Play'],
tasks: [
{
type: 'main',
title: 'Hauptblock',
durationMin: '15',
repetitions: 12,
intensity: 'hoch',
instructions: 'Volle Konzentration'
},
{
durationMin: '',
instructions: null
}
],
state: 'done',
notes: 'Saubere Linie'
});
expect(result).toEqual({
weekNo: 2,
sessionNo: 3,
mainExercise: 'Break-Aufbau',
secondaryExercises: ['Safety', 'Pattern Play'],
tasks: [
{
type: 'main',
title: 'Hauptblock',
durationMin: 15,
repetitions: 12,
intensity: 'hoch',
instructions: 'Volle Konzentration'
},
{
type: 'task',
title: '',
durationMin: 0,
repetitions: null,
intensity: null,
instructions: ''
}
],
state: 'done',
notes: 'Saubere Linie'
});
});
it('returns an error result when supabase is not configured', async () => {
const { saveTrainingPlanWithSessions } = await importService(null);
const result = await saveTrainingPlanWithSessions({
patType: 'PAT 1',
analysisSnapshot: {},
durationWeeks: 4,
sessionsPerWeek: 2,
sessions: []
});
expect(result.ok).toBe(false);
expect(result.error.message).toBe('Supabase ist nicht konfiguriert.');
});
it('sends a normalized RPC payload and returns the created plan id', async () => {
const rpc = vi.fn().mockResolvedValue({
data: 'plan-123',
error: null
});
const { saveTrainingPlanWithSessions } = await importService({ rpc });
const result = await saveTrainingPlanWithSessions({
patType: 'PAT 3',
analysisSnapshot: { score: 87 },
durationWeeks: '6',
sessionsPerWeek: '4',
sessions: [
{
weekNo: '1',
sessionNo: '2',
mainExercise: 'Lochen lang',
secondaryExercises: ['Positionsspiel', 'Safety'],
tasks: [
{
type: 'main',
title: 'Serie',
durationMin: '25',
repetitions: 20,
intensity: 'mittel',
instructions: 'Tempo konstant'
}
],
state: 'open',
notes: 'Tag 1'
}
]
});
expect(rpc).toHaveBeenCalledWith('create_training_plan_with_sessions', {
p_pat_type: 'PAT 3',
p_analysis_snapshot: { score: 87 },
p_duration_weeks: 6,
p_sessions_per_week: 4,
p_sessions: [
{
weekNo: 1,
sessionNo: 2,
mainExercise: 'Lochen lang',
secondaryExercises: ['Positionsspiel', 'Safety'],
tasks: [
{
type: 'main',
title: 'Serie',
durationMin: 25,
repetitions: 20,
intensity: 'mittel',
instructions: 'Tempo konstant'
}
],
state: 'open',
notes: 'Tag 1'
}
]
});
expect(result).toEqual({
ok: true,
planId: 'plan-123'
});
});
it('wraps RPC failures into a non-throwing error result', async () => {
const rpc = vi.fn().mockResolvedValue({
data: null,
error: new Error('Datenbank nicht erreichbar')
});
const { saveTrainingPlanWithSessions } = await importService({ rpc });
const result = await saveTrainingPlanWithSessions({
patType: 'PAT 2',
analysisSnapshot: {},
durationWeeks: 2,
sessionsPerWeek: 2,
sessions: []
});
expect(result.ok).toBe(false);
expect(result.error.message).toBe('Datenbank nicht erreichbar');
});
});

View File

@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest';
import {
calculateExercise,
calculateTotal,
getAchievement,
getPatTypeColor
} from './patCalculations';
describe('patCalculations', () => {
it('calculates average and points for regular exercises and ignores invalid values', () => {
const result = calculateExercise({
values: ['2', '4', 'x', ''],
faktor: 10
});
expect(result).toEqual({
durchschnitt: 3,
points: 30
});
});
it('calculates average and points across split exercise rows', () => {
const result = calculateExercise({
subA: ['1', '3'],
subB: ['5', ''],
faktor: 4
});
expect(result).toEqual({
durchschnitt: 3,
points: 12
});
});
it('sums points across exercises and treats missing values as zero', () => {
const total = calculateTotal([
{ points: 120 },
{ points: 0 },
{},
{ points: 35 }
]);
expect(total).toBe(155);
});
it('returns configured colors and a gray fallback for unknown PAT types', () => {
expect(getPatTypeColor('PAT 1')).toBe('bg-blue-100 text-blue-800');
expect(getPatTypeColor('Unbekannt')).toBe('bg-gray-100 text-gray-800');
});
it('returns the correct achievement by threshold and falls back to PAT Start rules', () => {
expect(getAchievement('PAT 3', 850)).toMatchObject({
name: '9. Gold',
subtitle: 'internationaler Topsportler'
});
expect(getAchievement('PAT 3', 549)).toMatchObject({
name: 'Nicht bestanden'
});
expect(getAchievement('Nicht definiert', 800)).toMatchObject({
name: '1. Weiss'
});
});
});