mirror of
https://github.com/wahyd4/heygo.git
synced 2026-08-09 05:15:52 +10:00
Add notification
This commit is contained in:
@@ -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
@@ -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}
|
||||
|
||||
@@ -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' && (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
════════════════════════════════════════════ */
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user