feat: add TTR simulator and calculation engine, enhance member model with TTR adjustment constant
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 59s

This commit is contained in:
Torsten Schulz (local)
2026-10-05 15:18:10 +02:00
parent 6fa54163f6
commit 6d982add75
21 changed files with 466 additions and 27 deletions

View File

@@ -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) {

View File

@@ -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;

View File

@@ -0,0 +1,2 @@
ALTER TABLE `member`
ADD COLUMN `ttr_adjustment_constant` DECIMAL(6,2) NOT NULL DEFAULT 16;

View File

@@ -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,

View File

@@ -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

View File

@@ -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);
});

View File

@@ -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() {

View File

@@ -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,
}
]
});

View File

@@ -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,

View File

@@ -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({

View File

@@ -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']],
});