diff --git a/migrations/0003_notifications.sql b/migrations/0003_notifications.sql new file mode 100644 index 0000000..2eaf9cb --- /dev/null +++ b/migrations/0003_notifications.sql @@ -0,0 +1,14 @@ +CREATE TABLE notifications ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title TEXT NOT NULL, + body TEXT, + kind TEXT NOT NULL, + related_submission_id TEXT REFERENCES promotion_submissions(id) ON DELETE CASCADE, + status TEXT NOT NULL DEFAULT 'unread' CHECK (status IN ('unread', 'viewed')), + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + viewed_at TEXT +); + +CREATE INDEX notifications_user_idx ON notifications(user_id, created_at DESC); +CREATE INDEX notifications_unread_idx ON notifications(user_id) WHERE status = 'unread'; diff --git a/src/App.tsx b/src/App.tsx index a5aa4d7..bacfe10 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,10 +6,11 @@ import AdminReviewPage from './routes/AdminReviewPage'; import DevLoginPage from './routes/DevLoginPage'; import LinkDetailPage from './routes/LinkDetailPage'; import LegalPage from './routes/LegalPage'; +import NotificationsPage from './routes/NotificationsPage'; import UserMenu from './components/UserMenu'; import { useCurrentUser } from './lib/auth'; - -type RouteId = 'home' | 'my-links' | 'admin' | 'login' | 'dev-login' | 'profile' | 'settings' | 'terms' | 'privacy' | 'link-detail'; +import { useUnreadNotifications } from './lib/notifications'; +import { resolveRouteForCurrentUser, type RouteId } from './lib/routing'; const NAV_TABS: readonly { id: RouteId; label: string }[] = [ { id: 'my-links', label: 'My Links' }, @@ -19,7 +20,7 @@ function readRouteFromHash(): RouteId { const hash = window.location.hash; if (!hash || hash === '#/' || hash === '#') return 'home'; if (hash.match(/^#\/links\//)) return 'link-detail'; - const match = hash.match(/^#\/(my-links|admin|login|dev-login|profile|settings|terms|privacy)/); + const match = hash.match(/^#\/(my-links|admin|login|dev-login|profile|settings|terms|privacy|notifications)/); return (match?.[1] as RouteId) ?? 'home'; } @@ -36,6 +37,7 @@ export default function App() { const [linkDetailId, setLinkDetailId] = useState(null); const [linkDetailScope, setLinkDetailScope] = useState<'private' | 'public'>('private'); const { user, linkBaseUrls, loading, refresh } = useCurrentUser(); + const { unreadCount, refresh: refreshUnread } = useUnreadNotifications(!!user); useEffect(() => { const syncRouteFromHash = () => { @@ -58,6 +60,15 @@ export default function App() { return () => window.removeEventListener('hashchange', onHashChange); }, []); + useEffect(() => { + if (!loading) { + const nextRoute = resolveRouteForCurrentUser(route, user); + if (nextRoute !== route) { + navigate(nextRoute); + } + } + }, [loading, route, user]); + const handleLogout = useCallback(async () => { try { await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }); @@ -68,6 +79,10 @@ export default function App() { navigate('home'); }, [refresh]); + const handleMarkedViewed = useCallback(() => { + void refreshUnread(); + }, [refreshUnread]); + const navTabs = NAV_TABS; return ( @@ -88,7 +103,7 @@ export default function App() { ))}
- +
@@ -102,6 +117,7 @@ export default function App() { {route === 'settings' ? : null} {route === 'terms' ? : null} {route === 'privacy' ? : null} + {route === 'notifications' ? : null} {route === 'link-detail' && linkDetailId ? ( void; } -export default function UserMenu({ user, loading, onLogout }: UserMenuProps) { +export default function UserMenu({ user, loading, unreadCount, onLogout }: UserMenuProps) { if (loading) { return
; } @@ -20,6 +21,10 @@ export default function UserMenu({ user, loading, onLogout }: UserMenuProps) { } const displayName = user.name || user.email?.split('@')[0] || 'User'; + const hasUnread = unreadCount > 0; + const unreadLabel = hasUnread + ? `${unreadCount} unread notification${unreadCount === 1 ? '' : 's'}` + : 'Notifications'; return (
@@ -32,6 +37,9 @@ export default function UserMenu({ user, loading, onLogout }: UserMenuProps) { {displayName.charAt(0).toUpperCase()} )} + {hasUnread ? ( + + ) : null} {displayName} {user.role === 'admin' && admin} @@ -40,6 +48,10 @@ export default function UserMenu({ user, loading, onLogout }: UserMenuProps) {
+ + Notifications + {hasUnread ? {unreadCount} : null} + Profile Settings {user.role === 'admin' && ( diff --git a/src/lib/api.ts b/src/lib/api.ts index 9634b93..7841861 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -252,6 +252,56 @@ export function needsChangesSubmission(id: string, input: ReviewActionInput = {} }); } +// ---- Notifications ---- + +export type NotificationStatus = 'unread' | 'viewed'; + +export interface Notification { + id: string; + title: string; + body: string | null; + kind: string; + status: NotificationStatus; + relatedSubmissionId: string | null; + proposedAlias: string | null; + privateLinkId: string | null; + privateLinkAlias: string | null; + createdAt: string; + viewedAt: string | null; +} + +export interface NotificationListResponse { + notifications: Notification[]; +} + +export interface UnreadCountResponse { + count: number; +} + +export interface DeleteBatchResponse { + ok: true; + deleted: number; +} + +export function listNotifications(): Promise { + return request('/api/notifications'); +} + +export function getUnreadNotificationCount(): Promise { + return request('/api/notifications/unread-count'); +} + +export function deleteNotification(id: string): Promise { + return deleteLink(`/api/notifications/${encodeURIComponent(id)}`); +} + +export function deleteNotifications(ids: string[]): Promise { + return request('/api/notifications/delete-batch', { + method: 'POST', + body: JSON.stringify({ ids }), + }); +} + // ---- Link detail (stats + history) ---- export interface DayCount { diff --git a/src/lib/notifications.ts b/src/lib/notifications.ts new file mode 100644 index 0000000..59b3137 --- /dev/null +++ b/src/lib/notifications.ts @@ -0,0 +1,49 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { getUnreadNotificationCount } from './api'; + +const POLL_INTERVAL_MS = 60_000; + +export function useUnreadNotifications(isAuthenticated: boolean) { + const [unreadCount, setUnreadCount] = useState(0); + const [loading, setLoading] = useState(false); + const timerRef = useRef(null); + + const refresh = useCallback(async () => { + if (!isAuthenticated) { + setUnreadCount(0); + return; + } + + setLoading(true); + try { + const result = await getUnreadNotificationCount(); + setUnreadCount(result.count ?? 0); + } catch { + // Network/auth errors are non-fatal for the badge; keep last known count. + } finally { + setLoading(false); + } + }, [isAuthenticated]); + + useEffect(() => { + if (!isAuthenticated) { + setUnreadCount(0); + return; + } + + void refresh(); + + timerRef.current = window.setInterval(() => { + void refresh(); + }, POLL_INTERVAL_MS); + + return () => { + if (timerRef.current != null) { + window.clearInterval(timerRef.current); + timerRef.current = null; + } + }; + }, [isAuthenticated, refresh]); + + return { unreadCount, loading, refresh }; +} diff --git a/src/lib/routing.ts b/src/lib/routing.ts new file mode 100644 index 0000000..f76a0da --- /dev/null +++ b/src/lib/routing.ts @@ -0,0 +1,9 @@ +export type RouteId = 'home' | 'my-links' | 'admin' | 'login' | 'dev-login' | 'profile' | 'settings' | 'terms' | 'privacy' | 'notifications' | 'link-detail'; + +export function resolveRouteForCurrentUser(route: RouteId, user: { id: string } | null): RouteId { + if (route === 'login' && user) { + return 'home'; + } + + return route; +} diff --git a/src/routes/LinkDetailPage.tsx b/src/routes/LinkDetailPage.tsx index a777e63..6bcef0e 100644 --- a/src/routes/LinkDetailPage.tsx +++ b/src/routes/LinkDetailPage.tsx @@ -1,8 +1,9 @@ import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'; import { createPortal } from 'react-dom'; import { Chart, registerables } from 'chart.js'; -import { CheckIcon, CopyIcon, ExternalLinkIcon, GlobeIcon, LockIcon } from 'lucide-react'; +import { CheckIcon, CopyIcon, ExternalLinkIcon, GlobeIcon, LockIcon, MegaphoneIcon } from 'lucide-react'; import CopyPublicLinkDialog from '../components/CopyPublicLinkDialog'; +import PromotionDialog from '../components/PromotionDialog'; import { ApiClientError, type ChangeLogEntry, @@ -16,6 +17,7 @@ import { getPrivateLinkStats, getPublicLinkHistory, getPublicLinkStats, + promoteLink, } from '../lib/api'; import type { CurrentUser, LinkBaseUrls } from '../lib/auth'; import { safeLinkTargetUrl } from '../lib/url'; @@ -68,6 +70,8 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas const [showCopyDialog, setShowCopyDialog] = useState(false); const [copySuccessNotice, setCopySuccessNotice] = useState(null); const [shortLinkNotice, setShortLinkNotice] = useState(null); + const [showPropose, setShowPropose] = useState(false); + const [proposeNotice, setProposeNotice] = useState(null); const chartRef = useRef(null); const chartInstance = useRef(null); @@ -93,6 +97,11 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas setCopying(false); }, [linkId, linkScope]); + useEffect(() => { + setShowPropose(false); + setProposeNotice(null); + }, [linkId, linkScope]); + useEffect(() => { if (!shortLinkNotice) { return; @@ -105,6 +114,18 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas return () => window.clearTimeout(timeoutId); }, [shortLinkNotice]); + useEffect(() => { + if (!proposeNotice) { + return; + } + + const timeoutId = window.setTimeout(() => { + setProposeNotice(null); + }, 4200); + + return () => window.clearTimeout(timeoutId); + }, [proposeNotice]); + useEffect(() => { if (linkScope !== 'private') { setCopySuccessNotice(null); @@ -253,6 +274,7 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas const safeUrl = link?.linkType === 'redirect' ? safeLinkTargetUrl(link.targetUrl) : null; const canCopyToPrivate = linkScope === 'public' && currentUser != null && link != null; + const canPropose = linkScope === 'private' && currentUser != null && link != null; async function createPrivateCopy(alias: string) { if (!link || link.scope !== 'public') { @@ -316,6 +338,16 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas } } + async function handlePropose(input: { privateLinkId: string; proposedAlias: string; note?: string }) { + try { + await promoteLink(input); + setShowPropose(false); + setProposeNotice('Submitted for review — admins will be notified.'); + } catch (err) { + throw new Error(err instanceof ApiClientError ? err.message : 'Failed to submit promotion'); + } + } + return (
{copySuccessNotice ? ( @@ -358,6 +390,16 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas {copying ? 'Copying…' : 'Copy to My Links'} ) : null} + {canPropose ? ( + + ) : null}
{shortLinkNotice ? (
@@ -365,6 +407,12 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas {shortLinkNotice}
) : null} + {proposeNotice ? ( +
+
+ ) : null}
{copyError ?

{copyError}

: null} @@ -465,6 +513,14 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas }} /> ) : null} + + {showPropose && link ? ( + setShowPropose(false)} + /> + ) : null} ); } diff --git a/src/routes/NotificationsPage.tsx b/src/routes/NotificationsPage.tsx new file mode 100644 index 0000000..49042b8 --- /dev/null +++ b/src/routes/NotificationsPage.tsx @@ -0,0 +1,251 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { CheckIcon, Trash2Icon } from 'lucide-react'; +import { + ApiClientError, + type Notification, + deleteNotification, + deleteNotifications, + listNotifications, +} from '../lib/api'; + +const KIND_LABELS: Record = { + promotion_approved: 'Approved', + promotion_rejected: 'Rejected', + promotion_needs_changes: 'Needs changes', +}; + +interface NotificationsPageProps { + onMarkedViewed: () => void; +} + +export default function NotificationsPage({ onMarkedViewed }: NotificationsPageProps) { + const [notifications, setNotifications] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [selected, setSelected] = useState>(new Set()); + const [deleting, setDeleting] = useState(false); + const [notice, setNotice] = useState(null); + + const hasUnread = useMemo(() => notifications.some((n) => n.status === 'unread'), [notifications]); + + const refresh = useCallback(async () => { + setLoading(true); + setError(null); + try { + const result = await listNotifications(); + setNotifications(result.notifications ?? []); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load notifications'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void refresh(); + }, [refresh]); + + useEffect(() => { + if (!hasUnread) { + return; + } + onMarkedViewed(); + setNotifications((prev) => prev.map((n) => (n.status === 'unread' ? { ...n, status: 'viewed' } : n))); + }, [hasUnread, onMarkedViewed]); + + useEffect(() => { + if (!notice) { + return; + } + const timeoutId = window.setTimeout(() => setNotice(null), 2200); + return () => window.clearTimeout(timeoutId); + }, [notice]); + + function toggleSelect(id: string) { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + } + + function selectAll(ids: string[]) { + setSelected(new Set(ids)); + } + + async function handleDeleteOne(notification: Notification) { + if (!window.confirm(`Delete "${notification.title}"?`)) { + return; + } + setError(null); + try { + await deleteNotification(notification.id); + setNotifications((prev) => prev.filter((n) => n.id !== notification.id)); + setSelected((prev) => { + const next = new Set(prev); + next.delete(notification.id); + return next; + }); + setNotice('Notification deleted'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to delete notification'); + } + } + + async function handleDeleteSelected() { + if (selected.size === 0 || deleting) { + return; + } + if (!window.confirm(`Delete ${selected.size} notification${selected.size === 1 ? '' : 's'}?`)) { + return; + } + setError(null); + setDeleting(true); + try { + const ids = Array.from(selected); + await deleteNotifications(ids); + setNotifications((prev) => prev.filter((n) => !selected.has(n.id))); + setSelected(new Set()); + setNotice(`${ids.length} notification${ids.length === 1 ? '' : 's'} deleted`); + } catch (err) { + setError(err instanceof ApiClientError ? err.message : 'Failed to delete notifications'); + } finally { + setDeleting(false); + } + } + + const allIds = notifications.map((n) => n.id); + const allSelected = allIds.length > 0 && allIds.every((id) => selected.has(id)); + + return ( +
+
+
+

Notifications

+

Updates about your promotion proposals.

+
+
+ +
+
+ + {error ?

{error}

: null} + + {selected.size > 0 ? ( +
+ {selected.size} selected. + + +
+ ) : null} + + {notice ? ( +
+
+ ) : null} + + {loading ? ( +

Loading notifications…

+ ) : notifications.length === 0 ? ( +

No notifications yet.

+ ) : ( +
+
+ +
+
    + {notifications.map((notification) => ( + toggleSelect(notification.id)} + onDelete={() => void handleDeleteOne(notification)} + /> + ))} +
+
+ )} +
+ ); +} + +function NotificationItem({ + notification, + selected, + onToggleSelect, + onDelete, +}: { + notification: Notification; + selected: boolean; + onToggleSelect: () => void; + onDelete: () => void; +}) { + const kindLabel = KIND_LABELS[notification.kind] ?? notification.kind; + const kindClassName = `notification-kind notification-kind--${notification.kind}`; + + return ( +
  • +
    + +
    +
    +
    + {notification.status === 'unread' ? : null} + {kindLabel} +

    {notification.title}

    +
    + {notification.body ?

    {notification.body}

    : null} +
    + + {notification.proposedAlias ? ( + Proposed: #{notification.proposedAlias} + ) : null} + {notification.privateLinkAlias ? ( + Private link: #{notification.privateLinkAlias} + ) : null} + {notification.viewedAt ? ( + Viewed {new Date(notification.viewedAt).toLocaleString()} + ) : null} +
    +
    +
    + +
    +
  • + ); +} diff --git a/src/styles.css b/src/styles.css index 9a05798..0b4f857 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1388,6 +1388,40 @@ button.link-action--confirm:hover { background: var(--accent-soft); } margin: 0.3rem 0; } +.dropdown-item--with-badge { + align-items: center; + display: flex; + justify-content: space-between; + gap: 0.5rem; +} + +.dropdown-badge { + background: var(--danger); + border-radius: 999px; + color: white; + font-size: 0.7rem; + font-weight: 700; + line-height: 1; + min-width: 1.1rem; + padding: 0.15rem 0.4rem; + text-align: center; +} + +.user-avatar { + position: relative; +} +.user-avatar__dot { + background: var(--danger); + border: 2px solid var(--surface); + border-radius: 50%; + height: 10px; + position: absolute; + right: -2px; + top: -2px; + width: 10px; + box-shadow: 0 0 0 1px rgba(239,68,68,0.25); +} + /* ═══════════════════════════════════════════ Dev login form ════════════════════════════════════════════ */ @@ -1408,6 +1442,143 @@ button.link-action--confirm:hover { background: var(--accent-soft); } .success-msg { color: var(--success); font-size: 0.9rem; margin: 0; } .error-msg { color: var(--danger); font-size: 0.9rem; margin: 0; } +.notifications-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.notifications-list__select-all { + align-items: center; + display: flex; + font-size: 0.8125rem; + gap: 0.5rem; +} +.notifications-list__select-all label { + align-items: center; + display: flex; + gap: 0.45rem; +} + +.notifications-list ul { + display: flex; + flex-direction: column; + gap: 0.5rem; + list-style: none; + margin: 0; + padding: 0; +} + +.notification-item { + align-items: flex-start; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + display: grid; + grid-template-columns: auto 1fr auto; + gap: 0.75rem; + padding: 0.875rem 1rem; + transition: border-color 0.15s, background 0.15s; +} +.notification-item.is-unread { + background: color-mix(in srgb, var(--accent-soft) 55%, var(--surface) 45%); + border-color: #C7D2FE; +} +.notification-item.is-selected { + background: var(--accent-soft); + border-color: #A5B4FC; +} + +.notification-item__select { + padding-top: 0.2rem; +} + +.notification-item__body { + display: flex; + flex-direction: column; + gap: 0.35rem; + min-width: 0; +} + +.notification-item__header { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.notification-item__title { + font-size: 0.9375rem; + font-weight: 600; + margin: 0; +} + +.notification-unread-dot { + background: var(--accent); + border-radius: 50%; + display: inline-block; + flex-shrink: 0; + height: 8px; + width: 8px; +} + +.notification-kind { + border-radius: 999px; + display: inline-flex; + align-items: center; + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.02em; + padding: 0.15rem 0.55rem; + white-space: nowrap; +} +.notification-kind--promotion_approved { background: var(--success-soft); color: #065F46; } +.notification-kind--promotion_rejected { background: var(--danger-soft); color: #B91C1C; } +.notification-kind--promotion_needs_changes { background: var(--warning-soft); color: #92400E; } + +.notification-item__text { + color: var(--text); + font-size: 0.875rem; + line-height: 1.45; + margin: 0; + word-break: break-word; +} + +.notification-item__details { + align-items: center; + display: flex; + flex-wrap: wrap; + font-size: 0.75rem; + gap: 0.5rem 0.85rem; + color: var(--muted); +} + +.detail-chip { + align-items: center; + display: inline-flex; + gap: 0.3rem; +} + +.notification-item__actions { + display: flex; + flex-shrink: 0; + gap: 0.35rem; +} +.notification-item__actions .link-action { + display: inline-flex; + align-items: center; + gap: 0.4rem; +} + +@media (max-width: 640px) { + .notification-item { + grid-template-columns: auto 1fr; + } + .notification-item__actions { + grid-column: 2; + } +} + /* ═══════════════════════════════════════════ Responsive ════════════════════════════════════════════ */ diff --git a/tests/api.notifications-ui.test.ts b/tests/api.notifications-ui.test.ts new file mode 100644 index 0000000..0a167ec --- /dev/null +++ b/tests/api.notifications-ui.test.ts @@ -0,0 +1,98 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + ApiClientError, + deleteNotification, + deleteNotifications, + getUnreadNotificationCount, + listNotifications, +} from '../src/lib/api'; + +const originalFetch = globalThis.fetch; + +function mockFetch(responder: (input: RequestInfo | URL, init?: RequestInit) => { + status?: number; + body?: unknown; + statusText?: string; +}) { + const calls: { path: string; init?: RequestInit }[] = []; + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const path = typeof input === 'string' ? input : new URL(input.toString()).pathname; + calls.push({ path, init }); + const { status = 200, body = {}, statusText = 'OK' } = responder(input, init); + return new Response(JSON.stringify(body), { + status, + statusText, + headers: { 'content-type': 'application/json' }, + }); + }); + globalThis.fetch = fetchMock as unknown as typeof fetch; + return { calls, fetchMock }; +} + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +describe('notifications api client', () => { + it('listNotifications GETs /api/notifications with credentials', async () => { + const { calls } = mockFetch(() => ({ + body: { notifications: [{ id: 'n1', title: 'A', kind: 'promotion_approved', status: 'viewed' }] }, + })); + + const result = await listNotifications(); + + expect(calls[0].path).toBe('/api/notifications'); + expect(calls[0].init?.method).toBeUndefined(); + expect(calls[0].init?.credentials).toBe('include'); + expect(result.notifications).toHaveLength(1); + }); + + it('getUnreadNotificationCount GETs the unread-count endpoint', async () => { + const { calls } = mockFetch(() => ({ body: { count: 3 } })); + + const result = await getUnreadNotificationCount(); + + expect(calls[0].path).toBe('/api/notifications/unread-count'); + expect(result.count).toBe(3); + }); + + it('deleteNotification issues DELETE to /api/notifications/:id', async () => { + const { calls } = mockFetch(() => ({ body: { ok: true } })); + + const result = await deleteNotification('n1'); + + expect(calls[0].path).toBe('/api/notifications/n1'); + expect(calls[0].init?.method).toBe('DELETE'); + expect(result.ok).toBe(true); + }); + + it('deleteNotifications POSTs the ids array to the batch endpoint', async () => { + const { calls } = mockFetch(() => ({ body: { ok: true, deleted: 2 } })); + + const result = await deleteNotifications(['n1', 'n2']); + + expect(calls[0].path).toBe('/api/notifications/delete-batch'); + expect(calls[0].init?.method).toBe('POST'); + const body = JSON.parse(calls[0].init?.body as string); + expect(body).toEqual({ ids: ['n1', 'n2'] }); + expect(result.deleted).toBe(2); + }); + + it('encodes notification ids with special characters', async () => { + const { calls } = mockFetch(() => ({ body: { ok: true } })); + + await deleteNotification('a/b'); + + expect(calls[0].path).toBe('/api/notifications/a%2Fb'); + }); + + it('surfaces server errors as ApiClientError', async () => { + mockFetch(() => ({ status: 401, body: { error: 'Authentication required' } })); + + await expect(listNotifications()).rejects.toMatchObject({ + name: 'ApiClientError', + status: 401, + message: 'Authentication required', + }); + }); +}); diff --git a/tests/api.notifications.test.ts b/tests/api.notifications.test.ts new file mode 100644 index 0000000..4d12dce --- /dev/null +++ b/tests/api.notifications.test.ts @@ -0,0 +1,414 @@ +import { describe, expect, it } from 'vitest'; +import worker from '../worker/index'; +import { AUTH_SESSION_COOKIE_NAME, hashSessionToken } from '../worker/auth'; + +type NotificationStatus = 'unread' | 'viewed'; + +type NotificationRow = { + id: string; + user_id: string; + title: string; + body: string | null; + kind: string; + related_submission_id: string | null; + status: NotificationStatus; + created_at: string; + viewed_at: string | null; + proposed_alias?: string | null; + private_link_id?: string | null; + private_link_alias?: string | null; +}; + +type SessionRow = { + id: string; + email: string | null; + name: string | null; + image_url: string | null; + role: 'user' | 'admin'; + expires_at: string; + session_token_hash: string; +}; + +type SubmissionRow = { + id: string; + private_link_id: string; + submitted_by_user_id: string; + proposed_alias: string; + note: string | null; + status: 'pending' | 'approved' | 'rejected' | 'needs_changes'; + reviewed_by_user_id: string | null; + rejection_reason: string | null; + public_link_id: string | null; + created_at: string; + reviewed_at: string | null; +}; + +type LinkRow = { + id: string; + scope: 'public' | 'private'; + owner_user_id: string | null; + alias: string; + link_type: 'redirect' | 'custom'; + target_url: string | null; + content_markdown: string | null; + description: string | null; + status: 'active' | 'archived' | 'deleted'; + click_count: number; + created_at: string; + updated_at: string; +}; + +type AllResult = { + results: T[]; + success: true; + meta: Record; +}; + +class FakeD1Database { + readonly runCalls: { sql: string; params: unknown[] }[] = []; + + constructor( + readonly notifications: NotificationRow[] = [], + readonly submissions: SubmissionRow[] = [], + readonly links: LinkRow[] = [], + private readonly sessions: SessionRow[] = [], + ) {} + + prepare(sql: string): FakeD1PreparedStatement { + return new FakeD1PreparedStatement(this, sql); + } + + findSession(hash: string): SessionRow | null { + return this.sessions.find((s) => s.session_token_hash === hash) ?? null; + } + + findNotification(id: string): NotificationRow | null { + return this.notifications.find((n) => n.id === id) ?? null; + } + + listForUser(userId: string): NotificationRow[] { + return this.notifications + .filter((n) => n.user_id === userId) + .sort((a, b) => b.created_at.localeCompare(a.created_at)) + .map((n) => this.enrich(n)); + } + + enrich(n: NotificationRow): NotificationRow { + const submission = this.submissions.find((s) => s.id === n.related_submission_id) ?? null; + const link = submission ? this.links.find((l) => l.id === submission.private_link_id) ?? null : null; + return { + ...n, + proposed_alias: submission?.proposed_alias ?? null, + private_link_id: submission?.private_link_id ?? null, + private_link_alias: link?.alias ?? null, + }; + } + + countUnread(userId: string): number { + return this.notifications.filter((n) => n.user_id === userId && n.status === 'unread').length; + } + + insertNotification(params: unknown[]): NotificationRow { + const [id, userId, title, body, kind, relatedSubmissionId] = params; + const row: NotificationRow = { + id: String(id), + user_id: String(userId), + title: String(title), + body: body == null ? null : String(body), + kind: String(kind), + related_submission_id: relatedSubmissionId == null ? null : String(relatedSubmissionId), + status: 'unread', + created_at: '2026-06-20T00:00:00.000Z', + viewed_at: null, + }; + this.notifications.push(row); + return row; + } +} + +class FakeD1PreparedStatement { + private params: unknown[] = []; + + constructor( + private readonly db: FakeD1Database, + private readonly sql: string, + ) {} + + bind(...params: unknown[]): FakeD1PreparedStatement { + this.params = params; + return this; + } + + async first(): Promise { + if (this.sql.includes('session_token_hash')) { + const row = this.db.findSession(String(this.params[0])); + if (!row) return null; + return { + id: row.id, + email: row.email, + name: row.name, + image_url: row.image_url, + role: row.role, + expires_at: row.expires_at, + } as T; + } + + if (this.sql.includes('SELECT COUNT(*)')) { + return { count: this.db.countUnread(String(this.params[0])) } as T; + } + + if (this.sql.includes('SELECT id, user_id, status FROM notifications')) { + const row = this.db.findNotification(String(this.params[0])); + if (!row) return null; + return { id: row.id, user_id: row.user_id, status: row.status } as T; + } + + return null; + } + + async all(): Promise> { + if (this.sql.includes('FROM notifications n')) { + return { results: this.db.listForUser(String(this.params[0])) as unknown as T[], success: true, meta: {} }; + } + return { results: [], success: true, meta: {} }; + } + + async run(): Promise { + this.db.runCalls.push({ sql: this.sql, params: this.params }); + + if (this.sql.startsWith('INSERT INTO notifications')) { + this.db.insertNotification(this.params); + } + + if (this.sql.startsWith('UPDATE notifications') && this.sql.includes("status='viewed'")) { + const userId = String(this.params[0]); + for (const n of this.db.notifications) { + if (n.user_id === userId && n.status === 'unread') { + n.status = 'viewed'; + n.viewed_at = '2026-06-20T00:00:00.000Z'; + } + } + } + + if (this.sql.startsWith('DELETE FROM notifications')) { + const id = String(this.params[0]); + const userId = String(this.params[1]); + const idx = this.db.notifications.findIndex((n) => n.id === id && n.user_id === userId); + const changes = idx === -1 ? 0 : 1; + if (idx !== -1) this.db.notifications.splice(idx, 1); + return { success: true, meta: { changes } } as unknown as D1Result; + } + + return { success: true, meta: { changes: 1 } } as unknown as D1Result; + } +} + +class FakeExecutionContext { + waitUntil(): void {} + passThroughOnException(): void {} +} + +function notification(overrides: Partial = {}): NotificationRow { + return { + id: 'notif_1', + user_id: 'user_1', + title: 'Promotion approved', + body: 'Your link was approved.', + kind: 'promotion_approved', + related_submission_id: 'sub_1', + status: 'unread', + created_at: '2026-06-20T00:00:00.000Z', + viewed_at: null, + ...overrides, + }; +} + +function futureIso(): string { + return new Date(Date.now() + 60 * 60 * 1000).toISOString(); +} + +async function userSession(token: string, userId: string, role: 'user' | 'admin' = 'user'): Promise { + return { + id: userId, + email: `${userId}@heygo.cc`, + name: userId, + image_url: null, + role, + expires_at: futureIso(), + session_token_hash: await hashSessionToken(token), + }; +} + +function cookie(token: string): string { + return `${AUTH_SESSION_COOKIE_NAME}=${token}`; +} + +function makeEnv( + notifications: NotificationRow[] = [], + submissions: SubmissionRow[] = [], + links: LinkRow[] = [], + sessions: SessionRow[] = [], +) { + const db = new FakeD1Database(notifications, submissions, links, sessions); + return { + env: { + DB: db as unknown as D1Database, + PUBLIC_HOST: 'heygo.cc', + PRIVATE_HOST: 'my.heygo.cc', + APP_BASE_URL: 'https://heygo.cc', + COOKIE_DOMAIN: '.heygo.cc', + }, + db, + ctx: new FakeExecutionContext(), + }; +} + +async function fetchWorker( + path: string, + opts: { + method?: string; + body?: unknown; + notifications?: NotificationRow[]; + sessions?: SessionRow[]; + cookie?: string; + } = {}, +) { + const { env, db, ctx } = makeEnv(opts.notifications ?? [], [], [], opts.sessions ?? []); + const headers = new Headers(); + if (opts.cookie) headers.set('cookie', opts.cookie); + if (opts.body !== undefined) headers.set('content-type', 'application/json'); + + const response = await worker.fetch( + new Request(`https://heygo.cc${path}`, { + method: opts.method ?? 'GET', + headers, + body: opts.body === undefined ? undefined : JSON.stringify(opts.body), + }) as unknown as Parameters[0], + env as unknown as Parameters[1], + ctx as unknown as Parameters[2], + ); + + return { response, db }; +} + +async function expectJson(response: Response): Promise { + expect(response.headers.get('content-type')).toContain('application/json'); + return response.json() as Promise; +} + +describe('notifications API', () => { + it('lists the current user notifications and auto-marks them viewed', async () => { + const session = await userSession('token-a', 'user_1'); + const notifications = [ + notification({ id: 'n1', user_id: 'user_1', status: 'unread', title: 'A', created_at: '2026-06-20T00:00:00.000Z' }), + notification({ id: 'n2', user_id: 'user_1', status: 'unread', title: 'B', created_at: '2026-06-20T01:00:00.000Z' }), + notification({ id: 'n_other', user_id: 'user_2', title: 'Other' }), + ]; + const { response, db } = await fetchWorker('/api/notifications', { + cookie: cookie('token-a'), + sessions: [session], + notifications, + }); + + expect(response.status).toBe(200); + const body = await expectJson(response); + expect(body.notifications.map((n: { id: string }) => n.id)).toEqual(['n2', 'n1']); + expect(db.notifications.filter((n) => n.user_id === 'user_1').every((n) => n.status === 'viewed')).toBe(true); + }); + + it('returns the unread count for the current user', async () => { + const session = await userSession('token-a', 'user_1'); + const notifications = [ + notification({ id: 'n1', user_id: 'user_1', status: 'unread' }), + notification({ id: 'n2', user_id: 'user_1', status: 'viewed' }), + notification({ id: 'n3', user_id: 'user_2', status: 'unread' }), + ]; + const { response } = await fetchWorker('/api/notifications/unread-count', { + cookie: cookie('token-a'), + sessions: [session], + notifications, + }); + + expect(response.status).toBe(200); + const body = await expectJson(response); + expect(body.count).toBe(1); + }); + + it('deletes a notification owned by the current user', async () => { + const session = await userSession('token-a', 'user_1'); + const notifications = [notification({ id: 'n1', user_id: 'user_1' })]; + const { response, db } = await fetchWorker('/api/notifications/n1', { + method: 'DELETE', + cookie: cookie('token-a'), + sessions: [session], + notifications, + }); + + expect(response.status).toBe(200); + expect(db.notifications.some((n) => n.id === 'n1')).toBe(false); + }); + + it('returns 404 when deleting another user notification', async () => { + const session = await userSession('token-a', 'user_1'); + const notifications = [notification({ id: 'n_secret', user_id: 'user_2' })]; + const { response, db } = await fetchWorker('/api/notifications/n_secret', { + method: 'DELETE', + cookie: cookie('token-a'), + sessions: [session], + notifications, + }); + + expect(response.status).toBe(404); + expect(db.notifications.some((n) => n.id === 'n_secret')).toBe(true); + }); + + it('deletes multiple notifications via delete-batch', async () => { + const session = await userSession('token-a', 'user_1'); + const notifications = [ + notification({ id: 'n1', user_id: 'user_1' }), + notification({ id: 'n2', user_id: 'user_1' }), + notification({ id: 'n3', user_id: 'user_2' }), + ]; + const { response, db } = await fetchWorker('/api/notifications/delete-batch', { + method: 'POST', + cookie: cookie('token-a'), + sessions: [session], + notifications, + body: { ids: ['n1', 'n2', 'n3'] }, + }); + + expect(response.status).toBe(200); + const body = await expectJson(response); + expect(body.ok).toBe(true); + expect(body.deleted).toBe(2); + expect(db.notifications.map((n) => n.id)).toEqual(['n3']); + }); + + it('rejects unauthenticated requests with 401', async () => { + const { response } = await fetchWorker('/api/notifications'); + expect(response.status).toBe(401); + await expect(expectJson(response)).resolves.toEqual({ error: 'Authentication required' }); + }); + + it('returns 400 for an invalid delete-batch body', async () => { + const session = await userSession('token-a', 'user_1'); + const { response } = await fetchWorker('/api/notifications/delete-batch', { + method: 'POST', + cookie: cookie('token-a'), + sessions: [session], + body: { ids: [] }, + }); + expect(response.status).toBe(400); + await expect(expectJson(response)).resolves.toHaveProperty('error'); + }); + + it('returns 405 for unsupported methods', async () => { + const session = await userSession('token-a', 'user_1'); + const { response } = await fetchWorker('/api/notifications', { + method: 'POST', + cookie: cookie('token-a'), + sessions: [session], + }); + expect(response.status).toBe(405); + }); +}); diff --git a/tests/api.promotions.test.ts b/tests/api.promotions.test.ts index a82eb3d..ed47138 100644 --- a/tests/api.promotions.test.ts +++ b/tests/api.promotions.test.ts @@ -64,9 +64,19 @@ type FakeD1Options = { throwOnInsert?: Error; }; +type NotificationRow = { + id: string; + user_id: string; + title: string; + body: string | null; + kind: string; + related_submission_id: string | null; +}; + class FakeD1Database { readonly preparedSql: string[] = []; readonly runCalls: { sql: string; params: unknown[] }[] = []; + readonly notifications: NotificationRow[] = []; constructor( readonly links: LinkRow[] = [], @@ -294,6 +304,18 @@ class FakeD1PreparedStatement { this.db.insertPublicLink(this.params); } + if (this.sql.startsWith('INSERT INTO notifications')) { + const [id, userId, title, body, kind, relatedSubmissionId] = this.params; + this.db.notifications.push({ + id: String(id), + user_id: String(userId), + title: String(title), + body: body == null ? null : String(body), + kind: String(kind), + related_submission_id: relatedSubmissionId == null ? null : String(relatedSubmissionId), + }); + } + if (this.sql.startsWith('UPDATE promotion_submissions')) { const submission = this.db.findSubmission(String(this.params[this.params.length - 1])); if (submission && submission.status === 'pending') { @@ -737,4 +759,78 @@ describe('promotion submission API', () => { expect(response.status).toBe(409); expect(db.submissions[0].status).toBe('pending'); }); + + it('creates an unread notification for the submitter on approval', async () => { + const admin = await userSession('admin-token', 'admin_1', 'admin'); + const links = [ + link({ id: 'priv_1', owner_user_id: 'user_1', alias: 'mylink', target_url: 'https://example.com' }), + ]; + const submissions = [ + submission({ id: 'sub_1', private_link_id: 'priv_1', submitted_by_user_id: 'user_1', proposed_alias: 'docs' }), + ]; + const { db } = await fetchWorker('/api/admin/promotions/sub_1/approve', { + method: 'POST', + cookie: cookie('admin-token'), + sessions: [admin], + links, + submissions, + }); + + expect(db.notifications).toHaveLength(1); + expect(db.notifications[0]).toMatchObject({ + user_id: 'user_1', + kind: 'promotion_approved', + title: 'Promotion approved', + related_submission_id: 'sub_1', + }); + expect(db.notifications[0].body).toContain('mylink'); + expect(db.notifications[0].body).toContain('docs'); + }); + + it('creates a notification with the reason on rejection', async () => { + const admin = await userSession('admin-token', 'admin_1', 'admin'); + const links = [link({ id: 'priv_1', owner_user_id: 'user_1', alias: 'mylink' })]; + const submissions = [ + submission({ id: 'sub_1', private_link_id: 'priv_1', submitted_by_user_id: 'user_1', proposed_alias: 'docs' }), + ]; + const { db } = await fetchWorker('/api/admin/promotions/sub_1/reject', { + method: 'POST', + cookie: cookie('admin-token'), + sessions: [admin], + links, + submissions, + body: { reason: 'too generic' }, + }); + + expect(db.notifications).toHaveLength(1); + expect(db.notifications[0]).toMatchObject({ + user_id: 'user_1', + kind: 'promotion_rejected', + title: 'Promotion rejected', + }); + expect(db.notifications[0].body).toContain('too generic'); + }); + + it('creates a notification on needs-changes', async () => { + const admin = await userSession('admin-token', 'admin_1', 'admin'); + const links = [link({ id: 'priv_1', owner_user_id: 'user_1', alias: 'mylink' })]; + const submissions = [ + submission({ id: 'sub_1', private_link_id: 'priv_1', submitted_by_user_id: 'user_1', proposed_alias: 'docs' }), + ]; + const { db } = await fetchWorker('/api/admin/promotions/sub_1/needs-changes', { + method: 'POST', + cookie: cookie('admin-token'), + sessions: [admin], + links, + submissions, + body: { reason: 'add a description' }, + }); + + expect(db.notifications).toHaveLength(1); + expect(db.notifications[0]).toMatchObject({ + user_id: 'user_1', + kind: 'promotion_needs_changes', + }); + expect(db.notifications[0].body).toContain('add a description'); + }); }); diff --git a/tests/routing.test.ts b/tests/routing.test.ts new file mode 100644 index 0000000..41a41db --- /dev/null +++ b/tests/routing.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; +import { resolveRouteForCurrentUser } from '../src/lib/routing'; + +describe('resolveRouteForCurrentUser', () => { + it('redirects logged-in users away from the login page', () => { + expect(resolveRouteForCurrentUser('login', { id: 'user_1' })).toBe('home'); + }); + + it('keeps anonymous users on the login page', () => { + expect(resolveRouteForCurrentUser('login', null)).toBe('login'); + }); + + it('leaves other routes unchanged for logged-in users', () => { + expect(resolveRouteForCurrentUser('my-links', { id: 'user_1' })).toBe('my-links'); + }); +}); diff --git a/worker/index.ts b/worker/index.ts index 518d937..3198f9b 100644 --- a/worker/index.ts +++ b/worker/index.ts @@ -3,6 +3,7 @@ import { withPrivateNoStoreHeaders } from './lib/responses'; import { handleAuthApi } from './routes/api.auth'; import { handleDevAuth } from './routes/api.dev-auth'; import { handleLinksApi } from './routes/api.links'; +import { handleNotificationsApi } from './routes/api.notifications'; import { handleOAuthApi } from './routes/api.oauth'; import { handleLinkGoRoute } from './routes/link-go'; import { handlePromotionsApi } from './routes/api.promotions'; @@ -122,6 +123,10 @@ export default { if (promotionsResponse) { return withPrivateHostNoStoreHeaders(url, promotionsResponse, env.PRIVATE_HOST); } + const notificationsResponse = await handleNotificationsApi(request, env); + if (notificationsResponse) { + return withPrivateHostNoStoreHeaders(url, notificationsResponse, env.PRIVATE_HOST); + } return withPrivateHostNoStoreHeaders(url, json({ error: 'Not found' }, { status: 404 }), env.PRIVATE_HOST); } diff --git a/worker/routes/api.notifications.ts b/worker/routes/api.notifications.ts new file mode 100644 index 0000000..943cca3 --- /dev/null +++ b/worker/routes/api.notifications.ts @@ -0,0 +1,224 @@ +import { z } from 'zod'; +import { AuthError, requireUser } from '../auth'; +import type { Env } from '../env'; + +const jsonHeaders = { + 'content-type': 'application/json; charset=utf-8', +}; + +type NotificationStatus = 'unread' | 'viewed'; + +type NotificationJoinedRow = { + id: string; + user_id: string; + title: string; + body: string | null; + kind: string; + related_submission_id: string | null; + status: NotificationStatus; + created_at: string; + viewed_at: string | null; + proposed_alias: string | null; + private_link_id: string | null; + private_link_alias: string | null; +}; + +const NOTIFICATION_LIST_QUERY = `SELECT n.id, n.user_id, n.title, n.body, n.kind, n.related_submission_id, n.status, n.created_at, n.viewed_at, + s.proposed_alias, s.private_link_id, l.alias AS private_link_alias +FROM notifications n +LEFT JOIN promotion_submissions s ON s.id = n.related_submission_id +LEFT JOIN links l ON l.id = s.private_link_id +WHERE n.user_id=? +ORDER BY n.created_at DESC +LIMIT 200`; + +const NOTIFICATION_BY_ID_QUERY = `SELECT id, user_id, status FROM notifications WHERE id=? LIMIT 1`; + +const UNREAD_COUNT_QUERY = `SELECT COUNT(*) AS count FROM notifications WHERE user_id=? AND status='unread'`; + +const MARK_VIEWED_QUERY = `UPDATE notifications SET status='viewed', viewed_at=strftime('%Y-%m-%dT%H:%M:%fZ', 'now') +WHERE user_id=? AND status='unread'`; + +const DELETE_BY_ID_QUERY = `DELETE FROM notifications WHERE id=? AND user_id=?`; + +const deleteBatchSchema = z.object({ + ids: z.array(z.string().min(1)).min(1).max(200), +}); + +export async function handleNotificationsApi(request: Request, env: Env): Promise { + const url = new URL(request.url); + const pathname = url.pathname; + + try { + if (pathname === '/api/notifications') { + if (request.method === 'GET') { + return await listNotifications(request, env); + } + return methodNotAllowed(); + } + + if (pathname === '/api/notifications/unread-count') { + if (request.method === 'GET') { + return await getUnreadCount(request, env); + } + return methodNotAllowed(); + } + + if (pathname === '/api/notifications/delete-batch') { + if (request.method === 'POST') { + return await deleteBatch(request, env); + } + return methodNotAllowed(); + } + + const singleMatch = pathname.match(/^\/api\/notifications\/([^/]+)$/); + if (singleMatch) { + const id = decodePathSegment(singleMatch[1]); + if (!id) { + return json({ error: 'Invalid notification id' }, { status: 400 }); + } + if (request.method === 'DELETE') { + return await deleteOne(request, env, id); + } + return methodNotAllowed(); + } + + return null; + } catch (error) { + const response = notificationApiErrorResponse(error); + if (response) { + return response; + } + throw error; + } +} + +async function listNotifications(request: Request, env: Env): Promise { + const user = await requireUser(request, env); + const result = await env.DB.prepare(NOTIFICATION_LIST_QUERY).bind(user.id).all(); + const rows = result.results ?? []; + + if (rows.some((row) => row.status === 'unread')) { + try { + await env.DB.prepare(MARK_VIEWED_QUERY).bind(user.id).run(); + } catch { + // Best-effort: the list is still returned even if marking viewed fails. + } + } + + return json({ notifications: rows.map(toNotificationJson) }); +} + +async function getUnreadCount(request: Request, env: Env): Promise { + const user = await requireUser(request, env); + const row = await env.DB.prepare(UNREAD_COUNT_QUERY).bind(user.id).first<{ count: number }>(); + return json({ count: row?.count ?? 0 }); +} + +async function deleteOne(request: Request, env: Env, id: string): Promise { + const user = await requireUser(request, env); + const existing = await env.DB.prepare(NOTIFICATION_BY_ID_QUERY).bind(id).first<{ id: string; user_id: string }>(); + if (!existing) { + return json({ error: 'Notification not found' }, { status: 404 }); + } + if (existing.user_id !== user.id) { + return json({ error: 'Notification not found' }, { status: 404 }); + } + + await env.DB.prepare(DELETE_BY_ID_QUERY).bind(id, user.id).run(); + return json({ ok: true }); +} + +async function deleteBatch(request: Request, env: Env): Promise { + const user = await requireUser(request, env); + let body: unknown; + try { + body = await request.json(); + } catch { + throw new RequestValidationError('Invalid JSON body'); + } + + const parsed = deleteBatchSchema.safeParse(body); + if (!parsed.success) { + throw new RequestValidationError(parsed.error.issues[0]?.message ?? 'Invalid request body'); + } + + const ids = parsed.data.ids; + let deleted = 0; + for (const id of ids) { + const result = await env.DB.prepare(DELETE_BY_ID_QUERY).bind(id, user.id).run(); + deleted += (result.meta?.changes ?? 0); + } + + return json({ ok: true, deleted }); +} + +export async function createNotification( + env: Env, + userId: string, + kind: string, + title: string, + body: string, + relatedSubmissionId: string | null, +): Promise { + try { + await env.DB.prepare( + `INSERT INTO notifications (id, user_id, title, body, kind, related_submission_id, status) + VALUES (?, ?, ?, ?, ?, ?, 'unread')`, + ) + .bind(crypto.randomUUID(), userId, title, body, kind, relatedSubmissionId) + .run(); + } catch { + // Best-effort: never block a review action because of a notification failure. + } +} + +function toNotificationJson(row: NotificationJoinedRow) { + return { + id: row.id, + title: row.title, + body: row.body, + kind: row.kind, + status: row.status, + relatedSubmissionId: row.related_submission_id, + proposedAlias: row.proposed_alias, + privateLinkId: row.private_link_id, + privateLinkAlias: row.private_link_alias, + createdAt: row.created_at, + viewedAt: row.viewed_at, + }; +} + +class RequestValidationError extends Error {} + +function decodePathSegment(value: string): string | null { + try { + return decodeURIComponent(value); + } catch { + return null; + } +} + +function json(body: unknown, init: ResponseInit = {}): Response { + return Response.json(body, { + ...init, + headers: { + ...jsonHeaders, + ...init.headers, + }, + }); +} + +function methodNotAllowed(): Response { + return json({ error: 'Method not allowed' }, { status: 405 }); +} + +function notificationApiErrorResponse(error: unknown): Response | null { + if (error instanceof RequestValidationError) { + return json({ error: error.message || 'Invalid request body' }, { status: 400 }); + } + if (error instanceof AuthError) { + return json({ error: error.message }, { status: error.status }); + } + return null; +} diff --git a/worker/routes/api.promotions.ts b/worker/routes/api.promotions.ts index f507165..218983a 100644 --- a/worker/routes/api.promotions.ts +++ b/worker/routes/api.promotions.ts @@ -1,6 +1,7 @@ import { z } from 'zod'; import { AuthError, requireAdmin, requireUser } from '../auth'; import type { Env } from '../env'; +import { createNotification } from './api.notifications'; import { validateAlias } from '../lib/aliases'; const jsonHeaders = { @@ -255,6 +256,15 @@ async function approveSubmission(request: Request, env: Env, id: string): Promis await env.DB.prepare(UPDATE_APPROVED).bind(admin.id, now, publicLinkId, id).run(); + await createNotification( + env, + submission.submitted_by_user_id, + 'promotion_approved', + 'Promotion approved', + `Your private link '${submission.private_link_alias ?? submission.proposed_alias}' was approved and published as '${submission.proposed_alias}'.`, + submission.id, + ); + const publicLink = { id: publicLinkId, alias: submission.proposed_alias, @@ -296,6 +306,15 @@ async function rejectSubmission(request: Request, env: Env, id: string): Promise const now = new Date().toISOString(); await env.DB.prepare(UPDATE_REJECTED).bind(admin.id, now, reason ?? null, id).run(); + await createNotification( + env, + submission.submitted_by_user_id, + 'promotion_rejected', + 'Promotion rejected', + `Your promotion request for '${submission.private_link_alias ?? submission.proposed_alias}' was rejected${reason ? `: ${reason}` : '.'}`, + submission.id, + ); + return json({ submission: toSubmissionJson({ ...submission, @@ -322,6 +341,15 @@ async function needsChangesSubmission(request: Request, env: Env, id: string): P const now = new Date().toISOString(); await env.DB.prepare(UPDATE_NEEDS_CHANGES).bind(admin.id, now, reason ?? null, id).run(); + await createNotification( + env, + submission.submitted_by_user_id, + 'promotion_needs_changes', + 'Promotion needs changes', + `Your promotion request for '${submission.private_link_alias ?? submission.proposed_alias}' needs changes${reason ? `: ${reason}` : '.'}`, + submission.id, + ); + return json({ submission: toSubmissionJson({ ...submission,