feat(ClubSettings): add country and state code fields for regional calendar data
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 43s

- Introduced `countryCode` and `stateCode` fields in the Club model to support regional calendar data.
- Updated ClubSettings component to allow users to select their country and state, enhancing the configuration options for clubs.
- Enhanced the ClubService to handle normalization of country and state codes during updates.
- Added new routes and middleware to support the training cancellation feature and calendar integration in the backend.
- Updated frontend navigation to include a calendar link, improving user access to scheduling features.
This commit is contained in:
Torsten Schulz (local)
2026-05-12 23:46:07 +02:00
parent 1e23171370
commit bea5facb7d
46 changed files with 4286 additions and 12 deletions

View File

@@ -0,0 +1,20 @@
import calendarHolidayService from '../services/calendarHolidayService.js';
export const getClubCalendarDays = async (req, res) => {
try {
const { authcode: token } = req.headers;
const { clubId } = req.params;
const { year } = req.query;
const result = await calendarHolidayService.getClubCalendarDays(token, clubId, year);
res.status(200).json(result);
} catch (error) {
if (error.message === 'clubnotfound') {
res.status(404).json({ error: 'clubnotfound' });
} else if (error.message === 'noaccess') {
res.status(403).json({ error: 'noaccess' });
} else {
console.error('[getClubCalendarDays] - error:', error);
res.status(502).json({ error: 'calendarproviderfailed' });
}
}
};

View File

@@ -65,6 +65,8 @@ export const updateClubSettings = async (req, res) => {
associationMemberNumber,
myTischtennisFedNickname,
autoFetchRankings,
countryCode,
stateCode,
memberDataQualityRequirements
} = req.body;
const updated = await ClubService.updateClubSettings(token, clubid, {
@@ -72,6 +74,8 @@ export const updateClubSettings = async (req, res) => {
associationMemberNumber,
myTischtennisFedNickname,
autoFetchRankings,
countryCode,
stateCode,
memberDataQualityRequirements
});
res.status(200).json(updated);

View File

@@ -0,0 +1,49 @@
import trainingCancellationService from '../services/trainingCancellationService.js';
import { getSafeErrorMessage } from '../utils/errorUtils.js';
export const getTrainingCancellations = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId } = req.params;
const { year } = req.query;
const result = await trainingCancellationService.getTrainingCancellations(userToken, clubId, year);
res.status(200).json(result);
} catch (error) {
console.error('[getTrainingCancellations] - Error:', error);
const message = getSafeErrorMessage(error, 'Fehler beim Laden der Trainingsausfälle');
res.status(error.statusCode || 500).json({ error: message });
}
};
export const upsertTrainingCancellation = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId } = req.params;
const { date, startDate, endDate, reason } = req.body;
const result = await trainingCancellationService.upsertTrainingCancellation(
userToken,
clubId,
startDate || date,
reason,
endDate || date || startDate
);
res.status(200).json(result);
} catch (error) {
console.error('[upsertTrainingCancellation] - Error:', error);
const message = getSafeErrorMessage(error, 'Fehler beim Speichern des Trainingsausfalls');
res.status(error.statusCode || 500).json({ error: message });
}
};
export const deleteTrainingCancellation = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId, cancellationId } = req.params;
const result = await trainingCancellationService.deleteTrainingCancellation(userToken, clubId, cancellationId);
res.status(200).json(result);
} catch (error) {
console.error('[deleteTrainingCancellation] - Error:', error);
const message = getSafeErrorMessage(error, 'Fehler beim Löschen des Trainingsausfalls');
res.status(error.statusCode || 500).json({ error: message });
}
};

View File

@@ -0,0 +1,3 @@
ALTER TABLE clubs
ADD COLUMN IF NOT EXISTS country_code VARCHAR(2) NOT NULL DEFAULT 'DE',
ADD COLUMN IF NOT EXISTS state_code VARCHAR(16) NULL;

View File

@@ -0,0 +1,13 @@
ALTER TABLE training_cancellations
ADD COLUMN IF NOT EXISTS start_date DATE NULL,
ADD COLUMN IF NOT EXISTS end_date DATE NULL;
UPDATE training_cancellations
SET
start_date = COALESCE(start_date, date),
end_date = COALESCE(end_date, date)
WHERE start_date IS NULL OR end_date IS NULL;
ALTER TABLE training_cancellations
MODIFY start_date DATE NOT NULL,
MODIFY end_date DATE NOT NULL;

View File

@@ -0,0 +1,14 @@
CREATE TABLE IF NOT EXISTS training_cancellations (
id INT AUTO_INCREMENT PRIMARY KEY,
club_id INT NOT NULL,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
date DATE NULL,
reason VARCHAR(255) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uniq_training_cancellation_club_range (club_id, start_date, end_date),
CONSTRAINT fk_training_cancellations_club
FOREIGN KEY (club_id) REFERENCES clubs(id)
ON DELETE CASCADE
);

View File

@@ -30,6 +30,19 @@ const Club = sequelize.define('Club', {
field: 'auto_fetch_rankings',
comment: 'Enable automatic TTR/QTTR rankings fetch for this club'
},
countryCode: {
type: DataTypes.STRING(2),
allowNull: false,
defaultValue: 'DE',
field: 'country_code',
comment: 'ISO 3166-1 alpha-2 country code for regional calendar data'
},
stateCode: {
type: DataTypes.STRING(16),
allowNull: true,
field: 'state_code',
comment: 'ISO 3166-2 subdivision code for regional calendar data, e.g. DE-NW'
},
memberDataQualityRequirements: {
type: DataTypes.JSON,
allowNull: true,

View File

@@ -0,0 +1,51 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
import Club from './Club.js';
const TrainingCancellation = sequelize.define('TrainingCancellation', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false,
},
clubId: {
type: DataTypes.INTEGER,
allowNull: false,
references: {
model: Club,
key: 'id',
},
onDelete: 'CASCADE',
},
startDate: {
type: DataTypes.DATEONLY,
allowNull: false,
field: 'start_date',
},
endDate: {
type: DataTypes.DATEONLY,
allowNull: false,
field: 'end_date',
},
date: {
type: DataTypes.DATEONLY,
allowNull: true,
},
reason: {
type: DataTypes.STRING(255),
allowNull: true,
},
}, {
tableName: 'training_cancellations',
underscored: true,
timestamps: true,
indexes: [
{
unique: true,
fields: ['club_id', 'start_date', 'end_date'],
},
],
});
export default TrainingCancellation;

View File

@@ -58,6 +58,7 @@ import TrainingGroup from './TrainingGroup.js';
import MemberTrainingGroup from './MemberTrainingGroup.js';
import ClubDisabledPresetGroup from './ClubDisabledPresetGroup.js';
import TrainingTime from './TrainingTime.js';
import TrainingCancellation from './TrainingCancellation.js';
import BillingTemplate from './BillingTemplate.js';
import BillingTemplateField from './BillingTemplateField.js';
import BillingRun from './BillingRun.js';
@@ -407,6 +408,8 @@ ClubDisabledPresetGroup.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
// Training Times
TrainingGroup.hasMany(TrainingTime, { foreignKey: 'trainingGroupId', as: 'trainingTimes' });
TrainingTime.belongsTo(TrainingGroup, { foreignKey: 'trainingGroupId', as: 'trainingGroup' });
Club.hasMany(TrainingCancellation, { foreignKey: 'clubId', as: 'trainingCancellations' });
TrainingCancellation.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
// Billing
Club.hasMany(BillingTemplate, { foreignKey: 'clubId', as: 'billingTemplates' });
@@ -484,6 +487,7 @@ export {
MemberTrainingGroup,
ClubDisabledPresetGroup,
TrainingTime,
TrainingCancellation,
BillingTemplate,
BillingTemplateField,
BillingRun,

View File

@@ -0,0 +1,9 @@
import express from 'express';
import { authenticate } from '../middleware/authMiddleware.js';
import { getClubCalendarDays } from '../controllers/calendarController.js';
const router = express.Router();
router.get('/club/:clubId/holidays', authenticate, getClubCalendarDays);
export default router;

View File

@@ -0,0 +1,16 @@
import express from 'express';
import { authenticate } from '../middleware/authMiddleware.js';
import {
deleteTrainingCancellation,
getTrainingCancellations,
upsertTrainingCancellation,
} from '../controllers/trainingCancellationController.js';
const router = express.Router();
router.use(authenticate);
router.get('/:clubId', getTrainingCancellations);
router.post('/:clubId', upsertTrainingCancellation);
router.delete('/:clubId/:cancellationId', deleteTrainingCancellation);
export default router;

View File

@@ -14,7 +14,7 @@ import {
PredefinedActivity, PredefinedActivityImage, DiaryDateActivity, DiaryMemberActivity, Match, League, Team, ClubTeam, ClubTeamMember, TeamDocument, Group,
GroupActivity, Tournament, TournamentGroup, TournamentMatch, TournamentResult,
TournamentMember, Accident, UserToken, OfficialTournament, OfficialCompetition, OfficialCompetitionMember, MyTischtennis, ClickTtAccount, MyTischtennisUpdateHistory, MyTischtennisFetchLog, ApiLog, MemberTransferConfig, MemberContact, MemberTtrHistory, MemberPlayInterest,
MemberOrder, MemberOrderHistory, MemberGroupPhoto, BillingTemplate, BillingTemplateField, BillingRun, BillingDocument, BillingDocumentValue, BillingUserSetting
MemberOrder, MemberOrderHistory, MemberGroupPhoto, BillingTemplate, BillingTemplateField, BillingRun, BillingDocument, BillingDocumentValue, BillingUserSetting, TrainingCancellation
} from './models/index.js';
import authRoutes from './routes/authRoutes.js';
import clubRoutes from './routes/clubRoutes.js';
@@ -55,9 +55,11 @@ import clickTtHttpPageRoutes from './routes/clickTtHttpPageRoutes.js';
import memberTransferConfigRoutes from './routes/memberTransferConfigRoutes.js';
import trainingGroupRoutes from './routes/trainingGroupRoutes.js';
import trainingTimeRoutes from './routes/trainingTimeRoutes.js';
import trainingCancellationRoutes from './routes/trainingCancellationRoutes.js';
import memberOrderRoutes from './routes/memberOrderRoutes.js';
import memberGroupPhotoRoutes from './routes/memberGroupPhotoRoutes.js';
import billingRoutes from './routes/billingRoutes.js';
import calendarRoutes from './routes/calendarRoutes.js';
import schedulerService from './services/schedulerService.js';
import { requestLoggingMiddleware } from './middleware/requestLoggingMiddleware.js';
import HttpError from './exceptions/HttpError.js';
@@ -151,6 +153,7 @@ const SEO_NOINDEX_PREFIXES = [
'/showclub',
'/members',
'/diary',
'/calendar',
'/pending-approvals',
'/schedule',
'/tournaments',
@@ -302,9 +305,11 @@ app.use('/api/clicktt', clickTtHttpPageRoutes);
app.use('/api/member-transfer-config', memberTransferConfigRoutes);
app.use('/api/training-groups', trainingGroupRoutes);
app.use('/api/training-times', trainingTimeRoutes);
app.use('/api/training-cancellations', trainingCancellationRoutes);
app.use('/api/member-orders', memberOrderRoutes);
app.use('/api/member-group-photos', memberGroupPhotoRoutes);
app.use('/api/billing', billingRoutes);
app.use('/api/calendar', calendarRoutes);
// Middleware für dynamischen kanonischen Tag (vor express.static)
const setCanonicalTag = (req, res, next) => {
@@ -559,6 +564,7 @@ app.use((err, req, res, next) => {
await safeSync(BillingDocumentValue);
await safeSync(BillingUserSetting);
await safeSync(ClubTeam);
await safeSync(TrainingCancellation);
await safeSync(TeamDocument);
// Foreign Keys wieder aktivieren

View File

@@ -0,0 +1,134 @@
import Club from '../models/Club.js';
import { checkAccess } from '../utils/userUtils.js';
const OPEN_HOLIDAYS_BASE_URL = 'https://openholidaysapi.org';
const CACHE_TTL_MS = 1000 * 60 * 60 * 24 * 14;
const REQUEST_TIMEOUT_MS = 8000;
const cache = new Map();
class CalendarHolidayService {
async getClubCalendarDays(userToken, clubId, year) {
await checkAccess(userToken, clubId);
const club = await Club.findByPk(clubId);
if (!club) {
throw new Error('clubnotfound');
}
const normalizedYear = this.normalizeYear(year);
const countryCode = this.normalizeCountryCode(club.countryCode);
const stateCode = this.normalizeStateCode(club.stateCode);
if (!countryCode || !stateCode) {
return {
countryCode,
stateCode,
year: normalizedYear,
holidays: [],
schoolHolidays: []
};
}
const cacheKey = `${countryCode}:${stateCode}:${normalizedYear}`;
const cached = cache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
return cached.data;
}
const validFrom = `${normalizedYear}-01-01`;
const validTo = `${normalizedYear}-12-31`;
const [holidays, schoolHolidays] = await Promise.all([
this.fetchOpenHolidays('PublicHolidays', { countryCode, stateCode, validFrom, validTo }),
this.fetchOpenHolidays('SchoolHolidays', { countryCode, stateCode, validFrom, validTo })
]);
const data = {
countryCode,
stateCode,
year: normalizedYear,
holidays: holidays
.filter(item => this.isRelevantForSubdivision(item, stateCode))
.map(item => this.normalizeOpenHoliday(item, 'holiday'))
.filter(Boolean),
schoolHolidays: schoolHolidays.map(item => this.normalizeOpenHoliday(item, 'schoolHoliday')).filter(Boolean)
};
cache.set(cacheKey, { data, expiresAt: Date.now() + CACHE_TTL_MS });
return data;
}
async fetchOpenHolidays(endpoint, { countryCode, stateCode, validFrom, validTo }) {
const params = new URLSearchParams({
countryIsoCode: countryCode,
languageIsoCode: 'DE',
validFrom,
validTo
});
if (endpoint === 'SchoolHolidays') {
params.set('subdivisionCode', stateCode);
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
const response = await fetch(`${OPEN_HOLIDAYS_BASE_URL}/${endpoint}?${params.toString()}`, {
headers: { accept: 'text/json' },
signal: controller.signal
});
if (!response.ok) {
throw new Error(`OpenHolidays ${endpoint} HTTP ${response.status}`);
}
const data = await response.json();
return Array.isArray(data) ? data : [];
} finally {
clearTimeout(timeout);
}
}
normalizeOpenHoliday(item, type) {
const startDate = item?.startDate || item?.date || item?.validFrom;
const endDate = item?.endDate || item?.date || item?.validTo || startDate;
if (!startDate || !endDate) return null;
return {
id: item.id || `${type}-${startDate}-${this.getName(item)}`,
type,
startDate: String(startDate).slice(0, 10),
endDate: String(endDate).slice(0, 10),
name: this.getName(item)
};
}
getName(item) {
const names = Array.isArray(item?.name) ? item.name : [];
const germanName = names.find(name => String(name.language || name.languageIsoCode || '').toUpperCase() === 'DE');
const firstName = germanName || names[0];
if (firstName?.text) return firstName.text;
if (typeof item?.name === 'string') return item.name;
if (typeof item?.localName === 'string') return item.localName;
return 'Kalendereintrag';
}
isRelevantForSubdivision(item, stateCode) {
const subdivisions = Array.isArray(item?.subdivisions) ? item.subdivisions : [];
if (subdivisions.length === 0) return true;
return subdivisions.some(subdivision => String(subdivision?.code || '').toUpperCase() === stateCode);
}
normalizeYear(year) {
const parsed = Number.parseInt(year, 10);
if (Number.isInteger(parsed) && parsed >= 2020 && parsed <= 2100) {
return parsed;
}
return new Date().getFullYear();
}
normalizeCountryCode(countryCode) {
const normalized = String(countryCode || 'DE').trim().toUpperCase();
return /^[A-Z]{2}$/.test(normalized) ? normalized : 'DE';
}
normalizeStateCode(stateCode) {
return String(stateCode || '').trim().toUpperCase();
}
}
export default new CalendarHolidayService();

View File

@@ -72,6 +72,8 @@ class ClubService {
associationMemberNumber,
myTischtennisFedNickname,
autoFetchRankings,
countryCode,
stateCode,
memberDataQualityRequirements
}) {
await checkAccess(userToken, clubId);
@@ -82,12 +84,24 @@ class ClubService {
const updates = { greetingText, associationMemberNumber };
if (myTischtennisFedNickname !== undefined) updates.myTischtennisFedNickname = myTischtennisFedNickname || null;
if (autoFetchRankings !== undefined) updates.autoFetchRankings = !!autoFetchRankings;
if (countryCode !== undefined) updates.countryCode = this.normalizeCountryCode(countryCode);
if (stateCode !== undefined) updates.stateCode = this.normalizeStateCode(stateCode);
if (memberDataQualityRequirements !== undefined) {
updates.memberDataQualityRequirements = this.normalizeMemberDataQualityRequirements(memberDataQualityRequirements);
}
return await club.update(updates);
}
normalizeCountryCode(countryCode) {
const normalized = String(countryCode || 'DE').trim().toUpperCase();
return /^[A-Z]{2}$/.test(normalized) ? normalized : 'DE';
}
normalizeStateCode(stateCode) {
const normalized = String(stateCode || '').trim().toUpperCase();
return normalized || null;
}
normalizeMemberDataQualityRequirements(settings) {
const defaults = {
requireStreet: true,

View File

@@ -0,0 +1,76 @@
import { Op } from 'sequelize';
import TrainingCancellation from '../models/TrainingCancellation.js';
import { checkAccess } from '../utils/userUtils.js';
import HttpError from '../exceptions/HttpError.js';
class TrainingCancellationService {
async getTrainingCancellations(userToken, clubId, year) {
await checkAccess(userToken, clubId);
const normalizedYear = this.normalizeYear(year);
return await TrainingCancellation.findAll({
where: {
clubId,
[Op.or]: [
{
startDate: { [Op.lte]: `${normalizedYear}-12-31` },
endDate: { [Op.gte]: `${normalizedYear}-01-01` },
},
{
date: { [Op.between]: [`${normalizedYear}-01-01`, `${normalizedYear}-12-31`] },
},
],
},
order: [['startDate', 'ASC'], ['date', 'ASC']],
});
}
async upsertTrainingCancellation(userToken, clubId, date, reason, endDate = null) {
await checkAccess(userToken, clubId);
const normalizedStartDate = this.normalizeDate(date);
const normalizedEndDate = this.normalizeDate(endDate || date);
if (!normalizedStartDate || !normalizedEndDate) {
throw new HttpError('Ungültiges Datum', 400);
}
if (normalizedStartDate > normalizedEndDate) {
throw new HttpError('Enddatum darf nicht vor dem Startdatum liegen', 400);
}
const [cancellation] = await TrainingCancellation.upsert({
clubId,
startDate: normalizedStartDate,
endDate: normalizedEndDate,
date: normalizedStartDate,
reason: String(reason || '').trim() || null,
});
return cancellation || await TrainingCancellation.findOne({
where: { clubId, startDate: normalizedStartDate, endDate: normalizedEndDate },
});
}
async deleteTrainingCancellation(userToken, clubId, cancellationId) {
await checkAccess(userToken, clubId);
const cancellation = await TrainingCancellation.findOne({
where: { id: cancellationId, clubId },
});
if (!cancellation) {
throw new HttpError('Trainingsausfall nicht gefunden', 404);
}
await cancellation.destroy();
return { success: true };
}
normalizeYear(year) {
const parsed = Number.parseInt(year, 10);
if (Number.isInteger(parsed) && parsed >= 2020 && parsed <= 2100) {
return parsed;
}
return new Date().getFullYear();
}
normalizeDate(date) {
const text = String(date || '').slice(0, 10);
return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : null;
}
}
export default new TrainingCancellationService();