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([ '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, 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; 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 = {}; 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)), }; }