Add user notification inbox and delivery controls

This commit is contained in:
JB
2026-07-25 15:29:21 -04:00
parent ff7f3a6d34
commit 29f4de807a
24 changed files with 606 additions and 18 deletions
+24
View File
@@ -25,6 +25,30 @@ CREATE TABLE IF NOT EXISTS avatar_reviews (
reviewed_by TEXT REFERENCES users(id),
review_note TEXT
);
CREATE TABLE IF NOT EXISTS notifications (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
category TEXT NOT NULL CHECK(category IN ('isac', 'contributions', 'comments', 'builds', 'guides', 'system')),
source TEXT NOT NULL,
title TEXT NOT NULL,
body TEXT NOT NULL,
link TEXT,
mandatory INTEGER NOT NULL DEFAULT 0 CHECK(mandatory IN (0, 1)),
created_at TEXT NOT NULL,
read_at TEXT,
emailed_at TEXT
);
CREATE INDEX IF NOT EXISTS notifications_user_created_idx ON notifications(user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS notifications_user_unread_idx ON notifications(user_id, read_at, created_at DESC);
CREATE TABLE IF NOT EXISTS notification_preferences (
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
category TEXT NOT NULL CHECK(category IN ('isac', 'contributions', 'comments', 'builds', 'guides', 'system')),
in_app_enabled INTEGER NOT NULL DEFAULT 1 CHECK(in_app_enabled IN (0, 1)),
browser_enabled INTEGER NOT NULL DEFAULT 0 CHECK(browser_enabled IN (0, 1)),
email_frequency TEXT NOT NULL DEFAULT 'none' CHECK(email_frequency IN ('none', 'immediate', 'daily')),
updated_at TEXT NOT NULL,
PRIMARY KEY(user_id, category)
);
CREATE INDEX IF NOT EXISTS avatar_reviews_status_submitted_idx ON avatar_reviews(status, submitted_at DESC);
CREATE INDEX IF NOT EXISTS avatar_reviews_user_submitted_idx ON avatar_reviews(user_id, submitted_at DESC);
CREATE TABLE IF NOT EXISTS builds (id TEXT PRIMARY KEY, owner_id TEXT NOT NULL REFERENCES users(id), title TEXT NOT NULL, slug TEXT NOT NULL UNIQUE, visibility TEXT NOT NULL DEFAULT 'draft', summary TEXT, guide_body TEXT, source_build_id TEXT REFERENCES builds(id), created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
+76
View File
@@ -659,3 +659,79 @@ the default SHD portrait. A stale review must never act on a newer upload.
Historical entries retain metadata and audit evidence even when their image is
no longer available. At narrow widths cards become one column, retain a large
portrait preview, and expose a minimum 44-pixel review action.
### Notification network
Authenticated navigation exposes a compact inbox glyph with a numeric unread
badge; mobile navigation provides the same explicit `INBOX` destination and
count. Notifications live under the agent-profile namespace and use an explicit
profile context trail back to account controls. The inbox is a dense
communication registry with a source rail, category,
title, timestamp, body, destination, and textual read state. Unread messages use
the orange action rail. Mandatory ISAC notices use the blue operational rail and
must always remain visible in the in-app inbox.
Preferences are channel-by-category rather than global. Each category controls
in-app, browser, and email delivery independently. ISAC in-app delivery is
locked on, while its browser and email channels remain optional. Email defaults
to off and offers `OFF`, `IMMEDIATE`, and `DAILY DIGEST`; browser delivery must
follow an explicit browser permission gesture. Never imply background push when
only active-site browser polling is available. At narrow widths preference rows
stack their identity above channel controls and notification entries collapse
their source rail above the message.
The main Agent Profile surfaces the five most recent enabled inbox messages
before authored-content modules and includes unread count as a profile metric.
This preview preserves mandatory, unread, source, and category states, uses
single-line truncation at desktop density, and links to either the notification
destination or full profile inbox. On mobile, metadata becomes its own row and
message body copy may wrap.
Inbox messages use sender identity and semantic tone. ISAC sources use the SHD
system mark, blue system labeling, and an explicit `SYSTEM` descriptor;
community activity uses an agent portrait and `AGENT` descriptor. Positive
outcomes use green, adverse or corrective outcomes use red, and informational
content stays neutral. Message titles and body copy state the actual outcome;
do not expose internal sentiment labels such as `POSITIVE`, `NEGATIVE`, or
`NEUTRAL`. Reserve visible badges for operationally meaningful severity such as
`WARNING` or `ERROR`, while the message wording remains understandable without
depending on color alone.
The inbox command header pairs the page identity with a compact bordered unread
summary and a same-height preferences action. Filter selects and adjacent
actions share a 40-pixel control height and vertically centered labels. Within
each message, the tone/title/timestamp header uses modest vertical padding and a
subtle lower rule: keep it dense, but never collapse it into a text-height strip.
On mobile the unread summary and preferences action share one grid row, while
filter controls stack into full-width, minimum 44-pixel targets.
Page hierarchy uses the shared eyebrow breadcrumb rather than a separate
breadcrumb row above a command header. It retains the short orange rule, renders
ancestors as orange links, separates levels with a muted slash, and marks the
final item as the current page. A top-level page may use a single current item.
Do not duplicate the same hierarchy in both an eyebrow and a second contextual
navigation strip; this creates visual overlap and wastes vertical space. The
breadcrumb always uses intrinsic height and must not inherit the full-height
primary-navigation treatment. Likewise, in-page command and message headings
must use their named component classes rather than the global application
`header` element treatment.
`VIEW DETAILS` opens the standard in-page modal rather than navigating
immediately. The modal repeats sender, source, category, semantic tone, time,
full message body, read control, and optional related-page destination. This
keeps review context in the inbox and makes outbound navigation deliberate.
ISAC message content uses the structured system-transmission body. A compact
blue `ISAC TRANSMISSION` kicker precedes the summary, and recognized supporting
fields such as `OVERSIGHT REASON`, `REQUIRED ACTION`, or `DETAILS` become a
labeled detail row beneath a subtle divider. The expanded message view uses the
same hierarchy at reading size. Community messages remain conversational body
copy; do not apply the system-transmission frame to agent comments.
Inbox entries expose two distinct actions. `VIEW DETAILS` opens the complete
message when preview copy is truncated to two lines. A separate bordered orange
response action navigates directly to the known workflow and uses specific
language such as `UPLOAD NEW AVATAR`, `REVISE CONTRIBUTION`, or
`VIEW BUILD & REPLY`; never substitute a generic destination label when the
notification type is known. The same response label appears in the detail
modal. On mobile, response actions become full-width 44-pixel targets.
+3
View File
@@ -15,6 +15,9 @@
"db:backup:production": "dotenvx run -f .env.production -- node scripts/db-backup.mjs",
"db:restore": "dotenvx run -f .env.development -- node scripts/db-restore.mjs",
"db:restore:production": "dotenvx run -f .env.production -- node scripts/db-restore.mjs",
"notifications:digest": "dotenvx run -f .env.development -- node scripts/notification-digest.mjs",
"notifications:digest:production": "dotenvx run -f .env.production -- node scripts/notification-digest.mjs",
"notifications:seed-test": "dotenvx run -f .env.development -- node scripts/seed-test-notifications.mjs",
"env:encrypt": "node scripts/env-files.mjs encrypt",
"env:decrypt": "node scripts/env-files.mjs decrypt",
"env:encrypt:development": "dotenvx encrypt -f .env.development",
+41
View File
@@ -0,0 +1,41 @@
import { connect } from '@tursodatabase/database';
import nodemailer from 'nodemailer';
import { resolve } from 'node:path';
if (!process.env.SMTP_HOST || !process.env.SMTP_FROM) throw new Error('SMTP delivery is not configured.');
const database = await connect(resolve(process.env.TDR_DATABASE_PATH || './data/shd-build.db'));
const transport = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT || 587),
secure: process.env.SMTP_SECURE === 'true',
auth: process.env.SMTP_USER ? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASSWORD } : undefined,
requireTLS: process.env.SMTP_SECURE !== 'true',
disableFileAccess: true,
disableUrlAccess: true
});
const origin = (process.env.APP_ORIGIN || 'http://localhost:5173').replace(/\/+$/, '');
const recipients = await (await database.prepare(`SELECT DISTINCT n.user_id,e.email
FROM notifications n
JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category AND p.email_frequency='daily'
JOIN user_emails e ON e.user_id=n.user_id
WHERE n.emailed_at IS NULL AND n.created_at>=p.updated_at`)).all();
let sent = 0;
for (const recipient of recipients) {
const items = await (await database.prepare(`SELECT n.id,n.title,n.body,n.link FROM notifications n
JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category AND p.email_frequency='daily'
WHERE n.user_id=? AND n.emailed_at IS NULL AND n.created_at>=p.updated_at ORDER BY n.created_at LIMIT 100`)).all(recipient.user_id);
if (!items.length) continue;
const escape = (value) => String(value).replace(/[&<>"']/g, (character) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[character]);
const rows = items.map((item) => `<div style="border-top:1px solid #34393a;padding:16px 0"><strong>${escape(item.title)}</strong><p style="color:#a5aaab">${escape(item.body)}</p>${item.link ? `<a style="color:#f6a821" href="${origin}${item.link}">VIEW INTELLIGENCE</a>` : ''}</div>`).join('');
await transport.sendMail({
from: process.env.SMTP_FROM,
to: recipient.email,
subject: `SHD.build daily notification summary · ${items.length} update${items.length === 1 ? '' : 's'}`,
text: items.map((item) => `${item.title}\n${item.body}\n${item.link ? `${origin}${item.link}` : ''}`).join('\n\n'),
html: `<div style="background:#0a0c0d;color:#ecebe5;padding:28px;font-family:Arial,sans-serif"><p style="color:#f6a821;font-size:12px;letter-spacing:2px">SHD DAILY INTELLIGENCE</p><h1>${items.length} notification${items.length === 1 ? '' : 's'}</h1>${rows}</div>`
});
const now = new Date().toISOString();
for (const item of items) await (await database.prepare('UPDATE notifications SET emailed_at=? WHERE id=? AND emailed_at IS NULL')).run(now, item.id);
sent += 1;
}
console.log(`Sent ${sent} daily notification digest${sent === 1 ? '' : 's'}.`);
+24
View File
@@ -0,0 +1,24 @@
import { connect } from '@tursodatabase/database';
import { randomUUID } from 'node:crypto';
import { resolve } from 'node:path';
const targetName = process.argv[2] || 'Aewyn';
const database = await connect(resolve(process.env.TDR_DATABASE_PATH || './data/shd-build.db'));
const user = await (await database.prepare(`SELECT users.id,COALESCE(user_profiles.display_name,users.username) AS display_name
FROM users LEFT JOIN user_profiles ON user_profiles.user_id=users.id
WHERE users.login_username=? COLLATE NOCASE OR users.username=? COLLATE NOCASE OR user_profiles.display_name=? COLLATE NOCASE
LIMIT 1`)).get(targetName, targetName, targetName);
if (!user) throw new Error(`No user matched ${targetName}.`);
const now = Date.now();
const samples = [
['isac', 'ISAC AVATAR OVERSIGHT', '[TEST] Custom avatar removed', 'Your custom avatar did not meet SHD.build image requirements and was removed. Oversight reason: The image contained unrelated promotional text.', '/account#avatar', 1],
['contributions', 'ISAC DATA OVERSIGHT', '[TEST] Contribution approved', 'Quick Draw brand-set data was approved and deployed to the live builder registry.', '/admin/brands', 1],
['contributions', 'ISAC DATA OVERSIGHT', '[TEST] Contribution needs revision', 'A talent update was not approved. Oversight reason: Tier II effect value does not match the supplied game evidence.', '/admin/talents', 1],
['comments', 'SHD COMMUNITY', '[TEST] New comment on your build', 'An agent commented on “First Wave Vector Critical Build”: This setup worked really well in Legendary missions.', '/builds', 0]
];
for (const [index, sample] of samples.entries()) {
await (await database.prepare(`INSERT INTO notifications
(id,user_id,category,source,title,body,link,mandatory,created_at)
VALUES(?,?,?,?,?,?,?,?,?)`)).run(randomUUID(), user.id, ...sample, new Date(now - index * 60_000).toISOString());
}
console.log(`Added ${samples.length} test notifications for ${user.display_name}.`);
+11
View File
File diff suppressed because one or more lines are too long
+30 -4
View File
@@ -4,9 +4,10 @@
import { onMount, tick } from 'svelte';
import type { Snippet } from 'svelte';
let { user, isacAccess, children }: {
let { user, isacAccess, notificationSummary, children }: {
user: { username: string; displayName: string; role: string } | null;
isacAccess: boolean;
notificationSummary: { unread: number };
children: Snippet;
} = $props();
@@ -19,9 +20,32 @@
let showCookieNotice = $state(false);
let mobileOpen = $state(false);
let mobilePanel = $state<HTMLElement | null>(null);
let unreadNotifications = $state(0);
$effect(() => { unreadNotifications = notificationSummary.unread; });
onMount(() => {
showCookieNotice = localStorage.getItem('shd-essential-cookie-notice') !== 'dismissed';
if (!user) return;
const poll = async () => {
try {
const response = await fetch('/api/notifications/unread');
if (!response.ok) return;
const result = await response.json();
unreadNotifications = Number(result.count ?? 0);
if (!('Notification' in window) || Notification.permission !== 'granted') return;
const shown = new Set<string>(JSON.parse(localStorage.getItem('shd-browser-notifications') ?? '[]'));
for (const item of result.notifications as Array<{ id: string; title: string; body: string; link?: string }>) {
if (shown.has(item.id)) continue;
const alert = new Notification(item.title, { body: item.body, icon: '/favicon.svg', tag: `shd-${item.id}` });
alert.onclick = () => { window.focus(); location.href = item.link || '/account/notifications'; alert.close(); };
shown.add(item.id);
}
localStorage.setItem('shd-browser-notifications', JSON.stringify([...shown].slice(-100)));
} catch { /* A transient poll failure must not interrupt navigation. */ }
};
void poll();
const interval = window.setInterval(poll, 30_000);
return () => window.clearInterval(interval);
});
function dismissCookieNotice() {
@@ -63,7 +87,7 @@
<a class:active={page.url.pathname.startsWith(link.href)} href={link.href}>{link.label}</a>
{/each}
{#if user}
<a class:active={page.url.pathname.startsWith('/account')} href="/account">My Profile</a>
<a class:active={page.url.pathname === '/account'} href="/account">My Profile</a>
{/if}
{#if isacAccess}
<a class:active={page.url.pathname.startsWith('/admin')} href="/admin">ISAC</a>
@@ -71,6 +95,7 @@
</nav>
{#if user}
<div class="account">
<a class="notification-link" href="/account/notifications" aria-label={`${unreadNotifications} unread notifications`}><span>◆</span>{#if unreadNotifications}<b>{unreadNotifications > 99 ? '99+' : unreadNotifications}</b>{/if}</a>
<a href="/account"><small><i></i> AGENT ONLINE</small>{user.displayName}</a>
<PendingForm action="/sign-in?/logout">{#snippet children(pending)}<button aria-busy={pending}>SIGN OUT →</button>{/snippet}</PendingForm>
</div>
@@ -90,11 +115,12 @@
<a class:active={page.url.pathname.startsWith(link.href)} href={link.href}><small>{String(index + 1).padStart(2, '0')}</small><span>{link.label}</span><b>›</b></a>
{/each}
{#if user}
<a class:active={page.url.pathname.startsWith('/account')} href="/account"><small>04</small><span>My Profile</span><b>›</b></a>
<a class:active={page.url.pathname.startsWith('/account/notifications')} href="/account/notifications"><small>04</small><span>Inbox</span>{#if unreadNotifications}<em>{unreadNotifications > 99 ? '99+' : unreadNotifications}</em>{:else}<b>›</b>{/if}</a>
<a class:active={page.url.pathname === '/account'} href="/account"><small>05</small><span>My Profile</span><b>›</b></a>
{:else}
<a class:active={page.url.pathname.startsWith('/sign-in')} href="/sign-in"><small>04</small><span>Sign In</span><b>›</b></a>
{/if}
{#if isacAccess}<a class:active={page.url.pathname.startsWith('/admin')} href="/admin"><small>05</small><span>ISAC</span><b>›</b></a>{/if}
{#if isacAccess}<a class:active={page.url.pathname.startsWith('/admin')} href="/admin"><small>06</small><span>ISAC</span><b>›</b></a>{/if}
{#if user}
<div class="mobile-agent-state"><span><i></i> AGENT ONLINE</span><strong>{user.displayName}</strong></div>
<PendingForm action="/sign-in?/logout">{#snippet children(pending)}<button class="mobile-sign-out" aria-busy={pending} disabled={pending}>{pending ? 'SIGNING OUT…' : 'SIGN OUT →'}</button>{/snippet}</PendingForm>
+19
View File
@@ -0,0 +1,19 @@
<script lang="ts">
type BreadcrumbItem = {
label: string;
href?: string;
};
let { items }: { items: BreadcrumbItem[] } = $props();
</script>
<nav class="eyebrow page-breadcrumb" aria-label="Breadcrumb">
<span class="breadcrumb-line" aria-hidden="true"></span>
<ol>
{#each items as item, index}
<li>
{#if item.href}<a href={item.href}>{item.label}</a>{:else}<span aria-current={index === items.length - 1 ? 'page' : undefined}>{item.label}</span>{/if}
</li>
{/each}
</ol>
</nav>
@@ -0,0 +1,24 @@
<script lang="ts">
let { body, expanded = false }: { body: string; expanded?: boolean } = $props();
const parsed = $derived.by(() => {
const detailMatch = body.match(/^(.*?)(?:\s+)(Oversight reason|Reason|Required action|Details):\s*(.+)$/i);
if (!detailMatch) return { summary: body, detailLabel: null, detail: null };
return {
summary: detailMatch[1].trim(),
detailLabel: detailMatch[2].toUpperCase(),
detail: detailMatch[3].trim()
};
});
</script>
<div class="system-message-body" class:expanded>
<div class="system-message-kicker"><span aria-hidden="true">◇</span><b>ISAC TRANSMISSION</b></div>
<p>{parsed.summary}</p>
{#if parsed.detail}
<div class="system-message-detail">
<span>{parsed.detailLabel}</span>
<p>{parsed.detail}</p>
</div>
{/if}
</div>
+15
View File
@@ -41,3 +41,18 @@ export async function sendAuthenticationLink(email: string, token: string, purpo
html: `<div style="background:#0a0c0d;color:#ecebe5;padding:28px;font-family:Arial,sans-serif"><p style="color:#f6a821;font-size:12px;letter-spacing:2px">SHD IDENTITY NETWORK</p><h1 style="font-size:24px">${subject}</h1><p style="color:#a5aaab">This one-time link expires in 15 minutes.</p><p><a href="${url}" style="display:inline-block;background:#f6a821;color:#111;padding:14px 20px;text-decoration:none;font-weight:800">${action}</a></p><p style="color:#737a7c;font-size:12px">If you did not request this, you can ignore this message.</p></div>`
});
}
export async function sendNotificationEmail(email: string, title: string, body: string, link: string | null): Promise<void> {
const url = link ? `${applicationOrigin()}${link.startsWith('/') ? link : `/${link}`}` : `${applicationOrigin()}/account/notifications`;
await mailer().sendMail({
from: env.SMTP_FROM,
to: email,
subject: `${title} — SHD.build`,
text: `${title}\n\n${body}\n\nOpen SHD.build: ${url}`,
html: `<div style="background:#0a0c0d;color:#ecebe5;padding:28px;font-family:Arial,sans-serif"><p style="color:#f6a821;font-size:12px;letter-spacing:2px">SHD NOTIFICATION NETWORK</p><h1 style="font-size:24px">${escapeHtml(title)}</h1><p style="color:#a5aaab;line-height:1.6">${escapeHtml(body)}</p><p><a href="${url}" style="display:inline-block;background:#f6a821;color:#111;padding:14px 20px;text-decoration:none;font-weight:800">OPEN SHD INBOX</a></p></div>`
});
}
function escapeHtml(value: string) {
return value.replace(/[&<>"']/g, (character) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[character]!);
}
+54
View File
@@ -0,0 +1,54 @@
import { randomUUID } from 'node:crypto';
import { getDatabase } from '$lib/server/database';
import { sendNotificationEmail } from '$lib/server/auth/mail';
export const NOTIFICATION_CATEGORIES = ['isac', 'contributions', 'comments', 'builds', 'guides', 'system'] as const;
export type NotificationCategory = (typeof NOTIFICATION_CATEGORIES)[number];
export async function createNotification(input: {
userId: string;
category: NotificationCategory;
source: string;
title: string;
body: string;
link?: string | null;
mandatory?: boolean;
}) {
const database = await getDatabase();
const preference = await (await database.prepare(
'SELECT in_app_enabled,browser_enabled,email_frequency FROM notification_preferences WHERE user_id=? AND category=?'
)).get(input.userId, input.category);
const mandatory = Boolean(input.mandatory || input.category === 'isac');
const inApp = mandatory || preference?.in_app_enabled === undefined || Boolean(preference.in_app_enabled);
const browser = Boolean(preference?.browser_enabled);
const emailFrequency = String(preference?.email_frequency ?? 'none');
if (!inApp && !browser && emailFrequency === 'none') return null;
const id = randomUUID();
const now = new Date().toISOString();
await (await database.prepare(`INSERT INTO notifications
(id,user_id,category,source,title,body,link,mandatory,created_at)
VALUES(?,?,?,?,?,?,?,?,?)`)).run(
id, input.userId, input.category, input.source, input.title.slice(0, 140),
input.body.slice(0, 2000), input.link ?? null, mandatory ? 1 : 0, now
);
if (emailFrequency === 'immediate') {
const identity = await (await database.prepare('SELECT email FROM user_emails WHERE user_id=?')).get(input.userId);
if (typeof identity?.email === 'string') {
try {
await sendNotificationEmail(identity.email, input.title, input.body, input.link ?? null);
await (await database.prepare('UPDATE notifications SET emailed_at=? WHERE id=?')).run(new Date().toISOString(), id);
} catch (error) {
console.error('Notification email delivery failed', error);
}
}
}
return id;
}
export async function notificationSummary(userId: string) {
const database = await getDatabase();
const row = await (await database.prepare(`SELECT COUNT(*) AS unread FROM notifications n
LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category
WHERE n.user_id=? AND n.read_at IS NULL AND (n.mandatory=1 OR COALESCE(p.in_app_enabled,1)=1)`)).get(userId);
return { unread: Number(row?.unread ?? 0) };
}
+2 -1
View File
@@ -1,8 +1,9 @@
import { getSessionUser } from '$lib/server/auth/session';
import { hasPermission } from '$lib/server/auth/authorization';
import type { LayoutServerLoad } from './$types';
import { notificationSummary } from '$lib/server/notifications';
export const load: LayoutServerLoad = async ({ cookies }) => {
const user = await getSessionUser(cookies);
return { user, isacAccess: await hasPermission(user, 'isac.access') };
return { user, isacAccess: await hasPermission(user, 'isac.access'), notifications: user ? await notificationSummary(user.id) : { unread: 0 } };
};
+1 -1
View File
@@ -8,4 +8,4 @@
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
</svelte:head>
<AppShell user={data.user} isacAccess={data.isacAccess}>{@render children()}</AppShell>
<AppShell user={data.user} isacAccess={data.isacAccess} notificationSummary={data.notifications}>{@render children()}</AppShell>
+10 -3
View File
@@ -14,7 +14,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const database = await getDatabase();
const [builds, guides, favorites, ratings, connection, emailIdentity, passkeys, authenticationMethods] = await Promise.all([
const [builds, guides, favorites, ratings, connection, emailIdentity, passkeys, authenticationMethods, notifications, notificationUnread] = await Promise.all([
(await database.prepare('SELECT id, title, slug, visibility, updated_at FROM builds WHERE owner_id = ? ORDER BY updated_at DESC')).all(user.id),
(await database.prepare("SELECT id, title, slug, visibility, updated_at FROM builds WHERE owner_id = ? AND guide_body IS NOT NULL AND length(trim(guide_body)) > 0 ORDER BY updated_at DESC")).all(user.id),
(await database.prepare('SELECT builds.id, builds.title, builds.slug, COALESCE(user_profiles.display_name,users.username) AS author FROM favorites JOIN builds ON builds.id = favorites.build_id JOIN users ON users.id = builds.owner_id LEFT JOIN user_profiles ON user_profiles.user_id=users.id WHERE favorites.user_id = ? ORDER BY favorites.created_at DESC')).all(user.id),
@@ -24,7 +24,14 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
FROM users LEFT JOIN discord_profiles ON discord_profiles.user_id=users.id WHERE users.id=?`)).get(user.id),
(await database.prepare('SELECT email,verified_at FROM user_emails WHERE user_id=?')).get(user.id),
(await database.prepare('SELECT id,name,device_type,backed_up,last_used_at,created_at FROM passkeys WHERE user_id=? ORDER BY created_at DESC')).all(user.id),
getAuthenticationMethods(database, user.id)
getAuthenticationMethods(database, user.id),
(await database.prepare(`SELECT n.id,n.category,n.source,n.title,n.body,n.link,n.mandatory,n.created_at,n.read_at
FROM notifications n LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category
WHERE n.user_id=? AND (n.mandatory=1 OR COALESCE(p.in_app_enabled,1)=1)
ORDER BY n.created_at DESC LIMIT 5`)).all(user.id),
(await database.prepare(`SELECT COUNT(*) AS count FROM notifications n
LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category
WHERE n.user_id=? AND n.read_at IS NULL AND (n.mandatory=1 OR COALESCE(p.in_app_enabled,1)=1)`)).get(user.id)
]);
const oauthErrorCode = url.searchParams.get('oauth_error');
const oauthErrors: Record<string, { title: string; message: string }> = {
@@ -48,7 +55,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
}
: null;
return {
account: { builds, guides, favorites, ratings },
account: { builds, guides, favorites, ratings, notifications, notificationUnread: Number(notificationUnread?.count ?? 0) },
discordConnection: connection?.discord_id ? {
id: String(connection.discord_id),
username: connection.discord_username ? String(connection.discord_username) : null,
+37 -3
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import PageBreadcrumb from '$lib/components/PageBreadcrumb.svelte';
import PendingForm from '$lib/components/PendingForm.svelte';
import ImageUploadInput from '$lib/components/ImageUploadInput.svelte';
import { browserSupportsWebAuthn, startRegistration } from '@simplewebauthn/browser';
@@ -24,6 +25,19 @@
function initialAvatarPanel(): AvatarPanel {
return form?.avatarError || form?.avatarReviewQueued ? 'upload' : 'presets';
}
const notificationIsIsac = (notification: any) => notification.category === 'isac' || String(notification.source).startsWith('ISAC');
const notificationTone = (notification: any) => {
const text = `${notification.title} ${notification.body}`.toLocaleLowerCase();
if (/(approved|accepted|deployed|restored|successful)/.test(text)) return 'positive';
if (/(removed|rejected|not approved|failed|could not|needs revision|violation)/.test(text)) return 'negative';
return 'neutral';
};
const notificationSeverity = (notification: any) => {
const text = `${notification.title} ${notification.body}`.toLocaleLowerCase();
if (/(error|failed|could not)/.test(text)) return 'ERROR';
if (/(warning|violation|needs revision)/.test(text)) return 'WARNING';
return null;
};
let accountPanel = $state<AccountPanel>(initialAccountPanel());
let accessPanel = $state<AccessPanel>(initialAccessPanel());
let avatarPanel = $state<AvatarPanel>(initialAvatarPanel());
@@ -65,8 +79,8 @@
<svelte:head><title>{user.displayName} — SHD.build</title></svelte:head>
<main class="page profile-page">
<section class="profile-command">
<div class="profile-ident"><img class="profile-avatar" src={user.avatarUrl} alt={`${user.displayName}'s avatar`} /><div><p class="eyebrow"><span></span> AGENT PROFILE</p><h1>{user.displayName}</h1><span class="role-badge">{user.role.toUpperCase()} ACCESS</span></div></div>
<div class="profile-metrics"><div><strong>{data.account.builds.length}</strong><span>BUILDS</span></div><div><strong>{data.account.guides.length}</strong><span>GUIDES</span></div><div><strong>{data.account.favorites.length}</strong><span>FAVORITES</span></div><div><strong>{data.account.ratings.length}</strong><span>RATED</span></div></div>
<div class="profile-ident"><img class="profile-avatar" src={user.avatarUrl} alt={`${user.displayName}'s avatar`} /><div><PageBreadcrumb items={[{ label: 'AGENT PROFILE' }]} /><h1>{user.displayName}</h1><span class="role-badge">{user.role.toUpperCase()} ACCESS</span></div></div>
<div class="profile-metrics"><div><strong>{data.account.builds.length}</strong><span>BUILDS</span></div><div><strong>{data.account.guides.length}</strong><span>GUIDES</span></div><div><strong>{data.account.favorites.length}</strong><span>FAVORITES</span></div><div><strong>{data.account.ratings.length}</strong><span>RATED</span></div><a href="/account/notifications"><strong>{data.account.notificationUnread}</strong><span>INBOX</span></a></div>
</section>
{#if data.chooseDisplayName}<div class="account-alert"><b>IDENTITY SETUP</b><span>Choose the SHD.build display name other agents will see. Unicode characters are supported.</span></div>{/if}
{#if data.discordLinked}<p class="success">Discord account linked successfully.</p>{/if}
@@ -74,6 +88,14 @@
{#if data.oauthError}<div class="account-alert error-alert" role="alert"><b>{data.oauthError.title}</b><span>{data.oauthError.message}</span></div>{/if}
<div class="profile-dashboard">
<section class="profile-feed">
<div class="profile-module profile-notifications">
<div class="module-head"><div><p class="eyebrow">SHD COMMUNICATION NETWORK</p><h2>NOTIFICATIONS</h2></div><a class="secondary" href="/account/notifications">OPEN INBOX{data.account.notificationUnread ? ` · ${data.account.notificationUnread} UNREAD` : ''} →</a></div>
<div class="profile-notification-list">
{#each data.account.notifications as notification}
<a class:unread={!notification.read_at} class:mandatory={Boolean(notification.mandatory)} class:isac-message={notificationIsIsac(notification)} class:positive={notificationTone(notification) === 'positive'} class:negative={notificationTone(notification) === 'negative'} href="/account/notifications"><div><img src={notificationIsIsac(notification) ? '/favicon.svg' : '/avatars/default/comms-specialist.webp'} alt=""/><span><b>{notification.source}</b><small>{String(notification.category).toUpperCase()} · {new Date(String(notification.created_at)).toLocaleDateString()}</small></span></div><span><strong>{notification.title}</strong><small>{notification.body}</small></span><em>{notificationSeverity(notification) ?? 'OPEN'} ›</em></a>
{:else}<div class="profile-empty compact"><span>◇</span><b>NO NOTIFICATIONS</b><p>ISAC and community messages will appear here.</p></div>{/each}
</div>
</div>
<div class="profile-module"><div class="module-head"><div><p class="eyebrow">LOADOUT ARCHIVE</p><h2>MY BUILDS</h2></div><a class="secondary" href="/builder">NEW BUILD +</a></div>
{#if data.account.builds.length}<div class="profile-table"><div class="profile-row heading"><span>LOADOUT</span><span>VISIBILITY</span><span>UPDATED</span><span></span></div>{#each data.account.builds as build}<div class="profile-row"><div><strong>{build.title}</strong><small>/{build.slug}</small></div><span>{build.visibility}</span><span>{new Date(String(build.updated_at)).toLocaleDateString()}</span><button>OPEN →</button></div>{/each}</div>{:else}<div class="profile-empty"><span>◇</span><b>NO BUILDS RECORDED</b><p>Your saved and published loadouts will appear here.</p><a href="/builder">CREATE YOUR FIRST BUILD →</a></div>{/if}
</div>
@@ -89,7 +111,7 @@
{#if accountPanel === 'identity'}
<div class="account-panel" id="identity-panel" role="tabpanel" aria-labelledby="identity-tab">
<p class="eyebrow">AGENT PORTRAIT</p><h2>AVATAR</h2>
<div id="avatar" class="profile-anchor" aria-hidden="true"></div><p class="eyebrow">AGENT PORTRAIT</p><h2>AVATAR</h2>
<div class="account-subtabs" role="tablist" aria-label="Avatar source">
<button class:active={avatarPanel === 'presets'} role="tab" aria-selected={avatarPanel === 'presets'} onclick={() => avatarPanel = 'presets'}>SHD PRESETS</button>
<button class:active={avatarPanel === 'upload'} role="tab" aria-selected={avatarPanel === 'upload'} onclick={() => avatarPanel = 'upload'}>CUSTOM UPLOAD</button>
@@ -190,3 +212,15 @@
</aside>
</div>
</main>
<style>
.profile-notification-list a > div { grid-template-columns: 30px minmax(0, 1fr); align-items: center; }
.profile-notification-list a > div > img { width: 30px; height: 30px; object-fit: cover; border: 1px solid #3b4446; border-radius: 50%; background: #111; }
.profile-notification-list a > div > span { min-width: 0; display: grid; gap: 3px; }
.profile-notification-list a.isac-message b { color: #8ecce5; }
.profile-notification-list a.positive { border-left: 2px solid var(--shd-success); background: #101713; }
.profile-notification-list a.negative { border-left: 2px solid var(--shd-danger); background: #191111; }
.profile-notification-list a.isac-message:not(.positive):not(.negative) { border-left: 2px solid #68a9c4; background: #10171a; }
.profile-notification-list a.positive em { color: var(--shd-success); }
.profile-notification-list a.negative em { color: var(--shd-danger); }
</style>
@@ -0,0 +1,74 @@
import { fail, redirect } from '@sveltejs/kit';
import { getSessionUser } from '$lib/server/auth/session';
import { getDatabase } from '$lib/server/database';
import { getPagination } from '$lib/server/pagination';
import { NOTIFICATION_CATEGORIES } from '$lib/server/notifications';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ cookies, url }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const database = await getDatabase();
const category = NOTIFICATION_CATEGORIES.includes(url.searchParams.get('category') as any) ? String(url.searchParams.get('category')) : '';
const unreadOnly = url.searchParams.get('state') === 'unread';
const conditions = ['n.user_id=?', '(n.mandatory=1 OR COALESCE(p.in_app_enabled,1)=1)'];
const args: unknown[] = [user.id];
if (category) { conditions.push('n.category=?'); args.push(category); }
if (unreadOnly) conditions.push('n.read_at IS NULL');
const where = `WHERE ${conditions.join(' AND ')}`;
const total = Number((await (await database.prepare(`SELECT COUNT(*) AS total FROM notifications n LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category ${where}`)).get(...args))?.total ?? 0);
const pagination = getPagination(url, total);
const notifications = await (await database.prepare(`SELECT n.* FROM notifications n
LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category
${where} ORDER BY n.created_at DESC LIMIT ? OFFSET ?`)).all(...args, pagination.perPage, pagination.offset);
const preferenceRows = await (await database.prepare('SELECT * FROM notification_preferences WHERE user_id=?')).all(user.id);
const preferences = NOTIFICATION_CATEGORIES.map((name) => {
const stored = preferenceRows.find((row) => row.category === name);
return {
category: name,
inApp: name === 'isac' ? true : stored?.in_app_enabled === undefined ? true : Boolean(stored.in_app_enabled),
browser: Boolean(stored?.browser_enabled),
emailFrequency: String(stored?.email_frequency ?? 'none')
};
});
const unread = Number((await (await database.prepare(`SELECT COUNT(*) AS count FROM notifications n
LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category
WHERE n.user_id=? AND n.read_at IS NULL AND (n.mandatory=1 OR COALESCE(p.in_app_enabled,1)=1)`)).get(user.id))?.count ?? 0);
return { notifications, preferences, pagination, filters: { category, unreadOnly }, unread, hasVerifiedEmail: Boolean(await (await database.prepare('SELECT 1 FROM user_emails WHERE user_id=?')).get(user.id)) };
};
export const actions: Actions = {
markRead: async ({ cookies, request }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const id = String((await request.formData()).get('id') ?? '');
await (await (await getDatabase()).prepare('UPDATE notifications SET read_at=COALESCE(read_at,?) WHERE id=? AND user_id=?')).run(new Date().toISOString(), id, user.id);
return { notificationUpdated: true };
},
markAllRead: async ({ cookies }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
await (await (await getDatabase()).prepare('UPDATE notifications SET read_at=? WHERE user_id=? AND read_at IS NULL')).run(new Date().toISOString(), user.id);
return { notificationsCleared: true };
},
savePreferences: async ({ cookies, request }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const form = await request.formData();
const database = await getDatabase();
const now = new Date().toISOString();
const hasEmail = Boolean(await (await database.prepare('SELECT 1 FROM user_emails WHERE user_id=?')).get(user.id));
for (const category of NOTIFICATION_CATEGORIES) {
const inApp = category === 'isac' || form.get(`inApp:${category}`) === 'on';
const browser = form.get(`browser:${category}`) === 'on';
const emailFrequency = String(form.get(`email:${category}`) ?? 'none');
if (!['none', 'immediate', 'daily'].includes(emailFrequency)) return fail(400, { notificationPreferenceError: 'Choose a valid email frequency.' });
if (emailFrequency !== 'none' && !hasEmail) return fail(400, { notificationPreferenceError: 'Add and verify a magic-link email before enabling email notifications.' });
await (await database.prepare(`INSERT INTO notification_preferences(user_id,category,in_app_enabled,browser_enabled,email_frequency,updated_at)
VALUES(?,?,?,?,?,?) ON CONFLICT(user_id,category) DO UPDATE SET
in_app_enabled=excluded.in_app_enabled,browser_enabled=excluded.browser_enabled,email_frequency=excluded.email_frequency,updated_at=excluded.updated_at`))
.run(user.id, category, inApp ? 1 : 0, browser ? 1 : 0, emailFrequency, now);
}
return { notificationPreferencesSaved: true };
}
};
@@ -0,0 +1,121 @@
<script lang="ts">
import PendingForm from '$lib/components/PendingForm.svelte';
import PageBreadcrumb from '$lib/components/PageBreadcrumb.svelte';
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
import SystemMessageBody from '$lib/components/SystemMessageBody.svelte';
let { data, form } = $props();
let settingsOpen = $state(false);
let selected = $state<any>(null);
let browserPermission = $state(typeof Notification === 'undefined' ? 'unsupported' : Notification.permission);
const labels: Record<string, string> = { isac: 'ISAC', contributions: 'CONTRIBUTIONS', comments: 'COMMENTS', builds: 'BUILDS', guides: 'GUIDES', system: 'SYSTEM' };
const date = (value: unknown) => new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(String(value)));
const isIsac = (notification: any) => notification.category === 'isac' || String(notification.source).startsWith('ISAC');
const tone = (notification: any) => {
const text = `${notification.title} ${notification.body}`.toLocaleLowerCase();
if (/(approved|accepted|deployed|restored|successful)/.test(text)) return 'positive';
if (/(removed|rejected|not approved|failed|could not|needs revision|violation)/.test(text)) return 'negative';
return 'neutral';
};
const severity = (notification: any) => {
const text = `${notification.title} ${notification.body}`.toLocaleLowerCase();
if (/(error|failed|could not)/.test(text)) return 'error';
if (/(warning|violation|needs revision)/.test(text)) return 'warning';
return null;
};
const avatar = (notification: any) => isIsac(notification) ? '/favicon.svg' : '/avatars/default/comms-specialist.webp';
const action = (notification: any) => {
if (!notification.link) return null;
const text = `${notification.title} ${notification.body}`.toLocaleLowerCase();
if (text.includes('avatar') && /(removed|rejected|not approved)/.test(text)) return { label: 'UPLOAD NEW AVATAR', href: '/account#avatar' };
if (notification.category === 'comments' && text.includes('guide')) return { label: 'VIEW GUIDE & REPLY', href: notification.link };
if (notification.category === 'comments') return { label: 'VIEW BUILD & REPLY', href: notification.link };
if (notification.category === 'contributions' && /(needs revision|not approved|could not)/.test(text)) return { label: 'REVISE CONTRIBUTION', href: notification.link };
if (notification.category === 'contributions' && /(approved|deployed)/.test(text)) return { label: 'VIEW PUBLISHED DATA', href: notification.link };
if (notification.category === 'guides') return { label: 'OPEN GUIDE', href: notification.link };
if (notification.category === 'builds') return { label: 'OPEN BUILD', href: notification.link };
if (notification.link === '/account') return { label: 'OPEN AGENT PROFILE', href: notification.link };
return { label: 'OPEN RELATED PAGE', href: notification.link };
};
async function enableBrowser() {
if (!('Notification' in window)) { browserPermission = 'unsupported'; return; }
browserPermission = await Notification.requestPermission();
}
</script>
<svelte:head><title>Notification inbox — SHD.build</title></svelte:head>
<main class="page profile-page notification-page">
<section class="notification-command" aria-labelledby="notification-page-title">
<div>
<PageBreadcrumb items={[{ label: 'AGENT PROFILE', href: '/account' }, { label: 'NOTIFICATIONS' }]} />
<h1 id="notification-page-title">INBOX</h1>
<p>ISAC directives, contribution decisions, and community activity.</p>
</div>
<div class="notification-command-actions">
<div class="notification-unread-summary"><strong>{data.unread}</strong><span>UNREAD</span></div>
<button class="secondary" type="button" aria-expanded={settingsOpen} onclick={() => settingsOpen = !settingsOpen}>PREFERENCES <span aria-hidden="true">⚙</span></button>
</div>
</section>
{#if settingsOpen}
<section class="notification-settings">
<div class="module-head"><div><p class="eyebrow">DELIVERY CONTROL</p><h2>NOTIFICATION PREFERENCES</h2></div><span>BROWSER: {browserPermission.toUpperCase()}</span></div>
<p class="notification-rule"><b>ISAC INBOX DELIVERY IS MANDATORY.</b> Browser and email delivery remain optional. Email is disabled by default.</p>
{#if browserPermission !== 'granted'}<button class="secondary browser-permission" type="button" onclick={enableBrowser}>ENABLE BROWSER PERMISSION →</button>{/if}
<PendingForm action="?/savePreferences" class="notification-preference-form">
<div class="notification-preference-head"><span>CHANNEL</span><span>INBOX</span><span>BROWSER</span><span>EMAIL</span></div>
{#each data.preferences as preference}
<div class="notification-preference-row"><div><b>{labels[preference.category]}</b><small>{preference.category === 'isac' ? 'MANDATORY OPERATIONS MESSAGES' : preference.category === 'contributions' ? 'ISAC DECISIONS REMAIN REQUIRED' : 'OPTIONAL ACTIVITY CHANNEL'}</small></div><label><input type="checkbox" name={`inApp:${preference.category}`} checked={preference.inApp} disabled={preference.category === 'isac'}/><span>{preference.category === 'isac' ? 'REQUIRED' : 'ENABLED'}</span></label><label><input type="checkbox" name={`browser:${preference.category}`} checked={preference.browser} disabled={browserPermission !== 'granted'}/><span>ALERT</span></label><select name={`email:${preference.category}`} disabled={!data.hasVerifiedEmail}><option value="none" selected={preference.emailFrequency === 'none'}>OFF</option><option value="immediate" selected={preference.emailFrequency === 'immediate'}>IMMEDIATE</option><option value="daily" selected={preference.emailFrequency === 'daily'}>DAILY DIGEST</option></select></div>
{/each}
{#if !data.hasVerifiedEmail}<p class="notification-rule">Add a verified magic-link email from Agent Profile → Access to enable email delivery.</p>{/if}
{#if form?.notificationPreferenceError}<p class="error">{form.notificationPreferenceError}</p>{/if}
{#if form?.notificationPreferencesSaved}<p class="success">Notification preferences synchronized.</p>{/if}
<div class="form-actions"><button class="primary">SAVE PREFERENCES →</button></div>
</PendingForm>
</section>
{/if}
<div class="notification-toolbar"><form><select name="category"><option value="">ALL CHANNELS</option>{#each Object.entries(labels) as [value,label]}<option {value} selected={data.filters.category === value}>{label}</option>{/each}</select><select name="state"><option value="">ALL MESSAGES</option><option value="unread" selected={data.filters.unreadOnly}>UNREAD ONLY</option></select><button class="secondary">APPLY FILTER →</button></form>{#if data.unread}<PendingForm action="?/markAllRead"><button class="secondary">MARK ALL READ</button></PendingForm>{/if}</div>
<RegistryPagination pagination={data.pagination}/>
<section class="notification-list">
{#each data.notifications as notification}
<article class:unread={!notification.read_at} class:mandatory={Boolean(notification.mandatory)} class:isac-message={isIsac(notification)} class:positive={tone(notification) === 'positive'} class:negative={tone(notification) === 'negative'}>
<div class="notification-source"><img src={avatar(notification)} alt=""/><span><b>{notification.source}</b><small>{labels[notification.category]}{isIsac(notification) ? ' · SYSTEM' : ' · AGENT'}</small></span></div>
<div><div class="notification-message-header"><div>{#if severity(notification)}<span class="notification-severity" class:error-severity={severity(notification) === 'error'}>{severity(notification)?.toUpperCase()}</span>{/if}<h2>{notification.title}</h2></div><time>{date(notification.created_at)}</time></div>{#if isIsac(notification)}<SystemMessageBody body={notification.body}/>{:else}<p class="notification-body-preview">{notification.body}</p>{/if}<footer><div class="notification-actions"><button type="button" onclick={() => selected = notification}>VIEW DETAILS</button>{#if action(notification)}<a class="notification-response-action" href={action(notification)?.href}>{action(notification)?.label} →</a>{/if}</div>{#if !notification.read_at}<PendingForm action="?/markRead"><input type="hidden" name="id" value={notification.id}/><button>MARK READ</button></PendingForm>{:else}<span>READ</span>{/if}</footer></div>
</article>
{:else}<div class="profile-empty"><span>◇</span><b>NO NOTIFICATIONS</b><p>No messages match the selected channel and state.</p></div>{/each}
</section>
</main>
{#if selected}
<div class="isac-modal-backdrop" role="presentation">
<div class="isac-confirm-modal notification-detail-modal" class:isac-message={isIsac(selected)} class:positive={tone(selected) === 'positive'} class:negative={tone(selected) === 'negative'} role="dialog" aria-modal="true" aria-labelledby="notification-detail-title">
<div class="notification-detail-sender"><img src={avatar(selected)} alt=""/><div><span>{isIsac(selected) ? 'ISAC SYSTEM MESSAGE' : 'COMMUNITY MESSAGE'}</span><b>{selected.source}</b></div><button aria-label="Close notification details" onclick={() => selected = null}>×</button></div>
<div class="notification-detail-state"><span>{labels[selected.category]}</span><b class:error-severity={severity(selected) === 'error'}>{severity(selected)?.toUpperCase() ?? (isIsac(selected) ? 'SYSTEM' : 'AGENT')}</b><time>{date(selected.created_at)}</time></div>
<h2 id="notification-detail-title">{selected.title}</h2>
{#if isIsac(selected)}<SystemMessageBody body={selected.body} expanded/>{:else}<p>{selected.body}</p>{/if}
<div class="form-actions">{#if !selected.read_at}<PendingForm action="?/markRead"><input type="hidden" name="id" value={selected.id}/><button class="secondary">MARK AS READ</button></PendingForm>{/if}<button type="button" class="secondary" onclick={() => selected = null}>CLOSE</button>{#if action(selected)}<a class="primary button" href={action(selected)?.href}>{action(selected)?.label} →</a>{/if}</div>
</div>
</div>
{/if}
<style>
.notification-message-header {
display: flex;
justify-content: space-between;
gap: 12px;
height: auto;
min-height: 30px;
align-items: center;
padding: 6px 9px 7px;
margin: 0;
border-bottom: 1px solid color-mix(in srgb, var(--shd-border) 72%, transparent);
}
.notification-message-header h2 {
margin: 0;
line-height: 1.2;
}
.notification-message-header time {
line-height: 1;
white-space: nowrap;
}
</style>
@@ -7,6 +7,7 @@ import { deleteUploadedAvatar } from '$lib/server/avatars';
import { getPagination } from '$lib/server/pagination';
import { recordIsacAudit } from '$lib/server/audit';
import type { Actions, PageServerLoad } from './$types';
import { createNotification } from '$lib/server/notifications';
const statuses = ['pending', 'approved', 'removed', 'withdrawn', 'superseded'];
@@ -77,6 +78,7 @@ export const actions: Actions = {
await (await database.prepare("UPDATE avatar_reviews SET status='removed',reviewed_at=?,reviewed_by=?,review_note=? WHERE id=?"))
.run(now, reviewer.id, note, id);
await recordIsacAudit({ actorId: reviewer.id, category: 'user_moderation', action: 'avatar.removed', entityId: String(review.user_id), before, after: { reviewId: id, status: 'removed', source: 'default', note } });
await createNotification({ userId: String(review.user_id), category: 'isac', source: 'ISAC AVATAR OVERSIGHT', title: 'Custom avatar removed', body: `Your custom avatar did not meet SHD.build image requirements and was removed. Oversight reason: ${note}`, link: '/account#avatar', mandatory: true });
return { avatarReviewSaved: true, avatarReviewToken: randomUUID() };
}
};
@@ -7,6 +7,7 @@ import { deserializeProposal, refreshProposalDependencies } from '$lib/server/da
import { getPagination } from '$lib/server/pagination';
import { recordIsacAudit } from '$lib/server/audit';
import type { Actions, PageServerLoad } from './$types';
import { createNotification } from '$lib/server/notifications';
const statuses = ['pending', 'approved', 'rejected', 'failed'];
const modules = ['stats', 'talents', 'brands', 'gear', 'weapons', 'protocols'];
@@ -110,10 +111,12 @@ export const actions: Actions = {
const details = (await response.text()).slice(0, 1000);
await (await database.prepare("UPDATE data_change_proposals SET status='failed',reviewed_by=?,review_note=?,reviewed_at=? WHERE id=?")).run(reviewer.id, details || note || 'Canonical validation failed.', now, id);
await recordIsacAudit({ actorId: reviewer.id, category: 'data_moderation', action: 'proposal.failed', entityId: id, before: { status: 'pending' }, after: { status: 'failed', responseStatus: response.status } });
await createNotification({ userId: String(proposal.proposed_by), category: 'contributions', source: 'ISAC DATA OVERSIGHT', title: 'Contribution could not be deployed', body: `${proposal.summary} did not pass current validation. Oversight evidence is available in your notification inbox.`, link: `/admin/${proposal.module}`, mandatory: true });
return fail(409, { moderationError: 'The proposal did not pass the current canonical validation and was marked failed.' });
}
await (await database.prepare("UPDATE data_change_proposals SET status='approved',applied_entity_id=COALESCE(entity_id,draft_entity_id),reviewed_by=?,review_note=?,reviewed_at=?,applied_at=? WHERE id=?")).run(reviewer.id, note || null, now, now, id);
await recordIsacAudit({ actorId: reviewer.id, category: 'data_moderation', action: 'proposal.approved', entityId: id, before: { status: 'pending' }, after: { status: 'approved', module: proposal.module, action: proposal.action, proposedBy: proposal.proposed_by, appliedEntityId: proposal.entity_id ?? proposal.draft_entity_id } });
await createNotification({ userId: String(proposal.proposed_by), category: 'contributions', source: 'ISAC DATA OVERSIGHT', title: 'Contribution approved', body: `${proposal.summary} was approved and deployed to the live builder data set.`, link: `/admin/${proposal.module}`, mandatory: true });
return { moderationSaved: true, moderationToken: randomUUID() };
},
reject: async ({ cookies, request }) => {
@@ -128,6 +131,7 @@ export const actions: Actions = {
const now = new Date().toISOString();
await (await database.prepare("UPDATE data_change_proposals SET status='rejected',reviewed_by=?,review_note=?,reviewed_at=? WHERE id=?")).run(reviewer.id, note, now, id);
await recordIsacAudit({ actorId: reviewer.id, category: 'data_moderation', action: 'proposal.rejected', entityId: id, before: { status: 'pending' }, after: { status: 'rejected', note, module: proposal.module, action: proposal.action, proposedBy: proposal.proposed_by } });
await createNotification({ userId: String(proposal.proposed_by), category: 'contributions', source: 'ISAC DATA OVERSIGHT', title: 'Contribution needs revision', body: `${String(proposal.action).replace(/([A-Z])/g, ' $1').trim()} was not approved. Oversight reason: ${note}`, link: `/admin/${proposal.module}`, mandatory: true });
return { moderationSaved: true, moderationToken: randomUUID() };
}
};
+8 -4
View File
@@ -11,6 +11,7 @@ import { canRemoveAuthenticationMethod, getAuthenticationMethods } from '$lib/se
import { getPagination } from '$lib/server/pagination';
import { purgeExpiredLoginMetadata } from '$lib/server/auth/session';
import { recordIsacAudit } from '$lib/server/audit';
import { createNotification } from '$lib/server/notifications';
export const load: PageServerLoad = async ({ cookies, params, url }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'users.view');
@@ -92,8 +93,10 @@ export const load: PageServerLoad = async ({ cookies, params, url }) => {
};
export const actions: Actions = {
removeCustomAvatar: async ({ cookies, params }) => {
removeCustomAvatar: async ({ cookies, params, request }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'avatars.review');
const reason = String((await request.formData()).get('reason') ?? '').trim().slice(0, 500);
if (reason.length < 3) return fail(400, { avatarModerationError: 'Provide a reason for removing this avatar.' });
const database = await getDatabase();
const avatar = await (await database.prepare(
'SELECT source,value,byte_size,updated_at FROM user_avatars WHERE user_id=?'
@@ -102,17 +105,18 @@ export const actions: Actions = {
return fail(409, { avatarModerationError: 'This agent does not currently use a custom avatar.' });
}
await deleteUploadedAvatar(database, params.id);
await (await database.prepare(`UPDATE avatar_reviews SET status='removed',reviewed_at=?,reviewed_by=?,review_note='Removed from agent operations'
await (await database.prepare(`UPDATE avatar_reviews SET status='removed',reviewed_at=?,reviewed_by=?,review_note=?
WHERE user_id=? AND avatar_updated_at=? AND status IN ('pending','approved')`))
.run(new Date().toISOString(), operator.id, params.id, avatar.updated_at);
.run(new Date().toISOString(), operator.id, reason, params.id, avatar.updated_at);
await recordIsacAudit({
actorId: operator.id,
category: 'user_moderation',
action: 'avatar.removed',
entityId: params.id,
before: { source: avatar.source, storageKey: avatar.value, byteSize: avatar.byte_size },
after: { source: 'default', reviewSource: 'agent_operations' }
after: { source: 'default', reviewSource: 'agent_operations', reason }
});
await createNotification({ userId: params.id, category: 'isac', source: 'ISAC USER OPERATIONS', title: 'Custom avatar removed', body: `Your custom avatar was removed by Oversight. Reason: ${reason}`, link: '/account#avatar', mandatory: true });
return { avatarRemoved: true };
},
setAccess: async ({ cookies, request, params }) => {
+1 -1
View File
@@ -68,7 +68,7 @@
<img src={data.profile.avatar_url} alt={`${data.profile.display_name}'s avatar`}/>
<div><b>{data.profile.avatar_source === 'upload' ? 'CUSTOM UPLOAD' : 'SHD PRESET'}</b><small>{data.profile.avatar_source === 'upload' ? `${Math.ceil(Number(data.profile.avatar_byte_size) / 1024)} KB · NORMALIZED WEBP` : 'APPROVED PORTRAIT LIBRARY'}</small></div>
{#if data.profile.avatar_source === 'upload' && can('avatars.review')}<button type="button" class="danger moderation-action" onclick={() => removalTarget = 'avatar'}>REMOVE</button>{:else}<span class:custom-avatar={data.profile.avatar_source === 'upload'}>{data.profile.avatar_source === 'upload' ? 'REVIEW' : 'APPROVED'}</span>{/if}
{#if removalTarget === 'avatar'}<div class="isac-method-confirm avatar-remove-confirm"><div><b>REMOVE CUSTOM AVATAR?</b><small>The uploaded file will be deleted and replaced by the default SHD portrait.</small></div><div><button type="button" class="secondary" onclick={() => removalTarget = null}>CANCEL</button><PendingForm action="?/removeCustomAvatar"><button class="primary">CONFIRM REMOVE</button></PendingForm></div></div>{/if}
{#if removalTarget === 'avatar'}<div class="isac-method-confirm avatar-remove-confirm avatar-remove-reason"><div><b>REMOVE CUSTOM AVATAR?</b><small>The uploaded file will be deleted and replaced by the default SHD portrait.</small></div><PendingForm action="?/removeCustomAvatar"><input name="reason" required minlength="3" maxlength="500" placeholder="REQUIRED REMOVAL REASON"/><div><button type="button" class="secondary" onclick={() => removalTarget = null}>CANCEL</button><button class="primary">CONFIRM REMOVE</button></div></PendingForm></div>{/if}
</div>
{#if data.profile.avatar_source === 'upload' && can('avatars.review')}
<p class="console-note avatar-review-note">Removing this image deletes the uploaded file and resets the agent to the default SHD portrait.</p>
@@ -0,0 +1,18 @@
import { getSessionUser } from '$lib/server/auth/session';
import { getDatabase } from '$lib/server/database';
import { error, json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ cookies }) => {
const user = await getSessionUser(cookies);
if (!user) error(401, 'Sign in required');
const database = await getDatabase();
const rows = await (await database.prepare(`SELECT n.id,n.title,n.body,n.link,n.created_at
FROM notifications n LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category
WHERE n.user_id=? AND n.read_at IS NULL AND COALESCE(p.browser_enabled,0)=1
ORDER BY n.created_at DESC LIMIT 20`)).all(user.id);
const count = Number((await (await database.prepare(`SELECT COUNT(*) AS count FROM notifications n
LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category
WHERE n.user_id=? AND n.read_at IS NULL AND (n.mandatory=1 OR COALESCE(p.in_app_enabled,1)=1)`)).get(user.id))?.count ?? 0);
return json({ count, notifications: rows });
};
+6
View File
@@ -0,0 +1,6 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = ({ url }) => {
redirect(308, `/account/notifications${url.search}`);
};
+1 -1
View File
@@ -23,7 +23,7 @@
<section id="accounts"><span>02</span><div><h2>ACCOUNTS & SECURITY</h2><p>You are responsible for activity performed through your account and for maintaining control of your authentication methods. You must provide accurate information, may not impersonate another person, and may not attempt to access another account. We may restrict accounts to protect users, enforce these terms, or comply with law.</p><p>Available authentication methods may include passkeys, verified email magic links, local username and password credentials, Discord, and other providers added later. Third-party authentication remains subject to that provider’s terms and privacy practices.</p></div></section>
<section id="content"><span>03</span><div><h2>USER CONTENT</h2><p>You retain ownership of original content you submit. You grant SHD.build a non-exclusive, worldwide, royalty-free license to host, reproduce, format, display, and distribute that content as necessary to operate the service. You represent that you have the rights required to post it.</p><p>Builds, guides, and comments may remain available after account deletion under a “Deleted Agent” attribution so community references are not broken. You may not upload unlawful, infringing, malicious, or privacy-invasive content.</p></div></section>
<section id="conduct"><span>04</span><div><h2>COMMUNITY CONDUCT</h2><p>Do not harass others; publish illegal or infringing material; distribute malware; evade access restrictions; scrape or overload the service; manipulate ratings; spam; or exploit security vulnerabilities. Community Oversight may remove content, restrict access, or preserve relevant records when reasonably necessary for safety, support, abuse prevention, or legal obligations.</p></div></section>
<section id="privacy"><span>05</span><div><h2>INFORMATION WE PROCESS</h2><p>Account data may include internal account identifiers, private login username, public display name, password hash, verified email, linked-provider identifiers and profile details, passkey public credentials, avatar, roles, permissions, builds, guides, favorites, ratings, and comments. Passwords are stored only as salted Argon2id hashes. Private passkey material remains on your authenticator.</p><p>For login security and support, events may include the time, success status, authentication provider, IP address, user-agent string, normalized browser, operating system and device type, approximate country/region/city when supplied by the hosting proxy, failure reason, automated risk flags, provider or passkey credential reference, associated session identifier, and session end time or reason. We do not sell this information. It is used for authentication, abuse prevention, troubleshooting, moderation, and account support.</p><p>OAuth and email providers process information under their own policies.</p></div></section>
<section id="privacy"><span>05</span><div><h2>INFORMATION WE PROCESS</h2><p>Account data may include internal account identifiers, private login username, public display name, password hash, verified email, linked-provider identifiers and profile details, passkey public credentials, avatar, roles, permissions, builds, guides, favorites, ratings, comments, notification messages, read state, and delivery preferences. Passwords are stored only as salted Argon2id hashes. Private passkey material remains on your authenticator.</p><p>For login security and support, events may include the time, success status, authentication provider, IP address, user-agent string, normalized browser, operating system and device type, approximate country/region/city when supplied by the hosting proxy, failure reason, automated risk flags, provider or passkey credential reference, associated session identifier, and session end time or reason. We do not sell this information. It is used for authentication, abuse prevention, troubleshooting, moderation, and account support.</p><p>Browser and email notification delivery is optional except that operational ISAC notices remain available in the in-app inbox. Email frequency is configurable by notification category and defaults to off. OAuth and email providers process information under their own policies.</p></div></section>
<section id="cookies"><span>06</span><div><h2>ESSENTIAL COOKIES</h2><p>SHD.build currently uses only first-party cookies that are strictly necessary to provide authentication and protect sign-in flows. We do not currently use analytics, advertising, behavioral-tracking, or personalization cookies. These essential cookies cannot be disabled through a preference control. You may prevent cookies through your browser, but account sign-in will not function correctly.</p><div class="legal-table-wrap"><table class="legal-table"><thead><tr><th>COOKIE</th><th>PURPOSE</th><th>DURATION</th><th>PROTECTION</th></tr></thead><tbody><tr><td><code>shd_session</code></td><td>Keeps you securely signed in and associates requests with your server-side session.</td><td>Up to 30 days, or until sign-out or session invalidation.</td><td>HTTP-only, SameSite=Lax, Secure in production.</td></tr><tr><td><code>shd_discord_oauth_state</code></td><td>Validates a Discord sign-in or linking response and helps prevent request forgery.</td><td>Up to 10 minutes; removed when the callback is processed.</td><td>HTTP-only, SameSite=Lax, Secure in production; limited to the Discord authentication path.</td></tr></tbody></table></div><p>The informational notice remembers its dismissal in browser local storage under <code>shd-essential-cookie-notice</code>. It is not sent with web requests and can be removed through your browser’s site-data controls. OAuth state remains in a short-lived HTTP-only cookie because it must be securely validated by the server and unavailable to browser scripts.</p><p>If non-essential technologies are introduced later, this policy and the site’s consent controls will be updated before those technologies are enabled where consent is required.</p></div></section>
<section id="retention"><span>07</span><div><h2>RETENTION & DELETION</h2><p>IP addresses, user-agent strings, normalized device information, and credential references in login history are retained for no more than 30 days, then automatically removed from the event. Non-sensitive facts such as event time, provider, outcome, failure category, and whether a session was created may remain for security and support history.</p><p>Deleting your account removes active sessions, credentials, verified email, provider profiles, passkeys, permissions, and uploaded avatar. You may also select immediate purging of retained login-event history during self-deletion. Public contributions may remain attributed to “Deleted Agent.” Backups and records required for fraud prevention, dispute resolution, or law may persist for a limited period where applicable.</p></div></section>
<section id="third-parties"><span>08</span><div><h2>THIRD-PARTY SERVICES</h2><p>SHD.build may connect to Discord, email delivery services, and future identity providers. Their services are governed by their own terms. Links to other sites do not imply endorsement. You authorize the minimum provider data requested when choosing a linked sign-in method.</p></div></section>