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,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();