From 6d982add75cc90f6377722ad8a9d8f1a2dd0d5c0 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Mon, 5 Oct 2026 15:18:10 +0200 Subject: [PATCH] feat: add TTR simulator and calculation engine, enhance member model with TTR adjustment constant --- backend/controllers/matchController.js | 2 +- .../20261005_add_hidden_test_user_flags.sql | 5 + ...005_add_member_ttr_adjustment_constant.sql | 2 + backend/models/Member.js | 7 + backend/models/User.js | 14 ++ backend/scripts/createTestUsers.js | 17 +- backend/scripts/runMigrations.js | 23 +++ backend/services/clubService.js | 4 + backend/services/emailService.js | 26 +++ backend/services/memberService.js | 12 ++ backend/services/permissionService.js | 8 +- frontend/package.json | 1 + frontend/src/components/DialogManager.vue | 3 + .../src/components/TtrSimulatorDialog.vue | 155 ++++++++++++++++++ .../schedule/ScheduleLayoutShell.vue | 10 ++ frontend/src/services/ttrCalculator.mjs | 80 +++++++++ frontend/src/views/ScheduleView.vue | 9 + frontend/tests/ttrCalculator.test.mjs | 44 +++++ .../de/tsschulz/tt_tagebuch/app/ui/AppRoot.kt | 32 +++- .../app/ui/TeamEditorLineupLogic.kt | 15 +- .../tt_tagebuch/app/ui/TeamPlanningScreen.kt | 24 ++- 21 files changed, 466 insertions(+), 27 deletions(-) create mode 100644 backend/migrations/20261005_add_hidden_test_user_flags.sql create mode 100644 backend/migrations/20261005_add_member_ttr_adjustment_constant.sql create mode 100644 frontend/src/components/TtrSimulatorDialog.vue create mode 100644 frontend/src/services/ttrCalculator.mjs create mode 100644 frontend/tests/ttrCalculator.test.mjs diff --git a/backend/controllers/matchController.js b/backend/controllers/matchController.js index d71155f7..47cd4f08 100755 --- a/backend/controllers/matchController.js +++ b/backend/controllers/matchController.js @@ -176,7 +176,7 @@ export const getMatchPlayers = async (req, res) => { const Member = (await import('../models/Member.js')).default; const members = await Member.findAll({ where: { clubId: clubId, active: true }, - attributes: ['id', 'firstName', 'lastName', 'gender'] + attributes: ['id', 'firstName', 'lastName', 'gender', 'ttr', 'qttr', 'ttrAdjustmentConstant'] }); return res.status(200).json(members); } catch (error) { diff --git a/backend/migrations/20261005_add_hidden_test_user_flags.sql b/backend/migrations/20261005_add_hidden_test_user_flags.sql new file mode 100644 index 00000000..0b564df3 --- /dev/null +++ b/backend/migrations/20261005_add_hidden_test_user_flags.sql @@ -0,0 +1,5 @@ +-- Hidden application-review users do not appear in club member/user lists +-- and must never receive outgoing emails. +ALTER TABLE `user` + ADD COLUMN `is_hidden` TINYINT(1) NOT NULL DEFAULT 0, + ADD COLUMN `suppress_emails` TINYINT(1) NOT NULL DEFAULT 0; diff --git a/backend/migrations/20261005_add_member_ttr_adjustment_constant.sql b/backend/migrations/20261005_add_member_ttr_adjustment_constant.sql new file mode 100644 index 00000000..ed9fe068 --- /dev/null +++ b/backend/migrations/20261005_add_member_ttr_adjustment_constant.sql @@ -0,0 +1,2 @@ +ALTER TABLE `member` + ADD COLUMN `ttr_adjustment_constant` DECIMAL(6,2) NOT NULL DEFAULT 16; diff --git a/backend/models/Member.js b/backend/models/Member.js index 2d85e9e7..c21919f9 100755 --- a/backend/models/Member.js +++ b/backend/models/Member.js @@ -214,6 +214,13 @@ const Member = sequelize.define('Member', { allowNull: true, defaultValue: null }, + ttrAdjustmentConstant: { + type: DataTypes.DECIMAL(6, 2), + allowNull: false, + defaultValue: 16, + field: 'ttr_adjustment_constant', + comment: 'Individuelle Änderungskonstante für TTR-Prognosen' + }, qttr: { type: DataTypes.INTEGER, allowNull: true, diff --git a/backend/models/User.js b/backend/models/User.js index 10e0f262..dfc6300a 100755 --- a/backend/models/User.js +++ b/backend/models/User.js @@ -34,6 +34,20 @@ const User = sequelize.define('User', { type: DataTypes.BOOLEAN, defaultValue: false, }, + isHidden: { + type: DataTypes.BOOLEAN, + defaultValue: false, + allowNull: false, + field: 'is_hidden', + comment: 'System accounts hidden from member and user lists', + }, + suppressEmails: { + type: DataTypes.BOOLEAN, + defaultValue: false, + allowNull: false, + field: 'suppress_emails', + comment: 'Do not deliver outgoing emails to this account', + }, authCode: { type: DataTypes.STRING, allowNull: true diff --git a/backend/scripts/createTestUsers.js b/backend/scripts/createTestUsers.js index adaf3416..25bfb380 100755 --- a/backend/scripts/createTestUsers.js +++ b/backend/scripts/createTestUsers.js @@ -12,7 +12,9 @@ const TEST_USERS = [ email: 'admin@test.de', password: 'test123', role: 'admin', - isOwner: false + isOwner: false, + isHidden: true, + suppressEmails: true }, { email: 'trainer@test.de', @@ -75,11 +77,21 @@ async function createTestUsers() { user = await User.create({ email: userData.email, password: userData.password, - isActive: true + isActive: true, + isHidden: Boolean(userData.isHidden), + suppressEmails: Boolean(userData.suppressEmails), }); report.push(' ✓ User created'); } + if (user.isHidden !== Boolean(userData.isHidden) || user.suppressEmails !== Boolean(userData.suppressEmails)) { + await user.update({ + isHidden: Boolean(userData.isHidden), + suppressEmails: Boolean(userData.suppressEmails), + }); + report.push(' ✓ Updated test account visibility and email settings'); + } + // Check if user is already in club let userClub = await UserClub.findOne({ where: { @@ -145,4 +157,3 @@ createTestUsers().catch(err => { process.exit(1); }); - diff --git a/backend/scripts/runMigrations.js b/backend/scripts/runMigrations.js index 1e55c199..0bd27fb1 100644 --- a/backend/scripts/runMigrations.js +++ b/backend/scripts/runMigrations.js @@ -1,6 +1,29 @@ import sequelize from '../database.js'; const migrations = [ + { + id: '20261005_add_hidden_test_user_flags', + async up() { + const [hiddenColumns] = await sequelize.query("SHOW COLUMNS FROM `user` LIKE 'is_hidden'"); + if (hiddenColumns.length === 0) { + await sequelize.query('ALTER TABLE `user` ADD COLUMN `is_hidden` TINYINT(1) NOT NULL DEFAULT 0'); + } + + const [emailSuppressionColumns] = await sequelize.query("SHOW COLUMNS FROM `user` LIKE 'suppress_emails'"); + if (emailSuppressionColumns.length === 0) { + await sequelize.query('ALTER TABLE `user` ADD COLUMN `suppress_emails` TINYINT(1) NOT NULL DEFAULT 0'); + } + }, + }, + { + id: '20261005_add_member_ttr_adjustment_constant', + async up() { + const [columns] = await sequelize.query("SHOW COLUMNS FROM `member` LIKE 'ttr_adjustment_constant'"); + if (columns.length === 0) { + await sequelize.query('ALTER TABLE `member` ADD COLUMN `ttr_adjustment_constant` DECIMAL(6,2) NOT NULL DEFAULT 16'); + } + }, + }, { id: '20260922_add_match_player_cancellations', async up() { diff --git a/backend/services/clubService.js b/backend/services/clubService.js index e04e1241..e771412d 100755 --- a/backend/services/clubService.js +++ b/backend/services/clubService.js @@ -59,6 +59,8 @@ class ClubService { { model: User, as: 'Users', + where: { isHidden: false }, + required: false, through: { attributes: [] } @@ -224,6 +226,8 @@ class ClubService { { model: User, as: 'user', + where: { isHidden: false }, + required: true, } ] }); diff --git a/backend/services/emailService.js b/backend/services/emailService.js index 35c29004..aadfe354 100755 --- a/backend/services/emailService.js +++ b/backend/services/emailService.js @@ -1,4 +1,5 @@ import nodemailer from 'nodemailer'; +import User from '../models/User.js'; let transporter = null; @@ -38,7 +39,32 @@ function getEmailTransportStatus() { }; } +function normalizeRecipientList(recipients) { + const values = Array.isArray(recipients) ? recipients : [recipients]; + return values + .flatMap((value) => String(value || '').split(',')) + .map((value) => value.trim().toLowerCase()) + .filter(Boolean); +} + async function sendMail(mailOptions) { + const recipients = normalizeRecipientList(mailOptions.to); + if (recipients.length) { + const suppressedUsers = await User.findAll({ + where: { email: recipients, suppressEmails: true }, + attributes: ['email'], + }); + const suppressedEmails = new Set(suppressedUsers.map((user) => String(user.email).toLowerCase())); + const allowedRecipients = recipients.filter((email) => !suppressedEmails.has(email)); + + if (!allowedRecipients.length) { + console.info('[email] Delivery suppressed for hidden test account'); + return { suppressed: true, accepted: [], rejected: [] }; + } + + mailOptions = { ...mailOptions, to: allowedRecipients.join(', ') }; + } + return getTransporter().sendMail({ from: getDefaultFrom(), ...mailOptions, diff --git a/backend/services/memberService.js b/backend/services/memberService.js index e765c921..5b281e65 100755 --- a/backend/services/memberService.js +++ b/backend/services/memberService.js @@ -1,4 +1,5 @@ import UserClub from "../models/UserClub.js"; +import User from "../models/User.js"; import Club from "../models/Club.js"; import { checkAccess, getUserByToken, hasUserClubAccess } from "../utils/userUtils.js"; import Member from "../models/Member.js"; @@ -221,6 +222,17 @@ class MemberService { if (!showAll || showAll === 'false') { where['active'] = true; } + const hiddenUsers = await User.findAll({ + where: { isHidden: true }, + attributes: ['id'], + }); + const hiddenUserIds = hiddenUsers.map((user) => user.id); + if (hiddenUserIds.length) { + where[Op.or] = [ + { userId: null }, + { userId: { [Op.notIn]: hiddenUserIds } }, + ]; + } const MemberContact = (await import('../models/MemberContact.js')).default; try { const members = await Member.findAll({ diff --git a/backend/services/permissionService.js b/backend/services/permissionService.js index f4aa18ff..8002cdd0 100755 --- a/backend/services/permissionService.js +++ b/backend/services/permissionService.js @@ -474,7 +474,13 @@ class PermissionService { const userClubs = await UserClub.findAll({ where: { clubId }, - include: [{ model: User, as: 'user', attributes: ['id', 'email'] }], + include: [{ + model: User, + as: 'user', + attributes: ['id', 'email'], + where: { isHidden: false }, + required: true, + }], order: [[{ model: User, as: 'user' }, 'email', 'ASC']], }); diff --git a/frontend/package.json b/frontend/package.json index 80cf0550..0e32bb01 100755 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,6 +5,7 @@ "scripts": { "dev": "vite", "build": "vite build", + "test": "node --test tests/*.test.mjs", "serve": "vite preview", "i18n:audit": "node scripts/audit-i18n.js", "i18n:status": "node scripts/translation-status.js", diff --git a/frontend/src/components/DialogManager.vue b/frontend/src/components/DialogManager.vue index e6edad35..ee26ecf0 100755 --- a/frontend/src/components/DialogManager.vue +++ b/frontend/src/components/DialogManager.vue @@ -58,6 +58,7 @@ import { mapGetters, mapActions } from 'vuex'; import MatchReportDialog from './MatchReportDialog.vue'; import MatchReportApiDialog from './MatchReportApiDialog.vue'; import MatchReportHeaderActions from './MatchReportHeaderActions.vue'; +import TtrSimulatorDialog from './TtrSimulatorDialog.vue'; const MyTischtennisAccount = defineAsyncComponent(() => import('../views/MyTischtennisAccount.vue')); const ClickTtAccount = defineAsyncComponent(() => import('../views/ClickTtAccount.vue')); @@ -78,6 +79,7 @@ export default { MatchReportDialog, MatchReportApiDialog, MatchReportHeaderActions, + TtrSimulatorDialog, MyTischtennisAccount, ClickTtAccount, PermissionsView, @@ -130,6 +132,7 @@ export default { 'MatchReportDialog': MatchReportDialog, 'MatchReportApiDialog': MatchReportApiDialog, 'MatchReportHeaderActions': MatchReportHeaderActions, + 'TtrSimulatorDialog': TtrSimulatorDialog, 'MyTischtennisAccount': MyTischtennisAccount, 'ClickTtAccount': ClickTtAccount, 'PermissionsView': PermissionsView, diff --git a/frontend/src/components/TtrSimulatorDialog.vue b/frontend/src/components/TtrSimulatorDialog.vue new file mode 100644 index 00000000..82c1672c --- /dev/null +++ b/frontend/src/components/TtrSimulatorDialog.vue @@ -0,0 +1,155 @@ + + + + + diff --git a/frontend/src/components/schedule/ScheduleLayoutShell.vue b/frontend/src/components/schedule/ScheduleLayoutShell.vue index 1f5dfa91..7603bb55 100755 --- a/frontend/src/components/schedule/ScheduleLayoutShell.vue +++ b/frontend/src/components/schedule/ScheduleLayoutShell.vue @@ -38,6 +38,14 @@ > 🧪 Spielbericht simulieren +