feat(ClubSettings): add country and state code fields for regional calendar data
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 43s
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:
134
backend/services/calendarHolidayService.js
Normal file
134
backend/services/calendarHolidayService.js
Normal 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();
|
||||
@@ -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,
|
||||
|
||||
76
backend/services/trainingCancellationService.js
Normal file
76
backend/services/trainingCancellationService.js
Normal 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();
|
||||
Reference in New Issue
Block a user