From f8722773d9bc0b689bc590439478218458eb4095 Mon Sep 17 00:00:00 2001 From: julianromli Date: Fri, 1 May 2026 16:39:28 +0700 Subject: [PATCH] feat(events): high priority fixes - Lock detail page to approved events only (getEventBySlug filters approved=true) - Connect sort control (nearest/latest) to client-side filtering via createdAt - Handle slug collisions with auto-suffix unique strategy (retry on 23505) - Compute event status (past/ongoing/upcoming) at read time based on date --- app/event/list/event-list-client.tsx | 3 +- docs/events-feature/next-steps.md | 41 ++++++++++++++++++++++++ lib/actions/events.ts | 47 ++++++++++++++++++++++++---- lib/events-utils.ts | 9 ++++++ lib/server/events-public.ts | 18 +++++++++-- types/events.ts | 1 + 6 files changed, 109 insertions(+), 10 deletions(-) create mode 100644 docs/events-feature/next-steps.md diff --git a/app/event/list/event-list-client.tsx b/app/event/list/event-list-client.tsx index e6b24b0..778c604 100644 --- a/app/event/list/event-list-client.tsx +++ b/app/event/list/event-list-client.tsx @@ -67,10 +67,11 @@ export default function EventListClient({ initialEvents }: EventListClientProps) persistViewMode(mode) } - // Apply filters and sort to mock data + // Apply filters and sort to server-fetched data const filteredEvents = applyFilters(initialEvents, { category: selectedCategory, locationType: selectedLocation, + sort: selectedSort, }) return ( diff --git a/docs/events-feature/next-steps.md b/docs/events-feature/next-steps.md new file mode 100644 index 0000000..d916de9 --- /dev/null +++ b/docs/events-feature/next-steps.md @@ -0,0 +1,41 @@ +# Events feature — next steps + +Prioritas: **High**, **Medium**, **Low**. Ringkasan dari review fitur AI Events (listing, detail, submit, approval admin). + +--- + +## High + +1. **Kunci halaman detail ke konten yang sudah disetujui** — Tambahkan filter `approved = true` pada `getEventBySlug` (dan pertimbangkan juga untuk related events / metadata OG), supaya event pending tidak bisa diakses lewat URL langsung. + +2. **Sambungkan kontrol “Urutkan” (Terdekat / Terbaru) ke data** — Saat ini `selectedSort` tidak memengaruhi hasil; samakan perilaku dengan `getEvents` (mis. `latest` = `created_at` desc) atau tambahkan helper sort di client yang konsisten dengan server. + +3. **Tangani tabrakan `slug` dan error Supabase yang jelas** — Insert bisa gagal jika slug duplikat; tangkap error unik, beri pesan UI (“nama sudah dipakai, ubah nama”), atau gunakan strategi slug unik (suffix). + +4. **`status` event selaras dengan waktu** — Job ringan/cron atau computed saat read: set `past` / `upcoming` berdasarkan tanggal agar badge dan CTA konsisten tanpa edit manual. + +--- + +## Medium + +5. **Rapikan `useEventForm`** — Hapus atau ganti “Phase 1 mock” di `handleSubmit` agar tidak menyesatkan maintainer; satu jalur submit lewat server action saja, atau panggil `submitEvent` dari hook dengan kontrak yang jelas. + +6. **UX kartu & CTA** — Pisahkan “Detail” vs “Daftar”; atau di grid arahkan tombol utama ke `registrationUrl` (dengan `rel`/security) dan sediakan link sekunder ke detail — kurangi klik yang tidak perlu jika niatnya registrasi. + +7. **Form lengkap vs skema DB** — Kolom `end_date` / `end_time` ada di DB dan detail sudah memakai `formatEventDateRange`, tapi form submit belum; tambahkan field opsional untuk event multi-hari. + +8. **Notifikasi & transparansi untuk submitter** — Email atau in-app (mis. “sedang ditinjau” / “disetujui”) dan halaman “event saya” read-only supaya pengguna tidak harus menebak setelah submit. + +--- + +## Low + +9. **Uji otomatis & konsistensi RLS** — Playwright untuk alur list → detail → submit → approve; plus tes bahwa policy Supabase selaras dengan asumsi app (anon tidak baca pending, dll.). + +10. **`lib/events-utils` mock vs nyata** — Deprecate atau hapus `getEventBySlug` / `getRelatedEvents` dari mock jika tidak dipakai; dokumentasikan satu sumber kebenaran (Supabase) untuk menghindari regressi dokumentasi (mis. file `.kiro` yang masih mengacu mock). + +--- + +## Urutan kerja yang disarankan + +Mulai dari **(1) + (2) + (3)** untuk dampak besar dengan risiko relatif terkendali. diff --git a/lib/actions/events.ts b/lib/actions/events.ts index 76b3443..3986df8 100644 --- a/lib/actions/events.ts +++ b/lib/actions/events.ts @@ -5,7 +5,18 @@ import { ROLES } from '@/lib/actions/admin/schemas' import { validateEventForm } from '@/lib/event-form-utils' import { createAdminClient } from '@/lib/supabase/admin' import { createClient } from '@/lib/supabase/server' -import type { AIEvent, EventCategory, EventFormData, EventLocationType } from '@/types/events' +import type { AIEvent, EventCategory, EventFormData, EventLocationType, EventStatus } from '@/types/events' + +function computeEventStatus(date: string): EventStatus { + const eventDate = new Date(date) + eventDate.setHours(0, 0, 0, 0) + const today = new Date() + today.setHours(0, 0, 0, 0) + + if (eventDate < today) return 'past' + if (eventDate.getTime() === today.getTime()) return 'ongoing' + return 'upcoming' +} // Helper to map DB result (snake_case) to AIEvent (camelCase) function mapEventFromDB(data: any): AIEvent { @@ -24,7 +35,8 @@ function mapEventFromDB(data: any): AIEvent { registrationUrl: data.registration_url, coverImage: data.cover_image, category: data.category as EventCategory, - status: data.status, + status: computeEventStatus(data.date), + createdAt: data.created_at, } } @@ -100,7 +112,12 @@ export async function getEventBySlug(slug: string) { const supabase = await createClient() - const { data, error } = await supabase.from('events').select('*').eq('slug', sanitizedSlug).single() + const { data, error } = await supabase + .from('events') + .select('*') + .eq('slug', sanitizedSlug) + .eq('approved', true) + .single() if (error) { console.error('Error fetching event by slug:', error) @@ -168,10 +185,28 @@ export async function submitEvent(formData: EventFormData) { submitted_by: user.id, // Use authenticated user ID } - const { error } = await supabase.from('events').insert(dbData) + // Insert with unique slug handling (auto-suffix on collision) + let currentSlug = dbData.slug + let insertError: any = null + const maxRetries = 5 - if (error) { - console.error('Error submitting event:', error) + for (let i = 0; i < maxRetries; i++) { + const { error } = await supabase.from('events').insert({ ...dbData, slug: currentSlug }) + if (!error) { + insertError = null + break + } + insertError = error + // 23505 = unique violation (assume slug collision) + if (error.code === '23505') { + currentSlug = `${dbData.slug}-${i + 2}` + continue + } + break + } + + if (insertError) { + console.error('Error submitting event:', insertError) return { success: false, error: 'Failed to submit event' } } diff --git a/lib/events-utils.ts b/lib/events-utils.ts index b344c23..40df66c 100644 --- a/lib/events-utils.ts +++ b/lib/events-utils.ts @@ -132,6 +132,7 @@ export interface EventFilters { locationType?: EventLocationType | 'All' startDate?: string endDate?: string + sort?: 'nearest' | 'latest' } export function applyFilters(events: AIEvent[], filters: EventFilters): AIEvent[] { @@ -149,5 +150,13 @@ export function applyFilters(events: AIEvent[], filters: EventFilters): AIEvent[ filtered = filterByDateRange(filtered, filters.startDate, filters.endDate) } + if (filters.sort === 'latest') { + return [...filtered].sort((a, b) => { + const dateA = a.createdAt ? new Date(a.createdAt).getTime() : 0 + const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0 + return dateB - dateA + }) + } + return sortByNearestDate(filtered) } diff --git a/lib/server/events-public.ts b/lib/server/events-public.ts index 1864e59..67dd180 100644 --- a/lib/server/events-public.ts +++ b/lib/server/events-public.ts @@ -1,7 +1,18 @@ import { createClient } from '@supabase/supabase-js' import { unstable_cache } from 'next/cache' import { getSupabaseConfig } from '@/lib/env-config' -import type { AIEvent, EventCategory, EventLocationType } from '@/types/events' +import type { AIEvent, EventCategory, EventLocationType, EventStatus } from '@/types/events' + +function computeEventStatus(date: string): EventStatus { + const eventDate = new Date(date) + eventDate.setHours(0, 0, 0, 0) + const today = new Date() + today.setHours(0, 0, 0, 0) + + if (eventDate < today) return 'past' + if (eventDate.getTime() === today.getTime()) return 'ongoing' + return 'upcoming' +} interface EventRow { id: string @@ -18,7 +29,7 @@ interface EventRow { registration_url: string cover_image: string category: EventCategory - status: AIEvent['status'] + created_at: string | null } function mapEventFromDB(data: EventRow): AIEvent { @@ -37,7 +48,8 @@ function mapEventFromDB(data: EventRow): AIEvent { registrationUrl: data.registration_url, coverImage: data.cover_image, category: data.category, - status: data.status, + status: computeEventStatus(data.date), + createdAt: data.created_at ?? undefined, } } diff --git a/types/events.ts b/types/events.ts index 8e294fb..32c1ab5 100644 --- a/types/events.ts +++ b/types/events.ts @@ -18,6 +18,7 @@ export interface AIEvent { coverImage: string category: EventCategory status: EventStatus + createdAt?: string } export interface EventFormData extends Omit {