feat: add community publishing and news syndication
This commit is contained in:
+280
-151
@@ -6,185 +6,314 @@ import { recordIsacAudit } from '$lib/server/audit';
|
||||
import { getContributorMode } from '$lib/server/contributor-mode';
|
||||
import { notifyOversightReview } from '$lib/server/oversight-notifications';
|
||||
|
||||
export const DATA_MODULES = ['configuration', 'calculations', 'specializations', 'attributes', 'talents', 'brands', 'gear', 'weapons', 'weaponmods', 'gearmods', 'protocols', 'skillchips'] as const;
|
||||
export type DataModule = typeof DATA_MODULES[number];
|
||||
export const DATA_MODULES = [
|
||||
'configuration',
|
||||
'calculations',
|
||||
'specializations',
|
||||
'attributes',
|
||||
'talents',
|
||||
'brands',
|
||||
'gear',
|
||||
'weapons',
|
||||
'weaponmods',
|
||||
'gearmods',
|
||||
'protocols',
|
||||
'skillchips',
|
||||
] as const;
|
||||
export type DataModule = (typeof DATA_MODULES)[number];
|
||||
|
||||
const proposalResultKeys: Partial<Record<DataModule, Record<string, { saved: string; mode?: string }>>> = {
|
||||
configuration: { saveCoreRanges: { saved: 'configurationSaved' } },
|
||||
calculations: {
|
||||
saveRevision: { saved: 'revisionSaved', mode: 'revisionMode' },
|
||||
saveCalibration: { saved: 'calibrationSaved', mode: 'calibrationMode' }
|
||||
},
|
||||
attributes: { save: { saved: 'saved', mode: 'editorMode' } },
|
||||
brands: { save: { saved: 'brandSaved', mode: 'brandMode' } },
|
||||
gear: {
|
||||
save: { saved: 'gearSaved', mode: 'gearMode' },
|
||||
saveAugmentProfile: { saved: 'augmentProfileSaved' }
|
||||
},
|
||||
weapons: { save: { saved: 'weaponSaved', mode: 'weaponMode' } },
|
||||
weaponmods: { save: { saved: 'weaponModSaved', mode: 'weaponModMode' } },
|
||||
gearmods: { save: { saved: 'gearModSaved', mode: 'gearModMode' } },
|
||||
talents: { saveTalent: { saved: 'talentSaved', mode: 'talentMode' } },
|
||||
protocols: {
|
||||
save: { saved: 'protocolSaved', mode: 'protocolMode' },
|
||||
saveProfile: { saved: 'profileSaved' }
|
||||
},
|
||||
skillchips: { save: { saved: 'skillChipSaved', mode: 'skillChipMode' } },
|
||||
specializations: {
|
||||
saveProgression: { saved: 'progressionSaved' },
|
||||
saveSpecialization: { saved: 'specializationSaved', mode: 'specializationMode' },
|
||||
saveFocus: { saved: 'focusSaved', mode: 'focusMode' },
|
||||
saveAbility: { saved: 'abilitySaved', mode: 'abilityMode' },
|
||||
saveCombatEffect: { saved: 'combatEffectSaved', mode: 'combatEffectMode' },
|
||||
saveFocusAbilities: { saved: 'focusAbilitiesSaved' },
|
||||
saveNode: { saved: 'nodeSaved', mode: 'nodeMode' }
|
||||
}
|
||||
const proposalResultKeys: Partial<
|
||||
Record<DataModule, Record<string, { saved: string; mode?: string }>>
|
||||
> = {
|
||||
configuration: { saveCoreRanges: { saved: 'configurationSaved' } },
|
||||
calculations: {
|
||||
saveRevision: { saved: 'revisionSaved', mode: 'revisionMode' },
|
||||
saveCalibration: { saved: 'calibrationSaved', mode: 'calibrationMode' },
|
||||
},
|
||||
attributes: { save: { saved: 'saved', mode: 'editorMode' } },
|
||||
brands: { save: { saved: 'brandSaved', mode: 'brandMode' } },
|
||||
gear: {
|
||||
save: { saved: 'gearSaved', mode: 'gearMode' },
|
||||
saveAugmentProfile: { saved: 'augmentProfileSaved' },
|
||||
},
|
||||
weapons: { save: { saved: 'weaponSaved', mode: 'weaponMode' } },
|
||||
weaponmods: { save: { saved: 'weaponModSaved', mode: 'weaponModMode' } },
|
||||
gearmods: { save: { saved: 'gearModSaved', mode: 'gearModMode' } },
|
||||
talents: { saveTalent: { saved: 'talentSaved', mode: 'talentMode' } },
|
||||
protocols: {
|
||||
save: { saved: 'protocolSaved', mode: 'protocolMode' },
|
||||
saveProfile: { saved: 'profileSaved' },
|
||||
},
|
||||
skillchips: { save: { saved: 'skillChipSaved', mode: 'skillChipMode' } },
|
||||
specializations: {
|
||||
saveProgression: { saved: 'progressionSaved' },
|
||||
saveSpecialization: {
|
||||
saved: 'specializationSaved',
|
||||
mode: 'specializationMode',
|
||||
},
|
||||
saveFocus: { saved: 'focusSaved', mode: 'focusMode' },
|
||||
saveAbility: { saved: 'abilitySaved', mode: 'abilityMode' },
|
||||
saveCombatEffect: { saved: 'combatEffectSaved', mode: 'combatEffectMode' },
|
||||
saveFocusAbilities: { saved: 'focusAbilitiesSaved' },
|
||||
saveNode: { saved: 'nodeSaved', mode: 'nodeMode' },
|
||||
},
|
||||
};
|
||||
|
||||
export function proposalActionResult(module: DataModule, action: string, edit: boolean, proposalId: string) {
|
||||
const keys = proposalResultKeys[module]?.[action];
|
||||
return keys
|
||||
? { [keys.saved]: true, ...(keys.mode ? { [keys.mode]: edit ? 'edit' : 'add' } : {}), proposalId, resultToken: randomUUID() }
|
||||
: { proposalQueued: true, proposalId, resultToken: randomUUID() };
|
||||
export function proposalActionResult(
|
||||
module: DataModule,
|
||||
action: string,
|
||||
edit: boolean,
|
||||
proposalId: string,
|
||||
) {
|
||||
const keys = proposalResultKeys[module]?.[action];
|
||||
return keys
|
||||
? {
|
||||
[keys.saved]: true,
|
||||
...(keys.mode ? { [keys.mode]: edit ? 'edit' : 'add' } : {}),
|
||||
proposalId,
|
||||
resultToken: randomUUID(),
|
||||
}
|
||||
: { proposalQueued: true, proposalId, resultToken: randomUUID() };
|
||||
}
|
||||
|
||||
type SerializedEntry =
|
||||
| { name: string; kind: 'text'; value: string }
|
||||
| { name: string; kind: 'file'; value: string; fileName: string; type: string };
|
||||
| { 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[];
|
||||
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()) {
|
||||
if (typeof value === 'string') entries.push({ name, kind: 'text', value });
|
||||
else if (value.size > 0) entries.push({
|
||||
name,
|
||||
kind: 'file',
|
||||
value: Buffer.from(await value.arrayBuffer()).toString('base64'),
|
||||
fileName: value.name,
|
||||
type: value.type
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
const entries: SerializedEntry[] = [];
|
||||
for (const [name, value] of form.entries()) {
|
||||
if (typeof value === 'string') entries.push({ name, kind: 'text', value });
|
||||
else if (value.size > 0)
|
||||
entries.push({
|
||||
name,
|
||||
kind: 'file',
|
||||
value: Buffer.from(await value.arrayBuffer()).toString('base64'),
|
||||
fileName: value.name,
|
||||
type: value.type,
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function deserializeProposal(payload: string): FormData {
|
||||
const form = new FormData();
|
||||
for (const entry of JSON.parse(payload) as SerializedEntry[]) {
|
||||
if (entry.kind === 'text') form.append(entry.name, entry.value);
|
||||
else form.append(entry.name, new Blob([Buffer.from(entry.value, 'base64')], { type: entry.type }), entry.fileName);
|
||||
}
|
||||
return form;
|
||||
const form = new FormData();
|
||||
for (const entry of JSON.parse(payload) as SerializedEntry[]) {
|
||||
if (entry.kind === 'text') form.append(entry.name, entry.value);
|
||||
else
|
||||
form.append(
|
||||
entry.name,
|
||||
new Blob([Buffer.from(entry.value, 'base64')], { type: entry.type }),
|
||||
entry.fileName,
|
||||
);
|
||||
}
|
||||
return form;
|
||||
}
|
||||
|
||||
export async function refreshProposalDependencies(proposalId: string, payload: string) {
|
||||
const database = await getDatabase();
|
||||
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 id<>? AND draft_entity_id IS NOT NULL`)).all(proposalId);
|
||||
const dependencies = candidates.filter((candidate) => text.includes(String(candidate.draft_entity_id)));
|
||||
await (await database.prepare('DELETE FROM data_change_proposal_dependencies WHERE proposal_id=?')).run(proposalId);
|
||||
for (const dependency of dependencies) {
|
||||
await (await database.prepare('INSERT INTO data_change_proposal_dependencies(proposal_id,depends_on_proposal_id) VALUES(?,?)')).run(proposalId, dependency.id);
|
||||
}
|
||||
return dependencies.map((dependency) => String(dependency.id));
|
||||
export async function refreshProposalDependencies(
|
||||
proposalId: string,
|
||||
payload: string,
|
||||
) {
|
||||
const database = await getDatabase();
|
||||
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 id<>? AND draft_entity_id IS NOT NULL`)
|
||||
).all(proposalId);
|
||||
const dependencies = candidates.filter((candidate) =>
|
||||
text.includes(String(candidate.draft_entity_id)),
|
||||
);
|
||||
await (
|
||||
await database.prepare(
|
||||
'DELETE FROM data_change_proposal_dependencies WHERE proposal_id=?',
|
||||
)
|
||||
).run(proposalId);
|
||||
for (const dependency of dependencies) {
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO data_change_proposal_dependencies(proposal_id,depends_on_proposal_id) VALUES(?,?)',
|
||||
)
|
||||
).run(proposalId, dependency.id);
|
||||
}
|
||||
return dependencies.map((dependency) => String(dependency.id));
|
||||
}
|
||||
|
||||
const serializedTextValues = (payload: string) =>
|
||||
(JSON.parse(payload) as SerializedEntry[]).filter((entry): entry is Extract<SerializedEntry, { kind: 'text' }> => entry.kind === 'text');
|
||||
(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,
|
||||
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 ?? '';
|
||||
const fieldValue = (name: string) => {
|
||||
const matches = values.filter((entry) => entry.name === name).map((entry) => entry.value);
|
||||
return matches.length > 1 ? matches : matches[0] ?? '';
|
||||
};
|
||||
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, fieldValue(name)]))
|
||||
};
|
||||
});
|
||||
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 ?? '';
|
||||
const fieldValue = (name: string) => {
|
||||
const matches = values
|
||||
.filter((entry) => entry.name === name)
|
||||
.map((entry) => entry.value);
|
||||
return matches.length > 1 ? matches : (matches[0] ?? '');
|
||||
};
|
||||
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, fieldValue(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,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, draftEntityId, dependencies: dependencies.map((item) => item.id), summary }
|
||||
});
|
||||
await notifyOversightReview({
|
||||
category: 'data_changes',
|
||||
itemId: id,
|
||||
title: 'Data change awaiting review',
|
||||
summary: `${summary} was submitted to the Oversight queue and is ready for review.`,
|
||||
link: `/isac/data-moderation?search=${encodeURIComponent(name || entityId || id)}`
|
||||
}).catch((error) => console.error('Unable to notify Data Oversight reviewers', error));
|
||||
return { id, entityId, draftEntityId };
|
||||
export async function queueDataProposal(
|
||||
user: AuthUser,
|
||||
module: DataModule,
|
||||
action: string,
|
||||
form: FormData,
|
||||
) {
|
||||
const id = randomUUID();
|
||||
const contributorNote = String(form.get('contributorNote') ?? '')
|
||||
.normalize('NFKC')
|
||||
.trim();
|
||||
if (
|
||||
contributorNote.length > 1_000 ||
|
||||
/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(contributorNote)
|
||||
) {
|
||||
throw new Error(
|
||||
'Contributor rationale must contain at most 1,000 visible characters.',
|
||||
);
|
||||
}
|
||||
form.delete('contributorNote');
|
||||
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,draft_entity_id,summary,contributor_note,payload_json,status,proposed_by,created_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,'pending',?,?)`)
|
||||
).run(
|
||||
id,
|
||||
module,
|
||||
action,
|
||||
entityId,
|
||||
draftEntityId,
|
||||
summary,
|
||||
contributorNote || null,
|
||||
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,
|
||||
draftEntityId,
|
||||
dependencies: dependencies.map((item) => item.id),
|
||||
summary,
|
||||
contributorNote: contributorNote || null,
|
||||
},
|
||||
});
|
||||
await notifyOversightReview({
|
||||
category: 'data_changes',
|
||||
itemId: id,
|
||||
title: 'Data change awaiting review',
|
||||
summary: `${summary} was submitted to the Oversight queue and is ready for review.`,
|
||||
link: `/isac/data-moderation?search=${encodeURIComponent(name || entityId || id)}`,
|
||||
}).catch((error) =>
|
||||
console.error('Unable to notify Data Oversight reviewers', error),
|
||||
);
|
||||
return { id, entityId, draftEntityId };
|
||||
}
|
||||
|
||||
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: eligibleToPropose,
|
||||
contributorEnabled: Boolean(contributorMode?.enabled)
|
||||
};
|
||||
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: eligibleToPropose,
|
||||
contributorEnabled: Boolean(contributorMode?.enabled),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user