feat(isac): add moderated contributor mode
This commit is contained in:
+6
-1
@@ -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}
|
||||
|
||||
|
||||
+12
-2
@@ -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 } });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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<SerializedEntry[]> {
|
||||
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<SerializedEntry, { kind: 'text' }> => entry.kind === 'text');
|
||||
|
||||
export async function getPendingEntities(_userId: string, definitions: PendingDefinition[]): Promise<Array<{
|
||||
id: string;
|
||||
proposalId: string;
|
||||
pending: true;
|
||||
active: number;
|
||||
kind: string;
|
||||
name: string;
|
||||
proposerName: string;
|
||||
[key: string]: unknown;
|
||||
}>> {
|
||||
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)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
|
||||
@@ -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 @@
|
||||
<main class="admin-page">
|
||||
<header class="admin-command">
|
||||
<div class="command-title"><p class="eyebrow"><span></span> INTELLIGENT SYSTEM ANALYTIC COMPUTER</p><h1>ISAC</h1><p>SHD.build operations and intelligence control surface</p></div>
|
||||
<div class="command-status"><div><i></i><span>SYSTEM</span><strong>ONLINE</strong></div><div><span>ACCESS</span><strong>AUTHORIZED</strong></div><div><span>ENVIRONMENT</span><strong>LOCAL</strong></div></div>
|
||||
<div class="command-status"><div><i></i><span>SYSTEM</span><strong>ONLINE</strong></div><div><span>ACCESS</span><strong>AUTHORIZED</strong></div><div><span>ENVIRONMENT</span><strong>LOCAL</strong></div><div class="data-mode-status" class:admin={dataModeClass === 'admin'} class:data-manager={dataModeClass === 'data-manager'} class:approver={dataModeClass === 'approver'} class:contributor={dataModeClass === 'contributor'} class:read-only={dataModeClass === 'read-only'}><span>DATA MODE</span><strong>{dataMode}</strong></div></div>
|
||||
</header>
|
||||
<div class="admin-dashboard">
|
||||
<aside class="admin-sidebar">
|
||||
@@ -40,7 +44,7 @@
|
||||
{#if canSeeData}
|
||||
<div class="sidebar-label" class:section-label={visibleUserSections.length > 0}><span>DATA MANAGEMENT</span><b>{String(dataSections.length).padStart(2, '0')} MODULES</b></div>
|
||||
<nav aria-label="Data management">
|
||||
{#each dataSections as section}<a class:active={page.url.pathname === section.href} href={section.href}><small>{section.code}</small><span>{section.label}</span>{#if data.pendingProposals[section.module]}<em>{data.pendingProposals[section.module]}</em>{/if}<b>›</b></a>{/each}
|
||||
{#each dataSections as section}<a class:active={page.url.pathname === section.href} class:has-pending={Boolean(data.pendingProposals[section.module])} href={section.href}><small>{section.code}</small><span>{section.label}</span>{#if data.pendingProposals[section.module]}<em aria-label={`${data.pendingProposals[section.module]} pending change proposal${data.pendingProposals[section.module] === 1 ? '' : 's'}`} title="Pending change proposals">{data.pendingProposals[section.module]}</em>{/if}<b>›</b></a>{/each}
|
||||
</nav>
|
||||
{/if}
|
||||
{#if visibleOversightSections.length}
|
||||
@@ -56,8 +60,8 @@
|
||||
}
|
||||
}}>
|
||||
{#if page.url.searchParams.has('proposal')}<div class="proposal-submitted" role="status"><b>CHANGE QUEUED</b><span>Your suggestion is awaiting data moderation and has not changed the live data set.</span></div>{/if}
|
||||
{#if dataReadOnly}<div class="data-access-banner"><b>READ-ONLY DATA ACCESS</b><span>You can inspect the registries but cannot submit or apply changes.</span></div>
|
||||
{:else if can('data.propose') && !can('data.manage')}<div class="data-access-banner contributor"><b>CONTRIBUTOR MODE</b><span>Saved edits and additions enter the approval queue; live builder data is not changed immediately.</span></div>{/if}
|
||||
{#if dataReadOnly}<div class="data-access-banner"><div><b>READ-ONLY DATA ACCESS</b><span>You can inspect registries without submitting changes.</span></div>{#if can('data.propose')}<a class="mode-toggle" href={`/admin/contributor-guidelines?returnTo=${encodeURIComponent(page.url.pathname + page.url.search)}`}>ENABLE CONTRIBUTOR MODE →</a>{/if}</div>
|
||||
{:else if contributorEnabled && !guidelinesPage && can('data.propose') && !can('data.manage')}<div class="data-access-banner contributor"><div><b>CONTRIBUTOR MODE</b><span>Suggestions enter moderation and do not immediately change live data.</span></div><form method="POST" action="/admin/contributor-guidelines?/disable"><input type="hidden" name="returnTo" value={page.url.pathname + page.url.search}/><button class="mode-toggle" type="submit">RETURN TO READ-ONLY</button></form></div>{/if}
|
||||
{#if readOnlyBlocked}<p class="error data-access-error" role="alert">This account has read-only game-data access.</p>{/if}
|
||||
{@render children()}
|
||||
</section>
|
||||
|
||||
@@ -62,7 +62,7 @@ export const actions: Actions = {
|
||||
const uniqueStatIds = [...new Set(effects.map((effect) => effect.statId))];
|
||||
const validStats = await (await database.prepare(`SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id IN (${uniqueStatIds.map(() => '?').join(',')})`)).get(...uniqueStatIds);
|
||||
if (Number(validStats?.count) !== uniqueStatIds.length) return fail(400, { brandError: 'One or more selected attributes are unavailable.' });
|
||||
const brandId = id || randomUUID(), now = new Date().toISOString();
|
||||
const brandId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID(), now = new Date().toISOString();
|
||||
const before = id ? { record: await auditRecord('brand_sets', id), icon: await gameDataIconMetadata(database, 'brand_set', id), effects: await (await database.prepare('SELECT * FROM brand_set_effects WHERE brand_set_id=? ORDER BY pieces_required')).all(id) } : null;
|
||||
if (id) await (await database.prepare('UPDATE brand_sets SET name=?,active=1,updated_at=? WHERE id=?')).run(name, now, id);
|
||||
else await (await database.prepare('INSERT INTO brand_sets(id,name,created_at,updated_at) VALUES(?,?,?,?)')).run(brandId, name, now, now);
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { getPermissions } from '$lib/server/auth/authorization';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { CONTRIBUTOR_GUIDELINES_VERSION, getContributorMode } from '$lib/server/contributor-mode';
|
||||
import { recordIsacAudit } from '$lib/server/audit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
const safeReturn = (value: FormDataEntryValue | null) => {
|
||||
const path = String(value ?? '/admin/stats');
|
||||
return path.startsWith('/admin/') && !path.startsWith('//') ? path : '/admin/stats';
|
||||
};
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) redirect(303, '/sign-in');
|
||||
const permissions = await getPermissions(user);
|
||||
const eligible = permissions.includes('*') || permissions.includes('data.propose');
|
||||
if (!eligible) redirect(303, '/admin/stats');
|
||||
return {
|
||||
contributorMode: await getContributorMode(user.id),
|
||||
returnTo: safeReturn(url.searchParams.get('returnTo'))
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
enable: async ({ cookies, request }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) redirect(303, '/sign-in');
|
||||
const permissions = await getPermissions(user);
|
||||
if (!permissions.includes('*') && !permissions.includes('data.propose')) return fail(403, { contributorError: 'This account is not eligible to contribute data.' });
|
||||
const form = await request.formData();
|
||||
if (form.get('acceptGuidelines') !== 'on') return fail(400, { contributorError: 'Accept the contributor guidelines to enable contributor mode.' });
|
||||
const returnTo = safeReturn(form.get('returnTo'));
|
||||
const database = await getDatabase();
|
||||
const before = await getContributorMode(user.id);
|
||||
const now = new Date().toISOString();
|
||||
await (await database.prepare(`INSERT INTO contributor_agreements(user_id,guidelines_version,accepted_at,enabled,enabled_at,disabled_at)
|
||||
VALUES(?,?,?,1,?,NULL)
|
||||
ON CONFLICT(user_id) DO UPDATE SET guidelines_version=excluded.guidelines_version,accepted_at=excluded.accepted_at,
|
||||
enabled=1,enabled_at=excluded.enabled_at,disabled_at=NULL`)).run(user.id, CONTRIBUTOR_GUIDELINES_VERSION, now, now);
|
||||
await recordIsacAudit({
|
||||
actorId: user.id,
|
||||
category: 'data_moderation',
|
||||
action: before.accepted ? 'contributor.enabled' : 'contributor.guidelines_accepted',
|
||||
entityId: user.id,
|
||||
before,
|
||||
after: await getContributorMode(user.id)
|
||||
});
|
||||
redirect(303, returnTo);
|
||||
},
|
||||
disable: async ({ cookies, request }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) redirect(303, '/sign-in');
|
||||
const returnTo = safeReturn((await request.formData()).get('returnTo'));
|
||||
const database = await getDatabase();
|
||||
const before = await getContributorMode(user.id);
|
||||
if (before.enabled) {
|
||||
await (await database.prepare('UPDATE contributor_agreements SET enabled=0,disabled_at=? WHERE user_id=?')).run(new Date().toISOString(), user.id);
|
||||
await recordIsacAudit({
|
||||
actorId: user.id,
|
||||
category: 'data_moderation',
|
||||
action: 'contributor.disabled',
|
||||
entityId: user.id,
|
||||
before,
|
||||
after: await getContributorMode(user.id)
|
||||
});
|
||||
}
|
||||
redirect(303, returnTo);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
<script lang="ts">
|
||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||
let { data, form } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Contributor guidelines — ISAC</title></svelte:head>
|
||||
|
||||
<div class="module-toolbar contributor-guidelines-toolbar">
|
||||
<div><p class="eyebrow">DATA CONTRIBUTION / OPERATING AGREEMENT</p><h2>CONTRIBUTOR GUIDELINES</h2><p>Review the requirements for suggesting changes to SHD.build source data.</p></div>
|
||||
<div class="guideline-version"><span>REVISION</span><strong>{data.contributorMode.version}</strong></div>
|
||||
</div>
|
||||
|
||||
<div class="contributor-guidelines">
|
||||
<section>
|
||||
<span>01</span>
|
||||
<div><h3>ACCURATE, VERIFIABLE DATA</h3><p>Enter information as it appears in The Division Resurgence. Check spelling, categories, tiers, units, ranges, and effect values before submitting. Do not guess when a value is unknown; explain uncertainty in the proposal context or leave the contribution until it can be verified.</p></div>
|
||||
</section>
|
||||
<section>
|
||||
<span>02</span>
|
||||
<div><h3>CLEAR, CONSISTENT CONTENT</h3><p>Use concise names and descriptions that match established game terminology. Avoid jokes, advertising, personal messages, copied community commentary, or unrelated content. Search existing live and pending records first to avoid duplicate work.</p></div>
|
||||
</section>
|
||||
<section>
|
||||
<span>03</span>
|
||||
<div><h3>APPROPRIATE IMAGES</h3><p>Upload only relevant game-data icons or assets you are permitted to share. Images must be safe for a general audience and must not contain harassment, explicit material, personal information, watermarks, advertisements, or misleading edits. Prefer clean transparent PNG, WebP, or SVG assets that remain legible at builder-icon sizes.</p></div>
|
||||
</section>
|
||||
<section>
|
||||
<span>04</span>
|
||||
<div><h3>HOW PROPOSALS WORK</h3><p>Contributor saves do not immediately change the live builder. Each addition or edit enters the moderation queue with your account, timestamp, submitted values, and dependencies. Approvers may accept, reject, or ask for correction. Actions are audit logged.</p></div>
|
||||
</section>
|
||||
<section>
|
||||
<span>05</span>
|
||||
<div><h3>COLLABORATIVE DEPENDENCIES</h3><p>You may reference pending records submitted by any contributor. The interface marks them as pending and identifies their proposer. Dependent proposals must be approved in order; rejection or validation failure upstream blocks dependent work from deployment.</p></div>
|
||||
</section>
|
||||
<section>
|
||||
<span>06</span>
|
||||
<div><h3>RESPONSIBLE ACCESS</h3><p>Use contributor mode only while preparing suggestions. Repeated low-quality, abusive, deceptive, or rights-infringing submissions may be rejected and contribution access may be restricted. You can return to read-only mode at any time without affecting proposals already submitted.</p></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{#if form?.contributorError}<p class="error" role="alert">{form.contributorError}</p>{/if}
|
||||
|
||||
<PendingForm action="?/enable">
|
||||
{#snippet children(pending)}
|
||||
<input type="hidden" name="returnTo" value={data.returnTo}/>
|
||||
<label class="contributor-acceptance">
|
||||
<input type="checkbox" name="acceptGuidelines" required/>
|
||||
<span><b>I ACCEPT THE CONTRIBUTOR GUIDELINES</b><small>I understand that submissions are attributed, moderated, dependency checked, and audit logged.</small></span>
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<a class="button secondary" href={data.returnTo}>KEEP READ-ONLY MODE</a>
|
||||
<button class="primary" disabled={pending} aria-busy={pending}>{pending ? 'ENABLING…' : data.contributorMode.accepted ? 'ENABLE CONTRIBUTOR MODE →' : 'ACCEPT & ENABLE →'}</button>
|
||||
</div>
|
||||
{/snippet}
|
||||
</PendingForm>
|
||||
@@ -45,8 +45,18 @@ export const actions: Actions = {
|
||||
const database = await getDatabase();
|
||||
const proposal = await (await database.prepare("SELECT * FROM data_change_proposals WHERE id=? AND status='pending'")).get(id);
|
||||
if (!proposal) return fail(404, { moderationError: 'That pending proposal no longer exists.' });
|
||||
const unresolved = await (await database.prepare(`SELECT dependency.id,dependency.summary,dependency.status
|
||||
FROM data_change_proposal_dependencies link
|
||||
JOIN data_change_proposals dependency ON dependency.id=link.depends_on_proposal_id
|
||||
WHERE link.proposal_id=? AND dependency.status<>'approved'`)).all(id);
|
||||
if (unresolved.length) {
|
||||
const names = unresolved.map((item) => `${item.summary} (${item.status})`).join(', ');
|
||||
return fail(409, { moderationError: `Approve this proposal's dependencies first: ${names}.` });
|
||||
}
|
||||
const target = new URL(`/admin/${proposal.module}?/${proposal.action}`, url.origin);
|
||||
const response = await fetch(target, { method: 'POST', body: deserializeProposal(String(proposal.payload_json)), headers: { origin: url.origin } });
|
||||
const proposalForm = deserializeProposal(String(proposal.payload_json));
|
||||
if (proposal.draft_entity_id && !proposal.entity_id) proposalForm.set('_proposedEntityId', String(proposal.draft_entity_id));
|
||||
const response = await fetch(target, { method: 'POST', body: proposalForm, headers: { origin: url.origin } });
|
||||
const now = new Date().toISOString();
|
||||
if (!response.ok) {
|
||||
const details = (await response.text()).slice(0, 1000);
|
||||
@@ -54,8 +64,8 @@ export const actions: Actions = {
|
||||
await recordIsacAudit({ actorId: reviewer.id, category: 'data_moderation', action: 'proposal.failed', entityId: id, before: { status: 'pending' }, after: { status: 'failed', responseStatus: response.status } });
|
||||
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',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 } });
|
||||
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 } });
|
||||
return { moderationSaved: true, moderationToken: randomUUID() };
|
||||
},
|
||||
reject: async ({ cookies, request }) => {
|
||||
|
||||
@@ -6,12 +6,13 @@ import { randomUUID } from 'node:crypto';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { getPagination } from '$lib/server/pagination';
|
||||
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
|
||||
import { getPendingEntities } from '$lib/server/data-moderation';
|
||||
|
||||
const slots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask'];
|
||||
const armoredSlots = new Set(['Gloves', 'Knee Pads', 'Body Armor']);
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
await requireDataView(await getSessionUser(cookies));
|
||||
const user = await requireDataView(await getSessionUser(cookies));
|
||||
const database = await getDatabase();
|
||||
const summary = await (await database.prepare(`SELECT COUNT(*) AS total,
|
||||
SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active,
|
||||
@@ -22,21 +23,27 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
: filter === 'talented' ? 'WHERE EXISTS (SELECT 1 FROM gear_talents WHERE gear_id=gear.id)' : '';
|
||||
const filtered = await (await database.prepare(`SELECT COUNT(*) AS total FROM gear ${where}`)).get();
|
||||
const pagination = getPagination(url, Number(filtered?.total ?? 0));
|
||||
const [gear, brandLinks, talentLinks, brands, talents] = await Promise.all([
|
||||
const [gear, brandLinks, talentLinks, brands, talents, pending] = await Promise.all([
|
||||
(await database.prepare(`SELECT * FROM gear ${where} ORDER BY active DESC, slot, name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(pagination.perPage, pagination.offset),
|
||||
(await database.prepare('SELECT gear_id,brand_set_id FROM gear_brand_sets')).all(),
|
||||
(await database.prepare('SELECT gear_id,talent_id FROM gear_talents')).all(),
|
||||
(await database.prepare('SELECT id,name FROM brand_sets WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
|
||||
(await database.prepare(`SELECT id,name,category FROM talents WHERE active=1 AND category IN ('Backpack','Vest','Body Armor') ORDER BY name COLLATE NOCASE`)).all()
|
||||
(await database.prepare(`SELECT id,name,category FROM talents WHERE active=1 AND category IN ('Backpack','Vest','Body Armor') ORDER BY name COLLATE NOCASE`)).all(),
|
||||
getPendingEntities(user.id, [
|
||||
{ module: 'brands', action: 'save', kind: 'brand' },
|
||||
{ module: 'talents', action: 'saveTalent', kind: 'talent', fields: ['category'] }
|
||||
])
|
||||
]);
|
||||
const pendingBrands = pending.filter((item) => item.kind === 'brand');
|
||||
const pendingTalents = pending.filter((item) => item.kind === 'talent' && ['Backpack', 'Body Armor'].includes(String(item.category)));
|
||||
return {
|
||||
gear: gear.map((item) => ({
|
||||
...item,
|
||||
brandIds: brandLinks.filter((link) => link.gear_id === item.id).map((link) => String(link.brand_set_id)),
|
||||
talentIds: talentLinks.filter((link) => link.gear_id === item.id).map((link) => String(link.talent_id))
|
||||
})),
|
||||
brands,
|
||||
talents: talents.map((talent) => ({ ...talent, category: talent.category === 'Vest' ? 'Body Armor' : talent.category })),
|
||||
brands: [...pendingBrands, ...brands],
|
||||
talents: [...pendingTalents, ...talents.map((talent) => ({ ...talent, category: talent.category === 'Vest' ? 'Body Armor' : talent.category }))],
|
||||
summary,
|
||||
pagination,
|
||||
filter
|
||||
@@ -87,7 +94,7 @@ export const actions: Actions = {
|
||||
const validTalents = await (await database.prepare(`SELECT COUNT(*) AS count FROM talents WHERE active=1 AND category IN (${allowedCategories.map(() => '?').join(',')}) AND id IN (${talentIds.map(() => '?').join(',')})`)).get(...allowedCategories, ...talentIds);
|
||||
if (Number(validTalents?.count) !== talentIds.length) return fail(400, { gearError: `Every selected talent must be a ${slot} talent.` });
|
||||
}
|
||||
const gearId = id || randomUUID(), now = new Date().toISOString();
|
||||
const gearId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID(), now = new Date().toISOString();
|
||||
const before = id ? { record: await auditRecord('gear', id), brandIds: await (await database.prepare('SELECT brand_set_id FROM gear_brand_sets WHERE gear_id=?')).all(id), talentIds: await (await database.prepare('SELECT talent_id FROM gear_talents WHERE gear_id=?')).all(id) } : null;
|
||||
if (id) {
|
||||
await (await database.prepare(`UPDATE gear SET name=?,slot=?,tier=?,level=?,base_health_min=?,base_health_max=?,base_armor_min=?,base_armor_max=?,active=1,updated_at=? WHERE id=?`))
|
||||
|
||||
@@ -41,12 +41,12 @@
|
||||
</div>
|
||||
<div class="selection-head"><div><span>COMPATIBLE BRAND SETS</span><small>SELECT AT LEAST TWO</small></div><b>{editing.brandIds.length} SELECTED</b></div>
|
||||
<div class="selection-grid">
|
||||
{#each data.brands as brand}<label><input type="checkbox" name="brandIds" value={brand.id} bind:group={editing.brandIds} /><span>{brand.name}</span><small>{editing.brandIds.includes(brand.id) ? 'LINKED' : 'AVAILABLE'}</small></label>{/each}
|
||||
{#each data.brands as brand}<label class:pending-reference={brand.pending}><input type="checkbox" name="brandIds" value={brand.id} bind:group={editing.brandIds} /><span>{brand.name}</span><small>{brand.pending ? `PENDING · ${brand.proposerName}` : editing.brandIds.includes(brand.id) ? 'LINKED' : 'AVAILABLE'}</small></label>{/each}
|
||||
</div>
|
||||
{#if editing.slot === 'Backpack' || editing.slot === 'Body Armor'}
|
||||
<div class="selection-head"><div><span>{editing.slot.toUpperCase()} TALENTS</span><small>SELECT AT LEAST ONE · REQUIRED</small></div><b>{editing.talentIds.length} SELECTED</b></div>
|
||||
<div class="selection-grid talent-selection">
|
||||
{#each gearTalents(editing.slot) as talent}<label><input type="checkbox" name="talentIds" value={talent.id} bind:group={editing.talentIds} /><span>{talent.name}</span><small>{editing.talentIds.includes(talent.id) ? 'EQUIPPED' : 'AVAILABLE'}</small></label>{/each}
|
||||
{#each gearTalents(editing.slot) as talent}<label class:pending-reference={talent.pending}><input type="checkbox" name="talentIds" value={talent.id} bind:group={editing.talentIds} /><span>{talent.name}</span><small>{talent.pending ? `PENDING · ${talent.proposerName}` : editing.talentIds.includes(talent.id) ? 'EQUIPPED' : 'AVAILABLE'}</small></label>{/each}
|
||||
</div>
|
||||
{#if gearTalents(editing.slot).length === 0}<p class="error">Create an active {editing.slot} talent before adding gear for this slot.</p>{/if}
|
||||
{/if}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
<svelte:head><title>Roles & permissions — ISAC</title></svelte:head>
|
||||
<div class="module-toolbar"><div><p class="eyebrow">ACCESS POLICY REGISTRY</p><h2>ROLES & PERMISSIONS</h2><p>Compose reusable access roles from discrete SHD.build capabilities.</p></div><button class="primary" onclick={() => editing = blank()}>ADD ROLE <span>+</span></button></div>
|
||||
<p class="access-baseline-note"><b>REGISTERED AGENT BASELINE</b><span>Every active account inherently receives isac.access, data.view, and data.propose. Roles add elevated capabilities and cannot remove this baseline.</span></p>
|
||||
<p class="access-baseline-note"><b>REGISTERED AGENT BASELINE</b><span>Every 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.</span></p>
|
||||
<KpiFilters className="role-kpis" active={data.filter} items={[
|
||||
{value:'',label:'TOTAL ROLES',count:data.summary.total,description:'Show all role definitions'},
|
||||
{value:'isac',label:'ISAC ACCESS',count:data.summary.isac,description:'Can open the console'},
|
||||
|
||||
@@ -36,7 +36,7 @@ export const actions: Actions = {
|
||||
for (let index = 0; index < 6; index += 2) if ((values[index] === null) !== (values[index+1] === null) || (values[index] !== null && Number(values[index]) > 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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -73,9 +73,9 @@
|
||||
<label class="field template-field">SHARED DESCRIPTION TEMPLATE<textarea name="descriptionTemplate" bind:value={talent.descriptionTemplate} placeholder="Hitting an enemy {1} times grants +{2}..." required></textarea><small>Use {'{1}'}, {'{2}'}, and so on to insert each effect value with its unit.</small></label>
|
||||
<div class="tier-preview"><div><span>TIER I PREVIEW</span><p>{preview('I') || 'Description preview'}</p></div><div><span>TIER II PREVIEW</span><p>{preview('II') || 'Description preview'}</p></div></div>
|
||||
<p class="label">SHARED ACTIVATION TRIGGERS</p>
|
||||
<div class="trigger-list shared-trigger-list">{#each data.triggers.filter((item) => Number(item.active) === 1) as item}<div><label><input name="triggerIds" type="checkbox" value={item.id} checked={talent.triggerIds.includes(String(item.id))} onchange={(event) => toggleTrigger(String(item.id), event.currentTarget.checked)}/> <span>{item.name}</span></label><button type="button" onclick={() => editTrigger(item)}>EDIT</button></div>{/each}</div>
|
||||
<div class="trigger-list shared-trigger-list">{#each data.triggers.filter((item) => Number(item.active) === 1) as item}<div class:pending-reference={item.pending}><label><input name="triggerIds" type="checkbox" value={item.id} checked={talent.triggerIds.includes(String(item.id))} onchange={(event) => toggleTrigger(String(item.id), event.currentTarget.checked)}/> <span>{item.name}</span></label>{#if item.pending}<b class="pending-reference-label">PENDING · {item.proposerName}</b>{:else}<button type="button" onclick={() => editTrigger(item)}>EDIT</button>{/if}</div>{/each}</div>
|
||||
<div class="effects-head"><p class="label">SHARED ATTRIBUTE EFFECTS · TIER VALUES</p><button type="button" onclick={addEffect}>ADD EFFECT +</button></div>
|
||||
<div class="shared-effects"><div class="shared-effect effect-heading"><span>ATTRIBUTE</span><span>TIER I VALUE</span><span>TIER II VALUE</span><span></span></div>{#each talent.effects as effect, index}<div class="shared-effect"><select bind:value={effect.statId} required><option value="">Select attribute…</option>{#each data.stats as stat}<option value={stat.id}>{stat.name} ({stat.unit || 'no unit'})</option>{/each}</select><input type="number" step="any" bind:value={effect.valueI} placeholder="TIER I" required/><input type="number" step="any" bind:value={effect.valueII} placeholder="TIER II" required/><button type="button" aria-label={`Remove effect ${index + 1}`} onclick={() => removeEffect(index)}>×</button></div>{/each}</div>
|
||||
<div class="shared-effects"><div class="shared-effect effect-heading"><span>ATTRIBUTE</span><span>TIER I VALUE</span><span>TIER II VALUE</span><span></span></div>{#each talent.effects as effect, index}<div class="shared-effect"><select bind:value={effect.statId} required><option value="">Select attribute…</option>{#each data.stats as stat}<option value={stat.id}>{stat.pending ? `PENDING · ${stat.proposerName} · ` : ''}{stat.name} ({stat.unit || 'no unit'})</option>{/each}</select><input type="number" step="any" bind:value={effect.valueI} placeholder="TIER I" required/><input type="number" step="any" bind:value={effect.valueII} placeholder="TIER II" required/><button type="button" aria-label={`Remove effect ${index + 1}`} onclick={() => removeEffect(index)}>×</button></div>{/each}</div>
|
||||
{#if form?.talentError}<p class="error">{form.talentError}</p>{/if}
|
||||
<div class="form-actions"><button type="button" class="secondary" onclick={() => talent = null}>CANCEL</button><button class="primary">SAVE TALENT →</button></div>
|
||||
</PendingForm>
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
<PendingForm action="?/setAccess">
|
||||
<div class="selection-head"><div><span>ASSIGNED ROLES</span><small>PERMISSION BUNDLES</small></div><b>{roleIds.length} SELECTED</b></div>
|
||||
<div class="selection-grid">{#each data.roles as role}<label><input type="checkbox" name="roleIds" value={role.id} bind:group={roleIds}/><span>{role.name}</span><small>{roleIds.includes(role.id) ? 'ASSIGNED' : 'AVAILABLE'}</small></label>{/each}</div>
|
||||
<p class="access-baseline-note compact"><b>REGISTERED AGENT BASELINE</b><span>isac.access · data.view · data.propose are inherent and are not removable here.</span></p>
|
||||
<p class="access-baseline-note compact"><b>REGISTERED AGENT BASELINE</b><span>isac.access · data.view · data.propose eligibility are inherent. Proposal submission also requires accepted guidelines and enabled contributor mode.</span></p>
|
||||
<div class="selection-head"><div><span>DIRECT PERMISSIONS</span><small>USER-SPECIFIC CAPABILITIES</small></div><b>{permissionIds.length} SELECTED</b></div>
|
||||
<div class="selection-grid permission-grid">{#each data.permissions as permission}<label title={permission.description}><input type="checkbox" name="permissionIds" value={permission.id} bind:group={permissionIds}/><span>{permission.name}</span><small>{permissionIds.includes(permission.id) ? 'GRANTED' : 'AVAILABLE'}</small></label>{/each}</div>
|
||||
{#if form?.accessError}<p class="error">{form.accessError}</p>{/if}{#if form?.accessSaved}<p class="success">Roles and direct permissions updated.</p>{/if}<div class="form-actions"><button type="button" class="primary" onclick={() => accessConfirmationOpen = true}>REVIEW ACCESS →</button></div>
|
||||
|
||||
Reference in New Issue
Block a user