diff --git a/.env b/.env index eaf1798..0cdbcb4 100644 --- a/.env +++ b/.env @@ -1,2 +1,3 @@ VITE_SUPABASE_URL=https://vzudjibddwcvfdnyvhhs.supabase.co -VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InZ6dWRqaWJkZHdjdmZkbnl2aGhzIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjUwMjk4MDksImV4cCI6MjA4MDYwNTgwOX0.wE8R1mpd2FY6m6d5-Hf3hlCG8OfeCkra6SjUvbt1mD0 \ No newline at end of file +VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InZ6dWRqaWJkZHdjdmZkbnl2aGhzIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjUwMjk4MDksImV4cCI6MjA4MDYwNTgwOX0.wE8R1mpd2FY6m6d5-Hf3hlCG8OfeCkra6SjUvbt1mD0 +SUPABASE_DB_PASSWORD=xDsHrwqC9VpifPTU \ No newline at end of file diff --git a/.env.example b/.env.example index 12cf1b5..0547fb8 100644 --- a/.env.example +++ b/.env.example @@ -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 REMINDER_APP_URL=https://your-app.example.com diff --git a/package-lock.json b/package-lock.json index 1a32847..cf5b6b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 4fcc13b..cd1bf5f 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/scripts/applyMigrations.js b/scripts/applyMigrations.js index df8a9f7..b342263 100644 --- a/scripts/applyMigrations.js +++ b/scripts/applyMigrations.js @@ -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); }); diff --git a/src/lib/assessmentShareService.test.js b/src/lib/assessmentShareService.test.js new file mode 100644 index 0000000..e7fe606 --- /dev/null +++ b/src/lib/assessmentShareService.test.js @@ -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'] + }); + }); +}); diff --git a/src/lib/trainingPlanService.test.js b/src/lib/trainingPlanService.test.js new file mode 100644 index 0000000..68a7952 --- /dev/null +++ b/src/lib/trainingPlanService.test.js @@ -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'); + }); +}); diff --git a/src/utils/patCalculations.test.js b/src/utils/patCalculations.test.js new file mode 100644 index 0000000..f4c3983 --- /dev/null +++ b/src/utils/patCalculations.test.js @@ -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' + }); + }); +});