feat: Enhance RSVP handling and real-time updates

- Added rsvpStatus and rsvpCompletedAt fields to RSVP data for better tracking.
- Implemented real-time event emission for RSVP updates to sync admin dashboard.
- Improved error handling during RSVP submissions and updates to ensure reliability.
- Updated guest list refresh logic in the admin dashboard to include RSVP status synchronization.
This commit is contained in:
2026-01-27 00:27:42 +02:00
parent 83b7e1730a
commit 0e0f8433fc
5 changed files with 169 additions and 82 deletions

View File

@@ -71,11 +71,15 @@ export async function POST(request: NextRequest) {
// Build update data
const updateData: {
isAttending: boolean;
rsvpStatus: 'ACCEPTED' | 'DECLINED';
rsvpCompletedAt?: Date;
dietaryRestrictions?: string | null;
notes?: string | null;
rsvpTimestamp?: Date;
} = {
isAttending: attending,
rsvpStatus: attending ? 'ACCEPTED' : 'DECLINED',
rsvpCompletedAt: new Date(),
rsvpTimestamp: new Date(),
};
@@ -119,6 +123,26 @@ export async function POST(request: NextRequest) {
invalidateCacheByTag(CACHE_TAGS.RSVP),
]);
// Emit real-time event for admin dashboard updates
try {
const { RealtimeManager } = await import('@/lib/realtime');
await RealtimeManager.sendEvent({
type: 'rsvp',
action: 'update',
data: {
guestId: updatedGuest.id,
guestName: updatedGuest.name,
isAttending: attending,
rsvpStatus: attending ? 'ACCEPTED' : 'DECLINED',
rsvpTimestamp: updatedGuest.rsvpTimestamp?.toISOString(),
},
timestamp: new Date().toISOString(),
});
} catch (error) {
// Don't fail the RSVP if real-time event fails
console.error('Failed to emit real-time RSVP event:', error);
}
logger.info('Admin submitted RSVP on behalf of guest', {
requestId,
adminId: session.adminId,

View File

@@ -532,7 +532,10 @@ export async function POST(request: NextRequest) {
updatedGuest = await prisma.guest.update({
where: { id: guest.id },
data: {
// Update both legacy and new RSVP status fields
isAttending: attending,
rsvpStatus: attending ? 'ACCEPTED' : 'DECLINED',
rsvpCompletedAt: new Date(), // Mark RSVP as completed
dietaryRestrictions: sanitizedDietary,
notes: sanitizedNotes || guest.notes,
rsvpTimestamp: new Date(),
@@ -560,19 +563,39 @@ export async function POST(request: NextRequest) {
});
} catch (error) {
// Fallback: update only legacy/core columns that exist in older schemas.
updatedGuest = await prisma.guest.update({
where: { id: guest.id },
data: {
isAttending: attending,
rsvpTimestamp: new Date(),
confirmationCode,
dietaryRestrictions: sanitizedDietary,
notes: sanitizedNotes || null,
phone: phone || guest.phone || null,
email: email || guest.email || null,
},
select: updateSelect,
});
// Try to include rsvpStatus if possible, but don't fail if it doesn't exist
try {
updatedGuest = await prisma.guest.update({
where: { id: guest.id },
data: {
isAttending: attending,
rsvpStatus: attending ? 'ACCEPTED' : 'DECLINED',
rsvpCompletedAt: new Date(),
rsvpTimestamp: new Date(),
confirmationCode,
dietaryRestrictions: sanitizedDietary,
notes: sanitizedNotes || null,
phone: phone || guest.phone || null,
email: email || guest.email || null,
},
select: updateSelect,
});
} catch (fallbackError) {
// If rsvpStatus field doesn't exist, fall back to legacy-only update
updatedGuest = await prisma.guest.update({
where: { id: guest.id },
data: {
isAttending: attending,
rsvpTimestamp: new Date(),
confirmationCode,
dietaryRestrictions: sanitizedDietary,
notes: sanitizedNotes || null,
phone: phone || guest.phone || null,
email: email || guest.email || null,
},
select: updateSelect,
});
}
// Keep original error in logs, but don't fail the RSVP.
logger.warn("RSVP submit: fell back to legacy guest update (schema drift)", {
requestId,
@@ -798,6 +821,7 @@ export async function POST(request: NextRequest) {
guestId: updatedGuest.id,
guestName: updatedGuest.name,
isAttending: attending,
rsvpStatus: attending ? 'ACCEPTED' : 'DECLINED', // Include rsvpStatus for proper syncing
rsvpTimestamp: updatedGuest.rsvpTimestamp?.toISOString(),
dietaryRestrictions: sanitizedDietary,
socialOptIn: socialOptIn || false,

View File

@@ -145,7 +145,27 @@ export async function PATCH(request: NextRequest) {
// Invalidate specific guest cache if possible
]);
// 6. Audit Log
// 6. Emit real-time event for admin dashboard updates
try {
const { RealtimeManager } = await import('@/lib/realtime');
await RealtimeManager.sendEvent({
type: 'rsvp',
action: 'update',
data: {
guestId: updatedGuest.id,
guestName: updatedGuest.name || guest.name,
isAttending: updateData.isAttending ?? updatedGuest.isAttending,
rsvpStatus: updateData.rsvpStatus || updatedGuest.rsvpStatus || 'PENDING',
rsvpTimestamp: updatedGuest.rsvpTimestamp?.toISOString(),
},
timestamp: new Date().toISOString(),
});
} catch (error) {
// Don't fail the RSVP update if real-time event fails
console.error('Failed to emit real-time RSVP update event:', error);
}
// 7. Audit Log
audit.dataAccessed('guest', 'rsvp', 'update', guest.id);
return NextResponse.json({

View File

@@ -1521,6 +1521,7 @@ export default function WeddingInfoHub() {
const navSections = [
{ id: "home", label: "Home", icon: Heart },
{ id: "gifts", label: "Gifts", icon: Gift },
{ id: "story", label: "Story", icon: BookOpen },
// Act II - The Plan
{ id: "itinerary", label: "Schedule", icon: Clock },
@@ -1536,7 +1537,6 @@ export default function WeddingInfoHub() {
{ id: "guestbook", label: "Guestbook", icon: MessageCircle },
{ id: "tribute", label: "Tributes", icon: Flame },
// Act VI - Extras
{ id: "gifts", label: "Gifts", icon: Gift },
{ id: "faq", label: "FAQ", icon: HelpCircle },
] as const;
@@ -1742,10 +1742,10 @@ export default function WeddingInfoHub() {
{/* Explore Scroll Hint */}
<motion.button
onClick={() => {
const storySection = document.getElementById("story");
if (storySection) {
const giftsSection = document.getElementById("gifts");
if (giftsSection) {
const offset = 80; // Account for floating nav
const elementTop = storySection.getBoundingClientRect().top + window.pageYOffset;
const elementTop = giftsSection.getBoundingClientRect().top + window.pageYOffset;
const offsetPosition = elementTop - offset;
window.scrollTo({
top: Math.max(0, offsetPosition),
@@ -1778,6 +1778,45 @@ export default function WeddingInfoHub() {
}}
/>
{/* GIFTS with narrative - Moved before story for easy access */}
<Section id="gifts" className="py-20">
<SectionTitle
icon={Gift}
title="Wishing Well"
subtitle="Your presence is the greatest gift"
/>
{/* Gifts narrative */}
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.8 }}
className="mb-10 text-center"
>
<p className="font-heading italic text-2xl md:text-3xl text-wedding-evergreen max-w-lg mx-auto leading-relaxed mb-6">
"Your presence at our celebration is the greatest gift we could ask for.
As we begin our life together, a contribution to our wishing well would
help us build our dreams."
</p>
<p className="text-sm text-wedding-ink/60 max-w-2xl mx-auto">
For friends and family who may not be able to attend, we've made our gift registries
and wishing well details available below. Your love and support mean the world to us.
</p>
</motion.div>
{/* Wishing Well Component - Unlocked for everyone */}
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.8, delay: 0.2 }}
className="max-w-2xl mx-auto"
>
<WishingWell />
</motion.div>
</Section>
{/* STORY with enhanced narrative storytelling */}
<Section id="story" className="py-20">
<SectionTitle
@@ -3320,45 +3359,6 @@ export default function WeddingInfoHub() {
subtitle="Additional resources and information"
/>
{/* GIFTS with narrative */}
<Section id="gifts" className="py-16">
<SectionTitle
icon={Gift}
title="Wishing Well"
subtitle="Your presence is the greatest gift"
/>
{/* Gifts narrative */}
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.8 }}
className="mb-10 text-center"
>
<p className="font-heading italic text-2xl md:text-3xl text-wedding-evergreen max-w-lg mx-auto leading-relaxed mb-6">
"Your presence at our celebration is the greatest gift we could ask for.
As we begin our life together, a contribution to our wishing well would
help us build our dreams."
</p>
<p className="text-sm text-wedding-ink/60 max-w-2xl mx-auto">
For friends and family who may not be able to attend, we've made our gift registries
and wishing well details available below. Your love and support mean the world to us.
</p>
</motion.div>
{/* Wishing Well Component - Unlocked for everyone */}
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.8, delay: 0.2 }}
className="max-w-2xl mx-auto"
>
<WishingWell />
</motion.div>
</Section>
{/* FAQ - Premium Enhanced */}
<Section id="faq" className="py-16">
<SectionTitle icon={HelpCircle} title="Frequently Asked Questions" subtitle="Everything you need to know" />

View File

@@ -678,32 +678,51 @@ export function AdminDashboard({
useAdminRealtime({
onRSVPUpdate: (event) => {
if (event.action === "create" || event.action === "update") {
// Refresh RSVP data with error handling
fetch('/api/admin/rsvp', { credentials: 'include' })
.then(async res => {
if (!res.ok) {
if (res.status === 401) {
console.warn('[AdminDashboard] Unauthorized for RSVP fetch');
return;
// Refresh both RSVP data and guest list to sync status
Promise.all([
// Refresh RSVP data
fetch('/api/admin/rsvp', { credentials: 'include' })
.then(async res => {
if (!res.ok) {
if (res.status === 401) {
console.warn('[AdminDashboard] Unauthorized for RSVP fetch');
return null;
}
console.warn(`[AdminDashboard] RSVP fetch returned ${res.status}`);
return null;
}
console.warn(`[AdminDashboard] RSVP fetch returned ${res.status}`);
return;
}
return res.json();
})
.then(data => {
if (data && data.success) {
setRsvpData(data);
toast({
title: "New RSVP",
description: "RSVP data has been updated",
variant: "success",
});
}
})
.catch(error => {
console.warn('[AdminDashboard] Failed to refresh RSVP data:', error);
});
return res.json();
})
.then(data => {
if (data && data.success) {
setRsvpData(data);
}
})
.catch(error => {
console.warn('[AdminDashboard] Failed to refresh RSVP data:', error);
}),
// Refresh guest list to sync RSVP status - CRITICAL for Master Guest List sync
refreshGuestList(),
]).then(() => {
// Only show toast for create actions to avoid spam on updates
if (event.action === "create") {
toast({
title: "New RSVP",
description: "Guest list and RSVP data have been refreshed",
variant: "success",
});
}
}).catch(error => {
console.warn('[AdminDashboard] Failed to refresh data after RSVP update:', error);
});
}
},
onGuestUpdate: (event) => {
// Also refresh guest list when guest data is updated directly
if (event.action === "update") {
refreshGuestList().catch(error => {
console.warn('[AdminDashboard] Failed to refresh guest list after guest update:', error);
});
}
},
onCheckinUpdate: (event) => {