1619 lines
64 KiB
TypeScript
1619 lines
64 KiB
TypeScript
import { databaseCategory } from '$lib/database/categories';
|
||
import type { DatabaseFact, DatabaseItem } from '$lib/database/items';
|
||
import type { GameIconName } from '$lib/game-icons';
|
||
import { gearBaseStatRange, weaponBaseRange } from '$lib/builder/loadout';
|
||
import {
|
||
gameDataIconUrl,
|
||
type GameDataIconType,
|
||
} from '$lib/server/game-data-icons';
|
||
import { managedImageUrl } from '$lib/server/managed-images';
|
||
import { getDatabase } from '$lib/server/database';
|
||
import { getPagination, getRegistrySearch } from '$lib/server/pagination';
|
||
import { error } from '@sveltejs/kit';
|
||
import type { PageServerLoad } from './$types';
|
||
import {
|
||
gameDataCorrectionHref,
|
||
gameDataDiscussionId,
|
||
gameDataTarget,
|
||
} from '$lib/game-data-targets';
|
||
import {
|
||
gameDataDiscussionSummary,
|
||
gameDataTargetExists,
|
||
} from '$lib/server/game-data-discussions';
|
||
import { getSessionUser } from '$lib/server/auth/session';
|
||
import { getPermissions } from '$lib/server/auth/authorization';
|
||
import { normalizeUserMarkdown, renderUserMarkdown } from '$lib/markdown';
|
||
import {
|
||
createUserContentReport,
|
||
getCommunityModerationPolicy,
|
||
moderateCommunityText,
|
||
recordCommunityModerationScan,
|
||
} from '$lib/server/community-moderation';
|
||
import { randomUUID } from 'node:crypto';
|
||
import { fail } from '@sveltejs/kit';
|
||
import { avatarUrl } from '$lib/avatar';
|
||
import {
|
||
orderProfileCommentThread,
|
||
prioritizeCommentConversation,
|
||
} from '$lib/server/profile-comment-thread';
|
||
import { recordIsacAudit } from '$lib/server/audit';
|
||
import { removePublicComment } from '$lib/server/comment-removal';
|
||
import { createNotification } from '$lib/server/notifications';
|
||
import type { Actions } from './$types';
|
||
import {
|
||
publicWeaponModSlots,
|
||
weaponModSlotDefinitionByName,
|
||
} from '$lib/weapons/mod-slots';
|
||
|
||
const number = (value: unknown) => {
|
||
const numeric = Number(value);
|
||
return Number.isFinite(numeric)
|
||
? Number.isInteger(numeric)
|
||
? String(numeric)
|
||
: numeric.toFixed(2).replace(/\.?0+$/, '')
|
||
: '—';
|
||
};
|
||
const valueWithUnit = (value: unknown, unit: unknown) =>
|
||
`${number(value)}${unit ? (unit === '%' ? '%' : ` ${unit}`) : ''}`;
|
||
const range = (min: unknown, max: unknown, unit: unknown = '') =>
|
||
`${valueWithUnit(min, unit)}–${valueWithUnit(max, unit)}`;
|
||
const list = (value: unknown, fallback = 'NONE') =>
|
||
typeof value === 'string' && value ? value : fallback;
|
||
const gearIcon = (slot: unknown) =>
|
||
String(slot).toLowerCase().replaceAll(' ', '_') as GameIconName;
|
||
const weaponIcon = (category: unknown) =>
|
||
String(category).toLowerCase() as GameIconName;
|
||
const iconUrl = (type: GameDataIconType, id: unknown, updatedAt: unknown) =>
|
||
updatedAt ? gameDataIconUrl(type, String(id), updatedAt) : '';
|
||
const parsedObject = (value: unknown): Record<string, unknown> => {
|
||
try {
|
||
const parsed = JSON.parse(String(value ?? '{}'));
|
||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||
? (parsed as Record<string, unknown>)
|
||
: {};
|
||
} catch {
|
||
return {};
|
||
}
|
||
};
|
||
const talentCardItem = (
|
||
row: Record<string, any>,
|
||
mechanics: Array<Record<string, any>>,
|
||
weaponSlotOverride?: number,
|
||
): DatabaseItem => {
|
||
const talentCategory = String(row.category);
|
||
const isWeaponTalent = talentCategory === 'Weapon';
|
||
const normalizedCategory =
|
||
talentCategory === 'Vest' ? 'Body Armor' : talentCategory;
|
||
const weaponSlot =
|
||
weaponSlotOverride ??
|
||
(row.weapon_slot ? Number(row.weapon_slot) : undefined);
|
||
const slotLabel =
|
||
isWeaponTalent && weaponSlot
|
||
? `WEAPON ${weaponSlot}`
|
||
: normalizedCategory.toUpperCase();
|
||
const afterLines: DatabaseFact[] = [
|
||
...(row.duration_seconds
|
||
? [
|
||
{
|
||
label: 'EFFECT DURATION',
|
||
value: `${number(row.duration_seconds)} SECONDS`,
|
||
},
|
||
]
|
||
: []),
|
||
...(Number(row.max_stacks) > 1
|
||
? [{ label: 'MAXIMUM STACKS', value: String(row.max_stacks) }]
|
||
: []),
|
||
...(row.cooldown_seconds
|
||
? [
|
||
{
|
||
label: 'TALENT COOLDOWN',
|
||
value: `${number(row.cooldown_seconds)} SECONDS`,
|
||
},
|
||
]
|
||
: []),
|
||
];
|
||
const filterKey = isWeaponTalent ? 'weaponTalent' : 'talent';
|
||
const destination = isWeaponTalent ? 'weapons' : 'gear';
|
||
|
||
return {
|
||
id: String(row.id),
|
||
targetType: 'talent',
|
||
name: String(row.name),
|
||
kicker: `${slotLabel} TALENT`,
|
||
variant: 'talent',
|
||
iconUrl: iconUrl('talent', row.id, row.icon_updated_at ?? row.updated_at),
|
||
renderUrl: row.render_id
|
||
? managedImageUrl(String(row.render_id), row.render_updated_at)
|
||
: '',
|
||
headerMetaIcon: isWeaponTalent ? 'smg' : gearIcon(normalizedCategory),
|
||
headerBadge:
|
||
isWeaponTalent && weaponSlot
|
||
? `${weaponSlot} · ${weaponSlot === 1 ? 'TOP' : 'BOTTOM'}`
|
||
: undefined,
|
||
badges: [
|
||
...(row.weapon_scope === 'Exotic'
|
||
? [{ label: 'EXOTIC', tone: 'exotic' }]
|
||
: []),
|
||
],
|
||
facts: [],
|
||
description: row.tier_i
|
||
? String(row.tier_i)
|
||
: 'Description is not available.',
|
||
secondaryDescription: row.tier_ii
|
||
? String(row.tier_ii)
|
||
: 'Description is not available.',
|
||
descriptionStyle: 'data',
|
||
afterLines,
|
||
actions: [
|
||
{
|
||
label: `FIND COMPATIBLE ${isWeaponTalent ? 'WEAPONS' : 'GEAR'}`,
|
||
href: `/database/${destination}?${filterKey}=${encodeURIComponent(String(row.id))}`,
|
||
},
|
||
],
|
||
};
|
||
};
|
||
|
||
export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
||
const category = databaseCategory(params.category);
|
||
if (!category) error(404, 'Database category not found');
|
||
const database = await getDatabase();
|
||
const { search, pattern } = getRegistrySearch(url);
|
||
const searchArgs = search ? [pattern] : [];
|
||
const searchClause = search
|
||
? " AND item.name LIKE ? ESCAPE '\\' COLLATE NOCASE"
|
||
: '';
|
||
let queryArgs: unknown[] = searchArgs;
|
||
let filters: Array<{
|
||
key: string;
|
||
label: string;
|
||
kind: 'segmented' | 'searchable';
|
||
selected: string[];
|
||
options: Array<{
|
||
value: string;
|
||
label: string;
|
||
description?: string;
|
||
searchText?: string;
|
||
iconUrl?: string;
|
||
gameIcon?: GameIconName;
|
||
}>;
|
||
}> = [];
|
||
let countSql = '';
|
||
let rowsSql = '';
|
||
|
||
switch (category.slug) {
|
||
case 'gear': {
|
||
const [brandRows, talentRows] = await Promise.all([
|
||
(
|
||
await database.prepare(`SELECT brands.id,brands.name,
|
||
(SELECT GROUP_CONCAT(DISTINCT attributes.name)
|
||
FROM brand_set_effects effects
|
||
JOIN stat_attributes attributes ON attributes.id=effects.stat_attribute_id
|
||
WHERE effects.brand_set_id=brands.id) AS bonus_attributes,
|
||
icons.updated_at FROM brand_sets brands
|
||
LEFT JOIN game_data_icons icons ON icons.entity_type='brand_set' AND icons.entity_id=brands.id
|
||
WHERE brands.active=1 ORDER BY brands.name COLLATE NOCASE`)
|
||
).all(),
|
||
(
|
||
await database.prepare(`SELECT talents.id,talents.name,
|
||
(SELECT description FROM talent_tiers tiers WHERE tiers.talent_id=talents.id AND tiers.tier='I') AS tier_i_description,
|
||
icons.updated_at FROM talents
|
||
LEFT JOIN game_data_icons icons ON icons.entity_type='talent' AND icons.entity_id=talents.id
|
||
WHERE talents.active=1 AND talents.category IN ('Backpack','Body Armor','Vest')
|
||
ORDER BY talents.name COLLATE NOCASE`)
|
||
).all(),
|
||
]);
|
||
const slots = [
|
||
'Holster',
|
||
'Gloves',
|
||
'Backpack',
|
||
'Knee Pads',
|
||
'Body Armor',
|
||
'Mask',
|
||
];
|
||
const validBrands = new Set(brandRows.map((row) => String(row.id)));
|
||
const validTalents = new Set(talentRows.map((row) => String(row.id)));
|
||
const selectedSlots = url.searchParams
|
||
.getAll('slot')
|
||
.filter((value) => slots.includes(value));
|
||
const selectedBrands = url.searchParams
|
||
.getAll('brand')
|
||
.filter((value) => validBrands.has(value));
|
||
const selectedTalents = url.searchParams
|
||
.getAll('talent')
|
||
.filter((value) => validTalents.has(value));
|
||
const conditions = ['item.active=1'];
|
||
queryArgs = [];
|
||
if (search) {
|
||
conditions.push("item.name LIKE ? ESCAPE '\\' COLLATE NOCASE");
|
||
queryArgs.push(pattern);
|
||
}
|
||
if (selectedSlots.length) {
|
||
conditions.push(
|
||
`item.slot IN (${selectedSlots.map(() => '?').join(',')})`,
|
||
);
|
||
queryArgs.push(...selectedSlots);
|
||
}
|
||
if (selectedBrands.length) {
|
||
conditions.push(`EXISTS (SELECT 1 FROM gear_brand_sets filter_brands
|
||
WHERE filter_brands.gear_id=item.id AND filter_brands.brand_set_id IN (${selectedBrands.map(() => '?').join(',')}))`);
|
||
queryArgs.push(...selectedBrands);
|
||
}
|
||
if (selectedTalents.length) {
|
||
conditions.push(`EXISTS (SELECT 1 FROM gear_talents filter_talents
|
||
WHERE filter_talents.gear_id=item.id AND filter_talents.talent_id IN (${selectedTalents.map(() => '?').join(',')}))`);
|
||
queryArgs.push(...selectedTalents);
|
||
}
|
||
const where = conditions.join(' AND ');
|
||
countSql = `SELECT COUNT(*) AS total FROM gear item WHERE ${where}`;
|
||
rowsSql = `SELECT item.*,render.id AS render_id,render.updated_at AS render_updated_at
|
||
FROM gear item
|
||
LEFT JOIN managed_images render ON render.source_entity_type='gear_render' AND render.source_entity_id=item.id
|
||
WHERE ${where}
|
||
ORDER BY item.slot,item.tier,item.name COLLATE NOCASE LIMIT ? OFFSET ?`;
|
||
filters = [
|
||
{
|
||
key: 'slot',
|
||
label: 'GEAR SLOT',
|
||
kind: 'segmented',
|
||
selected: selectedSlots,
|
||
options: slots.map((slot) => ({
|
||
value: slot,
|
||
label: slot,
|
||
gameIcon: gearIcon(slot),
|
||
})),
|
||
},
|
||
{
|
||
key: 'brand',
|
||
label: 'BRAND SETS',
|
||
kind: 'searchable',
|
||
selected: selectedBrands,
|
||
options: brandRows.map((row) => ({
|
||
value: String(row.id),
|
||
label: String(row.name),
|
||
searchText: String(row.bonus_attributes ?? ''),
|
||
iconUrl: iconUrl('brand_set', row.id, row.updated_at),
|
||
})),
|
||
},
|
||
{
|
||
key: 'talent',
|
||
label: 'TALENTS',
|
||
kind: 'searchable',
|
||
selected: selectedTalents,
|
||
options: talentRows.map((row) => ({
|
||
value: String(row.id),
|
||
label: String(row.name),
|
||
description: String(row.tier_i_description ?? ''),
|
||
iconUrl: iconUrl('talent', row.id, row.updated_at),
|
||
})),
|
||
},
|
||
];
|
||
break;
|
||
}
|
||
case 'weapons': {
|
||
const talentRows = await (
|
||
await database.prepare(`SELECT DISTINCT talents.id,talents.name,
|
||
(SELECT description FROM talent_tiers tiers WHERE tiers.talent_id=talents.id AND tiers.tier='I') AS tier_i_description,
|
||
icons.updated_at
|
||
FROM weapon_talent_options options
|
||
JOIN weapons source_weapons ON source_weapons.id=options.weapon_id AND source_weapons.active=1
|
||
JOIN talents ON talents.id=options.talent_id AND talents.active=1
|
||
LEFT JOIN game_data_icons icons ON icons.entity_type='talent' AND icons.entity_id=talents.id
|
||
ORDER BY talents.name COLLATE NOCASE`)
|
||
).all();
|
||
const weaponTypes = ['Shotgun', 'Rifle', 'MMR', 'AR', 'LMG', 'SMG'];
|
||
const damageTypes = ['Blast', 'Pierce', 'Shredding'];
|
||
const validTalents = new Set(talentRows.map((row) => String(row.id)));
|
||
const selectedTypes = url.searchParams
|
||
.getAll('weaponType')
|
||
.filter((value) => weaponTypes.includes(value));
|
||
const selectedDamageTypes = url.searchParams
|
||
.getAll('damageType')
|
||
.filter((value) => damageTypes.includes(value));
|
||
const selectedTalents = url.searchParams
|
||
.getAll('weaponTalent')
|
||
.filter((value) => validTalents.has(value));
|
||
const conditions = ['item.active=1'];
|
||
queryArgs = [];
|
||
if (search) {
|
||
conditions.push("item.name LIKE ? ESCAPE '\\' COLLATE NOCASE");
|
||
queryArgs.push(pattern);
|
||
}
|
||
if (selectedTypes.length) {
|
||
conditions.push(
|
||
`item.category IN (${selectedTypes.map(() => '?').join(',')})`,
|
||
);
|
||
queryArgs.push(...selectedTypes);
|
||
}
|
||
if (selectedDamageTypes.length) {
|
||
conditions.push(
|
||
`item.damage_type IN (${selectedDamageTypes.map(() => '?').join(',')})`,
|
||
);
|
||
queryArgs.push(...selectedDamageTypes);
|
||
}
|
||
if (selectedTalents.length) {
|
||
conditions.push(`EXISTS (SELECT 1 FROM weapon_talent_options filter_talents
|
||
WHERE filter_talents.weapon_id=item.id AND filter_talents.talent_id IN (${selectedTalents.map(() => '?').join(',')}))`);
|
||
queryArgs.push(...selectedTalents);
|
||
}
|
||
const where = conditions.join(' AND ');
|
||
countSql = `SELECT COUNT(*) AS total FROM weapons item WHERE ${where}`;
|
||
rowsSql = `SELECT item.*,render.id AS render_id,render.updated_at AS render_updated_at,
|
||
native.damage_min,native.damage_max,native.reference_level,
|
||
(SELECT COUNT(*) FROM weapon_talent_options options WHERE options.weapon_id=item.id AND options.talent_slot=1) AS talent_1_count,
|
||
(SELECT COUNT(*) FROM weapon_talent_options options WHERE options.weapon_id=item.id AND options.talent_slot=2) AS talent_2_count
|
||
FROM weapons item
|
||
LEFT JOIN weapon_tier_ranges native ON native.weapon_id=item.id AND native.tier=item.native_tier
|
||
LEFT JOIN managed_images render ON render.source_entity_type='weapon_render' AND render.source_entity_id=item.id
|
||
WHERE ${where}
|
||
ORDER BY item.is_exotic DESC,item.category,item.name COLLATE NOCASE LIMIT ? OFFSET ?`;
|
||
filters = [
|
||
{
|
||
key: 'weaponType',
|
||
label: 'WEAPON TYPE',
|
||
kind: 'segmented',
|
||
selected: selectedTypes,
|
||
options: weaponTypes.map((type) => ({
|
||
value: type,
|
||
label: type,
|
||
gameIcon: weaponIcon(type),
|
||
})),
|
||
},
|
||
{
|
||
key: 'damageType',
|
||
label: 'DAMAGE TYPE',
|
||
kind: 'segmented',
|
||
selected: selectedDamageTypes,
|
||
options: damageTypes.map((type) => ({
|
||
value: type,
|
||
label: type,
|
||
gameIcon: weaponIcon(type),
|
||
})),
|
||
},
|
||
{
|
||
key: 'weaponTalent',
|
||
label: 'TALENT POOL',
|
||
kind: 'searchable',
|
||
selected: selectedTalents,
|
||
options: talentRows.map((row) => ({
|
||
value: String(row.id),
|
||
label: String(row.name),
|
||
description: String(row.tier_i_description ?? ''),
|
||
iconUrl: iconUrl('talent', row.id, row.updated_at),
|
||
})),
|
||
},
|
||
];
|
||
break;
|
||
}
|
||
case 'weapon-mods':
|
||
countSql = `SELECT COUNT(*) AS total FROM weapon_mods item WHERE item.active=1${searchClause}`;
|
||
rowsSql = `SELECT item.*,weapons.name AS exotic_weapon,
|
||
render.id AS render_id,render.updated_at AS render_updated_at,
|
||
GROUP_CONCAT(DISTINCT categories.weapon_category) AS categories,
|
||
(SELECT COUNT(*) FROM weapon_mod_secondary_options) AS secondary_count,
|
||
(SELECT COUNT(*) FROM weapon_mod_effects effects WHERE effects.weapon_mod_id=item.id) AS effect_count
|
||
FROM weapon_mods item
|
||
LEFT JOIN weapons ON weapons.id=item.exotic_weapon_id
|
||
LEFT JOIN weapon_mod_compatible_categories categories ON categories.weapon_mod_id=item.id
|
||
LEFT JOIN managed_images render ON render.source_entity_type='weapon_mod_render' AND render.source_entity_id=item.id
|
||
WHERE item.active=1${searchClause}
|
||
GROUP BY item.id ORDER BY item.slot_type,item.name COLLATE NOCASE LIMIT ? OFFSET ?`;
|
||
break;
|
||
case 'gear-mods':
|
||
countSql = `SELECT COUNT(*) AS total FROM gear_mods item WHERE item.active=1${searchClause}`;
|
||
rowsSql = `SELECT item.*,render.id AS render_id,render.updated_at AS render_updated_at,
|
||
(SELECT COUNT(*) FROM gear_mod_secondary_options) AS secondary_count,
|
||
(SELECT MIN(primary_level_1_min) FROM gear_mod_quality_values quality WHERE quality.gear_mod_id=item.id) AS level_1_floor,
|
||
(SELECT MAX(primary_level_20_max) FROM gear_mod_quality_values quality WHERE quality.gear_mod_id=item.id) AS level_20_ceiling
|
||
FROM gear_mods item
|
||
LEFT JOIN managed_images render ON render.source_entity_type='gear_mod_render' AND render.source_entity_id=item.id
|
||
WHERE item.active=1${searchClause}
|
||
ORDER BY item.name COLLATE NOCASE LIMIT ? OFFSET ?`;
|
||
break;
|
||
case 'os-protocols':
|
||
countSql = `SELECT COUNT(*) AS total FROM os_protocols item WHERE item.active=1${searchClause}`;
|
||
rowsSql = `SELECT item.*,attributes.name AS primary_stat,attributes.unit AS primary_unit,
|
||
primary_effect.base_value,talents.name AS talent_name,talents.description_template AS talent_description,icons.updated_at AS icon_updated_at,
|
||
render.id AS render_id,render.updated_at AS render_updated_at
|
||
FROM os_protocols item
|
||
LEFT JOIN os_protocol_primary_effects primary_effect ON primary_effect.protocol_id=item.id
|
||
LEFT JOIN stat_attributes attributes ON attributes.id=primary_effect.stat_attribute_id
|
||
LEFT JOIN os_protocol_talents talents ON talents.protocol_id=item.id
|
||
LEFT JOIN game_data_icons icons ON icons.entity_type='os_protocol' AND icons.entity_id=item.id
|
||
LEFT JOIN managed_images render ON render.source_entity_type='os_protocol_render' AND render.source_entity_id=item.id
|
||
WHERE item.active=1${searchClause}
|
||
ORDER BY item.quality,item.primary_attribute_type,item.name COLLATE NOCASE LIMIT ? OFFSET ?`;
|
||
break;
|
||
case 'talents': {
|
||
const talentSlots = [
|
||
{ value: 'Backpack', label: 'Backpack', gameIcon: 'backpack' as const },
|
||
{
|
||
value: 'Body Armor',
|
||
label: 'Body Armor',
|
||
gameIcon: 'body_armor' as const,
|
||
},
|
||
{
|
||
value: 'Weapon 1',
|
||
label: 'Weapon 1 · Top',
|
||
gameIcon: 'smg' as const,
|
||
},
|
||
{
|
||
value: 'Weapon 2',
|
||
label: 'Weapon 2 · Bottom',
|
||
gameIcon: 'smg' as const,
|
||
},
|
||
];
|
||
const selectedTalentSlots = url.searchParams
|
||
.getAll('talentSlot')
|
||
.filter((value) => talentSlots.some((slot) => slot.value === value));
|
||
const conditions = ['item.active=1'];
|
||
queryArgs = [];
|
||
if (search) {
|
||
conditions.push("item.name LIKE ? ESCAPE '\\' COLLATE NOCASE");
|
||
queryArgs.push(pattern);
|
||
}
|
||
if (selectedTalentSlots.length) {
|
||
const slotConditions = selectedTalentSlots.map((slot) => {
|
||
if (slot === 'Backpack') return "item.category='Backpack'";
|
||
if (slot === 'Body Armor')
|
||
return "item.category IN ('Body Armor','Vest')";
|
||
queryArgs.push(slot === 'Weapon 1' ? 1 : 2);
|
||
return `item.category='Weapon' AND EXISTS (
|
||
SELECT 1 FROM weapon_talent_slots filter_slots
|
||
WHERE filter_slots.talent_id=item.id AND filter_slots.slot=?
|
||
)`;
|
||
});
|
||
conditions.push(
|
||
`(${slotConditions.map((condition) => `(${condition})`).join(' OR ')})`,
|
||
);
|
||
}
|
||
const where = conditions.join(' AND ');
|
||
countSql = `SELECT COUNT(*) AS total FROM talents item WHERE ${where}`;
|
||
rowsSql = `SELECT item.*,
|
||
(SELECT description FROM talent_tiers tiers WHERE tiers.talent_id=item.id AND tiers.tier='I') AS tier_i,
|
||
(SELECT description FROM talent_tiers tiers WHERE tiers.talent_id=item.id AND tiers.tier='II') AS tier_ii,
|
||
(SELECT slot FROM weapon_talent_slots slots WHERE slots.talent_id=item.id) AS weapon_slot,
|
||
(SELECT scope FROM talent_weapon_scopes scopes WHERE scopes.talent_id=item.id) AS weapon_scope,
|
||
icons.updated_at AS icon_updated_at,render.id AS render_id,render.updated_at AS render_updated_at
|
||
FROM talents item
|
||
LEFT JOIN game_data_icons icons ON icons.entity_type='talent' AND icons.entity_id=item.id
|
||
LEFT JOIN managed_images render ON render.source_entity_type='talent_render' AND render.source_entity_id=item.id
|
||
WHERE ${where}
|
||
ORDER BY item.category,item.name COLLATE NOCASE LIMIT ? OFFSET ?`;
|
||
filters = [
|
||
{
|
||
key: 'talentSlot',
|
||
label: 'TALENT SLOT',
|
||
kind: 'segmented',
|
||
selected: selectedTalentSlots,
|
||
options: talentSlots,
|
||
},
|
||
];
|
||
break;
|
||
}
|
||
case 'brands':
|
||
countSql = `SELECT COUNT(*) AS total FROM brand_sets item WHERE item.active=1${searchClause}`;
|
||
rowsSql = `SELECT item.*,icons.updated_at AS icon_updated_at,render.id AS render_id,render.updated_at AS render_updated_at
|
||
FROM brand_sets item
|
||
LEFT JOIN game_data_icons icons ON icons.entity_type='brand_set' AND icons.entity_id=item.id
|
||
LEFT JOIN managed_images render ON render.source_entity_type='brand_set_render' AND render.source_entity_id=item.id
|
||
WHERE item.active=1${searchClause}
|
||
ORDER BY item.name COLLATE NOCASE LIMIT ? OFFSET ?`;
|
||
break;
|
||
case 'skill-chips':
|
||
countSql = `SELECT COUNT(*) AS total FROM skill_chip_sets item WHERE item.active=1${searchClause}`;
|
||
rowsSql = `SELECT item.*,bonus.min_value,bonus.max_value,attributes.name AS bonus_stat,attributes.unit AS bonus_unit,
|
||
talents.name AS talent_name,talents.description_template AS talent_description,
|
||
icons.updated_at AS icon_updated_at,render.id AS render_id,render.updated_at AS render_updated_at
|
||
FROM skill_chip_sets item
|
||
LEFT JOIN skill_chip_two_piece_bonuses bonus ON bonus.skill_chip_set_id=item.id
|
||
LEFT JOIN stat_attributes attributes ON attributes.id=bonus.stat_attribute_id
|
||
LEFT JOIN skill_chip_talents talents ON talents.skill_chip_set_id=item.id
|
||
LEFT JOIN game_data_icons icons ON icons.entity_type='skill_chip' AND icons.entity_id=item.id
|
||
LEFT JOIN managed_images render ON render.source_entity_type='skill_chip_render' AND render.source_entity_id=item.id
|
||
WHERE item.active=1${searchClause}
|
||
ORDER BY item.name COLLATE NOCASE LIMIT ? OFFSET ?`;
|
||
break;
|
||
case 'specializations':
|
||
countSql = `SELECT COUNT(*) AS total FROM specializations item WHERE item.active=1${searchClause}`;
|
||
rowsSql = `SELECT item.*,
|
||
(SELECT COUNT(*) FROM specialization_focuses focus WHERE focus.specialization_id=item.id AND focus.active=1) AS focus_count,
|
||
(SELECT COUNT(*) FROM specialization_focuses focus WHERE focus.specialization_id=item.id AND focus.builder_ready=1 AND focus.active=1) AS ready_focus_count,
|
||
(SELECT COUNT(*) FROM specialization_abilities ability WHERE ability.specialization_id=item.id AND ability.active=1) AS ability_count,
|
||
(SELECT COUNT(*) FROM specialization_tree_nodes node
|
||
JOIN specialization_focuses node_focus ON node_focus.id=node.focus_id
|
||
WHERE node_focus.specialization_id=item.id AND node_focus.active=1 AND node.active=1) AS node_count,
|
||
(SELECT GROUP_CONCAT(name, ', ') FROM (
|
||
SELECT focus.name FROM specialization_focuses focus
|
||
WHERE focus.specialization_id=item.id AND focus.active=1
|
||
ORDER BY focus.display_order,focus.name COLLATE NOCASE
|
||
)) AS focus_names,
|
||
(SELECT GROUP_CONCAT(name, ', ') FROM (
|
||
SELECT ability.name FROM specialization_abilities ability
|
||
WHERE ability.specialization_id=item.id AND ability.active=1
|
||
ORDER BY ability.name COLLATE NOCASE
|
||
)) AS ability_names,
|
||
icons.updated_at AS icon_updated_at,render.id AS render_id,render.updated_at AS render_updated_at
|
||
FROM specializations item
|
||
LEFT JOIN game_data_icons icons ON icons.entity_type='specialization' AND icons.entity_id=item.id
|
||
LEFT JOIN managed_images render ON render.source_entity_type='specialization_render' AND render.source_entity_id=item.id
|
||
WHERE item.active=1${searchClause}
|
||
ORDER BY item.name COLLATE NOCASE LIMIT ? OFFSET ?`;
|
||
break;
|
||
}
|
||
|
||
const total = Number(
|
||
(await (await database.prepare(countSql)).get(...queryArgs))?.total ?? 0,
|
||
);
|
||
const pagination = getPagination(url, total);
|
||
const rows = await (
|
||
await database.prepare(rowsSql)
|
||
).all(...queryArgs, pagination.perPage, pagination.offset);
|
||
const protocolTalentEffects =
|
||
category.slug === 'os-protocols' && rows.length
|
||
? await (
|
||
await database.prepare(`SELECT talents.protocol_id,effects.base_value,attributes.unit
|
||
FROM os_protocol_talents talents
|
||
JOIN os_protocol_talent_effects effects ON effects.talent_id=talents.id
|
||
JOIN stat_attributes attributes ON attributes.id=effects.stat_attribute_id
|
||
WHERE talents.protocol_id IN (${rows.map(() => '?').join(',')})
|
||
ORDER BY talents.protocol_id,effects.sort_order`)
|
||
).all(...rows.map((row) => row.id))
|
||
: [];
|
||
const weaponRelations =
|
||
category.slug === 'weapons' && rows.length
|
||
? await (
|
||
await database.prepare(`SELECT options.weapon_id,options.talent_slot,talents.*,
|
||
(SELECT description FROM talent_tiers tiers WHERE tiers.talent_id=talents.id AND tiers.tier='I') AS tier_i,
|
||
(SELECT description FROM talent_tiers tiers WHERE tiers.talent_id=talents.id AND tiers.tier='II') AS tier_ii,
|
||
icons.updated_at AS icon_updated_at
|
||
FROM weapon_talent_options options
|
||
JOIN talents ON talents.id=options.talent_id AND talents.active=1
|
||
LEFT JOIN game_data_icons icons ON icons.entity_type='talent' AND icons.entity_id=talents.id
|
||
WHERE options.weapon_id IN (${rows.map(() => '?').join(',')})
|
||
ORDER BY options.talent_slot,talents.name COLLATE NOCASE`)
|
||
).all(...rows.map((row) => row.id))
|
||
: [];
|
||
const weaponModSlotRows =
|
||
category.slug === 'weapons' && rows.length
|
||
? await (
|
||
await database.prepare(`SELECT weapon_id,tier,slot_type FROM weapon_mod_slots
|
||
WHERE weapon_id IN (${rows.map(() => '?').join(',')})
|
||
ORDER BY weapon_id,tier,slot_type`)
|
||
).all(...rows.map((row) => row.id))
|
||
: [];
|
||
const weaponModSlotIconRows =
|
||
category.slug === 'weapons'
|
||
? await (
|
||
await database.prepare(
|
||
"SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='weapon_mod_slot'",
|
||
)
|
||
).all()
|
||
: [];
|
||
const gearRelations =
|
||
category.slug === 'gear' && rows.length
|
||
? await (
|
||
await database.prepare(`SELECT relation.*,icons.updated_at
|
||
FROM (
|
||
SELECT links.gear_id,'brand' AS kind,brands.id,brands.name,
|
||
NULL AS category,NULL AS cooldown_seconds,NULL AS duration_seconds,NULL AS max_stacks,
|
||
NULL AS tier_i,NULL AS tier_ii
|
||
FROM gear_brand_sets links JOIN brand_sets brands ON brands.id=links.brand_set_id
|
||
WHERE brands.active=1
|
||
UNION ALL
|
||
SELECT links.gear_id,'talent' AS kind,talents.id,talents.name,
|
||
talents.category,talents.cooldown_seconds,talents.duration_seconds,talents.max_stacks,
|
||
(SELECT description FROM talent_tiers tiers WHERE tiers.talent_id=talents.id AND tiers.tier='I') AS tier_i,
|
||
(SELECT description FROM talent_tiers tiers WHERE tiers.talent_id=talents.id AND tiers.tier='II') AS tier_ii
|
||
FROM gear_talents links JOIN talents ON talents.id=links.talent_id
|
||
WHERE talents.active=1
|
||
) relation
|
||
LEFT JOIN game_data_icons icons ON icons.entity_id=relation.id
|
||
AND icons.entity_type=CASE relation.kind WHEN 'brand' THEN 'brand_set' ELSE 'talent' END
|
||
WHERE relation.gear_id IN (${rows.map(() => '?').join(',')})
|
||
ORDER BY relation.kind,relation.name COLLATE NOCASE`)
|
||
).all(...rows.map((row) => row.id))
|
||
: [];
|
||
const gearBrandEffects =
|
||
category.slug === 'gear' &&
|
||
gearRelations.some((relation) => relation.kind === 'brand')
|
||
? await (
|
||
await database.prepare(`SELECT effects.brand_set_id,effects.pieces_required,effects.value,
|
||
attributes.name,attributes.unit FROM brand_set_effects effects
|
||
JOIN stat_attributes attributes ON attributes.id=effects.stat_attribute_id
|
||
WHERE effects.brand_set_id IN (${gearRelations
|
||
.filter((relation) => relation.kind === 'brand')
|
||
.map(() => '?')
|
||
.join(',')})
|
||
ORDER BY effects.pieces_required`)
|
||
).all(
|
||
...gearRelations
|
||
.filter((relation) => relation.kind === 'brand')
|
||
.map((relation) => relation.id),
|
||
)
|
||
: [];
|
||
const brandEffects =
|
||
category.slug === 'brands' && rows.length
|
||
? await (
|
||
await database.prepare(`SELECT effects.brand_set_id,effects.pieces_required,effects.value,
|
||
attributes.name,attributes.unit FROM brand_set_effects effects
|
||
JOIN stat_attributes attributes ON attributes.id=effects.stat_attribute_id
|
||
WHERE effects.brand_set_id IN (${rows.map(() => '?').join(',')}) ORDER BY effects.pieces_required`)
|
||
).all(...rows.map((row) => row.id))
|
||
: [];
|
||
const talentIds = [
|
||
...new Set(
|
||
category.slug === 'talents'
|
||
? rows.map((row) => String(row.id))
|
||
: category.slug === 'weapons'
|
||
? weaponRelations.map((relation) => String(relation.id))
|
||
: category.slug === 'gear'
|
||
? gearRelations
|
||
.filter((relation) => relation.kind === 'talent')
|
||
.map((relation) => String(relation.id))
|
||
: [],
|
||
),
|
||
];
|
||
const talentMechanics = talentIds.length
|
||
? await (
|
||
await database.prepare(`SELECT rules.source_id AS talent_id,rules.id AS rule_id,
|
||
clauses.id AS clause_id,clauses.label AS clause_label,clauses.event_kind,
|
||
clauses.conditions_json,clauses.reducer_json,clauses.cooldown_seconds,
|
||
outcomes.variant_key,outcomes.outcome_key,outcomes.application_kind,outcomes.effect_scope,
|
||
outcomes.value,outcomes.damage_basis,attributes.name AS attribute_name,attributes.unit AS attribute_unit,
|
||
outcomes.sort_order
|
||
FROM conditional_effect_rules rules
|
||
JOIN conditional_effect_clauses clauses ON clauses.rule_id=rules.id AND clauses.active=1
|
||
LEFT JOIN conditional_effect_outcomes outcomes ON outcomes.clause_id=clauses.id
|
||
LEFT JOIN stat_attributes attributes ON attributes.id=outcomes.stat_attribute_id
|
||
WHERE rules.source_type='talent' AND rules.configured=1
|
||
AND rules.source_id IN (${talentIds.map(() => '?').join(',')})
|
||
ORDER BY rules.source_id,clauses.sort_order,outcomes.outcome_key,outcomes.sort_order`)
|
||
).all(...talentIds)
|
||
: [];
|
||
const mechanicsFor = (talentId: unknown) =>
|
||
talentMechanics.filter(
|
||
(mechanic) => String(mechanic.talent_id) === String(talentId),
|
||
);
|
||
|
||
const items: DatabaseItem[] = rows.map((row) => {
|
||
switch (category.slug) {
|
||
case 'gear': {
|
||
const relations = gearRelations.filter(
|
||
(relation) => relation.gear_id === row.id,
|
||
);
|
||
const level50Health = gearBaseStatRange(
|
||
row.base_health_min,
|
||
row.base_health_max,
|
||
50,
|
||
);
|
||
const level50Armor =
|
||
row.base_armor_min === null
|
||
? null
|
||
: gearBaseStatRange(row.base_armor_min, row.base_armor_max, 50);
|
||
const relatedItems = (
|
||
kind: 'brand' | 'talent',
|
||
type: GameDataIconType,
|
||
) =>
|
||
relations
|
||
.filter((relation) => relation.kind === kind)
|
||
.map((relation) => {
|
||
const relationIcon = iconUrl(
|
||
type,
|
||
relation.id,
|
||
relation.updated_at,
|
||
);
|
||
const effects =
|
||
kind === 'brand'
|
||
? gearBrandEffects.filter(
|
||
(effect) => effect.brand_set_id === relation.id,
|
||
)
|
||
: [];
|
||
return {
|
||
id: String(relation.id),
|
||
name: String(relation.name),
|
||
iconUrl: relationIcon,
|
||
preview:
|
||
kind === 'brand'
|
||
? {
|
||
id: String(relation.id),
|
||
targetType: 'brand_set',
|
||
name: String(relation.name),
|
||
kicker: 'BRAND SET // EQUIPMENT',
|
||
iconUrl: relationIcon,
|
||
factLayout: 'stack' as const,
|
||
actions: [
|
||
{
|
||
label: 'FIND GEAR WITH THIS BRAND',
|
||
href: `/database/gear?brand=${encodeURIComponent(String(relation.id))}`,
|
||
},
|
||
],
|
||
facts: effects.map((effect) => ({
|
||
label: `${effect.pieces_required} PIECES`,
|
||
value: `${effect.name} · +${valueWithUnit(effect.value, effect.unit)}`,
|
||
})),
|
||
}
|
||
: talentCardItem(
|
||
{
|
||
...relation,
|
||
icon_updated_at: relation.updated_at,
|
||
},
|
||
mechanicsFor(relation.id),
|
||
),
|
||
};
|
||
});
|
||
const iconGroups = [
|
||
{
|
||
label: 'BRAND OPTIONS',
|
||
items: relatedItems('brand', 'brand_set'),
|
||
emptyLabel: 'NO BRANDS ASSIGNED',
|
||
},
|
||
...(['Backpack', 'Body Armor'].includes(String(row.slot))
|
||
? [
|
||
{
|
||
label: 'AVAILABLE TALENTS',
|
||
items: relatedItems('talent', 'talent'),
|
||
emptyLabel: 'NO TALENTS ASSIGNED',
|
||
},
|
||
]
|
||
: []),
|
||
];
|
||
return {
|
||
id: String(row.id),
|
||
name: String(row.name),
|
||
kicker: `${row.slot} // T${row.tier}`,
|
||
renderUrl: row.render_id
|
||
? managedImageUrl(String(row.render_id), row.render_updated_at)
|
||
: '',
|
||
gameIcon: gearIcon(row.slot),
|
||
headerBadges: [
|
||
`${Number(row.tier) === 0 ? 1 : 2} MOD SLOT${Number(row.tier) === 0 ? '' : 'S'}`,
|
||
],
|
||
headerBadge: `T${row.tier}`,
|
||
factLayout: 'stack' as const,
|
||
facts: [
|
||
{
|
||
label: 'BASE HEALTH',
|
||
value: `${level50Health.min.toLocaleString('en-US')}–${level50Health.max.toLocaleString('en-US')}`,
|
||
emphasis: 'primary' as const,
|
||
note: 'AT LEVEL 50',
|
||
},
|
||
...(level50Armor === null
|
||
? []
|
||
: [
|
||
{
|
||
label: 'BASE ARMOR',
|
||
value: `${level50Armor.min.toLocaleString('en-US')}–${level50Armor.max.toLocaleString('en-US')}`,
|
||
emphasis: 'primary' as const,
|
||
note: 'AT LEVEL 50',
|
||
},
|
||
]),
|
||
],
|
||
iconGroups,
|
||
};
|
||
}
|
||
case 'weapons': {
|
||
const relatedTalents = weaponRelations.filter(
|
||
(relation) => relation.weapon_id === row.id,
|
||
);
|
||
const modSlots = publicWeaponModSlots(
|
||
Number(row.native_tier),
|
||
[0, 1, 2].map((tier) => ({
|
||
tier,
|
||
slots: weaponModSlotRows
|
||
.filter(
|
||
(slot) =>
|
||
slot.weapon_id === row.id && Number(slot.tier) === tier,
|
||
)
|
||
.map((slot) => String(slot.slot_type)),
|
||
})),
|
||
).map((slot) => {
|
||
const definition = weaponModSlotDefinitionByName(slot.slot);
|
||
const icon = definition
|
||
? weaponModSlotIconRows.find(
|
||
(row) => row.entity_id === definition.id,
|
||
)
|
||
: null;
|
||
return {
|
||
...slot,
|
||
iconUrl:
|
||
definition && icon
|
||
? gameDataIconUrl(
|
||
'weapon_mod_slot',
|
||
definition.id,
|
||
icon.updated_at,
|
||
)
|
||
: '',
|
||
};
|
||
});
|
||
const talentItems = (slot: number) =>
|
||
relatedTalents
|
||
.filter((relation) => Number(relation.talent_slot) === slot)
|
||
.map((relation) => {
|
||
const talentIcon = iconUrl(
|
||
'talent',
|
||
relation.id,
|
||
relation.icon_updated_at,
|
||
);
|
||
return {
|
||
id: String(relation.id),
|
||
name: String(relation.name),
|
||
iconUrl: talentIcon,
|
||
preview: talentCardItem(
|
||
relation,
|
||
mechanicsFor(relation.id),
|
||
slot,
|
||
),
|
||
};
|
||
});
|
||
const level50Damage =
|
||
row.damage_min === null
|
||
? null
|
||
: weaponBaseRange(row.damage_min, row.damage_max, 50);
|
||
return {
|
||
id: String(row.id),
|
||
name: String(row.name),
|
||
kicker: `${row.category} // ${row.damage_type}`,
|
||
renderUrl: row.render_id
|
||
? managedImageUrl(String(row.render_id), row.render_updated_at)
|
||
: '',
|
||
gameIcon: weaponIcon(row.category),
|
||
headerBadges: [
|
||
`T${row.native_tier}`,
|
||
...(Number(row.is_exotic) ? ['EXOTIC'] : []),
|
||
],
|
||
headerMetaIcon: weaponIcon(row.damage_type),
|
||
factLayout: 'stack' as const,
|
||
facts: [
|
||
{
|
||
label: 'DAMAGE',
|
||
value: level50Damage
|
||
? `${level50Damage.min.toLocaleString('en-US')}–${level50Damage.max.toLocaleString('en-US')}`
|
||
: 'NOT CONFIGURED',
|
||
emphasis: 'primary' as const,
|
||
gameIcon: weaponIcon(row.damage_type),
|
||
note: 'AT LEVEL 50',
|
||
},
|
||
{
|
||
label: 'RATE / MAG',
|
||
value: `${number(row.rpm)} RPM · ${number(row.magazine)} MAG`,
|
||
},
|
||
{ label: 'RELOAD', value: valueWithUnit(row.reload_speed, 's') },
|
||
{
|
||
label: 'HANDLING',
|
||
value: '',
|
||
meters: [
|
||
{ label: 'ACCURACY', value: Number(row.accuracy) || 0 },
|
||
{ label: 'STABILITY', value: Number(row.stability) || 0 },
|
||
],
|
||
},
|
||
],
|
||
weaponModSlots: modSlots,
|
||
iconGroups: [
|
||
{
|
||
label: 'TALENT 1 POOL',
|
||
items: talentItems(1),
|
||
emptyLabel: 'NO TALENTS ASSIGNED',
|
||
},
|
||
{
|
||
label: 'TALENT 2 POOL',
|
||
items: talentItems(2),
|
||
emptyLabel: 'NO TALENTS ASSIGNED',
|
||
},
|
||
],
|
||
};
|
||
}
|
||
case 'weapon-mods':
|
||
return {
|
||
id: String(row.id),
|
||
name: String(row.name),
|
||
kicker: `${row.slot_type} // WEAPON MOD`,
|
||
renderUrl: row.render_id
|
||
? managedImageUrl(String(row.render_id), row.render_updated_at)
|
||
: '',
|
||
badges: row.exotic_weapon_id
|
||
? [{ label: 'EXOTIC', tone: 'exotic' }]
|
||
: [{ label: 'STANDARD → HIGH-END' }],
|
||
facts: [
|
||
{ label: 'LEVELS', value: '01–20' },
|
||
{
|
||
label: 'SECONDARY POOL',
|
||
value: row.exotic_weapon_id
|
||
? 'FIXED ATTRIBUTES'
|
||
: `${row.secondary_count} OPTIONS`,
|
||
},
|
||
{
|
||
label: 'MOD EFFECTS',
|
||
value: row.exotic_weapon_id ? 'FIXED' : String(row.effect_count),
|
||
},
|
||
],
|
||
lines: [
|
||
{
|
||
label: row.exotic_weapon_id
|
||
? 'ASSIGNED WEAPON'
|
||
: 'WEAPON CATEGORIES',
|
||
value: row.exotic_weapon_id
|
||
? list(row.exotic_weapon)
|
||
: list(row.categories).toUpperCase(),
|
||
},
|
||
],
|
||
};
|
||
case 'gear-mods':
|
||
return {
|
||
id: String(row.id),
|
||
name: String(row.name),
|
||
kicker: 'GEAR MOD // STANDARD QUALITIES',
|
||
renderUrl: row.render_id
|
||
? managedImageUrl(String(row.render_id), row.render_updated_at)
|
||
: '',
|
||
badges: [{ label: 'STANDARD → HIGH-END' }],
|
||
facts: [
|
||
{ label: 'LEVELS', value: '01–20' },
|
||
{ label: 'PRIMARY OPTIONS', value: '5' },
|
||
{
|
||
label: 'SECONDARY POOL',
|
||
value: `${row.secondary_count} OPTIONS`,
|
||
},
|
||
],
|
||
lines: [
|
||
{
|
||
label: 'OBSERVED PRIMARY ENVELOPE',
|
||
value: `${number(row.level_1_floor)} → ${number(row.level_20_ceiling)}`,
|
||
},
|
||
],
|
||
};
|
||
case 'os-protocols':
|
||
return {
|
||
id: String(row.id),
|
||
name: String(row.name),
|
||
kicker: `${row.primary_attribute_type} // OS PROTOCOL`,
|
||
renderUrl: row.render_id
|
||
? managedImageUrl(String(row.render_id), row.render_updated_at)
|
||
: '',
|
||
iconUrl: iconUrl('os_protocol', row.id, row.icon_updated_at),
|
||
topBorderTone: String(row.quality).toLowerCase().replace('-', ''),
|
||
headerBadge: String(row.quality).toUpperCase(),
|
||
headerBadgeTone: String(row.quality).toLowerCase().replace('-', ''),
|
||
factLayout: 'stack' as const,
|
||
facts: [],
|
||
protocolPreview: {
|
||
primaryStat: String(row.primary_stat ?? 'NOT CONFIGURED'),
|
||
primaryUnit: String(row.primary_unit ?? ''),
|
||
primaryBaseValue: Number(row.base_value),
|
||
talentDescription: row.talent_description
|
||
? String(row.talent_description)
|
||
: undefined,
|
||
talentEffects: protocolTalentEffects
|
||
.filter((effect) => effect.protocol_id === row.id)
|
||
.map((effect) => ({
|
||
baseValue: Number(effect.base_value),
|
||
unit: String(effect.unit ?? ''),
|
||
})),
|
||
},
|
||
description: row.talent_description
|
||
? String(row.talent_description)
|
||
: undefined,
|
||
};
|
||
case 'talents':
|
||
return talentCardItem(row, mechanicsFor(row.id));
|
||
case 'brands': {
|
||
const effects = brandEffects.filter(
|
||
(effect) => effect.brand_set_id === row.id,
|
||
);
|
||
return {
|
||
id: String(row.id),
|
||
name: String(row.name),
|
||
kicker: 'BRAND SET // EQUIPMENT',
|
||
renderUrl: row.render_id
|
||
? managedImageUrl(String(row.render_id), row.render_updated_at)
|
||
: '',
|
||
iconUrl: iconUrl('brand_set', row.id, row.icon_updated_at),
|
||
factLayout: 'stack' as const,
|
||
actions: [
|
||
{
|
||
label: 'FIND GEAR WITH THIS BRAND',
|
||
href: `/database/gear?brand=${encodeURIComponent(String(row.id))}`,
|
||
},
|
||
],
|
||
facts: effects.map((effect) => ({
|
||
label: `${effect.pieces_required} PIECES`,
|
||
value: `${effect.name} · +${valueWithUnit(effect.value, effect.unit)}`,
|
||
})),
|
||
};
|
||
}
|
||
case 'skill-chips':
|
||
return {
|
||
id: String(row.id),
|
||
name: String(row.name),
|
||
kicker: 'SKILL CHIP SET // 2–3 PIECES',
|
||
renderUrl: row.render_id
|
||
? managedImageUrl(String(row.render_id), row.render_updated_at)
|
||
: '',
|
||
iconUrl: iconUrl('skill_chip', row.id, row.icon_updated_at),
|
||
badges: [{ label: 'SUPERIOR' }, { label: 'HIGH-END' }],
|
||
facts: [
|
||
{
|
||
label: '2-PIECE BONUS',
|
||
value: row.bonus_stat
|
||
? `${row.bonus_stat} · ${range(row.min_value, row.max_value, row.bonus_unit)}`
|
||
: 'NOT CONFIGURED',
|
||
},
|
||
],
|
||
lines: [
|
||
{
|
||
label: '3-PIECE TALENT',
|
||
value: list(row.talent_name, 'NOT CONFIGURED'),
|
||
},
|
||
],
|
||
description: row.talent_description
|
||
? String(row.talent_description)
|
||
: undefined,
|
||
};
|
||
case 'specializations':
|
||
return {
|
||
id: String(row.id),
|
||
name: String(row.name),
|
||
kicker: 'SPECIALIZATION // FOCUS PATHS',
|
||
renderUrl: row.render_id
|
||
? managedImageUrl(String(row.render_id), row.render_updated_at)
|
||
: '',
|
||
iconUrl: iconUrl('specialization', row.id, row.icon_updated_at),
|
||
badges: Number(row.ready_focus_count)
|
||
? [{ label: 'BUILDER READY' }]
|
||
: [],
|
||
facts: [
|
||
{ label: 'FOCUS PATHS', value: String(row.focus_count) },
|
||
{ label: 'ABILITIES', value: String(row.ability_count) },
|
||
{ label: 'TREE NODES', value: String(row.node_count) },
|
||
],
|
||
lines: [
|
||
{ label: 'FOCUS PATHS', value: list(row.focus_names) },
|
||
{ label: 'ABILITIES', value: list(row.ability_names) },
|
||
],
|
||
description: row.description ? String(row.description) : undefined,
|
||
};
|
||
}
|
||
}) as DatabaseItem[];
|
||
|
||
const previewItems = items
|
||
.flatMap((item) => item.iconGroups ?? [])
|
||
.flatMap((group) => group.items)
|
||
.map((related) => related.preview)
|
||
.filter((preview): preview is DatabaseItem => Boolean(preview?.targetType));
|
||
const previewsByType = new Map<string, DatabaseItem[]>();
|
||
for (const preview of previewItems) {
|
||
const previews = previewsByType.get(preview.targetType!) ?? [];
|
||
previews.push(preview);
|
||
previewsByType.set(preview.targetType!, previews);
|
||
}
|
||
await Promise.all(
|
||
[...previewsByType.entries()].map(async ([type, previews]) => {
|
||
const uniqueIds = [...new Set(previews.map((preview) => preview.id))];
|
||
const summaries = await gameDataDiscussionSummary(
|
||
type as NonNullable<DatabaseItem['targetType']>,
|
||
uniqueIds,
|
||
);
|
||
for (const preview of previews) {
|
||
preview.pinnedCommunityNoteHtml =
|
||
summaries.get(preview.id)?.pinnedBodyHtml ?? undefined;
|
||
}
|
||
}),
|
||
);
|
||
|
||
const target = gameDataTarget(category.slug);
|
||
const discussionSummary = await gameDataDiscussionSummary(
|
||
target.type,
|
||
items.map((item) => item.id),
|
||
);
|
||
const selectedDiscussionId = url.searchParams.get('discussion')?.trim() ?? '';
|
||
const selectedItem =
|
||
items.find((item) => item.id === selectedDiscussionId) ?? null;
|
||
for (const item of items) {
|
||
const summary = discussionSummary.get(item.id);
|
||
item.targetType = target.type;
|
||
item.correctionHref = gameDataCorrectionHref(category.slug, item.id);
|
||
item.discussionCount = summary?.count ?? 0;
|
||
item.pinnedCommunityNoteHtml = summary?.pinnedBodyHtml ?? undefined;
|
||
item.discussionHref = `/database/${category.slug}?${new URLSearchParams({
|
||
...Object.fromEntries(url.searchParams),
|
||
discussion: item.id,
|
||
}).toString()}#discussion`;
|
||
}
|
||
const viewer = await getSessionUser(cookies);
|
||
const permissions = viewer ? await getPermissions(viewer) : [];
|
||
let discussionComments: Array<Record<string, unknown>> = [];
|
||
if (selectedItem) {
|
||
const discussionId = gameDataDiscussionId(target.type, selectedItem.id);
|
||
const rawComments = await (
|
||
await database.prepare(`SELECT comments.id,comments.author_id,comments.parent_id,
|
||
comments.body_markdown,comments.status,comments.created_at,comments.removal_reason,thread.pinned_comment_id,
|
||
COALESCE(profile.display_name,users.username,'Deleted Agent') AS display_name,profile.public_slug,
|
||
avatar.source AS avatar_source,avatar.value AS avatar_value,avatar.updated_at AS avatar_updated_at
|
||
FROM comment_threads thread JOIN comments ON comments.thread_id=thread.id
|
||
LEFT JOIN users ON users.id=comments.author_id LEFT JOIN user_profiles profile ON profile.user_id=users.id
|
||
LEFT JOIN user_avatars avatar ON avatar.user_id=users.id
|
||
WHERE thread.target_type='game_data' AND thread.target_id=? AND comments.status IN ('visible','removed')
|
||
ORDER BY CASE WHEN comments.id=thread.pinned_comment_id THEN 0 ELSE 1 END,
|
||
comments.created_at DESC LIMIT 100`)
|
||
).all(discussionId);
|
||
const orderedComments = orderProfileCommentThread(
|
||
rawComments.map((comment) => {
|
||
const status =
|
||
String(comment.status) === 'removed'
|
||
? ('removed' as const)
|
||
: ('visible' as const);
|
||
return {
|
||
id: String(comment.id),
|
||
parentId: comment.parent_id ? String(comment.parent_id) : null,
|
||
status,
|
||
createdAt: String(comment.created_at),
|
||
bodyHtml:
|
||
status === 'visible'
|
||
? renderUserMarkdown(comment.body_markdown)
|
||
: '',
|
||
isPinned:
|
||
String(comment.id) === String(comment.pinned_comment_id ?? ''),
|
||
removalReason: comment.removal_reason
|
||
? String(comment.removal_reason)
|
||
: null,
|
||
author: {
|
||
id: String(comment.author_id),
|
||
displayName: String(comment.display_name),
|
||
publicSlug: comment.public_slug
|
||
? String(comment.public_slug)
|
||
: null,
|
||
avatarUrl: avatarUrl(
|
||
comment.avatar_source,
|
||
comment.avatar_value,
|
||
String(comment.author_id),
|
||
comment.avatar_updated_at,
|
||
),
|
||
roles: [] as string[],
|
||
},
|
||
};
|
||
}),
|
||
);
|
||
const pinnedCommentId = rawComments.find(
|
||
(comment) => comment.pinned_comment_id,
|
||
)?.pinned_comment_id;
|
||
const threadedComments = prioritizeCommentConversation(
|
||
orderedComments,
|
||
pinnedCommentId ? String(pinnedCommentId) : null,
|
||
);
|
||
const rootIds = [
|
||
...new Set(threadedComments.map((comment) => comment.rootId)),
|
||
];
|
||
const followedRootIds =
|
||
viewer && rootIds.length
|
||
? new Set(
|
||
(
|
||
await (
|
||
await database.prepare(`SELECT root_comment_id FROM comment_thread_follows
|
||
WHERE user_id=? AND root_comment_id IN (${rootIds.map(() => '?').join(',')})`)
|
||
).all(viewer.id, ...rootIds)
|
||
).map((row) => String(row.root_comment_id)),
|
||
)
|
||
: new Set<string>();
|
||
discussionComments = threadedComments.map((comment) => ({
|
||
...comment,
|
||
isFollowing: followedRootIds.has(comment.rootId),
|
||
canReport: Boolean(viewer) && viewer?.id !== comment.author.id,
|
||
canDelete:
|
||
Boolean(viewer) &&
|
||
(viewer?.id === comment.author.id ||
|
||
permissions.includes('*') ||
|
||
permissions.includes('community.comments.moderate')),
|
||
}));
|
||
}
|
||
|
||
return {
|
||
category,
|
||
items,
|
||
pagination,
|
||
search,
|
||
filters,
|
||
selectedItem,
|
||
discussionComments,
|
||
viewer: {
|
||
id: viewer?.id ?? null,
|
||
canComment: Boolean(viewer),
|
||
canPin:
|
||
permissions.includes('*') ||
|
||
permissions.includes('community.comments.moderate'),
|
||
},
|
||
};
|
||
};
|
||
|
||
export const actions: Actions = {
|
||
commentDiscussion: async ({ params, cookies, request }) => {
|
||
const commenter = await getSessionUser(cookies);
|
||
const form = await request.formData();
|
||
const parentId = String(form.get('parentId') ?? '').trim() || null;
|
||
const failure = (status: number, discussionError: string) =>
|
||
fail(status, {
|
||
discussionError,
|
||
discussionParentId: parentId,
|
||
discussionResultToken: randomUUID(),
|
||
});
|
||
if (!commenter) return failure(401, 'Sign in to add a community note.');
|
||
const category = databaseCategory(params.category);
|
||
if (!category) return failure(404, 'Database category not found.');
|
||
const target = gameDataTarget(category.slug);
|
||
const targetId = String(form.get('itemId') ?? '').trim();
|
||
if (!targetId || !(await gameDataTargetExists(target.type, targetId)))
|
||
return failure(404, 'That active data record is no longer available.');
|
||
const body = normalizeUserMarkdown(form.get('body'), 2_000);
|
||
if (body === null || Array.from(body).length < 2)
|
||
return failure(400, 'Notes must contain 2–2,000 visible characters.');
|
||
const database = await getDatabase();
|
||
const discussionId = gameDataDiscussionId(target.type, targetId);
|
||
const now = new Date().toISOString();
|
||
await (
|
||
await database.prepare(
|
||
`INSERT OR IGNORE INTO comment_threads(id,target_type,target_id,created_at) VALUES(?,'game_data',?,?)`,
|
||
)
|
||
).run(randomUUID(), discussionId, now);
|
||
const thread = await (
|
||
await database.prepare(
|
||
"SELECT id FROM comment_threads WHERE target_type='game_data' AND target_id=?",
|
||
)
|
||
).get(discussionId);
|
||
if (!thread?.id) return failure(500, 'The discussion could not be opened.');
|
||
if (parentId) {
|
||
const parent = await (
|
||
await database.prepare(
|
||
`SELECT id FROM comments WHERE id=? AND thread_id=? AND status='visible'`,
|
||
)
|
||
).get(parentId, thread.id);
|
||
if (!parent)
|
||
return failure(404, 'That note is no longer available for replies.');
|
||
}
|
||
const policy = await getCommunityModerationPolicy();
|
||
const moderation = await moderateCommunityText({
|
||
contentType: 'comment',
|
||
text: body,
|
||
policy,
|
||
});
|
||
const commentId = randomUUID();
|
||
const status =
|
||
moderation.decision === 'approved' ? 'visible' : 'pending_review';
|
||
await (
|
||
await database.prepare(
|
||
`INSERT INTO comments(id,thread_id,author_id,parent_id,body_markdown,status,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)`,
|
||
)
|
||
).run(commentId, thread.id, commenter.id, parentId, body, status, now, now);
|
||
await recordCommunityModerationScan({
|
||
contentType: 'comment',
|
||
contentId: commentId,
|
||
authorId: commenter.id,
|
||
result: moderation,
|
||
});
|
||
if (status === 'visible' && parentId) {
|
||
const rows = await (
|
||
await database.prepare(`SELECT id,parent_id,status,created_at FROM comments
|
||
WHERE thread_id=? AND status IN ('visible','removed')`)
|
||
).all(thread.id);
|
||
const conversation = orderProfileCommentThread(
|
||
rows.map((row) => ({
|
||
id: String(row.id),
|
||
parentId: row.parent_id ? String(row.parent_id) : null,
|
||
status:
|
||
String(row.status) === 'removed'
|
||
? ('removed' as const)
|
||
: ('visible' as const),
|
||
createdAt: String(row.created_at),
|
||
})),
|
||
).find((comment) => comment.id === parentId);
|
||
if (conversation) {
|
||
const followers = await (
|
||
await database.prepare(`SELECT user_id FROM comment_thread_follows
|
||
WHERE root_comment_id=? AND user_id<>?`)
|
||
).all(conversation.rootId, commenter.id);
|
||
const link = `/database/${category.slug}?discussion=${encodeURIComponent(targetId)}#discussion-comment-${encodeURIComponent(commentId)}`;
|
||
await Promise.all(
|
||
followers.map((follower) =>
|
||
createNotification({
|
||
userId: String(follower.user_id),
|
||
category: 'comments',
|
||
source: 'community',
|
||
title: 'New database discussion reply',
|
||
body: `${commenter.displayName} replied in the discussion for this database record.`,
|
||
link,
|
||
actions: [{ label: 'VIEW REPLY', href: link }],
|
||
}),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
return {
|
||
discussionSaved: true,
|
||
discussionReviewQueued: status === 'pending_review',
|
||
discussionParentId: parentId,
|
||
discussionResultToken: randomUUID(),
|
||
};
|
||
},
|
||
setPinnedCommunityNote: async ({ params, cookies, request }) => {
|
||
const operator = await getSessionUser(cookies);
|
||
if (!operator)
|
||
return fail(401, {
|
||
discussionError: 'Sign in to manage Community Notes.',
|
||
discussionResultToken: randomUUID(),
|
||
});
|
||
const permissions = await getPermissions(operator);
|
||
if (
|
||
!permissions.includes('*') &&
|
||
!permissions.includes('community.comments.moderate')
|
||
)
|
||
return fail(403, {
|
||
discussionError: 'Community moderation permission required.',
|
||
discussionResultToken: randomUUID(),
|
||
});
|
||
const category = databaseCategory(params.category);
|
||
if (!category)
|
||
return fail(404, {
|
||
discussionError: 'Database category not found.',
|
||
discussionResultToken: randomUUID(),
|
||
});
|
||
const form = await request.formData();
|
||
const targetId = String(form.get('itemId') ?? '').trim();
|
||
const commentId = String(form.get('commentId') ?? '').trim();
|
||
const intent = String(form.get('intent') ?? '');
|
||
if (!['pin', 'unpin'].includes(intent))
|
||
return fail(400, {
|
||
discussionError: 'Choose whether to pin or unpin this note.',
|
||
discussionResultToken: randomUUID(),
|
||
});
|
||
const target = gameDataTarget(category.slug);
|
||
const database = await getDatabase();
|
||
const discussionId = gameDataDiscussionId(target.type, targetId);
|
||
const thread = await (
|
||
await database.prepare(
|
||
"SELECT id,pinned_comment_id FROM comment_threads WHERE target_type='game_data' AND target_id=?",
|
||
)
|
||
).get(discussionId);
|
||
if (!thread)
|
||
return fail(404, {
|
||
discussionError: 'Discussion not found.',
|
||
discussionResultToken: randomUUID(),
|
||
});
|
||
if (intent === 'pin') {
|
||
const comment = await (
|
||
await database.prepare(
|
||
"SELECT id FROM comments WHERE id=? AND thread_id=? AND parent_id IS NULL AND status='visible'",
|
||
)
|
||
).get(commentId, thread.id);
|
||
if (!comment)
|
||
return fail(404, {
|
||
discussionError:
|
||
'Only a visible root message in this discussion can be pinned.',
|
||
discussionResultToken: randomUUID(),
|
||
});
|
||
}
|
||
await (
|
||
await database.prepare(
|
||
'UPDATE comment_threads SET pinned_comment_id=? WHERE id=?',
|
||
)
|
||
).run(intent === 'pin' ? commentId : null, thread.id);
|
||
await recordIsacAudit({
|
||
actorId: operator.id,
|
||
category: 'community_moderation',
|
||
action:
|
||
intent === 'pin' ? 'community_note.pinned' : 'community_note.unpinned',
|
||
entityId: thread.id,
|
||
before: {
|
||
targetType: target.type,
|
||
targetId,
|
||
pinnedCommentId: thread.pinned_comment_id ?? null,
|
||
},
|
||
after: {
|
||
targetType: target.type,
|
||
targetId,
|
||
pinnedCommentId: intent === 'pin' ? commentId : null,
|
||
},
|
||
});
|
||
return {
|
||
discussionPinned: intent === 'pin',
|
||
discussionResultToken: randomUUID(),
|
||
};
|
||
},
|
||
toggleDiscussionFollow: async ({ params, cookies, request }) => {
|
||
const viewer = await getSessionUser(cookies);
|
||
const form = await request.formData();
|
||
const itemId = String(form.get('itemId') ?? '').trim();
|
||
const commentId = String(form.get('commentId') ?? '').trim();
|
||
const intent = String(form.get('intent') ?? '').trim();
|
||
const failure = (status: number, message: string) =>
|
||
fail(status, {
|
||
discussionError: message,
|
||
discussionResultToken: randomUUID(),
|
||
});
|
||
if (!viewer) return failure(401, 'Sign in to follow a conversation.');
|
||
if (!commentId || !['follow', 'unfollow'].includes(intent))
|
||
return failure(400, 'Choose a conversation and follow preference.');
|
||
const category = databaseCategory(params.category);
|
||
if (!category) return failure(404, 'Database category not found.');
|
||
const target = gameDataTarget(category.slug);
|
||
const database = await getDatabase();
|
||
const discussionId = gameDataDiscussionId(target.type, itemId);
|
||
const rows = await (
|
||
await database.prepare(`SELECT comments.id,comments.parent_id,comments.status,comments.created_at
|
||
FROM comments JOIN comment_threads thread ON thread.id=comments.thread_id
|
||
WHERE thread.target_type='game_data' AND thread.target_id=? AND comments.status IN ('visible','removed')`)
|
||
).all(discussionId);
|
||
const conversation = orderProfileCommentThread(
|
||
rows.map((row) => ({
|
||
id: String(row.id),
|
||
parentId: row.parent_id ? String(row.parent_id) : null,
|
||
status:
|
||
String(row.status) === 'removed'
|
||
? ('removed' as const)
|
||
: ('visible' as const),
|
||
createdAt: String(row.created_at),
|
||
})),
|
||
).find((comment) => comment.id === commentId);
|
||
if (!conversation)
|
||
return failure(404, 'That conversation is no longer available.');
|
||
if (intent === 'follow') {
|
||
await (
|
||
await database.prepare(`INSERT OR IGNORE INTO comment_thread_follows(user_id,root_comment_id,created_at)
|
||
VALUES(?,?,?)`)
|
||
).run(viewer.id, conversation.rootId, new Date().toISOString());
|
||
} else {
|
||
await (
|
||
await database.prepare(
|
||
'DELETE FROM comment_thread_follows WHERE user_id=? AND root_comment_id=?',
|
||
)
|
||
).run(viewer.id, conversation.rootId);
|
||
}
|
||
return {
|
||
discussionFollowSaved: true,
|
||
discussionFollowing: intent === 'follow',
|
||
discussionResultToken: randomUUID(),
|
||
};
|
||
},
|
||
reportContent: async ({ params, cookies, request }) => {
|
||
const reporter = await getSessionUser(cookies);
|
||
const form = await request.formData();
|
||
const itemId = String(form.get('itemId') ?? '').trim();
|
||
const contentType = String(form.get('contentType') ?? '');
|
||
const contentId = String(form.get('contentId') ?? '').trim();
|
||
const reportCategory = String(form.get('category') ?? '');
|
||
const details = String(form.get('details') ?? '')
|
||
.normalize('NFKC')
|
||
.trim();
|
||
const failure = (status: number, message: string) =>
|
||
fail(status, {
|
||
userReportError: message,
|
||
userReportContentId: contentId,
|
||
userReportContentType: contentType,
|
||
userReportResultToken: randomUUID(),
|
||
});
|
||
if (!reporter)
|
||
return failure(401, 'Sign in to request an Oversight review.');
|
||
if (contentType !== 'comment')
|
||
return failure(400, 'Choose reportable content.');
|
||
if (
|
||
![
|
||
'spam',
|
||
'harassment',
|
||
'hate',
|
||
'sexual',
|
||
'violence',
|
||
'self_harm',
|
||
'privacy',
|
||
'other',
|
||
].includes(reportCategory)
|
||
)
|
||
return failure(
|
||
400,
|
||
'Choose the policy concern that best fits this report.',
|
||
);
|
||
if (
|
||
details.length > 500 ||
|
||
/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(details)
|
||
)
|
||
return failure(
|
||
400,
|
||
'Report details may contain up to 500 visible characters.',
|
||
);
|
||
const category = databaseCategory(params.category);
|
||
if (!category) return failure(404, 'Database category not found.');
|
||
const target = gameDataTarget(category.slug);
|
||
const discussionId = gameDataDiscussionId(target.type, itemId);
|
||
const database = await getDatabase();
|
||
const comment = await (
|
||
await database.prepare(`SELECT comments.author_id FROM comments
|
||
JOIN comment_threads thread ON thread.id=comments.thread_id
|
||
WHERE comments.id=? AND comments.status='visible' AND thread.target_type='game_data' AND thread.target_id=?`)
|
||
).get(contentId, discussionId);
|
||
if (!comment) return failure(404, 'That comment is no longer available.');
|
||
if (String(comment.author_id) === reporter.id)
|
||
return failure(400, 'You cannot report your own comment.');
|
||
const report = await createUserContentReport({
|
||
contentType: 'comment',
|
||
contentId,
|
||
reporterId: reporter.id,
|
||
category: reportCategory as
|
||
| 'spam'
|
||
| 'harassment'
|
||
| 'hate'
|
||
| 'sexual'
|
||
| 'violence'
|
||
| 'self_harm'
|
||
| 'privacy'
|
||
| 'other',
|
||
details,
|
||
});
|
||
if (!report)
|
||
return failure(
|
||
409,
|
||
'You already have a pending report for this content.',
|
||
);
|
||
return {
|
||
userReportSaved: true,
|
||
userReportContentId: contentId,
|
||
userReportContentType: contentType,
|
||
userReportResultToken: randomUUID(),
|
||
};
|
||
},
|
||
deleteDiscussionComment: async ({ params, cookies, request }) => {
|
||
const viewer = await getSessionUser(cookies);
|
||
const form = await request.formData();
|
||
const itemId = String(form.get('itemId') ?? '').trim();
|
||
const commentId = String(form.get('commentId') ?? '').trim();
|
||
const failure = (status: number, message: string) =>
|
||
fail(status, {
|
||
discussionError: message,
|
||
discussionResultToken: randomUUID(),
|
||
});
|
||
if (!viewer) return failure(401, 'Sign in to remove a comment.');
|
||
const category = databaseCategory(params.category);
|
||
if (!category) return failure(404, 'Database category not found.');
|
||
const target = gameDataTarget(category.slug);
|
||
const discussionId = gameDataDiscussionId(target.type, itemId);
|
||
const database = await getDatabase();
|
||
const comment = await (
|
||
await database.prepare(`SELECT comments.id,comments.author_id,comments.body_markdown,comments.parent_id
|
||
FROM comments JOIN comment_threads thread ON thread.id=comments.thread_id
|
||
WHERE comments.id=? AND comments.status='visible' AND thread.target_type='game_data' AND thread.target_id=?`)
|
||
).get(commentId, discussionId);
|
||
if (!comment) return failure(404, 'That comment is no longer available.');
|
||
const permissions = await getPermissions(viewer);
|
||
const isModerator =
|
||
permissions.includes('*') ||
|
||
permissions.includes('community.comments.moderate');
|
||
if (String(comment.author_id) !== viewer.id && !isModerator)
|
||
return failure(403, 'You can remove only your own comment.');
|
||
const result = await removePublicComment({
|
||
database,
|
||
commentId,
|
||
removedBy: viewer.id,
|
||
removalReason:
|
||
isModerator && String(comment.author_id) !== viewer.id
|
||
? 'Removed by Community Oversight'
|
||
: 'Removed by poster',
|
||
});
|
||
if (!result.changed)
|
||
return failure(409, 'That comment changed before it could be removed.');
|
||
if (isModerator && String(comment.author_id) !== viewer.id) {
|
||
await recordIsacAudit({
|
||
actorId: viewer.id,
|
||
category: 'community_moderation',
|
||
action: 'comment.removed',
|
||
entityId: commentId,
|
||
before: {
|
||
targetType: target.type,
|
||
targetId: itemId,
|
||
authorId: comment.author_id,
|
||
bodyMarkdown: comment.body_markdown,
|
||
parentId: comment.parent_id,
|
||
},
|
||
after: {
|
||
retainedPlaceholder: result.retainedPlaceholder,
|
||
removalReason: 'Removed by Community Oversight',
|
||
},
|
||
});
|
||
}
|
||
return { discussionDeleted: true, discussionResultToken: randomUUID() };
|
||
},
|
||
};
|