feat(calendar): implement local calendar invitations with email notifications
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m19s

- Added functionality to create, accept, decline, and delete local calendar invitations.
- Integrated email notifications for calendar event invitations and deletions.
- Updated database schema to support local invitations and outbound event tracking.
- Enhanced the calendar UI to display pending invitations and allow users to manage them.
- Localized new strings for invitations in English and German.
This commit is contained in:
2026-03-02 14:37:54 +01:00
parent d989e1291d
commit c2c10f9a4b
10 changed files with 606 additions and 18 deletions

View File

@@ -39,6 +39,7 @@ export default function Calendar() {
const [shareSearch, setShareSearch] = useState('');
const [shareResults, setShareResults] = useState([]);
const [sharedUsers, setSharedUsers] = useState([]);
const [pendingInvitations, setPendingInvitations] = useState([]);
const [fedAddress, setFedAddress] = useState('');
const [fedSending, setFedSending] = useState(false);
@@ -269,9 +270,11 @@ export default function Calendar() {
setShowShare(ev);
setShareSearch('');
setShareResults([]);
setPendingInvitations([]);
try {
const res = await api.get(`/calendar/events/${ev.id}`);
setSharedUsers(res.data.sharedUsers || []);
setPendingInvitations(res.data.pendingInvitations || []);
} catch { /* ignore */ }
};
@@ -281,7 +284,8 @@ export default function Calendar() {
try {
const res = await api.get(`/rooms/users/search?q=${encodeURIComponent(query)}`);
const sharedIds = new Set(sharedUsers.map(u => u.id));
setShareResults(res.data.users.filter(u => !sharedIds.has(u.id)));
const pendingIds = new Set(pendingInvitations.map(u => u.user_id));
setShareResults(res.data.users.filter(u => !sharedIds.has(u.id) && !pendingIds.has(u.id)));
} catch { setShareResults([]); }
};
@@ -290,9 +294,10 @@ export default function Calendar() {
try {
const res = await api.post(`/calendar/events/${showShare.id}/share`, { user_id: userId });
setSharedUsers(res.data.sharedUsers);
setPendingInvitations(res.data.pendingInvitations || []);
setShareSearch('');
setShareResults([]);
toast.success(t('calendar.shareAdded'));
toast.success(t('calendar.invitationSent'));
} catch (err) {
toast.error(err.response?.data?.error || t('calendar.shareFailed'));
}
@@ -303,10 +308,21 @@ export default function Calendar() {
try {
const res = await api.delete(`/calendar/events/${showShare.id}/share/${userId}`);
setSharedUsers(res.data.sharedUsers);
setPendingInvitations(res.data.pendingInvitations || []);
toast.success(t('calendar.shareRemoved'));
} catch { toast.error(t('calendar.shareFailed')); }
};
const handleCancelInvitation = async (userId) => {
if (!showShare) return;
try {
const res = await api.delete(`/calendar/events/${showShare.id}/share/${userId}`);
setSharedUsers(res.data.sharedUsers);
setPendingInvitations(res.data.pendingInvitations || []);
toast.success(t('calendar.invitationCancelled'));
} catch { toast.error(t('calendar.shareFailed')); }
};
const handleFedSend = async (e) => {
e.preventDefault();
if (!showFedShare) return;
@@ -721,8 +737,36 @@ export default function Calendar() {
)}
</div>
{pendingInvitations.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-semibold text-th-text-s uppercase tracking-wider">{t('calendar.pendingInvitations')}</p>
{pendingInvitations.map(u => (
<div key={u.user_id} className="flex items-center justify-between gap-3 p-3 bg-th-bg-s rounded-lg border border-th-border border-dashed">
<div className="flex items-center gap-3 min-w-0">
<div
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold flex-shrink-0"
style={{ backgroundColor: u.avatar_color || '#6366f1' }}
>
{(u.display_name || u.name).split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}
</div>
<div className="min-w-0">
<div className="text-sm font-medium text-th-text truncate">{u.display_name || u.name}</div>
<div className="text-xs text-th-warning">{t('calendar.invitationPending')}</div>
</div>
</div>
<button onClick={() => handleCancelInvitation(u.user_id)} className="p-1.5 rounded-lg hover:bg-th-hover text-th-text-s hover:text-th-error transition-colors">
<X size={16} />
</button>
</div>
))}
</div>
)}
{sharedUsers.length > 0 && (
<div className="space-y-2">
{pendingInvitations.length > 0 && (
<p className="text-xs font-semibold text-th-text-s uppercase tracking-wider">{t('calendar.accepted')}</p>
)}
{sharedUsers.map(u => (
<div key={u.id} className="flex items-center justify-between gap-3 p-3 bg-th-bg-s rounded-lg border border-th-border">
<div className="flex items-center gap-3 min-w-0">