From cff0ce1e1a3dd025e4a75759d1648a4f219120f7 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Fri, 30 Jan 2026 14:29:11 +0100 Subject: [PATCH] Add calendar feature: Integrate calendarRouter and CalendarEvent model, enhance CalendarView with API interactions for event management, and update localization files for error handling in both English and German. This update improves the calendar functionality and user experience. --- backend/app.js | 2 + backend/controllers/calendarController.js | 123 ++++++++++++++ backend/models/associations.js | 5 + backend/models/community/calendar_event.js | 86 ++++++++++ backend/models/index.js | 4 + backend/routers/calendarRouter.js | 14 ++ backend/services/calendarService.js | 169 +++++++++++++++++++ frontend/src/i18n/locales/de/personal.json | 4 +- frontend/src/i18n/locales/en/personal.json | 4 +- frontend/src/views/personal/CalendarView.vue | 83 +++++---- 10 files changed, 460 insertions(+), 34 deletions(-) create mode 100644 backend/controllers/calendarController.js create mode 100644 backend/models/community/calendar_event.js create mode 100644 backend/routers/calendarRouter.js create mode 100644 backend/services/calendarService.js diff --git a/backend/app.js b/backend/app.js index 420dc32..66cc315 100644 --- a/backend/app.js +++ b/backend/app.js @@ -22,6 +22,7 @@ import termineRouter from './routers/termineRouter.js'; import vocabRouter from './routers/vocabRouter.js'; import dashboardRouter from './routers/dashboardRouter.js'; import newsRouter from './routers/newsRouter.js'; +import calendarRouter from './routers/calendarRouter.js'; import cors from 'cors'; import './jobs/sessionCleanup.js'; @@ -82,6 +83,7 @@ app.use('/api/blog', blogRouter); app.use('/api/termine', termineRouter); app.use('/api/dashboard', dashboardRouter); app.use('/api/news', newsRouter); +app.use('/api/calendar', calendarRouter); // Serve frontend SPA for non-API routes to support history mode clean URLs // /models/* nicht statisch ausliefern – nur über /api/models (Proxy mit Komprimierung) diff --git a/backend/controllers/calendarController.js b/backend/controllers/calendarController.js new file mode 100644 index 0000000..3baca85 --- /dev/null +++ b/backend/controllers/calendarController.js @@ -0,0 +1,123 @@ +import calendarService from '../services/calendarService.js'; + +function getHashedUserId(req) { + return req.headers?.userid; +} + +export default { + /** + * GET /api/calendar/events + * Get all events for the authenticated user + * Query params: startDate, endDate (optional) + */ + async getEvents(req, res) { + const hashedUserId = getHashedUserId(req); + if (!hashedUserId) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + try { + const { startDate, endDate } = req.query; + const events = await calendarService.getEvents(hashedUserId, { startDate, endDate }); + res.json(events); + } catch (error) { + console.error('Calendar getEvents:', error); + res.status(500).json({ error: error.message || 'Internal server error' }); + } + }, + + /** + * GET /api/calendar/events/:id + * Get a single event by ID + */ + async getEvent(req, res) { + const hashedUserId = getHashedUserId(req); + if (!hashedUserId) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + try { + const event = await calendarService.getEvent(hashedUserId, req.params.id); + res.json(event); + } catch (error) { + console.error('Calendar getEvent:', error); + if (error.message === 'Event not found') { + return res.status(404).json({ error: 'Event not found' }); + } + res.status(500).json({ error: error.message || 'Internal server error' }); + } + }, + + /** + * POST /api/calendar/events + * Create a new event + */ + async createEvent(req, res) { + const hashedUserId = getHashedUserId(req); + if (!hashedUserId) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + try { + const eventData = req.body; + if (!eventData.title || !eventData.startDate) { + return res.status(400).json({ error: 'Title and startDate are required' }); + } + + const event = await calendarService.createEvent(hashedUserId, eventData); + res.status(201).json(event); + } catch (error) { + console.error('Calendar createEvent:', error); + res.status(500).json({ error: error.message || 'Internal server error' }); + } + }, + + /** + * PUT /api/calendar/events/:id + * Update an existing event + */ + async updateEvent(req, res) { + const hashedUserId = getHashedUserId(req); + if (!hashedUserId) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + try { + const eventData = req.body; + if (!eventData.title || !eventData.startDate) { + return res.status(400).json({ error: 'Title and startDate are required' }); + } + + const event = await calendarService.updateEvent(hashedUserId, req.params.id, eventData); + res.json(event); + } catch (error) { + console.error('Calendar updateEvent:', error); + if (error.message === 'Event not found') { + return res.status(404).json({ error: 'Event not found' }); + } + res.status(500).json({ error: error.message || 'Internal server error' }); + } + }, + + /** + * DELETE /api/calendar/events/:id + * Delete an event + */ + async deleteEvent(req, res) { + const hashedUserId = getHashedUserId(req); + if (!hashedUserId) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + try { + await calendarService.deleteEvent(hashedUserId, req.params.id); + res.json({ success: true }); + } catch (error) { + console.error('Calendar deleteEvent:', error); + if (error.message === 'Event not found') { + return res.status(404).json({ error: 'Event not found' }); + } + res.status(500).json({ error: error.message || 'Internal server error' }); + } + } +}; diff --git a/backend/models/associations.js b/backend/models/associations.js index 6271662..38f0487 100644 --- a/backend/models/associations.js +++ b/backend/models/associations.js @@ -116,6 +116,7 @@ import VocabCourseProgress from './community/vocab_course_progress.js'; import VocabGrammarExerciseType from './community/vocab_grammar_exercise_type.js'; import VocabGrammarExercise from './community/vocab_grammar_exercise.js'; import VocabGrammarExerciseProgress from './community/vocab_grammar_exercise_progress.js'; +import CalendarEvent from './community/calendar_event.js'; import Campaign from './match3/campaign.js'; import Match3Level from './match3/level.js'; import Objective from './match3/objective.js'; @@ -1078,5 +1079,9 @@ export default function setupAssociations() { User.hasMany(VocabGrammarExerciseProgress, { foreignKey: 'userId', as: 'grammarExerciseProgress' }); VocabGrammarExerciseProgress.belongsTo(VocabGrammarExercise, { foreignKey: 'exerciseId', as: 'exercise' }); VocabGrammarExercise.hasMany(VocabGrammarExerciseProgress, { foreignKey: 'exerciseId', as: 'progress' }); + + // Calendar associations + CalendarEvent.belongsTo(User, { foreignKey: 'userId', as: 'user' }); + User.hasMany(CalendarEvent, { foreignKey: 'userId', as: 'calendarEvents' }); } diff --git a/backend/models/community/calendar_event.js b/backend/models/community/calendar_event.js new file mode 100644 index 0000000..f7f96df --- /dev/null +++ b/backend/models/community/calendar_event.js @@ -0,0 +1,86 @@ +import { Model, DataTypes } from 'sequelize'; +import { sequelize } from '../../utils/sequelize.js'; + +class CalendarEvent extends Model { } + +CalendarEvent.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + userId: { + type: DataTypes.INTEGER, + allowNull: false, + references: { + model: 'user', + key: 'id' + } + }, + title: { + type: DataTypes.STRING(255), + allowNull: false + }, + description: { + type: DataTypes.TEXT, + allowNull: true + }, + categoryId: { + type: DataTypes.STRING(50), + allowNull: false, + defaultValue: 'personal', + comment: 'Category key: personal, work, family, health, birthday, holiday, reminder, other' + }, + startDate: { + type: DataTypes.DATEONLY, + allowNull: false + }, + endDate: { + type: DataTypes.DATEONLY, + allowNull: true, + comment: 'End date for multi-day events, null means same as startDate' + }, + startTime: { + type: DataTypes.TIME, + allowNull: true, + comment: 'Start time, null for all-day events' + }, + endTime: { + type: DataTypes.TIME, + allowNull: true, + comment: 'End time, null for all-day events' + }, + allDay: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false + }, + createdAt: { + type: DataTypes.DATE, + defaultValue: DataTypes.NOW + }, + updatedAt: { + type: DataTypes.DATE, + defaultValue: DataTypes.NOW + } +}, { + sequelize, + modelName: 'CalendarEvent', + tableName: 'calendar_event', + schema: 'community', + timestamps: true, + underscored: true, + indexes: [ + { + fields: ['user_id'] + }, + { + fields: ['user_id', 'start_date'] + }, + { + fields: ['user_id', 'start_date', 'end_date'] + } + ] +}); + +export default CalendarEvent; diff --git a/backend/models/index.js b/backend/models/index.js index 7073fd0..453dfed 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -146,6 +146,7 @@ import VocabCourseProgress from './community/vocab_course_progress.js'; import VocabGrammarExerciseType from './community/vocab_grammar_exercise_type.js'; import VocabGrammarExercise from './community/vocab_grammar_exercise.js'; import VocabGrammarExerciseProgress from './community/vocab_grammar_exercise_progress.js'; +import CalendarEvent from './community/calendar_event.js'; const models = { SettingsType, @@ -297,6 +298,9 @@ const models = { VocabGrammarExerciseType, VocabGrammarExercise, VocabGrammarExerciseProgress, + + // Calendar + CalendarEvent, }; export default models; diff --git a/backend/routers/calendarRouter.js b/backend/routers/calendarRouter.js new file mode 100644 index 0000000..15a6f12 --- /dev/null +++ b/backend/routers/calendarRouter.js @@ -0,0 +1,14 @@ +import { Router } from 'express'; +import { authenticate } from '../middleware/authMiddleware.js'; +import calendarController from '../controllers/calendarController.js'; + +const router = Router(); + +// All routes require authentication +router.get('/events', authenticate, calendarController.getEvents); +router.get('/events/:id', authenticate, calendarController.getEvent); +router.post('/events', authenticate, calendarController.createEvent); +router.put('/events/:id', authenticate, calendarController.updateEvent); +router.delete('/events/:id', authenticate, calendarController.deleteEvent); + +export default router; diff --git a/backend/services/calendarService.js b/backend/services/calendarService.js new file mode 100644 index 0000000..3ad15b1 --- /dev/null +++ b/backend/services/calendarService.js @@ -0,0 +1,169 @@ +import CalendarEvent from '../models/community/calendar_event.js'; +import User from '../models/community/user.js'; +import { Op } from 'sequelize'; + +class CalendarService { + /** + * Get all calendar events for a user + * @param {string} hashedUserId - The user's hashed ID + * @param {object} options - Optional filters (startDate, endDate) + */ + async getEvents(hashedUserId, options = {}) { + const user = await User.findOne({ where: { hashedId: hashedUserId } }); + if (!user) { + throw new Error('User not found'); + } + + const where = { userId: user.id }; + + // Filter by date range if provided + if (options.startDate || options.endDate) { + where[Op.or] = []; + + if (options.startDate && options.endDate) { + // Events that overlap with the requested range + where[Op.or].push({ + startDate: { [Op.between]: [options.startDate, options.endDate] } + }); + where[Op.or].push({ + endDate: { [Op.between]: [options.startDate, options.endDate] } + }); + where[Op.or].push({ + [Op.and]: [ + { startDate: { [Op.lte]: options.startDate } }, + { endDate: { [Op.gte]: options.endDate } } + ] + }); + } else if (options.startDate) { + where[Op.or].push({ startDate: { [Op.gte]: options.startDate } }); + where[Op.or].push({ endDate: { [Op.gte]: options.startDate } }); + } else if (options.endDate) { + where[Op.or].push({ startDate: { [Op.lte]: options.endDate } }); + } + } + + const events = await CalendarEvent.findAll({ + where, + order: [['startDate', 'ASC'], ['startTime', 'ASC']] + }); + + return events.map(e => this.formatEvent(e)); + } + + /** + * Get a single event by ID + */ + async getEvent(hashedUserId, eventId) { + const user = await User.findOne({ where: { hashedId: hashedUserId } }); + if (!user) { + throw new Error('User not found'); + } + + const event = await CalendarEvent.findOne({ + where: { id: eventId, userId: user.id } + }); + + if (!event) { + throw new Error('Event not found'); + } + + return this.formatEvent(event); + } + + /** + * Create a new calendar event + */ + async createEvent(hashedUserId, eventData) { + const user = await User.findOne({ where: { hashedId: hashedUserId } }); + if (!user) { + throw new Error('User not found'); + } + + const event = await CalendarEvent.create({ + userId: user.id, + title: eventData.title, + description: eventData.description || null, + categoryId: eventData.categoryId || 'personal', + startDate: eventData.startDate, + endDate: eventData.endDate || eventData.startDate, + startTime: eventData.allDay ? null : eventData.startTime, + endTime: eventData.allDay ? null : eventData.endTime, + allDay: eventData.allDay || false + }); + + return this.formatEvent(event); + } + + /** + * Update an existing calendar event + */ + async updateEvent(hashedUserId, eventId, eventData) { + const user = await User.findOne({ where: { hashedId: hashedUserId } }); + if (!user) { + throw new Error('User not found'); + } + + const event = await CalendarEvent.findOne({ + where: { id: eventId, userId: user.id } + }); + + if (!event) { + throw new Error('Event not found'); + } + + await event.update({ + title: eventData.title, + description: eventData.description || null, + categoryId: eventData.categoryId || 'personal', + startDate: eventData.startDate, + endDate: eventData.endDate || eventData.startDate, + startTime: eventData.allDay ? null : eventData.startTime, + endTime: eventData.allDay ? null : eventData.endTime, + allDay: eventData.allDay || false + }); + + return this.formatEvent(event); + } + + /** + * Delete a calendar event + */ + async deleteEvent(hashedUserId, eventId) { + const user = await User.findOne({ where: { hashedId: hashedUserId } }); + if (!user) { + throw new Error('User not found'); + } + + const event = await CalendarEvent.findOne({ + where: { id: eventId, userId: user.id } + }); + + if (!event) { + throw new Error('Event not found'); + } + + await event.destroy(); + return { success: true }; + } + + /** + * Format event for API response + */ + formatEvent(event) { + return { + id: event.id, + title: event.title, + description: event.description, + categoryId: event.categoryId, + startDate: event.startDate, + endDate: event.endDate, + startTime: event.startTime ? event.startTime.substring(0, 5) : null, // HH:MM format + endTime: event.endTime ? event.endTime.substring(0, 5) : null, + allDay: event.allDay, + createdAt: event.createdAt, + updatedAt: event.updatedAt + }; + } +} + +export default new CalendarService(); diff --git a/frontend/src/i18n/locales/de/personal.json b/frontend/src/i18n/locales/de/personal.json index 7d6bec9..108ee2a 100644 --- a/frontend/src/i18n/locales/de/personal.json +++ b/frontend/src/i18n/locales/de/personal.json @@ -69,7 +69,9 @@ "descriptionPlaceholder": "Optionale Beschreibung...", "save": "Speichern", "cancel": "Abbrechen", - "delete": "Löschen" + "delete": "Löschen", + "saveError": "Fehler beim Speichern des Termins", + "deleteError": "Fehler beim Löschen des Termins" } } } diff --git a/frontend/src/i18n/locales/en/personal.json b/frontend/src/i18n/locales/en/personal.json index e8451c1..e5a34b4 100644 --- a/frontend/src/i18n/locales/en/personal.json +++ b/frontend/src/i18n/locales/en/personal.json @@ -69,7 +69,9 @@ "descriptionPlaceholder": "Optional description...", "save": "Save", "cancel": "Cancel", - "delete": "Delete" + "delete": "Delete", + "saveError": "Error saving event", + "deleteError": "Error deleting event" } } } diff --git a/frontend/src/views/personal/CalendarView.vue b/frontend/src/views/personal/CalendarView.vue index f55ffb5..1f4f274 100644 --- a/frontend/src/views/personal/CalendarView.vue +++ b/frontend/src/views/personal/CalendarView.vue @@ -270,14 +270,14 @@ @@ -287,6 +287,8 @@