From 6831e346641f4dd0e4485cfcf8466c6a3ad6a3e9 Mon Sep 17 00:00:00 2001 From: Caleb Bae Date: Wed, 6 May 2026 16:51:33 -0500 Subject: [PATCH 1/2] feat: push notifications --- client/web/src/components/PushPromptHost.tsx | 39 + .../src/pages/admin/_shared/AppSidebar.tsx | 6 + .../notifications/NotificationsPage.tsx | 54 ++ .../src/pages/superadmin/notifications/api.ts | 59 ++ .../components/NotificationFormDialog.tsx | 216 +++++ .../components/NotificationsTable.tsx | 212 +++++ .../pages/superadmin/notifications/store.ts | 104 +++ .../pages/superadmin/notifications/types.ts | 27 + client/web/src/providers.tsx | 2 + client/web/src/routes.tsx | 13 + client/web/src/shared/push/api.ts | 65 ++ client/web/src/shared/push/client.ts | 64 ++ client/web/src/shared/push/index.ts | 10 + client/web/src/shared/push/usePushPrompt.ts | 91 +++ client/web/src/sw.ts | 66 ++ client/web/tsconfig.app.json | 3 +- client/web/tsconfig.json | 3 +- client/web/tsconfig.sw.json | 20 + client/web/vite.config.ts | 20 +- cmd/api/api.go | 39 +- cmd/api/dispatcher.go | 129 +++ cmd/api/main.go | 9 + cmd/api/notifications.go | 141 ++++ cmd/api/notifications_test.go | 136 ++++ cmd/api/scheduled_notifications.go | 206 +++++ cmd/api/scheduled_notifications_test.go | 235 ++++++ cmd/genvapid/main.go | 19 + .../000015_add_push_subscriptions.down.sql | 2 + .../000015_add_push_subscriptions.up.sql | 16 + ...00016_add_scheduled_notifications.down.sql | 2 + .../000016_add_scheduled_notifications.up.sql | 20 + docs/docs.go | 748 ++++++++++++++++-- go.mod | 1 + go.sum | 55 ++ internal/store/mock_store.go | 96 ++- internal/store/push_subscriptions.go | 123 +++ internal/store/scheduled_notifications.go | 232 ++++++ internal/store/storage.go | 33 +- 38 files changed, 3222 insertions(+), 94 deletions(-) create mode 100644 client/web/src/components/PushPromptHost.tsx create mode 100644 client/web/src/pages/superadmin/notifications/NotificationsPage.tsx create mode 100644 client/web/src/pages/superadmin/notifications/api.ts create mode 100644 client/web/src/pages/superadmin/notifications/components/NotificationFormDialog.tsx create mode 100644 client/web/src/pages/superadmin/notifications/components/NotificationsTable.tsx create mode 100644 client/web/src/pages/superadmin/notifications/store.ts create mode 100644 client/web/src/pages/superadmin/notifications/types.ts create mode 100644 client/web/src/shared/push/api.ts create mode 100644 client/web/src/shared/push/client.ts create mode 100644 client/web/src/shared/push/index.ts create mode 100644 client/web/src/shared/push/usePushPrompt.ts create mode 100644 client/web/src/sw.ts create mode 100644 client/web/tsconfig.sw.json create mode 100644 cmd/api/dispatcher.go create mode 100644 cmd/api/notifications.go create mode 100644 cmd/api/notifications_test.go create mode 100644 cmd/api/scheduled_notifications.go create mode 100644 cmd/api/scheduled_notifications_test.go create mode 100644 cmd/genvapid/main.go create mode 100644 cmd/migrate/migrations/000015_add_push_subscriptions.down.sql create mode 100644 cmd/migrate/migrations/000015_add_push_subscriptions.up.sql create mode 100644 cmd/migrate/migrations/000016_add_scheduled_notifications.down.sql create mode 100644 cmd/migrate/migrations/000016_add_scheduled_notifications.up.sql create mode 100644 internal/store/push_subscriptions.go create mode 100644 internal/store/scheduled_notifications.go diff --git a/client/web/src/components/PushPromptHost.tsx b/client/web/src/components/PushPromptHost.tsx new file mode 100644 index 00000000..777ddd3c --- /dev/null +++ b/client/web/src/components/PushPromptHost.tsx @@ -0,0 +1,39 @@ +import { useEffect } from "react"; +import { toast } from "sonner"; + +import { usePushPrompt } from "@/shared/push/usePushPrompt"; + +const TOAST_ID = "push-prompt"; + +export function PushPromptHost() { + const { shouldPrompt, accept, dismiss } = usePushPrompt(); + + useEffect(() => { + if (!shouldPrompt) return; + + toast("Get notified about your application status", { + id: TOAST_ID, + description: + "Allow push notifications so we can let you know when reviews and announcements drop.", + duration: Infinity, + action: { + label: "Enable", + onClick: () => { + void accept(); + }, + }, + cancel: { + label: "Not now", + onClick: () => { + dismiss(); + }, + }, + }); + + return () => { + toast.dismiss(TOAST_ID); + }; + }, [shouldPrompt, accept, dismiss]); + + return null; +} diff --git a/client/web/src/pages/admin/_shared/AppSidebar.tsx b/client/web/src/pages/admin/_shared/AppSidebar.tsx index 6f31e781..60412b39 100644 --- a/client/web/src/pages/admin/_shared/AppSidebar.tsx +++ b/client/web/src/pages/admin/_shared/AppSidebar.tsx @@ -1,6 +1,7 @@ "use client"; import { + Bell, Calendar, ClipboardList, Handshake, @@ -75,6 +76,11 @@ const superAdminNav = [ url: "/admin/sa/application", icon: ClipboardList, }, + { + name: "Notifications", + url: "/admin/sa/notifications", + icon: Bell, + }, ]; export function AppSidebar({ ...props }: React.ComponentProps) { diff --git a/client/web/src/pages/superadmin/notifications/NotificationsPage.tsx b/client/web/src/pages/superadmin/notifications/NotificationsPage.tsx new file mode 100644 index 00000000..cd04a4d0 --- /dev/null +++ b/client/web/src/pages/superadmin/notifications/NotificationsPage.tsx @@ -0,0 +1,54 @@ +import { useEffect } from "react"; + +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; + +import { NotificationsTable } from "./components/NotificationsTable"; +import { useNotificationsStore } from "./store"; + +export default function NotificationsPage() { + const { + notifications, + loading, + saving, + fetch: fetchNotifications, + create, + update, + remove, + } = useNotificationsStore(); + + useEffect(() => { + const controller = new AbortController(); + fetchNotifications(controller.signal); + return () => controller.abort(); + }, [fetchNotifications]); + + if (loading && notifications.length === 0) { + return ( +
+ + + + + + {[...Array(3)].map((_, i) => ( + + ))} + + +
+ ); + } + + return ( +
+ +
+ ); +} diff --git a/client/web/src/pages/superadmin/notifications/api.ts b/client/web/src/pages/superadmin/notifications/api.ts new file mode 100644 index 00000000..49c0a3c6 --- /dev/null +++ b/client/web/src/pages/superadmin/notifications/api.ts @@ -0,0 +1,59 @@ +import { + deleteRequest, + getRequest, + patchRequest, + postRequest, +} from "@/shared/lib/api"; +import type { ApiResponse } from "@/types"; + +import type { + ScheduledNotification, + ScheduledNotificationListResponse, + ScheduledNotificationPayload, +} from "./types"; + +export async function fetchScheduledNotifications( + signal?: AbortSignal, +): Promise> { + return getRequest( + "/superadmin/notifications", + "scheduled notifications", + signal, + ); +} + +export async function createScheduledNotification( + payload: ScheduledNotificationPayload, + signal?: AbortSignal, +): Promise> { + return postRequest( + "/superadmin/notifications", + payload, + "scheduled notification", + signal, + ); +} + +export async function updateScheduledNotification( + id: string, + payload: ScheduledNotificationPayload, + signal?: AbortSignal, +): Promise> { + return patchRequest( + `/superadmin/notifications/${id}`, + payload, + "scheduled notification", + signal, + ); +} + +export async function deleteScheduledNotification( + id: string, + signal?: AbortSignal, +): Promise> { + return deleteRequest( + `/superadmin/notifications/${id}`, + "scheduled notification", + signal, + ); +} diff --git a/client/web/src/pages/superadmin/notifications/components/NotificationFormDialog.tsx b/client/web/src/pages/superadmin/notifications/components/NotificationFormDialog.tsx new file mode 100644 index 00000000..f8cbd6b8 --- /dev/null +++ b/client/web/src/pages/superadmin/notifications/components/NotificationFormDialog.tsx @@ -0,0 +1,216 @@ +import { Loader2 } from "lucide-react"; +import { useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import type { UserRole } from "@/types"; + +import type { + ScheduledNotification, + ScheduledNotificationPayload, +} from "../types"; + +const TARGET_ALL = "__all"; +type TargetOption = UserRole | typeof TARGET_ALL; + +const ROLE_OPTIONS: { value: TargetOption; label: string }[] = [ + { value: TARGET_ALL, label: "All users" }, + { value: "hacker", label: "Hackers" }, + { value: "admin", label: "Admins" }, + { value: "super_admin", label: "Super Admins" }, +]; + +function defaultScheduledLocal(): string { + const now = new Date(Date.now() + 5 * 60 * 1000); + now.setSeconds(0, 0); + return toLocalInputValue(now.toISOString()); +} + +function toLocalInputValue(iso: string): string { + const d = new Date(iso); + const tzOffsetMs = d.getTimezoneOffset() * 60_000; + const local = new Date(d.getTime() - tzOffsetMs); + return local.toISOString().slice(0, 16); +} + +interface NotificationFormProps { + notification: ScheduledNotification | null; + saving: boolean; + onSubmit: (payload: ScheduledNotificationPayload) => Promise; + onCancel: () => void; +} + +function NotificationForm({ + notification, + saving, + onSubmit, + onCancel, +}: NotificationFormProps) { + const [title, setTitle] = useState(notification?.title ?? ""); + const [body, setBody] = useState(notification?.body ?? ""); + const [url, setUrl] = useState(notification?.url ?? ""); + const [target, setTarget] = useState( + notification?.target_role ?? TARGET_ALL, + ); + const [scheduledAt, setScheduledAt] = useState( + notification?.scheduled_at + ? toLocalInputValue(notification.scheduled_at) + : defaultScheduledLocal(), + ); + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!title.trim() || !body.trim() || !scheduledAt) return; + + const isoScheduled = new Date(scheduledAt).toISOString(); + const ok = await onSubmit({ + title: title.trim(), + body: body.trim(), + url: url.trim() === "" ? null : url.trim(), + target_role: target === TARGET_ALL ? null : (target as UserRole), + scheduled_at: isoScheduled, + }); + if (ok) { + onCancel(); + } + }; + + return ( +
+
+ + setTitle(e.target.value)} + maxLength={100} + placeholder="Applications close in 1 hour" + required + /> +
+
+ +