feat(Calendar): integrate CalendarEvent model and enhance calendar functionality
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 43s

- Added CalendarEvent model to the backend, establishing relationships with the Club model for better event management.
- Updated server.js to include calendarEventRoutes, enabling API access for calendar events.
- Enhanced CalendarView.vue to support custom event creation and management, improving user interaction with the calendar.
- Refactored various components to streamline event handling and improve overall user experience in the calendar interface.
- Updated TODO and DEVELOPMENT documentation to reflect new calendar features and architectural decisions.
This commit is contained in:
Torsten Schulz (local)
2026-05-13 10:21:30 +02:00
parent 9be5f50ede
commit 004801b1a6
33 changed files with 2715 additions and 632 deletions

View File

@@ -0,0 +1,42 @@
import calendarEventService from '../services/calendarEventService.js';
import { getSafeErrorMessage } from '../utils/errorUtils.js';
export const listClubCalendarEvents = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId } = req.params;
const { year } = req.query;
const events = await calendarEventService.listClubEvents(userToken, clubId, year);
res.status(200).json(events);
} catch (error) {
console.error('[listClubCalendarEvents] - Error:', error);
const msg = getSafeErrorMessage(error, 'Fehler beim Laden der Kalender-Events');
res.status(error.statusCode || 500).json({ error: msg });
}
};
export const createClubCalendarEvent = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId } = req.params;
const event = await calendarEventService.createClubEvent(userToken, clubId, req.body);
res.status(201).json(event);
} catch (error) {
console.error('[createClubCalendarEvent] - Error:', error);
const msg = getSafeErrorMessage(error, 'Fehler beim Speichern des Kalender-Events');
res.status(error.statusCode || 500).json({ error: msg });
}
};
export const deleteClubCalendarEvent = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId, eventId } = req.params;
const result = await calendarEventService.deleteClubEvent(userToken, clubId, eventId);
res.status(200).json(result);
} catch (error) {
console.error('[deleteClubCalendarEvent] - Error:', error);
const msg = getSafeErrorMessage(error, 'Fehler beim Löschen des Kalender-Events');
res.status(error.statusCode || 500).json({ error: msg });
}
};

View File

@@ -0,0 +1,15 @@
CREATE TABLE IF NOT EXISTS calendar_events (
id INT AUTO_INCREMENT PRIMARY KEY,
club_id INT NOT NULL,
title VARCHAR(255) NOT NULL,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
category VARCHAR(64) NULL,
notes TEXT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_calendar_events_club_start (club_id, start_date),
CONSTRAINT fk_calendar_events_club
FOREIGN KEY (club_id) REFERENCES clubs(id)
ON DELETE CASCADE
);

View File

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

View File

@@ -59,6 +59,7 @@ import MemberTrainingGroup from './MemberTrainingGroup.js';
import ClubDisabledPresetGroup from './ClubDisabledPresetGroup.js';
import TrainingTime from './TrainingTime.js';
import TrainingCancellation from './TrainingCancellation.js';
import CalendarEvent from './CalendarEvent.js';
import BillingTemplate from './BillingTemplate.js';
import BillingTemplateField from './BillingTemplateField.js';
import BillingRun from './BillingRun.js';
@@ -410,6 +411,8 @@ TrainingGroup.hasMany(TrainingTime, { foreignKey: 'trainingGroupId', as: 'traini
TrainingTime.belongsTo(TrainingGroup, { foreignKey: 'trainingGroupId', as: 'trainingGroup' });
Club.hasMany(TrainingCancellation, { foreignKey: 'clubId', as: 'trainingCancellations' });
TrainingCancellation.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
Club.hasMany(CalendarEvent, { foreignKey: 'clubId', as: 'calendarEvents' });
CalendarEvent.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
// Billing
Club.hasMany(BillingTemplate, { foreignKey: 'clubId', as: 'billingTemplates' });
@@ -488,6 +491,7 @@ export {
ClubDisabledPresetGroup,
TrainingTime,
TrainingCancellation,
CalendarEvent,
BillingTemplate,
BillingTemplateField,
BillingRun,

View File

@@ -0,0 +1,16 @@
import express from 'express';
import { authenticate } from '../middleware/authMiddleware.js';
import {
createClubCalendarEvent,
deleteClubCalendarEvent,
listClubCalendarEvents,
} from '../controllers/calendarEventController.js';
const router = express.Router();
router.use(authenticate);
router.get('/:clubId', listClubCalendarEvents);
router.post('/:clubId', createClubCalendarEvent);
router.delete('/:clubId/:eventId', deleteClubCalendarEvent);
export default router;

View File

@@ -15,6 +15,7 @@ import {
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, TrainingCancellation
, CalendarEvent
} from './models/index.js';
import authRoutes from './routes/authRoutes.js';
import clubRoutes from './routes/clubRoutes.js';
@@ -60,6 +61,7 @@ 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 calendarEventRoutes from './routes/calendarEventRoutes.js';
import schedulerService from './services/schedulerService.js';
import { requestLoggingMiddleware } from './middleware/requestLoggingMiddleware.js';
import HttpError from './exceptions/HttpError.js';
@@ -310,6 +312,7 @@ app.use('/api/member-orders', memberOrderRoutes);
app.use('/api/member-group-photos', memberGroupPhotoRoutes);
app.use('/api/billing', billingRoutes);
app.use('/api/calendar', calendarRoutes);
app.use('/api/calendar-events', calendarEventRoutes);
// Middleware für dynamischen kanonischen Tag (vor express.static)
const setCanonicalTag = (req, res, next) => {
@@ -565,6 +568,7 @@ app.use((err, req, res, next) => {
await safeSync(BillingUserSetting);
await safeSync(ClubTeam);
await safeSync(TrainingCancellation);
await safeSync(CalendarEvent);
await safeSync(TeamDocument);
// Foreign Keys wieder aktivieren

View File

@@ -0,0 +1,59 @@
import { Op } from 'sequelize';
import CalendarEvent from '../models/CalendarEvent.js';
import { checkAccess } from '../utils/userUtils.js';
import HttpError from '../exceptions/HttpError.js';
class CalendarEventService {
async listClubEvents(userToken, clubId, year) {
await checkAccess(userToken, clubId);
const normalizedYear = this.normalizeYear(year);
return await CalendarEvent.findAll({
where: {
clubId,
startDate: { [Op.lte]: `${normalizedYear}-12-31` },
endDate: { [Op.gte]: `${normalizedYear}-01-01` },
},
order: [['startDate', 'ASC'], ['title', 'ASC']],
});
}
async createClubEvent(userToken, clubId, payload) {
await checkAccess(userToken, clubId);
const title = String(payload?.title || '').trim();
if (!title) throw new HttpError('Titel fehlt', 400);
const startDate = this.normalizeDate(payload?.startDate);
const endDate = this.normalizeDate(payload?.endDate || payload?.startDate);
if (!startDate || !endDate) throw new HttpError('Ungültiges Datum', 400);
if (startDate > endDate) throw new HttpError('Enddatum darf nicht vor dem Startdatum liegen', 400);
return await CalendarEvent.create({
clubId,
title,
startDate,
endDate,
category: payload?.category ? String(payload.category).trim().slice(0, 64) : null,
notes: payload?.notes ? String(payload.notes).trim() : null,
});
}
async deleteClubEvent(userToken, clubId, eventId) {
await checkAccess(userToken, clubId);
const event = await CalendarEvent.findOne({ where: { id: eventId, clubId } });
if (!event) throw new HttpError('Event nicht gefunden', 404);
await event.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 CalendarEventService();