diff --git a/backend/controllers/calendarController.js b/backend/controllers/calendarController.js index 3baca85..f9c7abd 100644 --- a/backend/controllers/calendarController.js +++ b/backend/controllers/calendarController.js @@ -119,5 +119,26 @@ export default { } res.status(500).json({ error: error.message || 'Internal server error' }); } + }, + + /** + * GET /api/calendar/birthdays + * Get friends' birthdays for a given year + * Query params: year (required) + */ + async getFriendsBirthdays(req, res) { + const hashedUserId = getHashedUserId(req); + if (!hashedUserId) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + try { + const year = parseInt(req.query.year) || new Date().getFullYear(); + const birthdays = await calendarService.getFriendsBirthdays(hashedUserId, year); + res.json(birthdays); + } catch (error) { + console.error('Calendar getFriendsBirthdays:', error); + res.status(500).json({ error: error.message || 'Internal server error' }); + } } }; diff --git a/backend/routers/calendarRouter.js b/backend/routers/calendarRouter.js index 15a6f12..7df3853 100644 --- a/backend/routers/calendarRouter.js +++ b/backend/routers/calendarRouter.js @@ -10,5 +10,6 @@ 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); +router.get('/birthdays', authenticate, calendarController.getFriendsBirthdays); export default router; diff --git a/backend/services/calendarService.js b/backend/services/calendarService.js index 3ad15b1..e7954df 100644 --- a/backend/services/calendarService.js +++ b/backend/services/calendarService.js @@ -1,5 +1,10 @@ import CalendarEvent from '../models/community/calendar_event.js'; import User from '../models/community/user.js'; +import Friendship from '../models/community/friendship.js'; +import UserParam from '../models/community/user_param.js'; +import UserParamType from '../models/type/user_param.js'; +import UserParamVisibility from '../models/community/user_param_visibility.js'; +import UserParamVisibilityType from '../models/type/user_param_visibility.js'; import { Op } from 'sequelize'; class CalendarService { @@ -146,6 +151,129 @@ class CalendarService { return { success: true }; } + /** + * Get friends' birthdays that are visible to the user + * @param {string} hashedUserId - The user's hashed ID + * @param {number} year - The year to get birthdays for + */ + async getFriendsBirthdays(hashedUserId, year) { + const user = await User.findOne({ where: { hashedId: hashedUserId } }); + if (!user) { + throw new Error('User not found'); + } + + // Get user's age for visibility check + const userAge = await this.getUserAge(user.id); + + // Get all accepted friendships + const friendships = await Friendship.findAll({ + where: { + accepted: true, + withdrawn: false, + denied: false, + [Op.or]: [ + { user1Id: user.id }, + { user2Id: user.id } + ] + } + }); + + const birthdays = []; + + for (const friendship of friendships) { + // Get the friend's user ID + const friendId = friendship.user1Id === user.id ? friendship.user2Id : friendship.user1Id; + + // Get the friend's birthdate param with visibility + const birthdateParam = await UserParam.findOne({ + where: { userId: friendId }, + include: [ + { + model: UserParamType, + as: 'paramType', + where: { description: 'birthdate' } + }, + { + model: UserParamVisibility, + as: 'param_visibilities', + include: [{ + model: UserParamVisibilityType, + as: 'visibility_type' + }] + } + ] + }); + + if (!birthdateParam || !birthdateParam.value) continue; + + // Check visibility + const visibility = birthdateParam.param_visibilities?.[0]?.visibility_type?.description || 'Invisible'; + if (!this.isBirthdayVisibleToFriend(visibility, userAge)) continue; + + // Get friend's username + const friend = await User.findOne({ + where: { id: friendId }, + attributes: ['username', 'hashedId'] + }); + + if (!friend) continue; + + // Parse birthdate and create birthday event for the requested year + const birthdate = new Date(birthdateParam.value); + if (isNaN(birthdate.getTime())) continue; + + const birthdayDate = `${year}-${String(birthdate.getMonth() + 1).padStart(2, '0')}-${String(birthdate.getDate()).padStart(2, '0')}`; + + birthdays.push({ + id: `birthday-${friend.hashedId}-${year}`, + title: friend.username, + categoryId: 'birthday', + startDate: birthdayDate, + endDate: birthdayDate, + allDay: true, + isBirthday: true, + friendHashedId: friend.hashedId + }); + } + + return birthdays; + } + + /** + * Check if birthdate is visible to a friend + */ + isBirthdayVisibleToFriend(visibility, requestingUserAge) { + // Visible to friends if visibility is 'All', 'Friends', or 'FriendsAndAdults' (if adult) + return visibility === 'All' || + visibility === 'Friends' || + (visibility === 'FriendsAndAdults' && requestingUserAge >= 18); + } + + /** + * Get user's age from birthdate + */ + async getUserAge(userId) { + const birthdateParam = await UserParam.findOne({ + where: { userId }, + include: [{ + model: UserParamType, + as: 'paramType', + where: { description: 'birthdate' } + }] + }); + + if (!birthdateParam || !birthdateParam.value) return 0; + + const birthdate = new Date(birthdateParam.value); + const today = new Date(); + let age = today.getFullYear() - birthdate.getFullYear(); + const monthDiff = today.getMonth() - birthdate.getMonth(); + if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthdate.getDate())) { + age--; + } + return age; + } + /** * Format event for API response */ diff --git a/frontend/src/views/personal/CalendarView.vue b/frontend/src/views/personal/CalendarView.vue index 25261ad..3e40742 100644 --- a/frontend/src/views/personal/CalendarView.vue +++ b/frontend/src/views/personal/CalendarView.vue @@ -64,11 +64,12 @@
+ 🎂 {{ event.title }}
@@ -95,10 +96,11 @@
+ 🎂 {{ event.title }}
@@ -152,10 +154,11 @@
+ 🎂 {{ event.title }}
@@ -205,10 +208,11 @@
+ 🎂 {{ event.title }}
@@ -368,6 +372,7 @@ export default { // Events storage events: [], + birthdays: [], loading: false, saving: false, @@ -513,6 +518,7 @@ export default { return `${year}-${month}-${day}`; }, navigatePrev() { + const oldYear = this.currentDate.getFullYear(); const newDate = new Date(this.currentDate); switch (this.currentView) { case 'month': @@ -529,8 +535,13 @@ export default { break; } this.currentDate = newDate; + // Reload birthdays if year changed + if (newDate.getFullYear() !== oldYear) { + this.loadBirthdays(); + } }, navigateNext() { + const oldYear = this.currentDate.getFullYear(); const newDate = new Date(this.currentDate); switch (this.currentView) { case 'month': @@ -547,9 +558,18 @@ export default { break; } this.currentDate = newDate; + // Reload birthdays if year changed + if (newDate.getFullYear() !== oldYear) { + this.loadBirthdays(); + } }, goToToday() { + const oldYear = this.currentDate.getFullYear(); this.currentDate = new Date(); + // Reload birthdays if year changed + if (this.currentDate.getFullYear() !== oldYear) { + this.loadBirthdays(); + } }, formatHour(hour) { return `${hour.toString().padStart(2, '0')}:00`; @@ -638,14 +658,16 @@ export default { return luminance > 0.5 ? '#000000' : '#ffffff'; }, getEventsForDate(dateStr) { - return this.events.filter(event => { + const allEvents = [...this.events, ...this.birthdays]; + return allEvents.filter(event => { const eventStart = event.startDate; const eventEnd = event.endDate || event.startDate; return dateStr >= eventStart && dateStr <= eventEnd; }); }, getEventsForDateAllDay(dateStr) { - return this.events.filter(event => { + const allEvents = [...this.events, ...this.birthdays]; + return allEvents.filter(event => { if (!event.allDay) return false; const eventStart = event.startDate; const eventEnd = event.endDate || event.startDate; @@ -704,6 +726,9 @@ export default { }); }, editEvent(event) { + // Birthday events are read-only + if (event.isBirthday) return; + this.editingEvent = event; this.eventForm = { title: event.title, @@ -784,12 +809,29 @@ export default { try { const response = await apiClient.get('/api/calendar/events'); this.events = response.data; + await this.loadBirthdays(); } catch (error) { console.error('Error loading events:', error); this.events = []; } finally { this.loading = false; } + }, + + async loadBirthdays() { + try { + const year = this.currentDate.getFullYear(); + const response = await apiClient.get(`/api/calendar/birthdays?year=${year}`); + this.birthdays = response.data; + } catch (error) { + console.error('Error loading birthdays:', error); + this.birthdays = []; + } + }, + + // Combined events (regular events + birthdays) + getAllEvents() { + return [...this.events, ...this.birthdays]; } } }; @@ -1047,6 +1089,27 @@ h2 { &:hover { opacity: 0.9; } + + &.birthday-event { + cursor: default; + + &:hover { + opacity: 1; + } + } +} + +.birthday-icon { + margin-right: 4px; +} + +.all-day-event.birthday-event, +.time-event.birthday-event { + cursor: default; + + &:hover { + opacity: 1; + } } // Week & Day View