1176 lines
40 KiB
TypeScript
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)),
|
|
};
|
|
}
|