Files
shd.build/src/lib/server/articles.ts
T
jb 7ca6cb8458
Validate and deploy production / validate (push) Canceled after 0s
Validate and deploy production / deploy (push) Canceled after 0s
Validate and deploy staging / validate (push) Successful in 33s
Validate and deploy staging / deploy (push) Successful in 1m9s
Updating mechanics for OS protocols
2026-08-03 16:12:20 -04:00

1176 lines
40 KiB
TypeScript

import type { Database } from '@tursodatabase/database';
import { createHash, randomUUID } from 'node:crypto';
import { error } from '@sveltejs/kit';
import { parseLoadoutDraft } from '$lib/builder/loadout';
import {
EMPTY_ARTICLE_DOCUMENT,
articleModerationText,
parseArticleDocument,
type ArticleDocument,
} from '$lib/articles/document';
import { getDatabase } from '$lib/server/database';
import { recordIsacAudit } from '$lib/server/audit';
import {
moderateCommunityText,
recordCommunityModerationScan,
} from '$lib/server/community-moderation';
import { managedImageUrl, readManagedImage } from '$lib/server/managed-images';
export type ArticleKind = 'guide' | 'news';
export type ArticleVisibility = 'private' | 'unlisted' | 'public';
export const NEWS_CHANNELS = [
'Official Game News',
'Patch & Data Updates',
'Community Reports',
'SHD.build Development',
] as const;
const VISIBILITIES = new Set<ArticleVisibility>([
'private',
'unlisted',
'public',
]);
const ID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const slugify = (value: string) =>
value
.normalize('NFKC')
.toLocaleLowerCase('en-US')
.replace(/[^\p{L}\p{N}]+/gu, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 72)
.replace(/-+$/g, '') || 'article';
const normalizeText = (value: unknown, max: number) => {
const text = String(value ?? '')
.normalize('NFKC')
.trim()
.replace(/\r\n?/g, '\n');
return [...text].length <= max && !/\p{Cc}/u.test(text.replace(/[\n\t]/g, ''))
? text
: null;
};
const hash = (value: unknown) =>
createHash('sha256').update(JSON.stringify(value)).digest('hex');
export async function syncArticleTags(database: Database) {
const [specializations, focuses] = await Promise.all([
(
await database.prepare(
'SELECT id,name,active FROM specializations ORDER BY name COLLATE NOCASE',
)
).all(),
(
await database.prepare(`SELECT focuses.id,focuses.name,focuses.active,specializations.name AS specialization_name
FROM specialization_focuses focuses JOIN specializations ON specializations.id=focuses.specialization_id
ORDER BY specializations.name COLLATE NOCASE,focuses.display_order,focuses.name COLLATE NOCASE`)
).all(),
]);
for (const item of specializations) {
await (
await database.prepare(`INSERT INTO article_tags(id,group_name,name,slug,source_type,source_id,active)
VALUES(?,?,?,?,?,?,?) ON CONFLICT(source_type,source_id) DO UPDATE SET
name=excluded.name,slug=excluded.slug,active=excluded.active`)
).run(
`specialization-${item.id}`,
'specialization',
String(item.name),
`specialization-${slugify(String(item.name))}-${item.id}`,
'specialization',
item.id,
Number(item.active) ? 1 : 0,
);
}
for (const item of focuses) {
const name = `${String(item.specialization_name)} · ${String(item.name)}`;
await (
await database.prepare(`INSERT INTO article_tags(id,group_name,name,slug,source_type,source_id,active)
VALUES(?,?,?,?,?,?,?) ON CONFLICT(source_type,source_id) DO UPDATE SET
name=excluded.name,slug=excluded.slug,active=excluded.active`)
).run(
`focus-${item.id}`,
'focus',
name,
`focus-${slugify(name)}-${item.id}`,
'focus',
item.id,
Number(item.active) ? 1 : 0,
);
}
}
export async function articleEditorOptions(
database: Database,
ownerId: string,
kind: ArticleKind,
) {
await syncArticleTags(database);
const [tags, builds] = await Promise.all([
kind === 'guide'
? (
await database.prepare(
"SELECT id,name,slug,group_name FROM article_tags WHERE active=1 AND group_name<>'news_channel' ORDER BY group_name,name COLLATE NOCASE",
)
).all()
: (
await database.prepare(
"SELECT id,name,slug,group_name FROM article_tags WHERE active=1 AND group_name='news_channel' ORDER BY name COLLATE NOCASE",
)
).all(),
kind === 'guide'
? (
await database.prepare(`SELECT builds.id,builds.title,builds.visibility,builds.owner_id=? AS owner
FROM builds WHERE builds.owner_id=? OR builds.visibility='public'
ORDER BY owner DESC,builds.updated_at DESC LIMIT 300`)
).all(ownerId, ownerId)
: [],
]);
return {
tags: tags.map((item) => ({
id: String(item.id),
name: String(item.name),
slug: String(item.slug),
group: String(item.group_name),
})),
builds: builds.map((item) => ({
id: String(item.id),
title: String(item.title),
visibility: String(item.visibility),
owner: Boolean(item.owner),
})),
};
}
export async function createArticle(input: {
kind: ArticleKind;
ownerId: string;
title?: string;
prefillBuildId?: string | null;
}) {
const database = await getDatabase();
const id = randomUUID();
const now = new Date().toISOString();
const title =
normalizeText(input.title, 140) ||
(input.kind === 'guide' ? 'Untitled Guide' : 'Untitled News Article');
const slug = `${slugify(title)}-${id.slice(0, 8)}`;
let document = structuredClone(EMPTY_ARTICLE_DOCUMENT);
if (input.prefillBuildId) {
const build = await (
await database.prepare('SELECT id FROM builds WHERE id=? AND owner_id=?')
).get(input.prefillBuildId, input.ownerId);
if (!build)
error(403, 'You can prefill a Guide only with one of your Builds.');
document.document.content = [
{ type: 'paragraph' },
{
type: 'buildEmbed',
attrs: {
buildId: input.prefillBuildId,
embedKey: `build-${randomUUID()}`,
},
},
{ type: 'paragraph' },
];
}
await (
await database.prepare(`INSERT INTO articles(id,kind,owner_id,slug,visibility,created_at,updated_at)
VALUES(?,?,?,?,'private',?,?)`)
).run(id, input.kind, input.ownerId, slug, now, now);
await (
await database.prepare(`INSERT INTO article_drafts(
article_id,title,deck,document_json,tags_json,version,updated_by,updated_at
) VALUES(?,?,?,?,'[]',1,?,?)`)
).run(id, title, '', JSON.stringify(document), input.ownerId, now);
return { id, slug };
}
export async function loadArticleEditor(
articleId: string,
actorId: string,
canManageNews: boolean,
) {
const database = await getDatabase();
const row = await (
await database.prepare(`SELECT articles.*,draft.title,draft.deck,draft.document_json,draft.tags_json,
draft.channel,draft.cover_image_id,draft.cover_alt,draft.cover_caption,draft.version,draft.updated_at AS draft_updated_at,
EXISTS(SELECT 1 FROM external_news_sources source WHERE source.article_id=articles.id) AS external_source
FROM articles JOIN article_drafts draft ON draft.article_id=articles.id WHERE articles.id=?`)
).get(articleId);
if (
!row ||
row.deleted_at ||
row.external_source ||
(row.kind === 'guide' ? row.owner_id !== actorId : !canManageNews)
)
error(404, 'Article not found');
const options = await articleEditorOptions(
database,
actorId,
String(row.kind) as ArticleKind,
);
return {
article: {
id: String(row.id),
kind: String(row.kind) as ArticleKind,
slug: String(row.slug),
visibility: String(row.visibility),
publishedRevisionId: row.published_revision_id
? String(row.published_revision_id)
: null,
pendingRevisionId: row.pending_revision_id
? String(row.pending_revision_id)
: null,
moderationHold: Boolean(row.moderation_hold),
},
draft: {
title: String(row.title),
deck: String(row.deck),
document: String(row.document_json),
tagIds: JSON.parse(String(row.tags_json)),
channel: row.channel ? String(row.channel) : '',
coverImageId: row.cover_image_id ? String(row.cover_image_id) : null,
coverUrl: row.cover_image_id
? managedImageUrl(String(row.cover_image_id), row.draft_updated_at)
: '',
coverAlt: String(row.cover_alt),
coverCaption: String(row.cover_caption),
version: Number(row.version),
},
...options,
};
}
export async function saveArticleDraft(input: {
articleId: string;
actorId: string;
canManageNews: boolean;
title: unknown;
deck: unknown;
document: unknown;
tagIds: unknown[];
channel?: unknown;
coverImageId?: unknown;
coverAlt?: unknown;
coverCaption?: unknown;
version: number;
}) {
const database = await getDatabase();
const article = await (
await database.prepare(
`SELECT articles.id,articles.kind,articles.owner_id,articles.deleted_at,
EXISTS(SELECT 1 FROM external_news_sources source WHERE source.article_id=articles.id) AS external_source
FROM articles WHERE articles.id=?`,
)
).get(input.articleId);
if (
!article ||
article.deleted_at ||
article.external_source ||
(article.kind === 'guide'
? article.owner_id !== input.actorId
: !input.canManageNews)
)
error(404, 'Article not found');
const title = normalizeText(input.title, 140);
const deck = normalizeText(input.deck, 320);
const coverAlt = normalizeText(input.coverAlt, 300);
const coverCaption = normalizeText(input.coverCaption, 500);
const coverImageId = String(input.coverImageId ?? '').trim();
const parsed = parseArticleDocument(input.document);
if (
!title ||
title.length < 2 ||
deck === null ||
coverAlt === null ||
coverCaption === null ||
!parsed
) {
return {
ok: false as const,
status: 400,
message: 'Check the title, summary, and article content before saving.',
};
}
const tagIds = [
...new Set(input.tagIds.map(String).filter((id) => id.length <= 100)),
].slice(0, 12);
const channel =
article.kind === 'news' ? normalizeText(input.channel, 80) : '';
if (
article.kind === 'news' &&
!NEWS_CHANNELS.includes(channel as (typeof NEWS_CHANNELS)[number])
) {
return {
ok: false as const,
status: 400,
message: 'Choose a valid News channel.',
};
}
if (coverImageId) {
const cover = await (
await database.prepare(
'SELECT image_id FROM article_media WHERE article_id=? AND image_id=?',
)
).get(input.articleId, coverImageId);
if (!cover)
return {
ok: false as const,
status: 400,
message: 'The selected cover image is unavailable.',
};
if (!coverAlt)
return {
ok: false as const,
status: 400,
message: 'Describe the cover image for readers who cannot see it.',
};
}
if (tagIds.length) {
const placeholders = tagIds.map(() => '?').join(',');
const expectedGroup =
article.kind === 'news'
? "group_name='news_channel'"
: "group_name<>'news_channel'";
const validTags = await (
await database.prepare(
`SELECT COUNT(*) AS count FROM article_tags WHERE active=1 AND ${expectedGroup} AND id IN (${placeholders})`,
)
).get(...tagIds);
if (Number(validTags?.count) !== tagIds.length)
return {
ok: false as const,
status: 400,
message: 'One or more article tags are unavailable.',
};
}
const now = new Date().toISOString();
const result = await (
await database.prepare(`UPDATE article_drafts SET title=?,deck=?,document_json=?,tags_json=?,channel=?,
cover_image_id=?,cover_alt=?,cover_caption=?,version=version+1,updated_by=?,updated_at=? WHERE article_id=? AND version=?`)
).run(
title,
deck,
JSON.stringify(parsed.document),
JSON.stringify(tagIds),
channel || null,
coverImageId || null,
coverAlt,
coverCaption,
input.actorId,
now,
input.articleId,
input.version,
);
if (!result.changes)
return {
ok: false as const,
status: 409,
message: 'This draft changed in another tab. Reload before saving again.',
};
await (
await database.prepare('UPDATE articles SET updated_at=? WHERE id=?')
).run(now, input.articleId);
return { ok: true as const, version: input.version + 1, savedAt: now };
}
async function buildSnapshots(
database: Database,
article: Record<string, unknown>,
embeds: Array<{ embedKey: string; buildId: string; ordinal: number }>,
) {
if (!embeds.length) return [];
const ids = [...new Set(embeds.map((embed) => embed.buildId))];
const placeholders = ids.map(() => '?').join(',');
const rows = await (
await database.prepare(`SELECT builds.*,COALESCE(user_profiles.display_name,users.username,'Deleted Agent') AS author
FROM builds JOIN users ON users.id=builds.owner_id LEFT JOIN user_profiles ON user_profiles.user_id=users.id
WHERE builds.id IN (${placeholders})`)
).all(...ids);
const byId = new Map(rows.map((row) => [String(row.id), row]));
const gearRows = await (
await database.prepare('SELECT id,name,slot FROM gear')
).all();
const weaponRows = await (
await database.prepare('SELECT id,name,category FROM weapons')
).all();
const gear = new Map(gearRows.map((row) => [String(row.id), row]));
const weapons = new Map(weaponRows.map((row) => [String(row.id), row]));
return embeds.map((embed) => {
const build = byId.get(embed.buildId);
if (!build) error(400, 'An embedded Build no longer exists.');
const ownerShared = String(build.owner_id) === String(article.owner_id);
if (!ownerShared && build.visibility !== 'public')
error(
400,
'A Build owned by another Agent must remain public to be embedded.',
);
const loadout = parseLoadoutDraft(JSON.parse(String(build.loadout_json)));
if (!loadout)
error(400, 'An embedded Build uses an unsupported loadout format.');
const snapshot = {
buildId: String(build.id),
title: String(build.title),
slug: String(build.slug),
author: String(build.author),
weapons: loadout.weapons.map((selection) => {
const item = weapons.get(selection.itemId);
return {
name: String(item?.name ?? 'Empty slot'),
category: String(item?.category ?? 'Not configured'),
};
}),
gear: Object.entries(loadout.gear).map(([slot, selection]) => ({
name: String(gear.get(selection.itemId)?.name ?? 'Empty slot'),
slot,
})),
};
return { ...embed, ownerShared, snapshot };
});
}
async function replaceSearchIndex(
database: Database,
articleId: string,
revision: {
title: string;
deck: string;
plainText: string;
tags: string[];
},
publicVisibility: boolean,
) {
await (
await database.prepare('DELETE FROM article_search WHERE article_id=?')
).run(articleId);
if (publicVisibility)
await (
await database.prepare(
'INSERT INTO article_search(article_id,title,deck,body,tags) VALUES(?,?,?,?,?)',
)
).run(
articleId,
revision.title,
revision.deck,
revision.plainText,
revision.tags.join(' '),
);
}
async function publishRevision(
database: Database,
input: {
articleId: string;
revisionId: string;
visibility: Exclude<ArticleVisibility, 'private'>;
baseRevisionId: string | null;
now: string;
requirePending?: boolean;
},
) {
const result = await (
await database.prepare(`UPDATE articles SET published_revision_id=?,pending_revision_id=NULL,
visibility=?,moderation_hold=0,published_at=COALESCE(published_at,?),updated_at=?
WHERE id=? AND deleted_at IS NULL AND COALESCE(published_revision_id,'')=COALESCE(?,'')
${input.requirePending ? 'AND pending_revision_id=?' : ''}`)
).run(
input.revisionId,
input.visibility,
input.now,
input.now,
input.articleId,
input.baseRevisionId,
...(input.requirePending ? [input.revisionId] : []),
);
if (!result.changes) return false;
const revision = await (
await database.prepare(`SELECT revision.title,revision.deck,revision.plain_text,
GROUP_CONCAT(tags.name,' ') AS tags FROM article_revisions revision
LEFT JOIN article_revision_tags links ON links.revision_id=revision.id
LEFT JOIN article_tags tags ON tags.id=links.tag_id WHERE revision.id=? GROUP BY revision.id`)
).get(input.revisionId);
await replaceSearchIndex(
database,
input.articleId,
{
title: String(revision?.title ?? ''),
deck: String(revision?.deck ?? ''),
plainText: String(revision?.plain_text ?? ''),
tags: String(revision?.tags ?? '')
.split(' ')
.filter(Boolean),
},
input.visibility === 'public',
);
await (
await database.prepare('DELETE FROM guide_curations WHERE article_id=?')
).run(input.articleId);
return true;
}
export async function submitArticle(input: {
articleId: string;
actorId: string;
canManageNews: boolean;
visibility: ArticleVisibility;
}) {
if (!VISIBILITIES.has(input.visibility))
return {
ok: false as const,
status: 400,
message: 'Choose a valid visibility.',
};
const database = await getDatabase();
const article = await (
await database.prepare(`SELECT articles.*,draft.title,draft.deck,draft.document_json,draft.tags_json,
draft.channel,draft.cover_image_id,draft.cover_alt,draft.cover_caption,draft.version,
EXISTS(SELECT 1 FROM external_news_sources source WHERE source.article_id=articles.id) AS external_source
FROM articles JOIN article_drafts draft ON draft.article_id=articles.id WHERE articles.id=?`)
).get(input.articleId);
if (
!article ||
article.deleted_at ||
article.external_source ||
(article.kind === 'guide'
? article.owner_id !== input.actorId
: !input.canManageNews)
)
error(404, 'Article not found');
if (input.visibility === 'private') {
const before = {
visibility: String(article.visibility),
publishedRevisionId: article.published_revision_id ?? null,
};
const now = new Date().toISOString();
await (
await database.prepare(
"UPDATE articles SET visibility='private',pending_revision_id=NULL,updated_at=? WHERE id=?",
)
).run(now, input.articleId);
await (
await database.prepare(
"UPDATE article_publication_requests SET status='superseded',decided_at=?,decision_reason='Withdrawn by author' WHERE article_id=? AND status IN ('scanning','pending_review')",
)
).run(now, input.articleId);
await (
await database.prepare('DELETE FROM article_search WHERE article_id=?')
).run(input.articleId);
await (
await database.prepare('DELETE FROM guide_curations WHERE article_id=?')
).run(input.articleId);
if (article.kind === 'news')
await recordIsacAudit({
actorId: input.actorId,
category: 'editorial_content',
action: 'news.unpublished',
entityId: input.articleId,
before,
after: { visibility: 'private' },
});
return { ok: true as const, state: 'private' as const };
}
const parsed = parseArticleDocument(String(article.document_json));
if (!parsed || !parsed.plainText)
return {
ok: false as const,
status: 400,
message: 'Add article content before publishing.',
};
const title = normalizeText(article.title, 140);
const deck = normalizeText(article.deck, 320);
if (!title || title.length < 2 || deck === null)
return {
ok: false as const,
status: 400,
message: 'Check the title and summary.',
};
const tags = JSON.parse(String(article.tags_json)) as string[];
if (article.kind === 'guide' && !tags.length)
return {
ok: false as const,
status: 400,
message: 'Choose at least one Guide tag.',
};
const snapshots =
article.kind === 'guide'
? await buildSnapshots(database, article, parsed.buildEmbeds)
: [];
if (article.kind === 'news' && parsed.buildEmbeds.length)
return {
ok: false as const,
status: 400,
message: 'News articles cannot embed Builds.',
};
if (parsed.imageIds.length) {
const placeholders = parsed.imageIds.map(() => '?').join(',');
const media = await (
await database.prepare(
`SELECT COUNT(*) AS count FROM article_media WHERE article_id=? AND image_id IN (${placeholders})`,
)
).get(input.articleId, ...parsed.imageIds);
if (Number(media?.count) !== parsed.imageIds.length)
return {
ok: false as const,
status: 400,
message: 'One or more article images are unavailable.',
};
}
const next = await (
await database.prepare(
'SELECT COALESCE(MAX(revision_number),0)+1 AS number FROM article_revisions WHERE article_id=?',
)
).get(input.articleId);
const revisionId = randomUUID();
const requestId = randomUUID();
const now = new Date().toISOString();
const snapshot = {
title,
deck,
document: parsed.document,
tags,
channel: article.channel ?? null,
coverImageId: article.cover_image_id ?? null,
coverAlt: article.cover_alt,
coverCaption: article.cover_caption,
builds: snapshots.map((item) => ({
embedKey: item.embedKey,
buildId: item.buildId,
snapshot: item.snapshot,
})),
};
await database.exec('BEGIN IMMEDIATE');
try {
await (
await database.prepare(`INSERT INTO article_revisions(
id,article_id,revision_number,title,deck,document_json,plain_text,channel,cover_image_id,cover_alt,cover_caption,
snapshot_hash,created_from_draft_version,created_by,created_at
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
).run(
revisionId,
input.articleId,
Number(next?.number ?? 1),
title,
deck,
JSON.stringify(parsed.document),
parsed.plainText,
article.channel ?? null,
article.cover_image_id ?? null,
article.cover_alt,
article.cover_caption,
hash(snapshot),
article.version,
input.actorId,
now,
);
for (let index = 0; index < tags.length; index += 1)
await (
await database.prepare(
'INSERT INTO article_revision_tags(revision_id,tag_id,ordinal) VALUES(?,?,?)',
)
).run(revisionId, tags[index], index);
for (const item of snapshots)
await (
await database.prepare(`INSERT INTO article_revision_build_embeds(
revision_id,embed_key,ordinal,build_id,owner_shared,build_snapshot_json
) VALUES(?,?,?,?,?,?)`)
).run(
revisionId,
item.embedKey,
item.ordinal,
item.buildId,
item.ownerShared ? 1 : 0,
JSON.stringify(item.snapshot),
);
if (article.kind === 'guide') {
await (
await database.prepare(`UPDATE article_publication_requests SET status='superseded',decided_at=?,
decision_reason='Replaced by a newer submission' WHERE article_id=? AND status IN ('scanning','pending_review')`)
).run(now, input.articleId);
await (
await database.prepare(`INSERT INTO article_publication_requests(
id,article_id,revision_id,base_published_revision_id,requested_visibility,status,requested_by,requested_at
) VALUES(?,?,?,?,?,'scanning',?,?)`)
).run(
requestId,
input.articleId,
revisionId,
article.published_revision_id ?? null,
input.visibility,
input.actorId,
now,
);
await (
await database.prepare(
'UPDATE articles SET pending_revision_id=?,updated_at=? WHERE id=?',
)
).run(revisionId, now, input.articleId);
} else {
const published = await publishRevision(database, {
articleId: input.articleId,
revisionId,
visibility: input.visibility,
baseRevisionId: article.published_revision_id
? String(article.published_revision_id)
: null,
now,
});
if (!published)
throw new Error('The News article changed before publication.');
await recordIsacAudit({
actorId: input.actorId,
category: 'editorial_content',
action: 'news.published',
entityId: input.articleId,
before: {
publishedRevisionId: article.published_revision_id ?? null,
visibility: article.visibility,
},
after: {
publishedRevisionId: revisionId,
visibility: input.visibility,
revisionNumber: Number(next?.number ?? 1),
snapshotHash: hash(snapshot),
},
});
}
await database.exec('COMMIT');
} catch (cause) {
await database.exec('ROLLBACK');
throw cause;
}
if (article.kind === 'news')
return { ok: true as const, state: 'published' as const, revisionId };
const moderationImages = (
await Promise.all(
[
...new Set([
...(article.cover_image_id ? [String(article.cover_image_id)] : []),
...parsed.imageIds,
]),
].map((id) => readManagedImage(database, id)),
)
).filter((image) => image !== null);
const moderation = await moderateCommunityText({
contentType: 'guide',
text: articleModerationText({
title,
deck,
plainText: parsed.plainText,
coverAlt: String(article.cover_alt),
coverCaption: String(article.cover_caption),
links: parsed.links,
}),
requireProvider: true,
images: moderationImages,
});
const scanId = await recordCommunityModerationScan({
contentType: 'guide',
contentId: input.articleId,
articleRevisionId: revisionId,
authorId: input.actorId,
result: moderation,
});
await database.exec('BEGIN IMMEDIATE');
try {
const claim = await (
await database.prepare(`UPDATE article_publication_requests SET status=?
WHERE id=? AND status='scanning'`)
).run(
moderation.decision === 'approved'
? 'approved'
: moderation.decision === 'error'
? 'error'
: 'pending_review',
requestId,
);
if (!claim.changes)
throw new Error('This Guide submission was superseded.');
if (moderation.decision === 'approved') {
const published = await publishRevision(database, {
articleId: input.articleId,
revisionId,
visibility: input.visibility,
baseRevisionId: article.published_revision_id
? String(article.published_revision_id)
: null,
now: new Date().toISOString(),
requirePending: true,
});
if (!published) throw new Error('The Guide changed before publication.');
await (
await database.prepare(`UPDATE article_publication_requests SET scan_id=?,decided_at=?,
decision_reason=? WHERE id=?`)
).run(scanId, new Date().toISOString(), moderation.reason, requestId);
} else {
await (
await database.prepare(
'UPDATE article_publication_requests SET scan_id=? WHERE id=?',
)
).run(scanId, requestId);
await (
await database.prepare(
'UPDATE articles SET moderation_hold=1 WHERE id=?',
)
).run(input.articleId);
}
await database.exec('COMMIT');
} catch (cause) {
await database.exec('ROLLBACK');
throw cause;
}
return {
ok: true as const,
state:
moderation.decision === 'approved'
? ('published' as const)
: ('pending_review' as const),
revisionId,
};
}
export async function deleteArticle(
articleId: string,
actorId: string,
canManageNews: boolean,
) {
const database = await getDatabase();
const article = await (
await database.prepare(
'SELECT * FROM articles WHERE id=? AND deleted_at IS NULL',
)
).get(articleId);
if (
!article ||
(article.kind === 'guide' ? article.owner_id !== actorId : !canManageNews)
)
error(404, 'Article not found');
const now = new Date().toISOString();
await database.exec('BEGIN IMMEDIATE');
try {
await (
await database.prepare(
"UPDATE articles SET visibility='private',deleted_at=?,deleted_by=?,pending_revision_id=NULL,updated_at=? WHERE id=?",
)
).run(now, actorId, now, articleId);
await (
await database.prepare(
"UPDATE article_publication_requests SET status='superseded',decided_at=?,decision_reason='Article deleted' WHERE article_id=? AND status IN ('scanning','pending_review')",
)
).run(now, articleId);
await (
await database.prepare('DELETE FROM article_search WHERE article_id=?')
).run(articleId);
await (
await database.prepare('DELETE FROM guide_curations WHERE article_id=?')
).run(articleId);
if (article.kind === 'news')
await recordIsacAudit({
actorId,
category: 'editorial_content',
action: 'news.deleted',
entityId: articleId,
before: {
visibility: article.visibility,
publishedRevisionId: article.published_revision_id ?? null,
},
after: { deletedAt: now },
});
await database.exec('COMMIT');
} catch (cause) {
await database.exec('ROLLBACK');
throw cause;
}
}
export async function loadPublishedArticle(
kind: ArticleKind,
slug: string,
viewerId?: string | null,
) {
const database = await getDatabase();
const row = await (
await database.prepare(`SELECT articles.*,revision.*,articles.id AS article_id,
COALESCE(profile.display_name,users.username,'Deleted Agent') AS author,profile.public_slug,
images.updated_at AS cover_updated_at,
source.provider AS external_provider,source.canonical_url AS external_canonical_url,
source.thumbnail_url AS external_thumbnail_url,source.thumbnail_alt AS external_thumbnail_alt,
source.source_published_at AS external_published_at,
COALESCE(rating.average,0) AS rating,COALESCE(rating.votes,0) AS votes,
curation.pinned_order,COALESCE(curation.featured,0) AS featured,COALESCE(curation.recommended,0) AS recommended
FROM articles JOIN article_revisions revision ON revision.id=articles.published_revision_id
JOIN users ON users.id=articles.owner_id LEFT JOIN user_profiles profile ON profile.user_id=users.id
LEFT JOIN managed_images images ON images.id=revision.cover_image_id
LEFT JOIN external_news_sources source ON source.article_id=articles.id
LEFT JOIN (SELECT article_id,AVG(value) AS average,COUNT(*) AS votes FROM guide_ratings GROUP BY article_id) rating ON rating.article_id=articles.id
LEFT JOIN guide_curations curation ON curation.article_id=articles.id AND curation.curated_revision_id=articles.published_revision_id
WHERE articles.kind=? AND articles.slug=? AND articles.deleted_at IS NULL`)
).get(kind, slug);
if (
!row ||
(row.visibility === 'private' && row.owner_id !== viewerId) ||
(row.moderation_hold && !row.published_revision_id)
)
error(404, 'Article not found');
const parsed = parseArticleDocument(String(row.document_json));
if (!parsed) error(500, 'Published article data is invalid');
const [embeds, imageRows, tags] = await Promise.all([
(
await database.prepare(
'SELECT * FROM article_revision_build_embeds WHERE revision_id=? ORDER BY ordinal',
)
).all(row.id),
parsed.imageIds.length
? (
await database.prepare(
`SELECT id,updated_at FROM managed_images WHERE id IN (${parsed.imageIds.map(() => '?').join(',')})`,
)
).all(...parsed.imageIds)
: [],
(
await database.prepare(`SELECT tags.id,tags.name,tags.slug,tags.group_name FROM article_revision_tags links
JOIN article_tags tags ON tags.id=links.tag_id WHERE links.revision_id=? ORDER BY links.ordinal`)
).all(row.id),
]);
const buildMap: Record<string, any> = {};
for (const embed of embeds) {
const snapshot = JSON.parse(String(embed.build_snapshot_json));
const live = await (
await database.prepare('SELECT visibility FROM builds WHERE id=?')
).get(embed.build_id);
const available =
Boolean(embed.owner_shared) || live?.visibility === 'public';
buildMap[String(embed.embed_key)] = available
? {
...snapshot,
ownerShared: Boolean(embed.owner_shared),
available: true,
}
: {
buildId: String(embed.build_id),
title: 'Build unavailable',
slug: '',
author: '',
ownerShared: false,
available: false,
weapons: [],
gear: [],
};
}
const imageMap = Object.fromEntries(
imageRows.map((image) => {
const node = parsed.document.document.content;
const find = (nodes: any[]): any =>
nodes
.flatMap((item) => [item, ...find(item.content ?? [])])
.find(
(item) =>
item.type === 'articleImage' && item.attrs?.imageId === image.id,
);
const imageNode = find(node ?? []);
return [
String(image.id),
{
url: managedImageUrl(String(image.id), image.updated_at),
alt: String(imageNode?.attrs?.alt ?? ''),
caption: String(imageNode?.attrs?.caption ?? ''),
},
];
}),
);
return {
article: {
id: String(row.article_id),
kind,
slug: String(row.slug),
visibility: String(row.visibility),
title: String(row.title),
deck: String(row.deck),
author: String(row.author),
ownerId: String(row.owner_id),
authorPublicSlug: row.public_slug ? String(row.public_slug) : null,
publishedAt: String(row.published_at),
updatedAt: String(row.updated_at),
channel: row.channel ? String(row.channel) : null,
coverUrl: row.cover_image_id
? managedImageUrl(String(row.cover_image_id), row.cover_updated_at)
: row.external_thumbnail_url
? `/images/news/${encodeURIComponent(String(row.article_id))}`
: '',
coverAlt: row.cover_image_id
? String(row.cover_alt)
: String(row.external_thumbnail_alt ?? ''),
coverCaption: String(row.cover_caption),
rating: Number(row.rating),
votes: Number(row.votes),
pinned: row.pinned_order !== null,
featured: Boolean(row.featured),
recommended: Boolean(row.recommended),
owner: row.owner_id === viewerId,
revisionId: String(row.id),
source: row.external_provider
? {
name:
row.external_provider === 'ubisoft'
? 'Ubisoft'
: String(row.external_provider),
url: String(row.external_canonical_url),
publishedAt: String(row.external_published_at),
}
: null,
},
document: parsed.document.document,
builds: buildMap,
images: imageMap,
tags: tags.map((tag) => ({
id: String(tag.id),
name: String(tag.name),
slug: String(tag.slug),
group: String(tag.group_name),
})),
};
}
export async function listPublishedArticles(input: {
kind: ArticleKind;
query?: string;
tagSlugs?: string[];
sort?: 'relevance' | 'newest' | 'rating' | 'rated';
channel?: string;
page?: number;
}) {
const database = await getDatabase();
await syncArticleTags(database);
const query = normalizeText(input.query, 80) || '';
const page = Math.max(1, Math.floor(input.page || 1));
const limit = 18;
const offset = (page - 1) * limit;
const tagSlugs = [
...new Set((input.tagSlugs ?? []).map((tag) => tag.trim()).filter(Boolean)),
].slice(0, 12);
const values: unknown[] = [input.kind];
let searchJoin = '';
let rankSelect = '0 AS search_rank';
let searchWhere = '';
let withClause = '';
if (query) {
const terms = query
.toLocaleLowerCase()
.split(/\s+/)
.filter(Boolean)
.slice(0, 10);
withClause = 'WITH search_terms(term) AS (SELECT value FROM json_each(?))';
searchJoin = 'JOIN article_search ON article_search.article_id=articles.id';
rankSelect = `(SELECT SUM(
CASE WHEN lower(article_search.title)=term THEN 40 WHEN instr(lower(article_search.title),term)>0 THEN 16 ELSE 0 END
+ CASE WHEN instr(lower(article_search.deck),term)>0 THEN 7 ELSE 0 END
+ CASE WHEN instr(lower(article_search.tags),term)>0 THEN 5 ELSE 0 END
+ CASE WHEN instr(lower(article_search.body),term)>0 THEN 2 ELSE 0 END
) FROM search_terms) AS search_rank`;
searchWhere = `AND NOT EXISTS(SELECT 1 FROM search_terms WHERE
instr(lower(article_search.title),term)=0 AND instr(lower(article_search.deck),term)=0
AND instr(lower(article_search.tags),term)=0 AND instr(lower(article_search.body),term)=0)`;
values.unshift(JSON.stringify(terms));
}
let tagWhere = '';
if (tagSlugs.length) {
tagWhere = `AND ${tagSlugs
.map(
() => `EXISTS(SELECT 1 FROM article_revision_tags filter_links
JOIN article_tags filter_tags ON filter_tags.id=filter_links.tag_id
WHERE filter_links.revision_id=revision.id AND filter_tags.slug=?)`,
)
.join(' AND ')}`;
values.push(...tagSlugs);
}
let channelWhere = '';
if (input.channel) {
channelWhere = 'AND revision.channel=?';
values.push(input.channel);
}
const sort = input.sort ?? (query ? 'relevance' : 'newest');
const order =
sort === 'rating'
? 'rating DESC,votes DESC,articles.published_at DESC'
: sort === 'rated'
? 'votes DESC,rating DESC,articles.published_at DESC'
: sort === 'relevance' && query
? 'search_rank DESC,articles.published_at DESC'
: 'CASE WHEN curation.pinned_order IS NULL THEN 1 ELSE 0 END,curation.pinned_order,articles.published_at DESC';
const base = `FROM articles JOIN article_revisions revision ON revision.id=articles.published_revision_id
${searchJoin}
JOIN users ON users.id=articles.owner_id LEFT JOIN user_profiles profile ON profile.user_id=users.id
LEFT JOIN managed_images cover ON cover.id=revision.cover_image_id
LEFT JOIN external_news_sources source ON source.article_id=articles.id
LEFT JOIN (SELECT article_id,AVG(value) AS rating,COUNT(*) AS votes FROM guide_ratings GROUP BY article_id) ratings ON ratings.article_id=articles.id
LEFT JOIN guide_curations curation ON curation.article_id=articles.id AND curation.curated_revision_id=articles.published_revision_id
WHERE articles.kind=? AND articles.visibility='public' AND articles.deleted_at IS NULL
${searchWhere} ${tagWhere} ${channelWhere}`;
const total = await (
await database.prepare(`${withClause} SELECT COUNT(*) AS count ${base}`)
).get(...values);
const rows = await (
await database.prepare(`${withClause} SELECT articles.id,articles.slug,articles.owner_id,articles.published_at,
revision.id AS revision_id,revision.title,revision.deck,revision.channel,revision.cover_image_id,cover.updated_at AS cover_updated_at,
source.provider AS external_provider,source.canonical_url AS external_canonical_url,
source.thumbnail_url AS external_thumbnail_url,source.thumbnail_alt AS external_thumbnail_alt,
COALESCE(profile.display_name,users.username,'Deleted Agent') AS author,profile.public_slug,
COALESCE(ratings.rating,0) AS rating,COALESCE(ratings.votes,0) AS votes,
curation.pinned_order,COALESCE(curation.featured,0) AS featured,COALESCE(curation.recommended,0) AS recommended,
${rankSelect} ${base} ORDER BY ${order} LIMIT ? OFFSET ?`)
).all(...values, limit, offset);
const items = [];
for (const row of rows) {
const tags = await (
await database.prepare(`SELECT tags.name,tags.slug,tags.group_name FROM article_revision_tags links
JOIN article_tags tags ON tags.id=links.tag_id WHERE links.revision_id=? ORDER BY links.ordinal`)
).all(row.revision_id);
items.push({
id: String(row.id),
slug: String(row.slug),
title: String(row.title),
deck: String(row.deck),
channel: row.channel ? String(row.channel) : null,
author: String(row.author),
ownerId: String(row.owner_id),
authorPublicSlug: row.public_slug ? String(row.public_slug) : null,
publishedAt: String(row.published_at),
source: row.external_provider
? {
name:
row.external_provider === 'ubisoft'
? 'Ubisoft'
: String(row.external_provider),
url: String(row.external_canonical_url),
}
: null,
coverUrl: row.cover_image_id
? managedImageUrl(String(row.cover_image_id), row.cover_updated_at)
: row.external_thumbnail_url
? `/images/news/${encodeURIComponent(String(row.id))}`
: '',
coverAlt: row.cover_image_id
? ''
: String(row.external_thumbnail_alt ?? ''),
rating: Number(row.rating),
votes: Number(row.votes),
pinnedOrder: row.pinned_order === null ? null : Number(row.pinned_order),
featured: Boolean(row.featured),
recommended: Boolean(row.recommended),
tags: tags.map((tag) => ({
name: String(tag.name),
slug: String(tag.slug),
group: String(tag.group_name),
})),
});
}
return {
items,
total: Number(total?.count ?? 0),
page,
pageCount: Math.max(1, Math.ceil(Number(total?.count ?? 0) / limit)),
};
}