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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user