Skip to content

Commit af60582

Browse files
committed
fix(admin): address announcement review findings
1 parent 95ed265 commit af60582

9 files changed

Lines changed: 105 additions & 8 deletions

File tree

‎e2e/admin-announcements.spec.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ test('admin creates a targeted announcement and deletes it from history', async
125125
targetUserIds: [TARGET_USER_ID],
126126
});
127127

128-
await page.getByRole('button', { name: 'Delete' }).click();
128+
await page.getByRole('button', { name: 'Delete announcement: Targeted note' }).click();
129129
await page.getByRole('button', { name: 'Delete announcement' }).click();
130130

131131
await expect(page.getByRole('heading', { name: 'Targeted note' })).toHaveCount(0);

‎src/components/admin/AdminAnnouncements.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,7 @@ export function AdminAnnouncements({ users }: AdminAnnouncementsProps) {
335335
variant="ghost"
336336
size="sm"
337337
className="self-start text-destructive hover:text-destructive"
338+
aria-label={`Delete announcement: ${announcement.title}`}
338339
onClick={() => setDeleteTarget(announcement)}
339340
>
340341
<Trash2 className="mr-2 h-4 w-4" aria-hidden="true" />

‎src/components/layout/AppLayout.tsx‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ export function AppLayout() {
5050
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
5151
const [announcements, setAnnouncements] = useState<UserAnnouncement[]>([]);
5252
const [dismissingId, setDismissingId] = useState<string | null>(null);
53-
const [dismissError, setDismissError] = useState<string | null>(null);
53+
const [dismissError, setDismissError] = useState<{ announcementId: string; message: string } | null>(null);
5454
const { t } = useTranslation();
5555
const location = useLocation();
5656

@@ -79,7 +79,10 @@ export function AppLayout() {
7979
await dismissAnnouncement(announcementId);
8080
setAnnouncements((current) => current.filter((announcement) => announcement.id !== announcementId));
8181
} catch (error) {
82-
setDismissError(error instanceof Error ? error.message : 'Could not dismiss this announcement.');
82+
setDismissError({
83+
announcementId,
84+
message: error instanceof Error ? error.message : 'Could not dismiss this announcement.',
85+
});
8386
} finally {
8487
setDismissingId(null);
8588
}
@@ -175,7 +178,7 @@ export function AppLayout() {
175178
position={1}
176179
total={announcements.length}
177180
dismissing={dismissingId === announcements[0].id}
178-
error={dismissError}
181+
error={dismissError?.announcementId === announcements[0].id ? dismissError.message : null}
179182
onDismiss={() => handleDismissAnnouncement(announcements[0].id)}
180183
/>
181184
)}

‎src/lib/announcements.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,9 @@ function normalizeAdminAnnouncement(row: AdminAnnouncementRow): AdminAnnouncemen
124124
}
125125

126126
function toIsoOrUndefined(value: string | undefined) {
127-
return value ? new Date(value).toISOString() : undefined;
127+
if (!value) return undefined;
128+
const date = new Date(value);
129+
return Number.isNaN(date.getTime()) ? value : date.toISOString();
128130
}
129131

130132
export async function listActiveAnnouncements(signal?: AbortSignal): Promise<UserAnnouncement[]> {

‎src/test/announcements.test.ts‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
import { createAnnouncement } from '@/lib/announcements';
3+
4+
const { mockSupabase } = vi.hoisted(() => ({
5+
mockSupabase: {
6+
auth: {
7+
getSession: vi.fn(),
8+
},
9+
},
10+
}));
11+
12+
vi.mock('@/lib/supabase/client', () => ({
13+
supabase: mockSupabase,
14+
SUPABASE_URL: 'http://localhost:54321',
15+
}));
16+
17+
describe('announcement API client', () => {
18+
beforeEach(() => {
19+
vi.clearAllMocks();
20+
mockSupabase.auth.getSession.mockResolvedValue({
21+
data: { session: { access_token: 'test-token' } },
22+
});
23+
});
24+
25+
it('passes invalid non-empty dates through for server-side validation', async () => {
26+
let requestBody: Record<string, unknown> = {};
27+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (_input, init) => {
28+
requestBody = JSON.parse(String(init?.body ?? '{}'));
29+
return new Response(JSON.stringify({
30+
announcement: {
31+
id: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee',
32+
title: 'Date validation',
33+
body: 'The server should reject invalid dates.',
34+
target_type: 'all',
35+
target_user_ids: null,
36+
starts_at: '2026-08-31T12:00:00+00:00',
37+
expires_at: null,
38+
created_by: '99999999-9999-4999-8999-999999999999',
39+
created_at: '2026-08-31T12:00:00+00:00',
40+
},
41+
}), { status: 201, headers: { 'Content-Type': 'application/json' } });
42+
});
43+
44+
await createAnnouncement({
45+
title: 'Date validation',
46+
body: 'The server should reject invalid dates.',
47+
targetType: 'all',
48+
startsAt: 'not-a-date',
49+
expiresAt: 'also-not-a-date',
50+
});
51+
52+
expect(requestBody.startsAt).toBe('not-a-date');
53+
expect(requestBody.expiresAt).toBe('also-not-a-date');
54+
});
55+
});

‎src/test/components/AppLayout.test.tsx‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,34 @@ describe('AppLayout announcements', () => {
6767
await waitFor(() => expect(mockListActiveAnnouncements).toHaveBeenCalledTimes(2));
6868
});
6969

70+
it('does not show a stale dismissal error under the next announcement', async () => {
71+
const nextAnnouncement: UserAnnouncement = {
72+
...announcement,
73+
id: 'bbbbbbbb-cccc-4ddd-8eee-ffffffffffff',
74+
title: 'New announcement',
75+
body: 'This message replaced the previous one.',
76+
};
77+
let rejectDismissal: (reason: Error) => void = () => undefined;
78+
const pendingDismissal = new Promise<void>((_resolve, reject) => {
79+
rejectDismissal = reject;
80+
});
81+
mockListActiveAnnouncements
82+
.mockResolvedValueOnce([announcement])
83+
.mockResolvedValueOnce([nextAnnouncement]);
84+
mockDismissAnnouncement.mockReturnValue(pendingDismissal);
85+
86+
renderLayout();
87+
await screen.findByText('Scheduled maintenance');
88+
fireEvent.click(screen.getByRole('button', { name: 'Dismiss announcement: Scheduled maintenance' }));
89+
fireEvent.click(screen.getByRole('link', { name: 'Open next page' }));
90+
await screen.findByText('New announcement');
91+
92+
rejectDismissal(new Error('Could not dismiss the old announcement'));
93+
94+
await waitFor(() => expect(mockDismissAnnouncement).toHaveBeenCalledWith(announcement.id));
95+
expect(screen.queryByText('Could not dismiss the old announcement')).not.toBeInTheDocument();
96+
});
97+
7098
it('keeps the app usable when announcements fail to load', async () => {
7199
mockListActiveAnnouncements.mockRejectedValue(new Error('Network unavailable'));
72100
renderLayout();

‎src/test/pages/AdminPage.test.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -505,7 +505,7 @@ describe('AdminPage Integration', () => {
505505
await waitFor(() => expect(screen.getByText('Admin Dashboard')).toBeInTheDocument());
506506
fireEvent.mouseDown(screen.getByRole('tab', { name: 'Announcements' }), { button: 0 });
507507
await screen.findByText('Remove me');
508-
fireEvent.click(screen.getByRole('button', { name: 'Delete' }));
508+
fireEvent.click(screen.getByRole('button', { name: 'Delete announcement: Remove me' }));
509509
fireEvent.click(await screen.findByRole('button', { name: 'Delete announcement' }));
510510

511511
await waitFor(() => expect(deleteBody.announcementId).toBe(announcementId));

‎supabase/migrations/20260831152911_admin_announcements.sql‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ CREATE TABLE public.admin_announcements (
1313
CONSTRAINT admin_announcements_target_shape CHECK (
1414
(target_type = 'all' AND target_user_ids IS NULL)
1515
OR
16-
(target_type = 'users' AND cardinality(target_user_ids) > 0)
16+
(target_type = 'users' AND target_user_ids IS NOT NULL AND cardinality(target_user_ids) > 0)
1717
),
1818
CONSTRAINT admin_announcements_valid_window CHECK (
1919
expires_at IS NULL OR expires_at > starts_at

‎supabase/tests/admin_announcements_rls_test.sql‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
BEGIN;
2-
SELECT plan(13);
2+
SELECT plan(14);
33

44
INSERT INTO auth.users (id)
55
VALUES
@@ -23,6 +23,14 @@ VALUES
2323
('aaaaaaaa-0000-4000-8000-000000000004', 'Future', 'Not active yet.', 'all', NULL, now() + interval '1 day', NULL, '99999999-9999-4999-8999-999999999999'),
2424
('aaaaaaaa-0000-4000-8000-000000000005', 'Expired', 'No longer active.', 'all', NULL, now() - interval '2 days', now() - interval '1 day', '99999999-9999-4999-8999-999999999999');
2525

26+
SELECT throws_ok(
27+
$$INSERT INTO public.admin_announcements (title, body, target_type, target_user_ids, created_by)
28+
VALUES ('Missing recipients', 'This must fail.', 'users', NULL, '99999999-9999-4999-8999-999999999999')$$,
29+
'23514',
30+
NULL,
31+
'targeted announcements require a non-null recipient array'
32+
);
33+
2634
SELECT ok(
2735
has_column_privilege('authenticated', 'public.admin_announcements', 'title', 'SELECT'),
2836
'authenticated users can read public announcement fields'

0 commit comments

Comments
 (0)