From e736cf448b1a18fed71908ef716dc6b63f0ce5a2 Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 31 Aug 2026 16:21:47 +0000 Subject: [PATCH 1/3] feat(admin): add targeted announcements --- e2e/admin-announcements.spec.ts | 133 +++++++ src/components/admin/AdminAnnouncements.tsx | 370 ++++++++++++++++++ src/components/layout/AnnouncementBanner.tsx | 62 +++ src/components/layout/AppLayout.tsx | 56 ++- src/lib/announcements.ts | 181 +++++++++ src/pages/AdminPage.tsx | 13 +- .../components/AdminAnnouncements.test.tsx | 77 ++++ src/test/components/AppLayout.test.tsx | 77 ++++ src/test/deno-assert-shim.ts | 10 + src/test/pages/AdminPage.test.tsx | 134 +++++++ supabase/config.toml | 3 + .../functions/_shared/announcements.test.ts | 67 ++++ supabase/functions/_shared/announcements.ts | 99 +++++ .../functions/_shared/native-cors.test.ts | 1 + supabase/functions/admin/index.ts | 110 ++++++ supabase/functions/announcements/index.ts | 105 +++++ .../20260831152911_admin_announcements.sql | 81 ++++ .../tests/admin_announcements_rls_test.sql | 108 +++++ 18 files changed, 1683 insertions(+), 4 deletions(-) create mode 100644 e2e/admin-announcements.spec.ts create mode 100644 src/components/admin/AdminAnnouncements.tsx create mode 100644 src/components/layout/AnnouncementBanner.tsx create mode 100644 src/lib/announcements.ts create mode 100644 src/test/components/AdminAnnouncements.test.tsx create mode 100644 src/test/components/AppLayout.test.tsx create mode 100644 supabase/functions/_shared/announcements.test.ts create mode 100644 supabase/functions/_shared/announcements.ts create mode 100644 supabase/functions/announcements/index.ts create mode 100644 supabase/migrations/20260831152911_admin_announcements.sql create mode 100644 supabase/tests/admin_announcements_rls_test.sql diff --git a/e2e/admin-announcements.spec.ts b/e2e/admin-announcements.spec.ts new file mode 100644 index 000000000..bddd887c4 --- /dev/null +++ b/e2e/admin-announcements.spec.ts @@ -0,0 +1,133 @@ +import { expect, test } from '@playwright/test'; +import { stubSupabaseSession } from './helpers/auth'; + +const ANNOUNCEMENT_ID = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee'; +const TARGET_USER_ID = '11111111-2222-4333-8444-555555555555'; + +test.beforeEach(async ({ page }) => { + await stubSupabaseSession(page, { userId: '99999999-9999-4999-8999-999999999999' }); + await page.addInitScript(() => { + window.localStorage.setItem('debate-trainer-onboarding', JSON.stringify({ + state: { hasCompletedOnboarding: true, isTourRunning: false }, + version: 0, + })); + }); +}); + +test('user sees and permanently dismisses an active announcement', async ({ page }) => { + let dismissBody: Record | null = null; + await page.route('**/functions/v1/announcements*', async (route) => { + const request = route.request(); + const url = new URL(request.url()); + if (request.method() === 'POST' && url.searchParams.get('action') === 'dismiss') { + dismissBody = request.postDataJSON(); + await route.fulfill({ json: { success: true } }); + return; + } + await route.fulfill({ + json: { + announcements: [{ + id: ANNOUNCEMENT_ID, + title: 'Scheduled maintenance', + body: 'DebateLab will be unavailable for ten minutes.', + starts_at: '2026-08-31T12:00:00.000Z', + expires_at: null, + created_at: '2026-08-31T11:00:00.000Z', + }], + }, + }); + }); + + await page.goto('/app'); + await expect(page.getByRole('heading', { name: 'Scheduled maintenance' })).toBeVisible(); + await expect(page.getByText('DebateLab will be unavailable for ten minutes.')).toBeVisible(); + + await page.getByRole('button', { name: 'Dismiss announcement: Scheduled maintenance' }).click(); + + await expect(page.getByRole('heading', { name: 'Scheduled maintenance' })).toHaveCount(0); + expect(dismissBody).toEqual({ announcementId: ANNOUNCEMENT_ID }); +}); + +test('admin creates a targeted announcement and deletes it from history', async ({ page }) => { + let createBody: Record | null = null; + let deleteBody: Record | null = null; + + await page.route('**/functions/v1/announcements*', (route) => route.fulfill({ json: { announcements: [] } })); + await page.route('**/functions/v1/admin*', async (route) => { + const request = route.request(); + const action = new URL(request.url()).searchParams.get('action'); + + if (action === 'stats') { + await route.fulfill({ json: { totals: { requests: 0, cost: 0, successRate: 100 }, daily: [], byFeature: {}, byUser: [] } }); + return; + } + if (action === 'users') { + await route.fulfill({ json: { users: [{ id: TARGET_USER_ID, email: 'target@example.com', createdAt: '2026-08-01T00:00:00.000Z', lastSignIn: '2026-08-31T00:00:00.000Z', requestCount: 2, role: 'user' }] } }); + return; + } + if (action === 'promo-codes') { + await route.fulfill({ json: { codes: [] } }); + return; + } + if (action === 'subscribers') { + await route.fulfill({ json: { subscribers: [] } }); + return; + } + if (action === 'announcements') { + await route.fulfill({ json: { announcements: [] } }); + return; + } + if (action === 'create-announcement') { + createBody = request.postDataJSON(); + await route.fulfill({ + status: 201, + json: { + announcement: { + id: ANNOUNCEMENT_ID, + title: 'Targeted note', + body: 'This message is only for the selected user.', + target_type: 'users', + target_user_ids: [TARGET_USER_ID], + starts_at: '2026-08-31T12:00:00.000Z', + expires_at: null, + created_by: '99999999-9999-4999-8999-999999999999', + created_at: '2026-08-31T12:00:00.000Z', + }, + }, + }); + return; + } + if (action === 'delete-announcement') { + deleteBody = request.postDataJSON(); + await route.fulfill({ json: { success: true } }); + return; + } + await route.fulfill({ status: 400, json: { error: 'Unexpected action' } }); + }); + + await page.goto('/app/admin'); + await expect(page.getByRole('heading', { name: 'Admin Dashboard' })).toBeVisible(); + await page.getByRole('tab', { name: 'Announcements' }).click(); + await expect(page.getByText('No announcements yet')).toBeVisible(); + + await page.getByLabel('Title').fill('Targeted note'); + await page.getByLabel('Message').fill('This message is only for the selected user.'); + await page.getByLabel('Audience').click(); + await page.getByRole('option', { name: 'Selected users' }).click(); + await page.getByRole('checkbox', { name: 'Select target@example.com' }).check(); + await page.getByRole('button', { name: 'Create announcement' }).click(); + + await expect(page.getByRole('heading', { name: 'Targeted note' })).toBeVisible(); + await expect.poll(() => createBody).toMatchObject({ + title: 'Targeted note', + body: 'This message is only for the selected user.', + targetType: 'users', + targetUserIds: [TARGET_USER_ID], + }); + + await page.getByRole('button', { name: 'Delete' }).click(); + await page.getByRole('button', { name: 'Delete announcement' }).click(); + + await expect(page.getByRole('heading', { name: 'Targeted note' })).toHaveCount(0); + await expect.poll(() => deleteBody).toEqual({ announcementId: ANNOUNCEMENT_ID }); +}); diff --git a/src/components/admin/AdminAnnouncements.tsx b/src/components/admin/AdminAnnouncements.tsx new file mode 100644 index 000000000..8487e8185 --- /dev/null +++ b/src/components/admin/AdminAnnouncements.tsx @@ -0,0 +1,370 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Loader2, Megaphone, Search, Trash2 } from 'lucide-react'; +import { + createAnnouncement, + deleteAnnouncement, + listAdminAnnouncements, + type AdminAnnouncement, + type AnnouncementTargetType, +} from '@/lib/announcements'; +import { formatEuropeanDateTime } from '@/lib/date-format'; +import { useToast } from '@/hooks/use-toast'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Textarea } from '@/components/ui/textarea'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; + +export interface AnnouncementTargetUser { + id: string; + email: string; + role: string; +} + +interface AdminAnnouncementsProps { + users: AnnouncementTargetUser[]; +} + +function isTargetType(value: string): value is AnnouncementTargetType { + return value === 'all' || value === 'users'; +} + +function getStatus(announcement: AdminAnnouncement) { + const now = Date.now(); + if (new Date(announcement.startsAt).getTime() > now) return 'Scheduled'; + if (announcement.expiresAt && new Date(announcement.expiresAt).getTime() <= now) return 'Expired'; + return 'Active'; +} + +export function AdminAnnouncements({ users }: AdminAnnouncementsProps) { + const [announcements, setAnnouncements] = useState([]); + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [deleting, setDeleting] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); + const [title, setTitle] = useState(''); + const [body, setBody] = useState(''); + const [targetType, setTargetType] = useState('all'); + const [selectedUserIds, setSelectedUserIds] = useState>(() => new Set()); + const [userSearch, setUserSearch] = useState(''); + const [startsAt, setStartsAt] = useState(''); + const [expiresAt, setExpiresAt] = useState(''); + const { toast } = useToast(); + + useEffect(() => { + const controller = new AbortController(); + const load = async () => { + try { + setAnnouncements(await listAdminAnnouncements(controller.signal)); + } catch (error) { + if (controller.signal.aborted) return; + toast({ + title: 'Could not load announcements', + description: error instanceof Error ? error.message : 'Try again.', + variant: 'destructive', + }); + } finally { + if (!controller.signal.aborted) setLoading(false); + } + }; + void load(); + return () => controller.abort(); + }, [toast]); + + const filteredUsers = useMemo(() => { + const query = userSearch.trim().toLowerCase(); + if (!query) return users; + return users.filter((user) => user.email.toLowerCase().includes(query)); + }, [userSearch, users]); + + const toggleUser = (userId: string, checked: boolean) => { + setSelectedUserIds((current) => { + const next = new Set(current); + if (checked) next.add(userId); + else next.delete(userId); + return next; + }); + }; + + const handleCreate = async () => { + if (!title.trim() || !body.trim()) return; + if (targetType === 'users' && selectedUserIds.size === 0) { + toast({ + title: 'Select at least one user', + description: 'Targeted announcements need a recipient.', + variant: 'destructive', + }); + return; + } + + try { + setSubmitting(true); + const announcement = await createAnnouncement({ + title, + body, + targetType, + targetUserIds: targetType === 'users' ? [...selectedUserIds] : undefined, + startsAt: startsAt || undefined, + expiresAt: expiresAt || undefined, + }); + setAnnouncements((current) => [announcement, ...current]); + setTitle(''); + setBody(''); + setTargetType('all'); + setSelectedUserIds(new Set()); + setStartsAt(''); + setExpiresAt(''); + toast({ title: 'Announcement created', description: 'Users will see it when it becomes active.' }); + } catch (error) { + toast({ + title: 'Could not create announcement', + description: error instanceof Error ? error.message : 'Try again.', + variant: 'destructive', + }); + } finally { + setSubmitting(false); + } + }; + + const handleDelete = async () => { + if (!deleteTarget) return; + try { + setDeleting(true); + await deleteAnnouncement(deleteTarget.id); + setAnnouncements((current) => current.filter((item) => item.id !== deleteTarget.id)); + setDeleteTarget(null); + toast({ title: 'Announcement deleted' }); + } catch (error) { + toast({ + title: 'Could not delete announcement', + description: error instanceof Error ? error.message : 'Try again.', + variant: 'destructive', + }); + } finally { + setDeleting(false); + } + }; + + return ( +
+ + + + + Send a dismissible message to everyone or selected users. + + +
+ + setTitle(event.target.value)} + maxLength={160} + placeholder="Scheduled maintenance" + /> +

{title.length}/160 characters

+
+ +
+ +