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 @@