Add friends' birthdays feature: Implement API endpoint to retrieve friends' birthdays for a specified year, enhance calendar service to handle visibility checks, and update CalendarView to display birthday events with distinct styling. This update improves user experience by allowing users to view important dates of their friends.
This commit is contained in:
@@ -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' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -64,11 +64,12 @@
|
||||
<div
|
||||
v-for="event in getEventsForDate(day.date)"
|
||||
:key="event.id"
|
||||
class="event-item"
|
||||
:class="['event-item', { 'birthday-event': event.isBirthday }]"
|
||||
:style="{ backgroundColor: getCategoryColor(event.categoryId) }"
|
||||
:title="event.title"
|
||||
@click.stop="editEvent(event)"
|
||||
>
|
||||
<span v-if="event.isBirthday" class="birthday-icon">🎂</span>
|
||||
{{ event.title }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -95,10 +96,11 @@
|
||||
<div
|
||||
v-for="event in getEventsForDateAllDay(day.date)"
|
||||
:key="event.id"
|
||||
class="all-day-event"
|
||||
:class="['all-day-event', { 'birthday-event': event.isBirthday }]"
|
||||
:style="{ backgroundColor: getCategoryColor(event.categoryId) }"
|
||||
@click="editEvent(event)"
|
||||
>
|
||||
<span v-if="event.isBirthday" class="birthday-icon">🎂</span>
|
||||
{{ event.title }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -152,10 +154,11 @@
|
||||
<div
|
||||
v-for="event in getEventsForDateAllDay(day.date)"
|
||||
:key="event.id"
|
||||
class="all-day-event"
|
||||
:class="['all-day-event', { 'birthday-event': event.isBirthday }]"
|
||||
:style="{ backgroundColor: getCategoryColor(event.categoryId) }"
|
||||
@click="editEvent(event)"
|
||||
>
|
||||
<span v-if="event.isBirthday" class="birthday-icon">🎂</span>
|
||||
{{ event.title }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -205,10 +208,11 @@
|
||||
<div
|
||||
v-for="event in getEventsForDateAllDay(currentDateStr)"
|
||||
:key="event.id"
|
||||
class="all-day-event"
|
||||
:class="['all-day-event', { 'birthday-event': event.isBirthday }]"
|
||||
:style="{ backgroundColor: getCategoryColor(event.categoryId) }"
|
||||
@click="editEvent(event)"
|
||||
>
|
||||
<span v-if="event.isBirthday" class="birthday-icon">🎂</span>
|
||||
{{ event.title }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user