Add notification

This commit is contained in:
2026-06-20 21:07:56 +10:00
parent d8af53a0de
commit 7cfe499be4
16 changed files with 1515 additions and 6 deletions
+14
View File
@@ -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';
+20 -4
View File
@@ -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<string | null>(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() {
))}
</nav>
<div className="app-bar-right">
<UserMenu user={user} loading={loading} onLogout={handleLogout} />
<UserMenu user={user} loading={loading} unreadCount={unreadCount} onLogout={handleLogout} />
</div>
</header>
@@ -102,6 +117,7 @@ export default function App() {
{route === 'settings' ? <PlaceholderPage title="Settings" /> : null}
{route === 'terms' ? <LegalPage kind="terms" /> : null}
{route === 'privacy' ? <LegalPage kind="privacy" /> : null}
{route === 'notifications' ? <NotificationsPage onMarkedViewed={handleMarkedViewed} /> : null}
{route === 'link-detail' && linkDetailId ? (
<LinkDetailPage
linkId={linkDetailId}
+13 -1
View File
@@ -3,10 +3,11 @@ import type { CurrentUser } from '../lib/auth';
interface UserMenuProps {
user: CurrentUser | null;
loading: boolean;
unreadCount: number;
onLogout: () => void;
}
export default function UserMenu({ user, loading, onLogout }: UserMenuProps) {
export default function UserMenu({ user, loading, unreadCount, onLogout }: UserMenuProps) {
if (loading) {
return <div className="user-menu-loading" aria-label="Loading user" />;
}
@@ -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 (
<div className="user-menu">
@@ -32,6 +37,9 @@ export default function UserMenu({ user, loading, onLogout }: UserMenuProps) {
{displayName.charAt(0).toUpperCase()}
</span>
)}
{hasUnread ? (
<span className="user-avatar__dot" aria-label={unreadLabel} title={unreadLabel} />
) : null}
</span>
<span className="user-name">{displayName}</span>
{user.role === 'admin' && <span className="admin-badge">admin</span>}
@@ -40,6 +48,10 @@ export default function UserMenu({ user, loading, onLogout }: UserMenuProps) {
</svg>
</div>
<div className="user-dropdown" role="menu">
<a href="#/notifications" className="dropdown-item dropdown-item--with-badge" role="menuitem">
<span>Notifications</span>
{hasUnread ? <span className="dropdown-badge" aria-label={unreadLabel}>{unreadCount}</span> : null}
</a>
<a href="#/profile" className="dropdown-item" role="menuitem">Profile</a>
<a href="#/settings" className="dropdown-item" role="menuitem">Settings</a>
{user.role === 'admin' && (
+50
View File
@@ -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<NotificationListResponse> {
return request<NotificationListResponse>('/api/notifications');
}
export function getUnreadNotificationCount(): Promise<UnreadCountResponse> {
return request<UnreadCountResponse>('/api/notifications/unread-count');
}
export function deleteNotification(id: string): Promise<DeleteResponse> {
return deleteLink(`/api/notifications/${encodeURIComponent(id)}`);
}
export function deleteNotifications(ids: string[]): Promise<DeleteBatchResponse> {
return request<DeleteBatchResponse>('/api/notifications/delete-batch', {
method: 'POST',
body: JSON.stringify({ ids }),
});
}
// ---- Link detail (stats + history) ----
export interface DayCount {
+49
View File
@@ -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<number | null>(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 };
}
+9
View File
@@ -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;
}
+57 -1
View File
@@ -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<CopySuccessNotice | null>(null);
const [shortLinkNotice, setShortLinkNotice] = useState<string | null>(null);
const [showPropose, setShowPropose] = useState(false);
const [proposeNotice, setProposeNotice] = useState<string | null>(null);
const chartRef = useRef<HTMLCanvasElement | null>(null);
const chartInstance = useRef<Chart | null>(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 (
<div style={{ width: '100%', maxWidth: 960, display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
{copySuccessNotice ? (
@@ -358,6 +390,16 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas
{copying ? 'Copying…' : 'Copy to My Links'}
</button>
) : null}
{canPropose ? (
<button
type="button"
className="link-action link-action--approve detail-copy-button"
onClick={() => setShowPropose(true)}
>
<MegaphoneIcon aria-hidden="true" size={14} />
<span>Propose</span>
</button>
) : null}
</div>
{shortLinkNotice ? (
<div className="detail-actions__notice" role="status" aria-live="polite">
@@ -365,6 +407,12 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas
<span>{shortLinkNotice}</span>
</div>
) : null}
{proposeNotice ? (
<div className="detail-actions__notice" role="status" aria-live="polite">
<CheckIcon aria-hidden="true" size={12} />
<span>{proposeNotice}</span>
</div>
) : null}
</div>
</div>
{copyError ? <p className="form-error" role="alert" style={{ marginTop: 0 }}>{copyError}</p> : null}
@@ -465,6 +513,14 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas
}}
/>
) : null}
{showPropose && link ? (
<PromotionDialog
links={[link]}
onSubmit={handlePropose}
onClose={() => setShowPropose(false)}
/>
) : null}
</div>
);
}
+251
View File
@@ -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<string, string> = {
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<Notification[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [deleting, setDeleting] = useState(false);
const [notice, setNotice] = useState<string | null>(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 (
<section className="panel">
<header className="panel-header">
<div>
<h1>Notifications</h1>
<p className="muted">Updates about your promotion proposals.</p>
</div>
<div className="panel-actions">
<button type="button" onClick={() => void refresh()} disabled={loading}>Refresh</button>
</div>
</header>
{error ? <p className="form-error" role="alert">{error}</p> : null}
{selected.size > 0 ? (
<div className="selection-bar" role="status">
<span>{selected.size} selected.</span>
<button
type="button"
className="link-action link-action--danger"
onClick={() => void handleDeleteSelected()}
disabled={deleting}
>
{deleting ? 'Deleting…' : 'Delete selected'}
</button>
<button type="button" className="link-action" onClick={() => setSelected(new Set())}>Clear</button>
</div>
) : null}
{notice ? (
<div className="detail-actions__notice" role="status" aria-live="polite" style={{ alignSelf: 'flex-start' }}>
<CheckIcon aria-hidden="true" size={12} />
<span>{notice}</span>
</div>
) : null}
{loading ? (
<p className="table-status" aria-busy="true">Loading notifications</p>
) : notifications.length === 0 ? (
<p className="table-empty">No notifications yet.</p>
) : (
<div className="notifications-list">
<div className="notifications-list__select-all">
<label>
<input
type="checkbox"
aria-label={allSelected ? 'Deselect all notifications' : 'Select all notifications'}
checked={allSelected}
onChange={() => selectAll(allSelected ? [] : allIds)}
/>
<span className="muted">{notifications.length} notification{notifications.length === 1 ? '' : 's'}</span>
</label>
</div>
<ul>
{notifications.map((notification) => (
<NotificationItem
key={notification.id}
notification={notification}
selected={selected.has(notification.id)}
onToggleSelect={() => toggleSelect(notification.id)}
onDelete={() => void handleDeleteOne(notification)}
/>
))}
</ul>
</div>
)}
</section>
);
}
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 (
<li className={`notification-item${selected ? ' is-selected' : ''}${notification.status === 'unread' ? ' is-unread' : ''}`}>
<div className="notification-item__select">
<input
type="checkbox"
aria-label={`Select ${notification.title}`}
checked={selected}
onChange={onToggleSelect}
/>
</div>
<div className="notification-item__body">
<div className="notification-item__header">
{notification.status === 'unread' ? <span className="notification-unread-dot" aria-label="Unread" /> : null}
<span className={kindClassName}>{kindLabel}</span>
<h3 className="notification-item__title">{notification.title}</h3>
</div>
{notification.body ? <p className="notification-item__text">{notification.body}</p> : null}
<div className="notification-item__details">
<time dateTime={notification.createdAt}>{new Date(notification.createdAt).toLocaleString()}</time>
{notification.proposedAlias ? (
<span className="detail-chip">Proposed: <span className="alias-pill">#{notification.proposedAlias}</span></span>
) : null}
{notification.privateLinkAlias ? (
<span className="detail-chip">Private link: <span className="alias-pill">#{notification.privateLinkAlias}</span></span>
) : null}
{notification.viewedAt ? (
<span className="muted">Viewed {new Date(notification.viewedAt).toLocaleString()}</span>
) : null}
</div>
</div>
<div className="notification-item__actions">
<button
type="button"
className="link-action link-action--danger"
onClick={onDelete}
aria-label={`Delete ${notification.title}`}
>
<Trash2Icon aria-hidden="true" size={14} />
<span>Delete</span>
</button>
</div>
</li>
);
}
+171
View File
@@ -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
════════════════════════════════════════════ */
+98
View File
@@ -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',
});
});
});
+414
View File
@@ -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<T> = {
results: T[];
success: true;
meta: Record<string, never>;
};
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<T>(): Promise<T | null> {
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<T>(): Promise<AllResult<T>> {
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<D1Result> {
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> = {}): 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<SessionRow> {
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<typeof worker.fetch>[0],
env as unknown as Parameters<typeof worker.fetch>[1],
ctx as unknown as Parameters<typeof worker.fetch>[2],
);
return { response, db };
}
async function expectJson<T = any>(response: Response): Promise<T> {
expect(response.headers.get('content-type')).toContain('application/json');
return response.json() as Promise<T>;
}
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);
});
});
+96
View File
@@ -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');
});
});
+16
View File
@@ -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');
});
});
+5
View File
@@ -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);
}
+224
View File
@@ -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<Response | null> {
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<Response> {
const user = await requireUser(request, env);
const result = await env.DB.prepare(NOTIFICATION_LIST_QUERY).bind(user.id).all<NotificationJoinedRow>();
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<Response> {
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<Response> {
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<Response> {
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<void> {
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;
}
+28
View File
@@ -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,