feat(isac): add moderated contributor mode
This commit is contained in:
@@ -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)
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user