diff --git a/database/schema.sql b/database/schema.sql index ca5a8e7..bcd3301 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -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); diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index 41f7217..c146d3b 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -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. diff --git a/package.json b/package.json index b0338f7..367ff41 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/notification-digest.mjs b/scripts/notification-digest.mjs new file mode 100644 index 0000000..f919ebc --- /dev/null +++ b/scripts/notification-digest.mjs @@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character]); + const rows = items.map((item) => `
${escape(item.title)}

${escape(item.body)}

${item.link ? `VIEW INTELLIGENCE` : ''}
`).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: `

SHD DAILY INTELLIGENCE

${items.length} notification${items.length === 1 ? '' : 's'}

${rows}
` + }); + 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'}.`); diff --git a/scripts/seed-test-notifications.mjs b/scripts/seed-test-notifications.mjs new file mode 100644 index 0000000..c2227df --- /dev/null +++ b/scripts/seed-test-notifications.mjs @@ -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}.`); diff --git a/src/app.css b/src/app.css index 7df1f8b..18207d7 100644 --- a/src/app.css +++ b/src/app.css @@ -140,4 +140,15 @@ .mobile-row-open{display:none}@media(max-width:700px){.mobile-detail-row{position:relative;min-height:48px;padding-bottom:25px;cursor:pointer}.dashboard-row>.mobile-row-open{position:absolute;z-index:2;inset:0;width:100%;height:100%;display:block;justify-self:stretch;margin:0;padding:0;border:0;background:transparent;color:transparent;font-size:0;cursor:pointer}.mobile-detail-row>.row-action,.mobile-detail-row>button:not(.mobile-row-open){display:none}.mobile-detail-row::after{content:"TAP TO VIEW ›";position:absolute;right:10px;bottom:7px;color:var(--shd-orange);font-size:6px;font-weight:900;letter-spacing:.8px;pointer-events:none}.moderation-row.mobile-detail-row::after{content:"TAP TO REVIEW ›"}.mobile-detail-row:hover{border-color:#665026;background:#171713}.mobile-detail-row:has(.mobile-row-open:focus-visible){outline:2px solid var(--shd-orange);outline-offset:-2px}.mobile-detail-row:has(.mobile-row-open:active){transform:translateY(1px);filter:brightness(.86)}}@media(prefers-reduced-motion:reduce){.mobile-detail-row:has(.mobile-row-open:active){transform:none}} .avatar-review-filter{grid-template-columns:minmax(220px,1fr) auto}.avatar-review-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:7px}.avatar-review-card{min-width:0;display:grid;grid-template-columns:112px minmax(0,1fr);border:1px solid var(--shd-border);background:#111415}.avatar-review-image{position:relative;min-height:142px;display:grid;place-items:center;overflow:hidden;border-right:1px solid var(--shd-border);background:#0b0e0f}.avatar-review-image img{width:100%;height:100%;object-fit:cover}.avatar-review-image>span{padding:10px;color:#596164;font-size:6px;font-weight:900;line-height:1.5;text-align:center}.avatar-review-image>b{position:absolute;right:0;bottom:0;padding:5px 7px;border-top:1px solid currentColor;border-left:1px solid currentColor;background:#171b1ce8;color:#889092;font-size:6px;letter-spacing:.7px}.avatar-review-image>b.pending{color:var(--shd-orange)}.avatar-review-image>b.approved{color:var(--shd-success)}.avatar-review-image>b.declined{color:var(--shd-danger)}.avatar-review-details{min-width:0;display:grid;align-content:start;gap:4px;padding:9px}.avatar-review-details>strong{font-size:10px}.avatar-review-details>small{overflow:hidden;color:#697174;font-size:6px;text-overflow:ellipsis;white-space:nowrap}.avatar-review-details dl{display:grid;gap:4px;margin:5px 0}.avatar-review-details dl>div{display:flex;justify-content:space-between;gap:8px;border-top:1px solid #292e2f;padding-top:4px}.avatar-review-details dt,.avatar-review-details dd{margin:0;font-size:6px}.avatar-review-details dt{color:#697174}.avatar-review-details dd{color:#b9bebe;text-align:right}.avatar-review-details>p{margin:2px 0;color:#8c9495;font-size:7px;line-height:1.4}.avatar-review-details>button{min-height:31px;margin-top:auto}.avatar-review-modal{width:min(680px,calc(100vw - 24px))}.avatar-review-preview{height:min(46vh,390px);display:grid;place-items:center;margin:9px 0;overflow:hidden;border:1px solid var(--shd-border);background:#090b0c}.avatar-review-preview img{width:100%;height:100%;object-fit:contain}.avatar-review-preview span{color:#697174;font-size:7px;letter-spacing:.8px}.avatar-decision-tabs{grid-template-columns:repeat(2,1fr)}@media(max-width:1150px){.avatar-review-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:700px){.avatar-review-filter,.avatar-review-grid{grid-template-columns:1fr}.avatar-review-card{grid-template-columns:96px minmax(0,1fr)}.avatar-review-image{min-height:132px}.avatar-review-details>button{min-height:44px}.avatar-decision-tabs{grid-template-columns:1fr}} .assignment-chips>button span,.assignment-results>button>span{display:grid;gap:2px}.assignment-chips>button span small,.assignment-results>button>span small{color:var(--shd-orange);font-size:5px;font-weight:900;letter-spacing:.55px} +.notification-link{position:relative;width:34px;height:34px;display:grid!important;place-items:center;border:1px solid var(--shd-border);color:var(--shd-orange)!important}.notification-link>span{font-size:9px}.notification-link>b{position:absolute;top:-5px;right:-5px;min-width:17px;height:17px;display:grid;place-items:center;padding:0 4px;border:1px solid #d98921;background:var(--shd-orange);color:#111;font-size:6px}.mobile-navigation>a>em{min-width:20px;height:20px;display:grid;place-items:center;padding:0 4px;background:var(--shd-orange);color:#111;font-size:6px;font-style:normal}.notification-page{max-width:1480px;margin:auto;padding:36px clamp(14px,4vw,56px) 80px}.notification-command{display:flex;align-items:end;justify-content:space-between;padding-bottom:18px;border-bottom:1px solid var(--shd-border)}.notification-command h1{margin:5px 0;font-size:48px}.notification-command p{margin:0;color:var(--shd-text-muted)}.notification-command>div:last-child{display:grid;grid-template-columns:auto auto;align-items:center;gap:2px 8px}.notification-command>div:last-child strong{grid-row:1/3;color:var(--shd-orange);font-size:32px}.notification-command>div:last-child span{font-size:7px;letter-spacing:1px}.notification-command>div:last-child button{grid-column:1/3;margin-top:6px}.notification-settings{margin:10px 0;padding:10px;border:1px solid #625025;border-top:2px solid var(--shd-orange);background:#111415}.notification-settings .module-head{padding-bottom:8px}.notification-settings .module-head>span{color:var(--shd-orange);font-size:7px;letter-spacing:.8px}.notification-rule{margin:6px 0;padding:8px;border-left:2px solid var(--shd-orange);background:#18150f;color:#8f887a;font-size:7px;line-height:1.5}.notification-rule b{color:#d9bc82}.browser-permission{margin:4px 0 8px}.notification-preference-form{display:grid}.notification-preference-head,.notification-preference-row{display:grid;grid-template-columns:minmax(190px,1fr) 100px 100px 150px;align-items:center;gap:7px;padding:7px 9px;border-top:1px solid var(--shd-border)}.notification-preference-head{background:#0d1011;color:#697174;font-size:6px;letter-spacing:1px}.notification-preference-row>div{display:grid;gap:3px}.notification-preference-row>div b{font-size:8px}.notification-preference-row>div small{color:#697174;font-size:5px}.notification-preference-row label{display:flex;align-items:center;gap:5px;color:#8a9293;font-size:6px;cursor:pointer}.notification-preference-row input{width:auto;margin:0;accent-color:var(--shd-orange)}.notification-preference-row select{height:31px;margin:0;font-size:7px}.notification-toolbar{display:flex;justify-content:space-between;gap:8px;margin-top:12px}.notification-toolbar form{display:flex;gap:6px}.notification-toolbar select{height:36px;margin:0;font-size:7px}.notification-list{display:grid;border:1px solid var(--shd-border)}.notification-list>article{display:grid;grid-template-columns:145px minmax(0,1fr);border-top:1px solid var(--shd-border);background:#101314}.notification-list>article:first-child{border-top:0}.notification-list>article.unread{background:#141719;box-shadow:inset 2px 0 var(--shd-orange)}.notification-list>article.mandatory{box-shadow:inset 2px 0 #68a9c4}.notification-source{display:grid;align-content:start;gap:4px;padding:13px;border-right:1px solid var(--shd-border);background:#0d1011}.notification-source b{color:var(--shd-orange);font-size:7px;line-height:1.4}.notification-source span{color:#667073;font-size:6px}.notification-list>article>div:last-child{padding:12px 14px}.notification-list article header{display:flex;justify-content:space-between;gap:12px}.notification-list h2{font-size:12px}.notification-list time{color:#697174;font-size:6px}.notification-list article p{margin:7px 0;color:#9aa1a2;font:11px/1.55 Georgia,serif}.notification-list article footer{display:flex;align-items:center;justify-content:space-between}.notification-list article footer a,.notification-list article footer button,.notification-list article footer span{color:var(--shd-orange);font-size:6px;font-weight:900;letter-spacing:.7px}.notification-list article footer button{padding:0;border:0;background:none;cursor:pointer}.notification-list article footer button:hover{color:#fff}.notification-list article footer form{margin-left:auto}@media(max-width:700px){.notification-page{padding-top:22px}.notification-command{align-items:start;flex-direction:column;gap:12px}.notification-command>div:last-child{width:100%}.notification-preference-head{display:none}.notification-preference-row{grid-template-columns:1fr 1fr 1fr}.notification-preference-row>div{grid-column:1/4}.notification-preference-row select{width:100%}.notification-toolbar{align-items:stretch;flex-direction:column}.notification-toolbar form{display:grid;grid-template-columns:1fr 1fr}.notification-toolbar form button{grid-column:1/3;min-height:44px}.notification-toolbar>form:last-child button{width:100%}.notification-list>article{grid-template-columns:1fr}.notification-source{display:flex;justify-content:space-between;border-right:0;border-bottom:1px solid var(--shd-border)}.notification-list article header{display:grid}.notification-list article footer{min-height:36px}} +.avatar-remove-reason{grid-template-columns:1fr;align-content:center}.avatar-remove-reason>form{display:grid;grid-template-columns:minmax(150px,1fr) auto;gap:6px}.avatar-remove-reason>form>input{height:30px;margin:0;padding:0 7px;font-size:7px}.avatar-remove-reason>form>div{height:30px;display:grid;grid-template-columns:58px 96px;gap:5px}.avatar-remove-reason>form button{height:30px;min-height:0}@media(max-width:600px){.avatar-remove-reason>form{grid-template-columns:1fr}.avatar-remove-reason>form>div{grid-template-columns:1fr 1fr}} +.page-breadcrumb{display:flex;align-items:center;gap:10px;margin:0}.page-breadcrumb .breadcrumb-line{flex:0 0 28px;width:28px;height:2px;background:var(--shd-orange)}.page-breadcrumb ol{display:flex;align-items:center;gap:0;margin:0;padding:0;list-style:none}.page-breadcrumb li{display:flex;align-items:center}.page-breadcrumb li+li:before{content:"/";margin:0 8px;color:#4b5254}.page-breadcrumb a{color:var(--shd-orange)}.page-breadcrumb [aria-current=page]{color:#9da1a2}@media(max-width:700px){.page-breadcrumb li+li:before{margin:0 6px}} +.page-breadcrumb{align-self:start;width:max-content;height:auto;min-height:14px;line-height:1}.page-breadcrumb .breadcrumb-line{margin:0}.page-breadcrumb ol{height:auto}.page-breadcrumb li{line-height:1}.page-breadcrumb li>span{display:inline;width:auto;height:auto;margin:0;background:transparent;vertical-align:baseline}.notification-message-header>div{display:flex;align-items:center;gap:8px;min-width:0}@media(max-width:700px){.notification-message-header{display:grid!important}.notification-message-header>div{align-items:flex-start;flex-direction:column}} +.notification-severity{flex:0 0 auto;padding:3px 5px;border:1px solid #8a6731;color:var(--shd-orange);font-size:5px;font-weight:900;letter-spacing:.6px}.notification-severity.error-severity,.notification-detail-state .error-severity{border-color:#6d3f39;color:var(--shd-danger)} +.system-message-body{display:grid;gap:6px;margin:8px 0 7px;padding:8px 10px;border-left:2px solid #68a9c4;background:linear-gradient(90deg,rgba(49,104,128,.12),rgba(49,104,128,.025))}.system-message-kicker{display:flex;align-items:center;gap:6px;color:#72b8d5}.system-message-kicker span{font-size:9px}.system-message-kicker b{font-size:5px;letter-spacing:1.15px}.system-message-body>p,.notification-list article .system-message-body>p{margin:0;color:#a8b1b3;font:11px/1.5 Georgia,serif}.system-message-detail{display:grid;grid-template-columns:96px minmax(0,1fr);gap:9px;padding-top:6px;border-top:1px solid rgba(112,185,214,.18)}.system-message-detail>span{padding-top:2px;color:#79bcd6;font-size:5px;font-weight:900;letter-spacing:.9px}.system-message-detail>p,.notification-list article .system-message-detail>p{margin:0;color:#8f999b;font:10px/1.5 Georgia,serif}.system-message-body.expanded{margin:0;min-height:110px;padding:15px 16px;gap:10px;border-left-color:#68a9c4;background:linear-gradient(90deg,rgba(49,104,128,.16),rgba(14,17,18,.9))}.system-message-body.expanded .system-message-kicker b{font-size:6px}.system-message-body.expanded>p{color:#c0c7c8;font-size:13px;line-height:1.7}.system-message-body.expanded .system-message-detail{grid-template-columns:125px minmax(0,1fr);padding-top:10px}.system-message-body.expanded .system-message-detail>span{font-size:6px}.system-message-body.expanded .system-message-detail>p{color:#adb6b8;font-size:12px;line-height:1.65}@media(max-width:700px){.system-message-detail,.system-message-body.expanded .system-message-detail{grid-template-columns:1fr;gap:4px}} +.system-message-body:not(.expanded)>p,.system-message-body:not(.expanded) .system-message-detail>p,.notification-body-preview{display:-webkit-box;overflow:hidden;-webkit-box-orient:vertical;-webkit-line-clamp:2}.notification-actions{display:flex;align-items:center;gap:7px}.notification-actions .notification-response-action{min-height:28px;display:inline-flex;align-items:center;padding:0 10px;border:1px solid #6b5329;background:#1b160d;color:var(--shd-orange);transition:var(--shd-transition)}.notification-actions .notification-response-action:hover{border-color:var(--shd-orange);background:#241b0d;color:#fff}.notification-actions .notification-response-action:active{transform:translateY(1px);filter:brightness(.9)}.notification-actions .notification-response-action:focus-visible{outline:2px solid var(--shd-orange);outline-offset:2px}.profile-anchor{position:relative;top:calc(-1 * var(--shd-header-height));visibility:hidden}@media(max-width:700px){.notification-actions{align-items:stretch;flex-direction:column}.notification-actions .notification-response-action{min-height:44px;justify-content:center}} +.profile-metrics>a{display:flex;flex-direction:column;justify-content:center;padding:0 16px;border-left:1px solid var(--shd-border);color:inherit}.profile-metrics>a:hover,.profile-metrics>a:focus-visible{background:#171b1c}.profile-metrics>a strong{color:var(--shd-orange)}.profile-notifications{border-top:2px solid #4f4329}.profile-notification-list{display:grid;border:1px solid var(--shd-border)}.profile-notification-list>a{min-width:0;display:grid;grid-template-columns:145px minmax(0,1fr) 55px;align-items:center;gap:10px;padding:9px 10px;border-top:1px solid var(--shd-border);background:#0f1213;color:#aeb4b5}.profile-notification-list>a:first-child{border-top:0}.profile-notification-list>a.unread{background:#151718;box-shadow:inset 2px 0 var(--shd-orange)}.profile-notification-list>a.mandatory{box-shadow:inset 2px 0 #68a9c4}.profile-notification-list>a:hover,.profile-notification-list>a:focus-visible{background:#191d1e}.profile-notification-list>a>div,.profile-notification-list>a>span{min-width:0;display:grid;gap:3px}.profile-notification-list>a b{color:var(--shd-orange);font-size:6px}.profile-notification-list>a strong{overflow:hidden;font-size:8px;text-overflow:ellipsis;white-space:nowrap}.profile-notification-list>a small{overflow:hidden;color:#697174;font-size:6px;text-overflow:ellipsis;white-space:nowrap}.profile-notification-list>a em{justify-self:end;color:var(--shd-orange);font-size:6px;font-style:normal;font-weight:900}.profile-empty.compact{min-height:90px}@media(max-width:700px){.profile-notification-list>a{grid-template-columns:1fr auto}.profile-notification-list>a>div{grid-column:1/3}.profile-notification-list>a>span small{white-space:normal}.profile-metrics>a{min-height:55px;border-top:1px solid var(--shd-border)}} +.profile-metrics>a{min-width:77px;height:59px;align-items:center;padding:7px;border:1px solid var(--shd-border);background:#111415} +.notification-command{align-items:center}.notification-command>div:first-child{display:grid;align-content:center}.notification-command>div:last-child{align-self:center}.notification-preference-head>span:not(:first-child){text-align:center}.notification-preference-row>label{justify-content:center}.notification-list>article{grid-template-columns:190px minmax(0,1fr);transition:border-color .14s ease,background .14s ease}.notification-list>article.isac-message{border-left:2px solid #68a9c4;background:linear-gradient(90deg,#101a1e 0,#101314 34%)}.notification-list>article.positive{border-left-color:var(--shd-success)}.notification-list>article.negative{border-left-color:var(--shd-danger)}.notification-list>article.positive:not(.isac-message){background:linear-gradient(90deg,#101914 0,#101314 34%)}.notification-list>article.negative:not(.isac-message){background:linear-gradient(90deg,#1b1111 0,#101314 34%)}.notification-source{grid-template-columns:38px minmax(0,1fr);align-items:center;align-content:center;gap:9px}.notification-source>img{width:38px;height:38px;object-fit:cover;border:1px solid #3a4548;border-radius:50%;background:#111}.notification-source>span{min-width:0;display:grid;gap:4px}.notification-source>span b,.notification-source>span small{overflow:hidden;text-overflow:ellipsis}.notification-source>span b{white-space:normal}.notification-source>span small{color:#667073;font-size:5px;white-space:nowrap}.notification-list article header>div{display:flex;align-items:center;gap:8px;min-width:0}.notification-tone{flex:0 0 auto;padding:3px 5px;border:1px solid #50585a;color:#899193;font-size:5px;font-weight:900;letter-spacing:.6px}.positive .notification-tone{border-color:#41684b;color:var(--shd-success)}.negative .notification-tone{border-color:#6d3f39;color:var(--shd-danger)}.isac-message .notification-source b{color:#8ecce5}.notification-list article footer>button:first-child{color:var(--shd-orange)}.notification-detail-modal{width:min(720px,calc(100vw - 24px));border-top:3px solid #687174}.notification-detail-modal.isac-message{border-top-color:#68a9c4}.notification-detail-modal.positive{border-top-color:var(--shd-success)}.notification-detail-modal.negative{border-top-color:var(--shd-danger)}.notification-detail-sender{display:grid;grid-template-columns:48px 1fr 34px;align-items:center;gap:10px;padding-bottom:10px;border-bottom:1px solid var(--shd-border)}.notification-detail-sender>img{width:48px;height:48px;object-fit:cover;border:1px solid #485052;border-radius:50%;background:#111}.notification-detail-sender>div{display:grid;gap:4px}.notification-detail-sender span{color:#697174;font-size:6px;letter-spacing:1px}.notification-detail-sender b{font-size:10px}.notification-detail-sender>button{height:34px;border:1px solid var(--shd-border);background:#111415;color:#aeb4b5;cursor:pointer}.notification-detail-state{display:grid;grid-template-columns:1fr auto auto;align-items:center;gap:12px;margin:10px 0;padding:7px 9px;border:1px solid var(--shd-border);background:#0d1011}.notification-detail-state span,.notification-detail-state time{color:#6f7779;font-size:6px;letter-spacing:.7px}.notification-detail-state b{color:#899193;font-size:7px}.notification-detail-modal.positive .notification-detail-state b{color:var(--shd-success)}.notification-detail-modal.negative .notification-detail-state b{color:var(--shd-danger)}.notification-detail-modal h2{margin:15px 0 8px;font-size:20px}.notification-detail-modal>p{min-height:90px;margin:0;padding:13px;border-left:2px solid var(--shd-border);background:#0e1112;color:#c0c5c6;font:13px/1.7 Georgia,serif}.notification-detail-modal.isac-message>p{border-left-color:#68a9c4}.notification-detail-modal.positive>p{border-left-color:var(--shd-success)}.notification-detail-modal.negative>p{border-left-color:var(--shd-danger)}.notification-detail-modal .form-actions{align-items:center}.notification-detail-modal .form-actions form{margin-right:auto}.notification-detail-modal .form-actions .button{min-height:40px}@media(max-width:700px){.notification-list>article{grid-template-columns:1fr}.notification-source{display:grid;grid-template-columns:38px 1fr;justify-content:initial}.notification-list article header>div{align-items:flex-start;flex-direction:column}.notification-detail-state{grid-template-columns:1fr auto}.notification-detail-state time{grid-column:1/3}.notification-detail-modal .form-actions{display:grid;grid-template-columns:1fr}.notification-detail-modal .form-actions form,.notification-detail-modal .form-actions button,.notification-detail-modal .form-actions a{width:100%;margin:0}} +.notification-command{padding:0 0 18px}.notification-command-actions{display:flex!important;align-items:stretch!important;gap:8px!important}.notification-unread-summary{min-width:86px;display:grid;grid-template-columns:auto 1fr;align-items:center;gap:7px;padding:7px 11px;border:1px solid var(--shd-border);background:#0d1011}.notification-unread-summary strong{grid-row:auto!important;font-size:25px!important;line-height:1}.notification-unread-summary span{align-self:center;line-height:1.2}.notification-command-actions>button,.notification-toolbar button{min-height:40px;display:inline-flex;align-items:center;justify-content:center;margin:0!important;padding:0 18px;line-height:1;white-space:nowrap}.notification-command-actions>button>span{font-size:10px}.notification-toolbar{align-items:center;padding:10px 0}.notification-toolbar form{align-items:center;margin:0}.notification-toolbar select{min-width:125px;height:40px}.notification-toolbar form:first-child button{min-width:150px}.notification-toolbar>form:last-child{margin-left:auto}@media(max-width:700px){.notification-command-actions{width:100%;display:grid!important;grid-template-columns:auto 1fr}.notification-command-actions>button{width:100%}.notification-toolbar{align-items:stretch}.notification-toolbar>form:last-child{margin-left:0}} diff --git a/src/lib/components/AppShell.svelte b/src/lib/components/AppShell.svelte index 093e2b0..385cf55 100644 --- a/src/lib/components/AppShell.svelte +++ b/src/lib/components/AppShell.svelte @@ -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(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(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 @@ {link.label} {/each} {#if user} - My Profile + My Profile {/if} {#if isacAccess} ISAC @@ -71,6 +95,7 @@ {#if user} @@ -90,11 +115,12 @@ {String(index + 1).padStart(2, '0')}{link.label}› {/each} {#if user} - 04My Profile› + 04Inbox{#if unreadNotifications}{unreadNotifications > 99 ? '99+' : unreadNotifications}{:else}›{/if} + 05My Profile› {:else} 04Sign In› {/if} - {#if isacAccess}05ISAC›{/if} + {#if isacAccess}06ISAC›{/if} {#if user}
AGENT ONLINE{user.displayName}
{#snippet children(pending)}{/snippet} diff --git a/src/lib/components/PageBreadcrumb.svelte b/src/lib/components/PageBreadcrumb.svelte new file mode 100644 index 0000000..772e93c --- /dev/null +++ b/src/lib/components/PageBreadcrumb.svelte @@ -0,0 +1,19 @@ + + + diff --git a/src/lib/components/SystemMessageBody.svelte b/src/lib/components/SystemMessageBody.svelte new file mode 100644 index 0000000..4d60db5 --- /dev/null +++ b/src/lib/components/SystemMessageBody.svelte @@ -0,0 +1,24 @@ + + +
+
ISAC TRANSMISSION
+

{parsed.summary}

+ {#if parsed.detail} +
+ {parsed.detailLabel} +

{parsed.detail}

+
+ {/if} +
diff --git a/src/lib/server/auth/mail.ts b/src/lib/server/auth/mail.ts index 084fe80..24470df 100644 --- a/src/lib/server/auth/mail.ts +++ b/src/lib/server/auth/mail.ts @@ -41,3 +41,18 @@ export async function sendAuthenticationLink(email: string, token: string, purpo html: `

SHD IDENTITY NETWORK

${subject}

This one-time link expires in 15 minutes.

${action}

If you did not request this, you can ignore this message.

` }); } + +export async function sendNotificationEmail(email: string, title: string, body: string, link: string | null): Promise { + 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: `

SHD NOTIFICATION NETWORK

${escapeHtml(title)}

${escapeHtml(body)}

OPEN SHD INBOX

` + }); +} + +function escapeHtml(value: string) { + return value.replace(/[&<>"']/g, (character) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character]!); +} diff --git a/src/lib/server/notifications.ts b/src/lib/server/notifications.ts new file mode 100644 index 0000000..e1f4a03 --- /dev/null +++ b/src/lib/server/notifications.ts @@ -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) }; +} diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts index 53b8e61..09b289d 100644 --- a/src/routes/+layout.server.ts +++ b/src/routes/+layout.server.ts @@ -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 } }; }; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index c893481..947506a 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -8,4 +8,4 @@ -{@render children()} +{@render children()} diff --git a/src/routes/account/+page.server.ts b/src/routes/account/+page.server.ts index a4257f2..bf2c0d1 100644 --- a/src/routes/account/+page.server.ts +++ b/src/routes/account/+page.server.ts @@ -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 = { @@ -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, diff --git a/src/routes/account/+page.svelte b/src/routes/account/+page.svelte index b3a7e0a..cf9a52e 100644 --- a/src/routes/account/+page.svelte +++ b/src/routes/account/+page.svelte @@ -1,4 +1,5 @@ + +Notification inbox — SHD.build +
+
+
+ +

INBOX

+

ISAC directives, contribution decisions, and community activity.

+
+
+
{data.unread}UNREAD
+ +
+
+ {#if settingsOpen} +
+

DELIVERY CONTROL

NOTIFICATION PREFERENCES

BROWSER: {browserPermission.toUpperCase()}
+

ISAC INBOX DELIVERY IS MANDATORY. Browser and email delivery remain optional. Email is disabled by default.

+ {#if browserPermission !== 'granted'}{/if} + +
CHANNELINBOXBROWSEREMAIL
+ {#each data.preferences as preference} +
{labels[preference.category]}{preference.category === 'isac' ? 'MANDATORY OPERATIONS MESSAGES' : preference.category === 'contributions' ? 'ISAC DECISIONS REMAIN REQUIRED' : 'OPTIONAL ACTIVITY CHANNEL'}
+ {/each} + {#if !data.hasVerifiedEmail}

Add a verified magic-link email from Agent Profile → Access to enable email delivery.

{/if} + {#if form?.notificationPreferenceError}

{form.notificationPreferenceError}

{/if} + {#if form?.notificationPreferencesSaved}

Notification preferences synchronized.

{/if} +
+
+
+ {/if} +
{#if data.unread}{/if}
+ +
+ {#each data.notifications as notification} +
+
{notification.source}{labels[notification.category]}{isIsac(notification) ? ' · SYSTEM' : ' · AGENT'}
+
{#if severity(notification)}{severity(notification)?.toUpperCase()}{/if}

{notification.title}

{#if isIsac(notification)}{:else}

{notification.body}

{/if}
+
+ {:else}
◇NO NOTIFICATIONS

No messages match the selected channel and state.

{/each} +
+
+ +{#if selected} + +{/if} + + diff --git a/src/routes/admin/avatar-oversight/+page.server.ts b/src/routes/admin/avatar-oversight/+page.server.ts index 9b98946..d400fa7 100644 --- a/src/routes/admin/avatar-oversight/+page.server.ts +++ b/src/routes/admin/avatar-oversight/+page.server.ts @@ -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() }; } }; diff --git a/src/routes/admin/data-moderation/+page.server.ts b/src/routes/admin/data-moderation/+page.server.ts index 16d5c83..af9d122 100644 --- a/src/routes/admin/data-moderation/+page.server.ts +++ b/src/routes/admin/data-moderation/+page.server.ts @@ -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() }; } }; diff --git a/src/routes/admin/users/[id]/+page.server.ts b/src/routes/admin/users/[id]/+page.server.ts index bea511d..56a892d 100644 --- a/src/routes/admin/users/[id]/+page.server.ts +++ b/src/routes/admin/users/[id]/+page.server.ts @@ -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 }) => { diff --git a/src/routes/admin/users/[id]/+page.svelte b/src/routes/admin/users/[id]/+page.svelte index 749ada5..7a71176 100644 --- a/src/routes/admin/users/[id]/+page.svelte +++ b/src/routes/admin/users/[id]/+page.svelte @@ -68,7 +68,7 @@ {`${data.profile.display_name}'s
{data.profile.avatar_source === 'upload' ? 'CUSTOM UPLOAD' : 'SHD PRESET'}{data.profile.avatar_source === 'upload' ? `${Math.ceil(Number(data.profile.avatar_byte_size) / 1024)} KB · NORMALIZED WEBP` : 'APPROVED PORTRAIT LIBRARY'}
{#if data.profile.avatar_source === 'upload' && can('avatars.review')}{:else}{data.profile.avatar_source === 'upload' ? 'REVIEW' : 'APPROVED'}{/if} - {#if removalTarget === 'avatar'}
REMOVE CUSTOM AVATAR?The uploaded file will be deleted and replaced by the default SHD portrait.
{/if} + {#if removalTarget === 'avatar'}
REMOVE CUSTOM AVATAR?The uploaded file will be deleted and replaced by the default SHD portrait.
{/if} {#if data.profile.avatar_source === 'upload' && can('avatars.review')}

Removing this image deletes the uploaded file and resets the agent to the default SHD portrait.

diff --git a/src/routes/api/notifications/unread/+server.ts b/src/routes/api/notifications/unread/+server.ts new file mode 100644 index 0000000..da022b5 --- /dev/null +++ b/src/routes/api/notifications/unread/+server.ts @@ -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 }); +}; diff --git a/src/routes/notifications/+page.server.ts b/src/routes/notifications/+page.server.ts new file mode 100644 index 0000000..5b14ad3 --- /dev/null +++ b/src/routes/notifications/+page.server.ts @@ -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}`); +}; diff --git a/src/routes/terms/+page.svelte b/src/routes/terms/+page.svelte index 7761f0b..85316b3 100644 --- a/src/routes/terms/+page.svelte +++ b/src/routes/terms/+page.svelte @@ -23,7 +23,7 @@
02

ACCOUNTS & SECURITY

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.

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.

03

USER CONTENT

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.

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.

04

COMMUNITY CONDUCT

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.

-
05

INFORMATION WE PROCESS

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.

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.

OAuth and email providers process information under their own policies.

+
05

INFORMATION WE PROCESS

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.

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.

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.

06

ESSENTIAL COOKIES

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.

The informational notice remembers its dismissal in browser local storage under shd-essential-cookie-notice. 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.

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.

07

RETENTION & DELETION

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.

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.

08

THIRD-PARTY SERVICES

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.