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:
20
backend/controllers/calendarController.js
Normal file
20
backend/controllers/calendarController.js
Normal 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' });
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
49
backend/controllers/trainingCancellationController.js
Normal file
49
backend/controllers/trainingCancellationController.js
Normal 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 });
|
||||
}
|
||||
};
|
||||
3
backend/migrations/add_calendar_region_to_clubs.sql
Normal file
3
backend/migrations/add_calendar_region_to_clubs.sql
Normal 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;
|
||||
13
backend/migrations/add_range_to_training_cancellations.sql
Normal file
13
backend/migrations/add_range_to_training_cancellations.sql
Normal 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;
|
||||
14
backend/migrations/create_training_cancellations_table.sql
Normal file
14
backend/migrations/create_training_cancellations_table.sql
Normal 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
|
||||
);
|
||||
@@ -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,
|
||||
|
||||
51
backend/models/TrainingCancellation.js
Normal file
51
backend/models/TrainingCancellation.js
Normal 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;
|
||||
@@ -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,
|
||||
|
||||
9
backend/routes/calendarRoutes.js
Normal file
9
backend/routes/calendarRoutes.js
Normal 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;
|
||||
16
backend/routes/trainingCancellationRoutes.js
Normal file
16
backend/routes/trainingCancellationRoutes.js
Normal 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;
|
||||
@@ -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
|
||||
|
||||
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