From 32dbef822b8067fca602d6cc701c059b257bd0ef Mon Sep 17 00:00:00 2001 From: JB Date: Sat, 25 Jul 2026 13:59:45 -0400 Subject: [PATCH] feat(isac): add moderated contributor mode --- database/schema.sql | 17 +++++ docs/DESIGN_SYSTEM.md | 38 ++++++++++ scripts/db-migrate.mjs | 12 ++++ src/app.css | 7 +- src/hooks.server.ts | 14 +++- src/lib/server/contributor-mode.ts | 16 +++++ src/lib/server/data-moderation.ts | 68 ++++++++++++++++-- src/routes/admin/+layout.server.ts | 5 ++ src/routes/admin/+layout.svelte | 14 ++-- src/routes/admin/brands/+page.server.ts | 2 +- .../contributor-guidelines/+page.server.ts | 72 +++++++++++++++++++ .../admin/contributor-guidelines/+page.svelte | 54 ++++++++++++++ .../admin/data-moderation/+page.server.ts | 16 ++++- src/routes/admin/gear/+page.server.ts | 19 +++-- src/routes/admin/gear/+page.svelte | 4 +- src/routes/admin/roles/+page.svelte | 2 +- src/routes/admin/stats/+page.server.ts | 2 +- src/routes/admin/talents/+page.server.ts | 21 ++++-- src/routes/admin/talents/+page.svelte | 4 +- src/routes/admin/users/[id]/+page.svelte | 2 +- 20 files changed, 353 insertions(+), 36 deletions(-) create mode 100644 src/lib/server/contributor-mode.ts create mode 100644 src/routes/admin/contributor-guidelines/+page.server.ts create mode 100644 src/routes/admin/contributor-guidelines/+page.svelte diff --git a/database/schema.sql b/database/schema.sql index b7e0be6..c3ec2b0 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -161,6 +161,8 @@ CREATE TABLE IF NOT EXISTS data_change_proposals ( module TEXT NOT NULL CHECK(module IN ('stats','talents','brands','gear','weapons','protocols')), action TEXT NOT NULL, entity_id TEXT, + draft_entity_id TEXT, + applied_entity_id TEXT, summary TEXT NOT NULL, payload_json TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','approved','rejected','failed')), @@ -173,6 +175,21 @@ CREATE TABLE IF NOT EXISTS data_change_proposals ( ); CREATE INDEX IF NOT EXISTS data_change_proposals_status_created_idx ON data_change_proposals(status,created_at DESC); CREATE INDEX IF NOT EXISTS data_change_proposals_module_status_idx ON data_change_proposals(module,status); +CREATE TABLE IF NOT EXISTS data_change_proposal_dependencies ( + proposal_id TEXT NOT NULL REFERENCES data_change_proposals(id) ON DELETE CASCADE, + depends_on_proposal_id TEXT NOT NULL REFERENCES data_change_proposals(id) ON DELETE RESTRICT, + PRIMARY KEY(proposal_id,depends_on_proposal_id), + CHECK(proposal_id<>depends_on_proposal_id) +); +CREATE INDEX IF NOT EXISTS data_change_proposal_dependencies_dependency_idx ON data_change_proposal_dependencies(depends_on_proposal_id); +CREATE TABLE IF NOT EXISTS contributor_agreements ( + user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + guidelines_version TEXT NOT NULL, + accepted_at TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1 CHECK(enabled IN (0,1)), + enabled_at TEXT NOT NULL, + disabled_at TEXT +); CREATE TABLE IF NOT EXISTS discord_profiles ( user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, discord_id TEXT NOT NULL UNIQUE, diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index c14c58e..2919d0e 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -532,6 +532,44 @@ pending is orange/actionable, approved is green/configured, and rejected or failed is red/archived. Review decisions always use the standard ISAC confirmation surface and must never apply a proposal from a table-row click alone. +Eligible contributors enter ISAC data modules in read-only mode by default. +The access banner pairs the explicit `READ-ONLY DATA ACCESS` state with a compact +action that opens the versioned contributor guidelines. Contributor mode may +only be enabled after affirmative checkbox acceptance. Once enabled, the banner +changes to orange, explains that saves enter moderation, and offers a +`RETURN TO READ-ONLY` toggle. Disabling is immediate and reversible and does not +withdraw existing proposals. Use text for every mode state; color alone is +insufficient. At narrow widths, stack the explanation and full-width toggle. +Read-only mode is an inspection surface: hide add controls, row edit actions, +mutation forms, and editors opened through URL state. Do not merely dim or +disable them. Registry navigation, KPI filters, pagination, search, evidence, +and other non-mutating inspection controls remain visible and interactive. + +The ISAC command-status cluster includes an explicit `DATA MODE` cell alongside +system, access, and environment. It reports the highest effective data authority +in this order: `ADMIN`, `DATA MANAGER`, `APPROVER`, `CONTRIBUTOR`, then +`READ-ONLY`. Use a neutral treatment for read-only, orange for contributor, +blue for approval authority, and green for direct-management authority. The +label and value are mandatory because color is supplemental. At medium widths +the four cells become a two-column grid; on narrow screens the cluster spans +the command header width. + +Data-navigation links with pending proposals use a dedicated four-column layout: +module code, flexible label, square orange count cell, then navigation arrow. +Never insert a count into the ordinary three-column link without changing its +grid, because the arrow will wrap to another row. The count includes textual +assistive labeling and strengthens its border and contrast on link hover or +active state. + +Pending records may be used as dependencies in later proposals by any +contributor. Render these choices in the ordinary assignment control with a +restrained orange border/background and the explicit text `PENDING` plus the +proposer's display name; never present them as live data. Approval is dependency +ordered: a dependent proposal stays pending until every referenced proposal is +approved, while rejected or failed dependencies block it. This state must be +conveyed by text as well as color and must remain legible in compact and +responsive editor layouts. + Audit History may use a five-column KPI filter rail when all durable operational domains need first-class visibility. It retains the standard compact KPI height and collapses through the shared dashboard breakpoints; do not increase card diff --git a/scripts/db-migrate.mjs b/scripts/db-migrate.mjs index 1fdbb06..2f90033 100644 --- a/scripts/db-migrate.mjs +++ b/scripts/db-migrate.mjs @@ -81,6 +81,18 @@ for (const [name, definition] of [ } } +const proposalColumns = await (await database.prepare("PRAGMA table_info('data_change_proposals')")).all(); +for (const [name, definition] of [ + ['draft_entity_id', 'TEXT'], + ['applied_entity_id', 'TEXT'] +]) { + if (!proposalColumns.some((column) => column.name === name)) { + await database.exec(`ALTER TABLE data_change_proposals ADD COLUMN ${name} ${definition}`); + } +} +await database.exec("UPDATE data_change_proposals SET draft_entity_id=id WHERE draft_entity_id IS NULL AND entity_id IS NULL"); +await database.exec('CREATE UNIQUE INDEX IF NOT EXISTS data_change_proposals_draft_entity_idx ON data_change_proposals(draft_entity_id) WHERE draft_entity_id IS NOT NULL'); + console.log(`Applied ${statements.length} schema statements to ${databasePath}`); if (!databaseAlreadyExisted) { diff --git a/src/app.css b/src/app.css index 47fcba0..d566d6e 100644 --- a/src/app.css +++ b/src/app.css @@ -67,7 +67,7 @@ .avatar-picker{display:grid;grid-template-columns:repeat(4,1fr);gap:6px;margin-bottom:10px}.avatar-preset-form fieldset{height:100%}.avatar-preset{display:grid;gap:5px;width:100%;height:100%;padding:4px;background:#0b0e0f;border:1px solid #303536;color:#747c7e}.avatar-preset:hover,.avatar-preset:focus-visible{border-color:#8a672b;color:#f6a821}.avatar-preset.selected{border-color:#f6a821;color:#f6a821;box-shadow:inset 0 -2px #f6a821}.avatar-preset img{display:block;width:100%;aspect-ratio:1;object-fit:cover}.avatar-preset span{font-size:6px;line-height:1.25;text-transform:uppercase;letter-spacing:.4px}.avatar-upload{margin-top:8px} .image-drop-input{display:grid;grid-template-columns:64px 1fr auto;align-items:center;gap:10px;min-height:82px;padding:8px;border:1px dashed #4b5152;background:#0b0e0f;cursor:pointer;transition:border-color .15s,background .15s,transform .1s}.image-drop-input:hover,.image-drop-input:focus-visible,.image-drop-input.dragging{border-color:var(--shd-orange);background:#15130e;outline:none}.image-drop-input.dragging{transform:translateY(1px);box-shadow:inset 0 0 0 1px #f6a82155}.image-file-input{position:absolute!important;width:1px!important;height:1px!important;overflow:hidden!important;clip:rect(0 0 0 0)!important;white-space:nowrap!important}.image-upload-preview{display:grid;place-items:center;width:64px;height:64px;border:1px solid #343a3b;background:#070909;color:#62696b;font-size:6px;text-align:center}.image-upload-preview img{display:block;width:100%;height:100%;object-fit:contain}.image-drop-copy{display:grid;gap:4px;min-width:0}.image-drop-copy b{color:#c6cbcc;font-size:8px;letter-spacing:1px}.image-drop-copy span{overflow:hidden;color:#747c7e;font-size:7px;text-overflow:ellipsis;white-space:nowrap}.image-upload-actions{display:flex;gap:5px}.image-upload-actions button{min-width:92px;padding:8px}.image-upload-error{display:block;margin-top:5px;color:var(--shd-danger)}@media(max-width:700px){.image-drop-input{grid-template-columns:56px 1fr}.image-upload-preview{width:56px;height:56px}.image-upload-actions{grid-column:1/-1}.image-upload-actions button{flex:1}} .icon-upload-field .image-drop-input{grid-template-columns:1fr;gap:6px;text-align:center}.icon-upload-field .image-upload-preview{width:100%;height:72px}.icon-upload-field .image-upload-preview img{width:64px;height:64px}.icon-upload-field .image-upload-actions{display:grid;grid-template-columns:1fr 1fr;width:100%}.icon-upload-field .image-upload-actions button{min-width:0;padding:7px 3px;font-size:6px} -.proposal-submitted,.data-access-banner{display:flex;align-items:center;gap:12px;margin-bottom:9px;padding:9px 11px;border-left:3px solid var(--shd-success);background:#101713;color:#a6cbb0}.proposal-submitted b,.data-access-banner b{font-size:8px;letter-spacing:1px}.proposal-submitted span,.data-access-banner span{font-size:7px;color:#7e9d86}.data-access-banner{border-left-color:#657174;background:#111617;color:#aeb7b9}.data-access-banner.contributor{border-left-color:var(--shd-orange);background:#19150e;color:#d8bc87}.data-access-error{margin:-3px 0 9px}.data-readonly form[method=post] button[type=submit],.data-readonly form[method=post] button:not([type]){filter:grayscale(1);opacity:.45}.admin-sidebar nav a em{min-width:18px;padding:3px 5px;background:#8a5b16;color:#fff1d0;font-size:6px;font-style:normal;text-align:center}.moderation-row{grid-template-columns:minmax(240px,1.5fr) minmax(140px,.8fr) 150px 80px 75px}.moderation-review{width:min(760px,100%)}.moderation-metadata{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;margin:10px 0}.moderation-metadata span{display:grid;gap:4px;padding:8px;border:1px solid var(--shd-border);color:var(--shd-text-muted);font-size:6px}.moderation-metadata b{color:var(--shd-text);font-size:8px}.moderation-review details{margin:10px 0;border:1px solid var(--shd-border);background:#0b0e0f}.moderation-review summary{padding:9px;color:var(--shd-orange);font-size:7px;cursor:pointer}.moderation-review pre{max-height:260px;overflow:auto;margin:0;padding:10px;border-top:1px solid var(--shd-border);color:#9ca6a8;font-size:7px;white-space:pre-wrap}.decision-tabs{display:grid;grid-template-columns:1fr 1fr;gap:5px;margin:10px 0}.decision-tabs button{padding:9px;border:1px solid var(--shd-border);background:#111415;color:var(--shd-text-muted)}.decision-tabs button.active{border-color:var(--shd-orange);color:var(--shd-orange)}@media(max-width:800px){.moderation-row{grid-template-columns:1fr}.moderation-metadata{grid-template-columns:1fr}} +.proposal-submitted,.data-access-banner{display:flex;align-items:center;gap:12px;margin-bottom:9px;padding:9px 11px;border-left:3px solid var(--shd-success);background:#101713;color:#a6cbb0}.proposal-submitted b,.data-access-banner b{font-size:8px;letter-spacing:1px}.proposal-submitted span,.data-access-banner span{font-size:7px;color:#7e9d86}.data-access-banner{border-left-color:#657174;background:#111617;color:#aeb7b9}.data-access-banner.contributor{border-left-color:var(--shd-orange);background:#19150e;color:#d8bc87}.data-access-error{margin:-3px 0 9px}.data-readonly form[method=post] button[type=submit],.data-readonly form[method=post] button:not([type]){filter:grayscale(1);opacity:.45}.admin-sidebar nav a.has-pending{grid-template-columns:33px minmax(0,1fr) 18px 18px}.admin-sidebar nav a.has-pending em{grid-column:3;display:grid;place-items:center;min-width:18px;height:18px;padding:0 5px;border:1px solid #a26c19;background:#211a0e;color:#ffc45d;font-size:6px;font-style:normal;font-weight:900;text-align:center;transform:translateX(8px)}.admin-sidebar nav a.has-pending b{grid-column:4;justify-self:end}.admin-sidebar nav a.has-pending:hover em,.admin-sidebar nav a.has-pending.active em{border-color:var(--shd-orange);background:#2b2111;color:#fff1d0}.moderation-row{grid-template-columns:minmax(240px,1.5fr) minmax(140px,.8fr) 150px 80px 75px}.moderation-review{width:min(760px,100%)}.moderation-metadata{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;margin:10px 0}.moderation-metadata span{display:grid;gap:4px;padding:8px;border:1px solid var(--shd-border);color:var(--shd-text-muted);font-size:6px}.moderation-metadata b{color:var(--shd-text);font-size:8px}.moderation-review details{margin:10px 0;border:1px solid var(--shd-border);background:#0b0e0f}.moderation-review summary{padding:9px;color:var(--shd-orange);font-size:7px;cursor:pointer}.moderation-review pre{max-height:260px;overflow:auto;margin:0;padding:10px;border-top:1px solid var(--shd-border);color:#9ca6a8;font-size:7px;white-space:pre-wrap}.decision-tabs{display:grid;grid-template-columns:1fr 1fr;gap:5px;margin:10px 0}.decision-tabs button{padding:9px;border:1px solid var(--shd-border);background:#111415;color:var(--shd-text-muted)}.decision-tabs button.active{border-color:var(--shd-orange);color:var(--shd-orange)}@media(max-width:800px){.moderation-row{grid-template-columns:1fr}.moderation-metadata{grid-template-columns:1fr}} .account-tabs{width:100%;height:auto;margin:0 0 16px;display:grid;grid-template-columns:repeat(3,1fr);border:1px solid var(--shd-border)}.account-tabs button,.account-subtabs button{min-width:0;height:34px;padding:0 5px;border:0;border-left:1px solid var(--shd-border);border-bottom:2px solid transparent;background:#0d1011;color:#6f7779;font-size:7px;font-weight:900;letter-spacing:.8px;cursor:pointer;transition:var(--shd-transition)}.account-tabs button:first-child,.account-subtabs button:first-child{border-left:0}.account-tabs button:hover,.account-tabs button:focus-visible,.account-subtabs button:hover,.account-subtabs button:focus-visible{color:var(--shd-text);background:#171b1c}.account-tabs button.active,.account-subtabs button.active{border-bottom-color:var(--shd-orange);background:#211a0e;color:var(--shd-orange)}.account-tabs button:active,.account-subtabs button:active{transform:translateY(1px);filter:brightness(.85)}.account-tabs button:focus-visible,.account-subtabs button:focus-visible{position:relative;z-index:1;outline:2px solid var(--shd-orange);outline-offset:2px}.account-panel{min-height:290px}.account-subtabs{display:grid;grid-template-columns:1fr 1fr;margin:0 0 10px;border:1px solid var(--shd-border)}.account-subtabs button{height:31px}.access-subtabs{display:grid;grid-template-columns:repeat(4,1fr);margin:-2px 0 17px;border:1px solid var(--shd-border);background:#0b0e0f}.access-subtabs button{min-width:0;height:42px;display:grid;place-content:center;gap:3px;padding:4px;border:0;border-left:1px solid var(--shd-border);border-bottom:2px solid transparent;background:transparent;color:var(--shd-text-dim);cursor:pointer;transition:var(--shd-transition)}.access-subtabs button:first-child{border-left:0}.access-subtabs button span{font-size:6px;font-weight:900;letter-spacing:.7px}.access-subtabs button b{font-size:9px}.access-subtabs button:hover,.access-subtabs button:focus-visible{background:#171b1c;color:var(--shd-text)}.access-subtabs button.active{border-bottom-color:var(--shd-orange);background:#211a0e;color:var(--shd-orange)}.access-subtabs button:active{transform:translateY(1px);filter:brightness(.85)}.access-subtabs button:focus-visible{position:relative;z-index:1;outline:2px solid var(--shd-orange);outline-offset:2px}.account-retention{color:var(--shd-text-muted);font:12px/1.55 Georgia,serif}.danger-eyebrow{color:var(--shd-danger)} .account-alert.error-alert{background:#261513;border-color:#75443b;border-left-color:#df8069}.account-alert.error-alert b{color:#df8069}.account-alert.error-alert span{color:#d2aaa1} .danger-zone{margin-top:12px;border:1px solid #563734;background:#181112}.danger-zone summary{padding:10px;color:#df8069;cursor:pointer;font-size:8px;font-weight:bold;letter-spacing:1px}.danger-zone>p,.danger-zone>form{margin:0;padding:0 10px 10px}.danger-zone>p{color:#9f8580;font-size:8px;line-height:1.5}.danger-zone form{padding-bottom:10px} @@ -129,4 +129,9 @@ .icon-editor-identity,.talent-icon-identity,.exotic-icon-identity{display:grid;grid-template-columns:150px minmax(0,1fr);gap:9px;align-items:start}.icon-editor-identity>.field{margin:0}.talent-icon-identity>.form-grid,.exotic-icon-identity>.form-grid{min-width:0}.icon-upload-field{min-width:0;margin:0;padding:7px;border:1px solid var(--shd-border);background:#0d1011}.icon-upload-field legend{padding:0 5px;color:var(--shd-text-muted);font-size:7px;font-weight:900;letter-spacing:.8px}.icon-upload-preview{height:86px;display:grid;place-items:center;margin-bottom:6px;border:1px solid #292f30;background:#111415}.icon-upload-preview img{width:72px;height:72px;object-fit:contain}.icon-upload-preview>span{color:#596164;font-size:7px;letter-spacing:.8px}.icon-upload-field>label{min-height:30px;display:grid;place-items:center;border:1px solid #424849;background:#15191a;color:#d7d9d5;font-size:6px;font-weight:900;letter-spacing:.6px;cursor:pointer}.icon-upload-field>label:hover,.icon-upload-field>label:focus-within{border-color:var(--shd-orange);color:var(--shd-orange)}.icon-upload-field>label:active{transform:translateY(1px);filter:brightness(.88)}.icon-upload-field>label input[type=file]{position:absolute;width:1px;height:1px;opacity:0}.icon-upload-field>label.icon-remove{min-height:24px;display:flex;gap:5px;margin-top:5px;border-color:#563734;background:#1d1212;color:var(--shd-danger)}.icon-upload-field>label.icon-remove input{width:auto;margin:0;accent-color:var(--shd-danger)}.icon-upload-field>small{display:block;margin-top:5px;color:#596164;font-size:5px;line-height:1.4}.registry-icon-identity{display:flex!important;align-items:center;gap:7px;min-width:0}.registry-icon-identity>img,.registry-icon-identity>span{flex:0 0 30px;width:30px;height:30px;object-fit:contain}.registry-icon-identity>span{display:grid;place-items:center;border:1px solid #303536;color:#596164}.registry-icon-identity>div{display:grid;min-width:0}.registry-icon-identity>div strong,.registry-icon-identity>div small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}@media(max-width:700px){.icon-editor-identity,.talent-icon-identity,.exotic-icon-identity{grid-template-columns:1fr}.icon-upload-field{max-width:none}.icon-upload-preview{height:70px}} .brand-editor-body{display:grid;grid-template-columns:122px minmax(0,1fr);gap:10px;margin-top:8px;align-items:start}.brand-editor-body>div{min-width:0}.brand-set-editor .brand-name{max-width:none;margin:0 0 7px}.brand-set-editor .icon-upload-field{padding:6px}.brand-set-editor .icon-upload-preview{height:58px;margin-bottom:5px}.brand-set-editor .icon-upload-preview img{width:50px;height:50px}.brand-set-editor .icon-upload-field>label{min-height:25px}.brand-set-editor .icon-upload-field>label.icon-remove{min-height:22px}.brand-set-editor .effect-band{padding:7px}.brand-set-editor .form-actions{margin-top:10px;padding-top:10px}.brand-archive-action{min-height:40px;padding:0 13px}.brand-archive-action>b{font-size:9px;transform:rotate(90deg)}@media(max-width:850px){.brand-editor-body{grid-template-columns:1fr}.brand-set-editor .icon-upload-field{display:grid;grid-template-columns:70px 1fr;gap:5px}.brand-set-editor .icon-upload-field legend,.brand-set-editor .icon-upload-field>small{grid-column:1/3}.brand-set-editor .icon-upload-preview{grid-row:1/3;height:55px;margin:0}.brand-set-editor .icon-upload-field>label{min-height:25px}}@media(max-width:560px){.brand-set-editor .icon-upload-field{grid-template-columns:1fr}.brand-set-editor .icon-upload-field legend,.brand-set-editor .icon-upload-field>small{grid-column:auto}.brand-set-editor .icon-upload-preview{grid-row:auto}.brand-archive-action{width:100%}} .brand-archive-action.restore>b{transform:none}.moderation-action.restore{border:1px solid #3d6849;background:#111d15;color:var(--shd-success)}.moderation-action.restore:hover,.moderation-action.restore:focus-visible{border-color:#69b77d;background:#17271c;color:#a4e5b6} +.pending-reference{border-color:color-mix(in srgb,var(--shd-orange) 58%,#303536)!important;background:color-mix(in srgb,var(--shd-orange) 7%,#121516)!important}.pending-reference-label{color:var(--shd-orange);font-family:var(--font-display);font-size:.62rem;letter-spacing:.08em;white-space:nowrap} +.data-access-banner{justify-content:space-between}.data-access-banner>div{display:grid;gap:3px}.data-access-banner form{margin:0}.mode-toggle{min-height:28px;display:inline-flex;align-items:center;padding:0 9px;border:1px solid #4b5152;background:#15191a;color:#c7cdce;font-size:6px;font-weight:900;letter-spacing:.65px;white-space:nowrap;cursor:pointer}.mode-toggle:hover,.mode-toggle:focus-visible{border-color:var(--shd-orange);color:var(--shd-orange)}.mode-toggle:active{transform:translateY(1px);filter:brightness(.86)} +.contributor-guidelines-toolbar{align-items:flex-end}.guideline-version{min-width:120px;display:grid;gap:4px;padding:8px 10px;border:1px solid var(--shd-border);background:#111415}.guideline-version span{color:var(--shd-text-muted);font-size:6px;letter-spacing:1px}.guideline-version strong{color:var(--shd-orange);font-size:9px}.contributor-guidelines{display:grid;grid-template-columns:1fr 1fr;gap:7px;margin:10px 0}.contributor-guidelines section{min-height:122px;display:grid;grid-template-columns:34px 1fr;border:1px solid var(--shd-border);background:#111415}.contributor-guidelines section>span{display:grid;place-items:start center;padding-top:11px;border-right:1px solid var(--shd-border);color:var(--shd-orange);font-size:8px;font-weight:900}.contributor-guidelines section>div{padding:11px}.contributor-guidelines h3{margin:0 0 6px;font-size:9px;letter-spacing:.7px}.contributor-guidelines p{margin:0;color:#858d8f;font-size:8px;line-height:1.55}.contributor-acceptance{display:flex;align-items:flex-start;gap:9px;padding:11px;border:1px solid #6f511c;background:#19150e;cursor:pointer}.contributor-acceptance input{width:auto;margin:2px 0 0;accent-color:var(--shd-orange)}.contributor-acceptance span{display:grid;gap:4px}.contributor-acceptance b{color:#e3c487;font-size:8px;letter-spacing:.7px}.contributor-acceptance small{color:#917d59;font-size:7px}.form-actions a.button{display:inline-flex;align-items:center;justify-content:center;text-decoration:none}@media(max-width:760px){.contributor-guidelines{grid-template-columns:1fr}.data-access-banner{align-items:flex-start;flex-direction:column}.mode-toggle{width:100%;justify-content:center}} +.command-status .data-mode-status{border-left:2px solid #667174}.command-status .data-mode-status strong{color:#b8c0c1;white-space:nowrap}.command-status .data-mode-status.contributor{border-left-color:var(--shd-orange)}.command-status .data-mode-status.contributor strong{color:#f2b64d}.command-status .data-mode-status.approver{border-left-color:#68a9c4}.command-status .data-mode-status.approver strong{color:#8ecce5}.command-status .data-mode-status.data-manager,.command-status .data-mode-status.admin{border-left-color:#75c08c}.command-status .data-mode-status.data-manager strong,.command-status .data-mode-status.admin strong{color:#8bd49f}@media(max-width:900px){.admin-command{align-items:flex-start;gap:12px}.command-status{display:grid;grid-template-columns:repeat(2,minmax(105px,1fr))}}@media(max-width:560px){.admin-command{display:grid}.command-status{width:100%}.command-status>div{min-width:0}} +.data-readonly .module-toolbar button,.data-readonly .dashboard-row:not(.table-heading)>button,.data-readonly .dashboard-editor,.data-readonly form[method=post]{display:none!important} diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 7a86a52..ef69bb6 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,4 +1,4 @@ -import type { Handle } from '@sveltejs/kit'; +import { json, type Handle } from '@sveltejs/kit'; import { getSessionUser } from '$lib/server/auth/session'; import { DATA_MODULES, queueDataProposal, shouldModerateDataMutation, type DataModule } from '$lib/server/data-moderation'; @@ -11,10 +11,20 @@ export const handle: Handle = async ({ event, resolve }) => { const user = await getSessionUser(event.cookies); const access = await shouldModerateDataMutation(user); if (!access.canManage && access.canPropose && user) { + if (!access.contributorEnabled) { + if (event.request.headers.get('accept')?.includes('application/json')) { + return json({ type: 'failure', status: 403, data: { contributorModeRequired: true } }, { status: 403 }); + } + return new Response('Contributor mode must be enabled before submitting data changes.', { status: 403 }); + } const proposalId = await queueDataProposal(user, module, action, await event.request.formData()); const target = new URL(event.url.pathname, event.url); target.searchParams.set('proposal', proposalId); - return new Response(null, { status: 303, headers: { location: `${target.pathname}${target.search}` } }); + const location = `${target.pathname}${target.search}`; + if (event.request.headers.get('accept')?.includes('application/json')) { + return json({ type: 'redirect', status: 303, location }); + } + return new Response(null, { status: 303, headers: { location } }); } } } diff --git a/src/lib/server/contributor-mode.ts b/src/lib/server/contributor-mode.ts new file mode 100644 index 0000000..b50e130 --- /dev/null +++ b/src/lib/server/contributor-mode.ts @@ -0,0 +1,16 @@ +import { getDatabase } from '$lib/server/database'; + +export const CONTRIBUTOR_GUIDELINES_VERSION = '2026-07-25'; + +export async function getContributorMode(userId: string) { + const database = await getDatabase(); + const agreement = await (await database.prepare( + 'SELECT guidelines_version,accepted_at,enabled,enabled_at,disabled_at FROM contributor_agreements WHERE user_id=?' + )).get(userId); + return { + accepted: agreement?.guidelines_version === CONTRIBUTOR_GUIDELINES_VERSION, + enabled: agreement?.guidelines_version === CONTRIBUTOR_GUIDELINES_VERSION && Number(agreement?.enabled) === 1, + agreement: agreement ?? null, + version: CONTRIBUTOR_GUIDELINES_VERSION + }; +} diff --git a/src/lib/server/data-moderation.ts b/src/lib/server/data-moderation.ts index 1f24fac..6ff9bd2 100644 --- a/src/lib/server/data-moderation.ts +++ b/src/lib/server/data-moderation.ts @@ -3,6 +3,7 @@ import { getDatabase } from '$lib/server/database'; import { getPermissions } from '$lib/server/auth/authorization'; import type { AuthUser } from '$lib/server/auth/session'; import { recordIsacAudit } from '$lib/server/audit'; +import { getContributorMode } from '$lib/server/contributor-mode'; export const DATA_MODULES = ['stats', 'talents', 'brands', 'gear', 'weapons', 'protocols'] as const; export type DataModule = typeof DATA_MODULES[number]; @@ -11,6 +12,13 @@ type SerializedEntry = | { name: string; kind: 'text'; value: string } | { name: string; kind: 'file'; value: string; fileName: string; type: string }; +type PendingDefinition = { + module: DataModule; + action: string; + kind: string; + fields?: string[]; +}; + async function serialize(form: FormData): Promise { const entries: SerializedEntry[] = []; for (const [name, value] of form.entries()) { @@ -35,32 +43,84 @@ export function deserializeProposal(payload: string): FormData { return form; } +const serializedTextValues = (payload: string) => + (JSON.parse(payload) as SerializedEntry[]).filter((entry): entry is Extract => entry.kind === 'text'); + +export async function getPendingEntities(_userId: string, definitions: PendingDefinition[]): Promise> { + if (!definitions.length) return []; + const database = await getDatabase(); + const clauses = definitions.map(() => '(proposal.module=? AND proposal.action=?)').join(' OR '); + const args = definitions.flatMap(({ module, action }) => [module, action]); + const rows = await (await database.prepare(`SELECT proposal.id,proposal.module,proposal.action,proposal.draft_entity_id,proposal.payload_json,proposal.created_at, + COALESCE(profile.display_name,proposer.username) AS proposer_name + FROM data_change_proposals proposal + JOIN users proposer ON proposer.id=proposal.proposed_by + LEFT JOIN user_profiles profile ON profile.user_id=proposer.id + WHERE proposal.status='pending' AND proposal.entity_id IS NULL AND (${clauses}) + ORDER BY proposal.created_at DESC`)).all(...args); + return rows.map((row) => { + const definition = definitions.find((item) => item.module === row.module && item.action === row.action)!; + const values = serializedTextValues(String(row.payload_json)); + const field = (name: string) => values.find((entry) => entry.name === name)?.value ?? ''; + return { + id: String(row.draft_entity_id), + proposalId: String(row.id), + pending: true, + active: 1, + kind: definition.kind, + name: field('name'), + proposerName: String(row.proposer_name), + ...Object.fromEntries((definition.fields ?? []).map((name) => [name, field(name)])) + }; + }); +} + export async function queueDataProposal(user: AuthUser, module: DataModule, action: string, form: FormData) { const id = randomUUID(); const entityId = String(form.get('id') ?? '').trim() || null; + const draftEntityId = entityId ? null : randomUUID(); const name = String(form.get('name') ?? '').trim(); const summary = `${action.replace(/([A-Z])/g, ' $1').toUpperCase()} · ${name || entityId || 'NEW RECORD'}`; const now = new Date().toISOString(); const payload = JSON.stringify(await serialize(form)); const database = await getDatabase(); await (await database.prepare(`INSERT INTO data_change_proposals - (id,module,action,entity_id,summary,payload_json,status,proposed_by,created_at) - VALUES(?,?,?,?,?,?,'pending',?,?)`)).run(id, module, action, entityId, summary, payload, user.id, now); + (id,module,action,entity_id,draft_entity_id,summary,payload_json,status,proposed_by,created_at) + VALUES(?,?,?,?,?,?,?,'pending',?,?)`)).run(id, module, action, entityId, draftEntityId, summary, payload, user.id, now); + const text = serializedTextValues(payload).map((entry) => entry.value).join('\n'); + const candidates = await (await database.prepare(`SELECT id,draft_entity_id FROM data_change_proposals + WHERE status='pending' AND draft_entity_id IS NOT NULL`)).all(); + const dependencies = candidates.filter((candidate) => candidate.id !== id && text.includes(String(candidate.draft_entity_id))); + for (const dependency of dependencies) { + await (await database.prepare('INSERT OR IGNORE INTO data_change_proposal_dependencies(proposal_id,depends_on_proposal_id) VALUES(?,?)')).run(id, dependency.id); + } await recordIsacAudit({ actorId: user.id, category: 'data_moderation', action: 'proposal.created', entityId: id, before: null, - after: { module, action, entityId, summary } + after: { module, action, entityId, draftEntityId, dependencies: dependencies.map((item) => item.id), summary } }); return id; } export async function shouldModerateDataMutation(user: AuthUser | null) { const permissions = await getPermissions(user); + const eligibleToPropose = permissions.includes('*') || permissions.includes('data.propose'); + const contributorMode = user && eligibleToPropose ? await getContributorMode(user.id) : null; return { canManage: permissions.includes('*') || permissions.includes('data.manage'), - canPropose: permissions.includes('*') || permissions.includes('data.propose') + canPropose: eligibleToPropose, + contributorEnabled: Boolean(contributorMode?.enabled) }; } diff --git a/src/routes/admin/+layout.server.ts b/src/routes/admin/+layout.server.ts index 5f56308..ccb3b5a 100644 --- a/src/routes/admin/+layout.server.ts +++ b/src/routes/admin/+layout.server.ts @@ -3,17 +3,22 @@ import { getPermissions } from '$lib/server/auth/authorization'; import { redirect } from '@sveltejs/kit'; import { getDatabase } from '$lib/server/database'; import type { LayoutServerLoad } from './$types'; +import { getContributorMode } from '$lib/server/contributor-mode'; export const load: LayoutServerLoad = async ({ cookies }) => { const user = await getSessionUser(cookies); if (!user) redirect(303, '/sign-in'); const permissions = await getPermissions(user); if (!permissions.includes('*') && !permissions.includes('isac.access')) redirect(303, '/account'); const canSeeData = permissions.includes('*') || ['data.view','data.manage','data.propose','data.approve'].some((permission) => permissions.includes(permission)); + const canManageData = permissions.includes('*') || permissions.includes('data.manage'); + const canProposeData = permissions.includes('*') || permissions.includes('data.propose'); + const contributorMode = !canManageData && canProposeData ? await getContributorMode(user.id) : null; const pendingRows = canSeeData ? await (await (await getDatabase()).prepare("SELECT module,COUNT(*) AS count FROM data_change_proposals WHERE status='pending' GROUP BY module")).all() : []; return { permissions, + contributorMode, pendingProposals: Object.fromEntries(pendingRows.map((row) => [String(row.module), Number(row.count)])), pendingProposalTotal: pendingRows.reduce((total, row) => total + Number(row.count), 0) }; diff --git a/src/routes/admin/+layout.svelte b/src/routes/admin/+layout.svelte index 745511a..50ab982 100644 --- a/src/routes/admin/+layout.svelte +++ b/src/routes/admin/+layout.svelte @@ -3,7 +3,11 @@ let { data, children } = $props(); const can = (permission: string) => data.permissions.includes('*') || data.permissions.includes(permission); const canSeeData = $derived(['data.view', 'data.manage', 'data.propose', 'data.approve'].some(can)); - const dataReadOnly = $derived(can('data.view') && !can('data.manage') && !can('data.propose')); + const contributorEnabled = $derived(Boolean(data.contributorMode?.enabled)); + const guidelinesPage = $derived(page.url.pathname === '/admin/contributor-guidelines'); + const dataReadOnly = $derived(can('data.view') && !can('data.manage') && (!can('data.propose') || !contributorEnabled) && !guidelinesPage); + const dataMode = $derived(can('*') ? 'ADMIN' : can('data.manage') ? 'DATA MANAGER' : can('data.approve') ? 'APPROVER' : contributorEnabled ? 'CONTRIBUTOR' : 'READ-ONLY'); + const dataModeClass = $derived(dataMode.toLowerCase().replace(' ', '-')); let readOnlyBlocked = $state(false); const dataSections = [ { href: '/admin/stats', label: 'Stats & attributes', code: 'ATTR', module: 'stats' }, @@ -29,7 +33,7 @@

INTELLIGENT SYSTEM ANALYTIC COMPUTER

ISAC

SHD.build operations and intelligence control surface

-
SYSTEMONLINE
ACCESSAUTHORIZED
ENVIRONMENTLOCAL
+
SYSTEMONLINE
ACCESSAUTHORIZED
ENVIRONMENTLOCAL
DATA MODE{dataMode}
COMPATIBLE BRAND SETSSELECT AT LEAST TWO
{editing.brandIds.length} SELECTED
- {#each data.brands as brand}{/each} + {#each data.brands as brand}{/each}
{#if editing.slot === 'Backpack' || editing.slot === 'Body Armor'}
{editing.slot.toUpperCase()} TALENTSSELECT AT LEAST ONE · REQUIRED
{editing.talentIds.length} SELECTED
- {#each gearTalents(editing.slot) as talent}{/each} + {#each gearTalents(editing.slot) as talent}{/each}
{#if gearTalents(editing.slot).length === 0}

Create an active {editing.slot} talent before adding gear for this slot.

{/if} {/if} diff --git a/src/routes/admin/roles/+page.svelte b/src/routes/admin/roles/+page.svelte index 9fbec39..d6b90f2 100644 --- a/src/routes/admin/roles/+page.svelte +++ b/src/routes/admin/roles/+page.svelte @@ -17,7 +17,7 @@ Roles & permissions — ISAC

ACCESS POLICY REGISTRY

ROLES & PERMISSIONS

Compose reusable access roles from discrete SHD.build capabilities.

-

REGISTERED AGENT BASELINEEvery active account inherently receives isac.access, data.view, and data.propose. Roles add elevated capabilities and cannot remove this baseline.

+

REGISTERED AGENT BASELINEEvery active account inherently receives isac.access, data.view, and data.propose eligibility. Submitting proposals additionally requires accepted guidelines and enabled contributor mode. Roles add elevated capabilities and cannot remove this baseline.

Number(values[index+1]))) return fail(400, { error: 'Each enabled source needs a valid minimum and maximum.' }); const database = await getDatabase(); if (await (await database.prepare('SELECT id FROM stat_attributes WHERE name = ? COLLATE NOCASE AND id <> ?')).get(name, id || 'new')) return fail(409, { error: 'That attribute already exists.' }); - const now = new Date().toISOString(), statId = id || randomUUID(); + const now = new Date().toISOString(), statId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID(); const before = id ? await auditRecord('stat_attributes', id) : null; if (id) await (await database.prepare(`UPDATE stat_attributes SET name=?,type=?,category=?,calculation_type=?,unit=?,gear_min=?,gear_max=?,weapon_min=?,weapon_max=?,skill_chip_min=?,skill_chip_max=?,cap_value=?,updated_at=? WHERE id=?`)).run(name,type,category,calculation,unit,...values,now,id); else await (await database.prepare(`INSERT INTO stat_attributes(id,name,type,category,calculation_type,unit,gear_min,gear_max,weapon_min,weapon_max,skill_chip_min,skill_chip_max,cap_value,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run(statId,name,type,category,calculation,unit,...values,now,now); diff --git a/src/routes/admin/talents/+page.server.ts b/src/routes/admin/talents/+page.server.ts index 70667b0..b8f848c 100644 --- a/src/routes/admin/talents/+page.server.ts +++ b/src/routes/admin/talents/+page.server.ts @@ -7,11 +7,12 @@ import type { Actions, PageServerLoad } from './$types'; import { getPagination } from '$lib/server/pagination'; import { auditRecord, recordIsacAudit } from '$lib/server/audit'; import { applyIconMutation, gameDataIconMetadata, gameDataIconUrl, GameDataIconValidationError, readIconMutation } from '$lib/server/game-data-icons'; +import { getPendingEntities } from '$lib/server/data-moderation'; type SharedEffect = { statId: string; valueI: number; valueII: number }; export const load: PageServerLoad = async ({ cookies, url }) => { - await requireDataView(await getSessionUser(cookies)); + const user = await requireDataView(await getSessionUser(cookies)); const db = await getDatabase(); const totalRow = await (await db.prepare('SELECT COUNT(*) AS total FROM talents')).get(); const filter = ['slot1', 'slot2', 'exotic', 'backpack', 'body-armor'].includes(url.searchParams.get('filter') ?? '') ? String(url.searchParams.get('filter')) : ''; @@ -35,7 +36,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => { (await db.prepare('SELECT id,name,exotic_talent_id FROM weapons WHERE exotic_talent_id IS NOT NULL')).all(), (await db.prepare("SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='talent'")).all() ]); - const [triggerSummary, slotSummary, exoticSummary, gearSummary] = await Promise.all([ + const [triggerSummary, slotSummary, exoticSummary, gearSummary, pending] = await Promise.all([ (await db.prepare('SELECT COUNT(*) AS total FROM talent_triggers')).get(), (await db.prepare(`SELECT SUM(CASE WHEN slot=1 THEN 1 ELSE 0 END) AS slot1, @@ -44,8 +45,14 @@ export const load: PageServerLoad = async ({ cookies, url }) => { (await db.prepare(`SELECT SUM(CASE WHEN category='Backpack' THEN 1 ELSE 0 END) AS backpack, SUM(CASE WHEN category IN ('Vest','Body Armor') THEN 1 ELSE 0 END) AS body_armor - FROM talents`)).get() + FROM talents`)).get(), + getPendingEntities(user.id, [ + { module: 'talents', action: 'saveTrigger', kind: 'trigger', fields: ['description'] }, + { module: 'stats', action: 'save', kind: 'stat', fields: ['unit'] } + ]) ]); + const pendingTriggers = pending.filter((item) => item.kind === 'trigger'); + const pendingStats = pending.filter((item) => item.kind === 'stat'); return { talents: talents.map((talent) => ({ ...talent, @@ -70,8 +77,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => { effects: effects.filter((effect) => effect.talent_tier_id === tier.id) })) })), - triggers, - stats, + triggers: [...pendingTriggers, ...triggers], + stats: [...pendingStats, ...stats], summary: { total: Number(totalRow?.total ?? 0), triggers: Number(triggerSummary?.total ?? 0), @@ -122,7 +129,7 @@ export const actions: Actions = { if (uptime && (!Number.isFinite(min) || !Number.isFinite(max) || Number(min) < 0 || Number(max) > 100 || Number(min) > Number(max))) return fail(400, { triggerError: 'Uptime must be a valid 0–100% range.' }); const db = await getDatabase(); if (await (await db.prepare('SELECT id FROM talent_triggers WHERE name=? COLLATE NOCASE AND id<>?')).get(name, id || 'new')) return fail(409, { triggerError: 'That trigger already exists.' }); - const now = new Date().toISOString(), triggerId = id || randomUUID(); + const now = new Date().toISOString(), triggerId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID(); const before = id ? await auditRecord('talent_triggers', id) : null; if (id) await (await db.prepare('UPDATE talent_triggers SET name=?,description=?,supports_uptime=?,min_uptime=?,max_uptime=?,updated_at=? WHERE id=?')).run(name, description, uptime ? 1 : 0, min, max, now, id); else await (await db.prepare('INSERT INTO talent_triggers(id,name,description,supports_uptime,min_uptime,max_uptime,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)')).run(triggerId, name, description, uptime ? 1 : 0, min, max, now, now); @@ -152,7 +159,7 @@ export const actions: Actions = { const statRows = await (await db.prepare(`SELECT id,unit FROM stat_attributes WHERE active=1 AND id IN (${sharedEffects.map(() => '?').join(',')})`)).all(...sharedEffects.map((effect) => effect.statId)); if (statRows.length !== sharedEffects.length) return fail(400, { talentError: 'One or more selected attributes are unavailable.' }); const units = new Map(statRows.map((stat) => [String(stat.id), stat.unit])); - const talentId = id || randomUUID(), now = new Date().toISOString(); + const talentId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID(), now = new Date().toISOString(); const before = id ? { record: await auditRecord('talents', id), icon: await gameDataIconMetadata(db, 'talent', id), template: await (await db.prepare('SELECT template FROM talent_description_templates WHERE talent_id=?')).get(id), tiers: await (await db.prepare('SELECT tier,description FROM talent_tiers WHERE talent_id=? ORDER BY tier')).all(id) } : null; if (id) await (await db.prepare('UPDATE talents SET name=?,category=?,updated_at=? WHERE id=?')).run(name, storedCategory, now, id); else await (await db.prepare('INSERT INTO talents(id,name,category,created_at,updated_at) VALUES(?,?,?,?,?)')).run(talentId, name, storedCategory, now, now); diff --git a/src/routes/admin/talents/+page.svelte b/src/routes/admin/talents/+page.svelte index b7c0a56..a5b6740 100644 --- a/src/routes/admin/talents/+page.svelte +++ b/src/routes/admin/talents/+page.svelte @@ -73,9 +73,9 @@
TIER I PREVIEW

{preview('I') || 'Description preview'}

TIER II PREVIEW

{preview('II') || 'Description preview'}

SHARED ACTIVATION TRIGGERS

-
{#each data.triggers.filter((item) => Number(item.active) === 1) as item}
{/each}
+
{#each data.triggers.filter((item) => Number(item.active) === 1) as item}
{#if item.pending}PENDING · {item.proposerName}{:else}{/if}
{/each}

SHARED ATTRIBUTE EFFECTS · TIER VALUES

-
ATTRIBUTETIER I VALUETIER II VALUE
{#each talent.effects as effect, index}
{/each}
+
ATTRIBUTETIER I VALUETIER II VALUE
{#each talent.effects as effect, index}
{/each}
{#if form?.talentError}

{form.talentError}

{/if}
diff --git a/src/routes/admin/users/[id]/+page.svelte b/src/routes/admin/users/[id]/+page.svelte index b2edd06..4b7fdf2 100644 --- a/src/routes/admin/users/[id]/+page.svelte +++ b/src/routes/admin/users/[id]/+page.svelte @@ -40,7 +40,7 @@
ASSIGNED ROLESPERMISSION BUNDLES
{roleIds.length} SELECTED
{#each data.roles as role}{/each}
-

REGISTERED AGENT BASELINEisac.access · data.view · data.propose are inherent and are not removable here.

+

REGISTERED AGENT BASELINEisac.access · data.view · data.propose eligibility are inherent. Proposal submission also requires accepted guidelines and enabled contributor mode.

DIRECT PERMISSIONSUSER-SPECIFIC CAPABILITIES
{permissionIds.length} SELECTED
{#each data.permissions as permission}{/each}
{#if form?.accessError}

{form.accessError}

{/if}{#if form?.accessSaved}

Roles and direct permissions updated.

{/if}