Allow entities to use full https:// URLs as their image path Validate external URLs to prevent http://, data:, or credentials Update API responses and frontend components to handle external sources
9974 lines
343 KiB
TypeScript
9974 lines
343 KiB
TypeScript
import { parseIdList, parseMatchMode, sqlForRelationFilter } from './filter.ts';
|
|
import { pool, query, queryOne } from './db.ts';
|
|
import {
|
|
isExternalImageUrl,
|
|
isUploadImagePath,
|
|
linkEntityImageUpload,
|
|
listEntityImageUploads,
|
|
uploadImageUrl,
|
|
uploadPublicBaseUrl
|
|
} from './uploads.ts';
|
|
import { Buffer } from 'node:buffer';
|
|
import { readFile } from 'node:fs/promises';
|
|
import { dirname, resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import type { PoolClient, QueryResultRow } from 'pg';
|
|
import {
|
|
requestAiModerationReview,
|
|
type AiModerationStatus
|
|
} from './aiModeration.ts';
|
|
import { createLifePostReactionNotification, createUserFollowNotification } from './notifications.ts';
|
|
import {
|
|
createThreadWebSocketTicket,
|
|
publishThreadMessageCreated,
|
|
publishThreadMessageModeration,
|
|
publishThreadReactionUpdated,
|
|
publishThreadReadUpdated
|
|
} from './threadsRealtime.ts';
|
|
|
|
type QueryValue = string | string[] | undefined;
|
|
|
|
type QueryParams = Record<string, QueryValue>;
|
|
type ListPage<T> = {
|
|
items: T[];
|
|
nextCursor: string | null;
|
|
hasMore: boolean;
|
|
};
|
|
export type ThreadReactionType = string;
|
|
export type ThreadReactionCounts = Record<string, number>;
|
|
export type ThreadChannelTag = { id: number; name: string; sortOrder: number };
|
|
export type ThreadChannel = {
|
|
id: number;
|
|
name: string;
|
|
allowUserThreads: boolean;
|
|
sortOrder: number;
|
|
tags: ThreadChannelTag[];
|
|
languages: Array<{ code: string; name: string }>;
|
|
unreadCount: number;
|
|
};
|
|
export type ThreadSummary = {
|
|
id: number;
|
|
channelId: number;
|
|
title: string;
|
|
languageCode: string;
|
|
tags: ThreadChannelTag[];
|
|
locked: boolean;
|
|
messageCount: number;
|
|
lastActiveAt: Date;
|
|
createdAt: Date;
|
|
author: { id: number; displayName: string } | null;
|
|
reactionCounts: ThreadReactionCounts;
|
|
myReactions: ThreadReactionType[];
|
|
followed: boolean;
|
|
unread: boolean;
|
|
};
|
|
export type ThreadMessage = {
|
|
id: number;
|
|
threadId: number;
|
|
body: string;
|
|
moderationStatus: AiModerationStatus;
|
|
moderationLanguageCode: string | null;
|
|
moderationReason: string | null;
|
|
createdAt: Date;
|
|
updatedAt: Date;
|
|
author: { id: number; displayName: string } | null;
|
|
reactionCounts: ThreadReactionCounts;
|
|
myReactions: ThreadReactionType[];
|
|
};
|
|
export type ThreadMessagesPage = {
|
|
items: ThreadMessage[];
|
|
beforeCursor: string | null;
|
|
hasMoreBefore: boolean;
|
|
};
|
|
export type ThreadsPage = ListPage<ThreadSummary>;
|
|
|
|
type DbClient = PoolClient;
|
|
type DataToolScope = 'pokemon' | 'habitats' | 'items' | 'artifacts' | 'recipes' | 'checklist';
|
|
type DataToolScopeSummary = {
|
|
scope: DataToolScope;
|
|
count: number;
|
|
};
|
|
type DataToolRows = Record<string, unknown>[];
|
|
type DataToolScopeData = Record<string, DataToolRows | undefined>;
|
|
type DataToolsBundle = {
|
|
version: 1;
|
|
exportedAt: string;
|
|
scopes: DataToolScope[];
|
|
data: Partial<Record<DataToolScope, DataToolScopeData>>;
|
|
};
|
|
type GlobalSearchGroupType =
|
|
| 'pokemon'
|
|
| 'habitats'
|
|
| 'items'
|
|
| 'ancient-artifacts'
|
|
| 'recipes'
|
|
| 'daily-checklist'
|
|
| 'life'
|
|
| 'users';
|
|
type GlobalSearchItem = {
|
|
id: number;
|
|
type: GlobalSearchGroupType;
|
|
title: string;
|
|
url: string;
|
|
summary: string | null;
|
|
meta: string | null;
|
|
image: EntityImageValue | PokemonImage | null;
|
|
};
|
|
type GlobalSearchGroup = {
|
|
type: GlobalSearchGroupType;
|
|
items: GlobalSearchItem[];
|
|
};
|
|
type GlobalSearchResults = {
|
|
query: string;
|
|
groups: GlobalSearchGroup[];
|
|
};
|
|
|
|
type TranslationField = 'name' | 'title' | 'details' | 'genus' | 'effect' | 'mosslaxEffect';
|
|
type TranslationInput = Record<string, Partial<Record<TranslationField, unknown>>>;
|
|
type EntityType =
|
|
| 'pokemon'
|
|
| 'pokemon-types'
|
|
| 'skills'
|
|
| 'environments'
|
|
| 'favorite-things'
|
|
| 'acquisition-methods'
|
|
| 'items'
|
|
| 'ancient-artifacts'
|
|
| 'maps'
|
|
| 'habitats'
|
|
| 'daily-checklist-items'
|
|
| 'game-versions'
|
|
| 'dish-categories'
|
|
| 'dish-flavors'
|
|
| 'dishes';
|
|
|
|
type ConfigType =
|
|
| 'pokemon-types'
|
|
| 'skills'
|
|
| 'environments'
|
|
| 'favorite-things'
|
|
| 'acquisition-methods'
|
|
| 'maps'
|
|
| 'game-versions'
|
|
| 'dish-flavors';
|
|
|
|
type ConfigDefinition = {
|
|
table: string;
|
|
entityType: EntityType;
|
|
hasItemDrop?: boolean;
|
|
hasTrading?: boolean;
|
|
hasChangeLog?: boolean;
|
|
hasDescription?: boolean;
|
|
oppositeColumn?: string;
|
|
};
|
|
type SortableContentType = 'items' | 'ancient-artifacts' | 'recipes' | 'habitats';
|
|
type SortableContentDefinition = {
|
|
table: string;
|
|
entityType: SortableContentType;
|
|
};
|
|
|
|
type IdQuantity = {
|
|
itemId: number;
|
|
quantity: number;
|
|
};
|
|
|
|
type SkillItemDrop = {
|
|
skillId: number;
|
|
itemId: number;
|
|
};
|
|
|
|
type PokemonStats = {
|
|
hp: number;
|
|
attack: number;
|
|
defense: number;
|
|
specialAttack: number;
|
|
specialDefense: number;
|
|
speed: number;
|
|
};
|
|
|
|
type PokemonImage = {
|
|
path: string;
|
|
url: string;
|
|
style: string;
|
|
version: string;
|
|
variant: string;
|
|
description: string;
|
|
source?: 'sprite' | 'upload' | 'external';
|
|
};
|
|
|
|
type EntityImageValue = {
|
|
path: string;
|
|
url: string;
|
|
};
|
|
|
|
type PokemonImageCandidate = Omit<PokemonImage, 'url'>;
|
|
|
|
type PokemonImageOptionsResult = {
|
|
id: number;
|
|
identifier: string;
|
|
images: PokemonImage[];
|
|
};
|
|
|
|
type TradingPreference = 'like' | 'neutral';
|
|
|
|
type PokemonTradingItemPayload = {
|
|
itemId: number;
|
|
preference: TradingPreference;
|
|
};
|
|
|
|
type PokemonPayload = {
|
|
dataId: number | null;
|
|
dataIdentifier: string;
|
|
displayId: number;
|
|
isEventItem: boolean;
|
|
name: string;
|
|
genus: string;
|
|
details: string;
|
|
heightInches: number;
|
|
weightPounds: number;
|
|
translations: TranslationInput;
|
|
typeIds: number[];
|
|
stats: PokemonStats;
|
|
environmentId: number;
|
|
skillIds: number[];
|
|
favoriteThingIds: number[];
|
|
skillItemDrops: SkillItemDrop[];
|
|
tradingItems: PokemonTradingItemPayload[];
|
|
image: PokemonImage | null;
|
|
};
|
|
|
|
type PokemonFetchResult = {
|
|
id: number;
|
|
identifier: string;
|
|
name: string;
|
|
genus: string;
|
|
heightInches: number;
|
|
weightPounds: number;
|
|
translations: TranslationInput;
|
|
typeIds: number[];
|
|
stats: PokemonStats;
|
|
};
|
|
|
|
type PokemonFetchOption = {
|
|
id: number;
|
|
identifier: string;
|
|
name: string;
|
|
};
|
|
|
|
type CsvRow = Record<string, string>;
|
|
type PokemonCsvData = {
|
|
pokemonRows: CsvRow[];
|
|
pokemonByLookup: Map<string, CsvRow>;
|
|
namesByPokemonId: Map<number, CsvRow>;
|
|
genusByPokemonId: Map<number, CsvRow>;
|
|
typesById: Map<number, CsvRow>;
|
|
canonicalTypeRows: CsvRow[];
|
|
};
|
|
|
|
type ItemPayload = {
|
|
name: string;
|
|
details: string;
|
|
basePrice: number | null;
|
|
ancientArtifactCategoryId: number | null;
|
|
ancientArtifactCategoryKey: string | null;
|
|
translations: TranslationInput;
|
|
categoryId: number;
|
|
categoryKey: string;
|
|
usageId: number | null;
|
|
usageKey: string | null;
|
|
dyeability: number;
|
|
patternEditable: boolean;
|
|
noRecipe: boolean;
|
|
isEventItem: boolean;
|
|
acquisitionMethodIds: number[];
|
|
tagIds: number[];
|
|
imagePath: string;
|
|
insertBeforeItemId: number | null;
|
|
insertAfterItemId: number | null;
|
|
};
|
|
|
|
type AncientArtifactPayload = {
|
|
name: string;
|
|
details: string;
|
|
translations: TranslationInput;
|
|
categoryId: number;
|
|
categoryKey: string;
|
|
tagIds: number[];
|
|
imagePath: string;
|
|
};
|
|
|
|
type RecipePayload = {
|
|
itemId: number;
|
|
acquisitionMethodIds: number[];
|
|
materials: IdQuantity[];
|
|
};
|
|
|
|
type DishCategoryPayload = {
|
|
name: string;
|
|
effect: string;
|
|
translations: TranslationInput;
|
|
cookwareItemId: number;
|
|
mainMaterialItemId: number;
|
|
totalMaterialQuantity: number;
|
|
};
|
|
|
|
type DishPayload = {
|
|
categoryId: number;
|
|
itemId: number;
|
|
flavorId: number;
|
|
secondaryMaterialItemIds: number[];
|
|
pokemonSkillId: number | null;
|
|
mosslaxEffect: string;
|
|
translations: TranslationInput;
|
|
};
|
|
|
|
type DailyChecklistPayload = {
|
|
title: string;
|
|
translations: TranslationInput;
|
|
};
|
|
|
|
type LifePostPayload = {
|
|
body: string;
|
|
gameVersionId: number | null;
|
|
languageCode: string | null;
|
|
};
|
|
|
|
type LifeCommentPayload = {
|
|
body: string;
|
|
languageCode: string | null;
|
|
};
|
|
|
|
type DiscussionEntityType = 'pokemon' | 'items' | 'recipes' | 'habitats' | 'ancient-artifacts';
|
|
type DiscussionEntityDefinition = {
|
|
table: string;
|
|
};
|
|
type EntityDiscussionCommentPayload = {
|
|
body: string;
|
|
languageCode: string | null;
|
|
};
|
|
type EntityDiscussionCommentRow = {
|
|
id: number;
|
|
entityType: DiscussionEntityType;
|
|
entityId: number;
|
|
parentCommentId: number | null;
|
|
body: string;
|
|
deleted: boolean;
|
|
moderationStatus: AiModerationStatus;
|
|
moderationLanguageCode: string | null;
|
|
moderationReason: string | null;
|
|
createdAt: Date;
|
|
createdAtCursor?: string;
|
|
updatedAt: Date;
|
|
author: { id: number; displayName: string } | null;
|
|
likeCount: number;
|
|
replyCount: number;
|
|
myLiked: boolean;
|
|
};
|
|
type EntityDiscussionComment = Omit<EntityDiscussionCommentRow, 'createdAtCursor'> & {
|
|
replies: EntityDiscussionComment[];
|
|
};
|
|
type EntityDiscussionCommentsPage = {
|
|
items: EntityDiscussionComment[];
|
|
nextCursor: string | null;
|
|
hasMore: boolean;
|
|
total: number;
|
|
};
|
|
|
|
type LifeReactionType = 'like' | 'helpful' | 'fun' | 'thanks';
|
|
type LifeReactionCounts = Record<LifeReactionType, number>;
|
|
type LifeReactionUser = {
|
|
user: { id: number; displayName: string };
|
|
reactionType: LifeReactionType;
|
|
reactedAt: Date;
|
|
};
|
|
type LifeReactionUsersPage = {
|
|
items: LifeReactionUser[];
|
|
nextCursor: string | null;
|
|
hasMore: boolean;
|
|
total: number;
|
|
};
|
|
type LifeReactionUserCursor = {
|
|
reactedAt: string;
|
|
userId: number;
|
|
};
|
|
|
|
type LifeCommentRow = {
|
|
id: number;
|
|
postId: number;
|
|
parentCommentId: number | null;
|
|
body: string;
|
|
deleted: boolean;
|
|
moderationStatus: AiModerationStatus;
|
|
moderationLanguageCode: string | null;
|
|
moderationReason: string | null;
|
|
createdAt: Date;
|
|
createdAtCursor?: string;
|
|
updatedAt: Date;
|
|
author: { id: number; displayName: string } | null;
|
|
likeCount: number;
|
|
replyCount: number;
|
|
myLiked: boolean;
|
|
};
|
|
|
|
type LifeComment = Omit<LifeCommentRow, 'createdAtCursor'> & {
|
|
replies: LifeComment[];
|
|
};
|
|
|
|
type LifePostRow = {
|
|
id: number;
|
|
body: string;
|
|
moderationStatus: AiModerationStatus;
|
|
moderationLanguageCode: string | null;
|
|
moderationReason: string | null;
|
|
createdAt: Date;
|
|
createdAtCursor: string;
|
|
updatedAt: Date;
|
|
author: { id: number; displayName: string } | null;
|
|
updatedBy: { id: number; displayName: string } | null;
|
|
gameVersion: { id: number; name: string; changeLog: string } | null;
|
|
};
|
|
|
|
type LifePost = Omit<LifePostRow, 'createdAtCursor'> & {
|
|
commentPreview: LifeComment[];
|
|
commentCount: number;
|
|
reactionCounts: LifeReactionCounts;
|
|
myReaction: LifeReactionType | null;
|
|
};
|
|
|
|
type LifePostCursor = {
|
|
createdAt: string;
|
|
id: number;
|
|
};
|
|
|
|
type LifePostSort = 'latest' | 'oldest';
|
|
type CommentSort = 'oldest' | 'latest' | 'most-liked' | 'most-replied';
|
|
type CommentCursor = {
|
|
createdAt: string;
|
|
id: number;
|
|
count?: number;
|
|
};
|
|
|
|
type LifePostFilters = {
|
|
authorId?: number;
|
|
followedByUserId?: number;
|
|
};
|
|
|
|
type LifePostsPage = {
|
|
items: LifePost[];
|
|
nextCursor: string | null;
|
|
hasMore: boolean;
|
|
};
|
|
|
|
type LifeCommentsPage = {
|
|
items: LifeComment[];
|
|
nextCursor: string | null;
|
|
hasMore: boolean;
|
|
total: number;
|
|
};
|
|
|
|
type PublicProfileUser = {
|
|
id: number;
|
|
displayName: string;
|
|
joinedAt: Date;
|
|
};
|
|
|
|
type PublicProfileStats = {
|
|
wikiEdits: number;
|
|
wikiCreates: number;
|
|
wikiUpdates: number;
|
|
wikiDeletes: number;
|
|
imageUploads: number;
|
|
lifePosts: number;
|
|
lifeComments: number;
|
|
lifeReactions: number;
|
|
discussionComments: number;
|
|
};
|
|
|
|
type PublicProfileContribution = {
|
|
contentType: string;
|
|
total: number;
|
|
creates: number;
|
|
updates: number;
|
|
deletes: number;
|
|
lastContributedAt: Date | null;
|
|
};
|
|
|
|
type PublicProfileViewerRelation = 'none' | 'following' | 'followed-by' | 'friends';
|
|
|
|
type PublicProfileSocial = {
|
|
followerCount: number;
|
|
followingCount: number;
|
|
friendCount: number;
|
|
viewerRelation: PublicProfileViewerRelation;
|
|
};
|
|
|
|
type PublicUserProfile = {
|
|
user: PublicProfileUser;
|
|
stats: PublicProfileStats;
|
|
social: PublicProfileSocial;
|
|
contributions: PublicProfileContribution[];
|
|
};
|
|
|
|
type UserReactionActivity = {
|
|
postId: number;
|
|
reactionType: LifeReactionType;
|
|
reactedAt: Date;
|
|
post: LifePost;
|
|
};
|
|
|
|
type UserReactionActivityPage = {
|
|
items: UserReactionActivity[];
|
|
nextCursor: string | null;
|
|
hasMore: boolean;
|
|
};
|
|
|
|
type UserCommentActivitySource = 'life' | 'discussion';
|
|
|
|
type UserCommentActivity = {
|
|
id: number;
|
|
source: UserCommentActivitySource;
|
|
body: string;
|
|
createdAt: Date;
|
|
target: {
|
|
type: 'life-post' | DiscussionEntityType;
|
|
id: number;
|
|
title: string;
|
|
excerpt: string;
|
|
};
|
|
};
|
|
|
|
type UserCommentActivityPage = {
|
|
items: UserCommentActivity[];
|
|
nextCursor: string | null;
|
|
hasMore: boolean;
|
|
};
|
|
|
|
type UserCommentActivityCursor = LifePostCursor & {
|
|
source: UserCommentActivitySource;
|
|
};
|
|
|
|
type HabitatPayload = {
|
|
name: string;
|
|
translations: TranslationInput;
|
|
isEventItem: boolean;
|
|
imagePath: string;
|
|
recipeItems: IdQuantity[];
|
|
pokemonAppearances: Array<{
|
|
pokemonId: number;
|
|
mapId: number;
|
|
timeOfDay: string;
|
|
weather: string;
|
|
rarity: number;
|
|
}>;
|
|
};
|
|
|
|
type LanguagePayload = {
|
|
code: string;
|
|
name: string;
|
|
enabled: boolean;
|
|
isDefault: boolean;
|
|
sortOrder: number;
|
|
};
|
|
|
|
export type ModuleSettings = {
|
|
tradingEnabled: boolean;
|
|
};
|
|
|
|
type ValidationError = Error & { statusCode: number };
|
|
type EditAction = 'create' | 'update' | 'delete';
|
|
type EditChange = {
|
|
label: string;
|
|
before: string;
|
|
after: string;
|
|
};
|
|
type EditHistoryEntry = {
|
|
action: EditAction;
|
|
changes: EditChange[];
|
|
createdAt: Date;
|
|
user: { id: number; displayName: string } | null;
|
|
};
|
|
type TranslationChangeSource = {
|
|
translations?: TranslationInput | null;
|
|
};
|
|
type PokemonChangeSource = {
|
|
displayId: number;
|
|
isEventItem: boolean;
|
|
name: string;
|
|
genus: string;
|
|
details: string;
|
|
heightInches: number;
|
|
weightPounds: number;
|
|
image: PokemonImage | null;
|
|
types: Array<{ name: string }>;
|
|
stats: PokemonStats;
|
|
environment: { name: string };
|
|
skills: Array<{ name: string; itemDrop?: { name: string } | null }>;
|
|
favorite_things: Array<{ name: string }>;
|
|
tradingItems: Array<{ name: string; preference: TradingPreference }>;
|
|
} & TranslationChangeSource;
|
|
type ItemChangeSource = {
|
|
name: string;
|
|
details: string;
|
|
basePrice: number | null;
|
|
ancientArtifactCategory: { name: string } | null;
|
|
isEventItem: boolean;
|
|
image: EntityImageValue | null;
|
|
category: { name: string };
|
|
usage: { name: string } | null;
|
|
customization: { dyeability: number; patternEditable: boolean };
|
|
noRecipe: boolean;
|
|
acquisitionMethods: Array<{ name: string }>;
|
|
tags: Array<{ name: string }>;
|
|
} & TranslationChangeSource;
|
|
type AncientArtifactChangeSource = {
|
|
name: string;
|
|
details: string;
|
|
image: EntityImageValue | null;
|
|
category: { name: string };
|
|
tags: Array<{ name: string }>;
|
|
} & TranslationChangeSource;
|
|
type HabitatChangeSource = {
|
|
name: string;
|
|
isEventItem: boolean;
|
|
image: EntityImageValue | null;
|
|
recipe: Array<{ name: string; quantity: number }>;
|
|
pokemon: Array<{ name: string; time_of_day: string; weather: string; rarity: number; map: { name: string } }>;
|
|
} & TranslationChangeSource;
|
|
type RecipeChangeSource = {
|
|
item: { name: string };
|
|
acquisition_methods: Array<{ name: string }>;
|
|
materials: Array<{ name: string; quantity: number }>;
|
|
};
|
|
|
|
type DishCategoryChangeSource = {
|
|
name: string;
|
|
effect: string;
|
|
translations?: TranslationInput;
|
|
cookware: { name: string };
|
|
mainMaterial: { name: string };
|
|
totalMaterialQuantity: number;
|
|
};
|
|
|
|
type DishChangeSource = {
|
|
category: { name: string };
|
|
item: { name: string };
|
|
flavor: { name: string };
|
|
secondaryMaterials: Array<{ name: string }>;
|
|
pokemonSkill: { name: string } | null;
|
|
mosslaxEffect: string;
|
|
translations?: TranslationInput;
|
|
};
|
|
type DailyChecklistChangeSource = {
|
|
title: string;
|
|
} & TranslationChangeSource;
|
|
type ConfigChangeSource = {
|
|
name: string;
|
|
description?: string;
|
|
opposite?: { name: string } | null;
|
|
hasItemDrop?: boolean;
|
|
hasTrading?: boolean;
|
|
changeLog?: string;
|
|
} & TranslationChangeSource;
|
|
|
|
const timeOfDays = ['早晨', '中午', '傍晚', '晚上'];
|
|
const weathers = ['晴天', '阴天', '雨天'];
|
|
const defaultLocale = 'en';
|
|
const localePattern = /^[a-z]{2}(-[A-Z]{2})?$/;
|
|
const defaultLifePostLimit = 20;
|
|
const maxLifePostLimit = 50;
|
|
const defaultCommentLimit = 20;
|
|
const maxCommentLimit = 50;
|
|
const lifeCommentPreviewLimit = 2;
|
|
const lifeReactionTypes = ['like', 'helpful', 'fun', 'thanks'] as const;
|
|
const pokemonTypeIconIds = new Set(Array.from({ length: 19 }, (_value, index) => index + 1));
|
|
const pokemonSpriteBaseUrl = 'https://pokesprite.tootaio.com';
|
|
const itemStaticImagePathPrefix = '/pokopia/items/';
|
|
const habitatStaticImagePathPrefix = '/pokopia/habitats/';
|
|
const pokemonSpriteRequestTimeoutMs = 2500;
|
|
const pokemonStatLabels: Array<{ key: keyof PokemonStats; label: string }> = [
|
|
{ key: 'hp', label: 'HP' },
|
|
{ key: 'attack', label: 'Attack' },
|
|
{ key: 'defense', label: 'Defense' },
|
|
{ key: 'specialAttack', label: 'Special Attack' },
|
|
{ key: 'specialDefense', label: 'Special Defense' },
|
|
{ key: 'speed', label: 'Speed' }
|
|
];
|
|
|
|
type SystemListOption = {
|
|
id: number;
|
|
key: string;
|
|
labels: Record<typeof defaultLocale | 'zh-CN', string>;
|
|
};
|
|
|
|
const itemCategoryOptions = [
|
|
{ id: 1, key: 'furniture', labels: { en: 'Furniture', 'zh-CN': '家具' } },
|
|
{ id: 2, key: 'misc', labels: { en: 'Misc', 'zh-CN': '杂项' } },
|
|
{ id: 3, key: 'outdoor', labels: { en: 'Outdoor', 'zh-CN': '户外' } },
|
|
{ id: 4, key: 'utilities', labels: { en: 'Utilities', 'zh-CN': '实用工具' } },
|
|
{ id: 5, key: 'buildings', labels: { en: 'Buildings', 'zh-CN': '建筑' } },
|
|
{ id: 6, key: 'blocks', labels: { en: 'Blocks', 'zh-CN': '方块' } },
|
|
{ id: 7, key: 'kits', labels: { en: 'Kits', 'zh-CN': '套件' } },
|
|
{ id: 8, key: 'nature', labels: { en: 'Nature', 'zh-CN': '自然' } },
|
|
{ id: 9, key: 'food', labels: { en: 'Food', 'zh-CN': '食物' } },
|
|
{ id: 10, key: 'materials', labels: { en: 'Materials', 'zh-CN': '材料' } },
|
|
{ id: 11, key: 'key-items', labels: { en: 'Key Items', 'zh-CN': '关键物品' } },
|
|
{ id: 12, key: 'other', labels: { en: 'Other', 'zh-CN': '其他' } }
|
|
] as const satisfies readonly SystemListOption[];
|
|
|
|
const itemUsageOptions = [
|
|
{ id: 1, key: 'decoration', labels: { en: 'Decoration', 'zh-CN': '装饰' } },
|
|
{ id: 2, key: 'relaxation', labels: { en: 'Relaxation', 'zh-CN': '休闲' } },
|
|
{ id: 3, key: 'toy', labels: { en: 'Toy', 'zh-CN': '玩具' } },
|
|
{ id: 4, key: 'road', labels: { en: 'Road', 'zh-CN': '道路' } },
|
|
{ id: 5, key: 'food', labels: { en: 'Food', 'zh-CN': '食物' } }
|
|
] as const satisfies readonly SystemListOption[];
|
|
|
|
const ancientArtifactCategoryOptions = [
|
|
{ id: 1, key: 'lost-relics-l', labels: { en: 'Lost Relics (L)', 'zh-CN': 'Lost Relics (L)' } },
|
|
{ id: 2, key: 'lost-relics-s', labels: { en: 'Lost Relics (S)', 'zh-CN': 'Lost Relics (S)' } },
|
|
{ id: 3, key: 'fossils', labels: { en: 'Fossils', 'zh-CN': '化石' } }
|
|
] as const satisfies readonly SystemListOption[];
|
|
|
|
const configDefinitions: Record<ConfigType, ConfigDefinition> = {
|
|
'pokemon-types': { table: 'pokemon_types', entityType: 'pokemon-types' },
|
|
skills: { table: 'skills', entityType: 'skills', hasItemDrop: true, hasTrading: true },
|
|
environments: { table: 'environments', entityType: 'environments', hasDescription: true, oppositeColumn: 'opposite_environment_id' },
|
|
'favorite-things': { table: 'favorite_things', entityType: 'favorite-things', oppositeColumn: 'opposite_favorite_thing_id' },
|
|
'acquisition-methods': { table: 'acquisition_methods', entityType: 'acquisition-methods' },
|
|
maps: { table: 'maps', entityType: 'maps' },
|
|
'game-versions': { table: 'game_versions', entityType: 'game-versions', hasChangeLog: true },
|
|
'dish-flavors': { table: 'dish_flavors', entityType: 'dish-flavors' }
|
|
};
|
|
|
|
const sortableContentDefinitions: Record<SortableContentType, SortableContentDefinition> = {
|
|
items: { table: 'items', entityType: 'items' },
|
|
'ancient-artifacts': { table: 'items', entityType: 'ancient-artifacts' },
|
|
recipes: { table: 'recipes', entityType: 'recipes' },
|
|
habitats: { table: 'habitats', entityType: 'habitats' }
|
|
};
|
|
|
|
const discussionEntityDefinitions: Record<DiscussionEntityType, DiscussionEntityDefinition> = {
|
|
pokemon: { table: 'pokemon' },
|
|
items: { table: 'items' },
|
|
recipes: { table: 'recipes' },
|
|
habitats: { table: 'habitats' },
|
|
'ancient-artifacts': { table: 'items' }
|
|
};
|
|
|
|
let pokemonCsvDataCache: Promise<PokemonCsvData> | null = null;
|
|
|
|
function asString(value: QueryValue): string | undefined {
|
|
return Array.isArray(value) ? value[0] : value;
|
|
}
|
|
|
|
const defaultPublicListLimit = 24;
|
|
const maxPublicListLimit = 72;
|
|
|
|
function isPagedListRequest(paramsQuery: QueryParams): boolean {
|
|
return asString(paramsQuery.limit) !== undefined || asString(paramsQuery.cursor) !== undefined;
|
|
}
|
|
|
|
function cleanPublicListLimit(value: QueryValue): number {
|
|
const limit = Number(asString(value));
|
|
return Number.isInteger(limit) && limit > 0 ? Math.min(limit, maxPublicListLimit) : defaultPublicListLimit;
|
|
}
|
|
|
|
function encodeOffsetCursor(offset: number): string {
|
|
return Buffer.from(JSON.stringify({ offset }), 'utf8').toString('base64url');
|
|
}
|
|
|
|
function decodeOffsetCursor(value: QueryValue): number {
|
|
const rawValue = asString(value);
|
|
if (!rawValue) {
|
|
return 0;
|
|
}
|
|
|
|
try {
|
|
const payload = JSON.parse(Buffer.from(rawValue, 'base64url').toString('utf8')) as { offset?: unknown };
|
|
const offset = Number(payload.offset);
|
|
return Number.isInteger(offset) && offset >= 0 ? offset : 0;
|
|
} catch {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
async function queryMaybePaged<T extends QueryResultRow>(
|
|
sql: string,
|
|
params: unknown[],
|
|
paramsQuery: QueryParams
|
|
): Promise<T[] | ListPage<T>> {
|
|
if (!isPagedListRequest(paramsQuery)) {
|
|
return query<T>(sql, params);
|
|
}
|
|
|
|
const limit = cleanPublicListLimit(paramsQuery.limit);
|
|
const offset = decodeOffsetCursor(paramsQuery.cursor);
|
|
const pagedParams = [...params, limit + 1, offset];
|
|
const rows = await query<T>(
|
|
`
|
|
${sql}
|
|
LIMIT $${pagedParams.length - 1}
|
|
OFFSET $${pagedParams.length}
|
|
`,
|
|
pagedParams
|
|
);
|
|
const items = rows.slice(0, limit);
|
|
const nextOffset = offset + items.length;
|
|
|
|
return {
|
|
items,
|
|
nextCursor: rows.length > limit ? encodeOffsetCursor(nextOffset) : null,
|
|
hasMore: rows.length > limit
|
|
};
|
|
}
|
|
|
|
export function cleanLocale(value: unknown): string {
|
|
const locale = typeof value === 'string' ? value.trim() : '';
|
|
return localePattern.test(locale) ? locale : defaultLocale;
|
|
}
|
|
|
|
function cleanModerationLanguageCode(value: unknown): string | null {
|
|
const languageCode = typeof value === 'string' ? value.trim() : '';
|
|
if (!languageCode || languageCode === 'all') {
|
|
return null;
|
|
}
|
|
|
|
if (!localePattern.test(languageCode)) {
|
|
throw validationError('server.validation.invalidField');
|
|
}
|
|
|
|
return languageCode;
|
|
}
|
|
|
|
function sqlLiteral(value: string): string {
|
|
return `'${value.replaceAll("'", "''")}'`;
|
|
}
|
|
|
|
function uploadedImageJson(pathExpression: string): string {
|
|
return `
|
|
CASE
|
|
WHEN lower(${pathExpression}) LIKE 'https://%' THEN json_build_object(
|
|
'path', ${pathExpression},
|
|
'url', ${pathExpression}
|
|
)
|
|
WHEN ${pathExpression} LIKE ${sqlLiteral(`${itemStaticImagePathPrefix}%`)}
|
|
OR ${pathExpression} LIKE ${sqlLiteral(`${habitatStaticImagePathPrefix}%`)} THEN json_build_object(
|
|
'path', ${pathExpression},
|
|
'url', ${sqlLiteral(pokemonSpriteBaseUrl)} || ${pathExpression}
|
|
)
|
|
WHEN ${pathExpression} <> '' THEN json_build_object(
|
|
'path', ${pathExpression},
|
|
'url', ${sqlLiteral(uploadPublicBaseUrl)} || ${pathExpression}
|
|
)
|
|
ELSE NULL
|
|
END
|
|
`;
|
|
}
|
|
|
|
function pokemonImageJson(alias: string): string {
|
|
return `
|
|
CASE
|
|
WHEN lower(${alias}.image_path) LIKE 'https://%' THEN json_build_object(
|
|
'path', ${alias}.image_path,
|
|
'url', ${alias}.image_path,
|
|
'style', '',
|
|
'version', '',
|
|
'variant', ${alias}.name,
|
|
'description', '',
|
|
'source', 'external'
|
|
)
|
|
WHEN ${alias}.image_path LIKE '/sprites/%' THEN json_build_object(
|
|
'path', ${alias}.image_path,
|
|
'url', ${sqlLiteral(pokemonSpriteBaseUrl)} || ${alias}.image_path,
|
|
'style', ${alias}.image_style,
|
|
'version', ${alias}.image_version,
|
|
'variant', ${alias}.image_variant,
|
|
'description', ${alias}.image_description,
|
|
'source', 'sprite'
|
|
)
|
|
WHEN ${alias}.image_path <> '' THEN json_build_object(
|
|
'path', ${alias}.image_path,
|
|
'url', ${sqlLiteral(uploadPublicBaseUrl)} || ${alias}.image_path,
|
|
'style', 'Upload',
|
|
'version', 'Community upload',
|
|
'variant', ${alias}.name,
|
|
'description', '',
|
|
'source', 'upload'
|
|
)
|
|
ELSE NULL
|
|
END
|
|
`;
|
|
}
|
|
|
|
function imagePathLabel(path: string | null | undefined): string {
|
|
const cleanPath = path?.trim() ?? '';
|
|
if (cleanPath === '') {
|
|
return '';
|
|
}
|
|
if (isExternalImageUrl(cleanPath)) {
|
|
return cleanPath;
|
|
}
|
|
|
|
const parts = cleanPath.split('/');
|
|
return parts.length >= 3 ? `${parts[1]} / ${parts[2]}` : cleanPath;
|
|
}
|
|
|
|
function localizedField(
|
|
entityType: EntityType,
|
|
entityIdExpression: string,
|
|
baseExpression: string,
|
|
fieldName: TranslationField,
|
|
locale: string
|
|
): string {
|
|
const entity = sqlLiteral(entityType);
|
|
const field = sqlLiteral(fieldName);
|
|
const requestedLocale = sqlLiteral(cleanLocale(locale));
|
|
const defaultLocaleSql = sqlLiteral(defaultLocale);
|
|
|
|
return `
|
|
COALESCE(
|
|
(
|
|
SELECT et.value
|
|
FROM entity_translations et
|
|
WHERE et.entity_type = ${entity}
|
|
AND et.entity_id = ${entityIdExpression}
|
|
AND et.locale = ${requestedLocale}
|
|
AND et.field_name = ${field}
|
|
),
|
|
(
|
|
SELECT et.value
|
|
FROM entity_translations et
|
|
WHERE et.entity_type = ${entity}
|
|
AND et.entity_id = ${entityIdExpression}
|
|
AND et.locale = ${defaultLocaleSql}
|
|
AND et.field_name = ${field}
|
|
),
|
|
${baseExpression}
|
|
)
|
|
`;
|
|
}
|
|
|
|
function localizedName(entityType: EntityType, entityAlias: string, locale: string): string {
|
|
return localizedField(entityType, `${entityAlias}.id`, `${entityAlias}.name`, 'name', locale);
|
|
}
|
|
|
|
function translationsSelect(entityType: EntityType, entityIdExpression: string): string {
|
|
return `
|
|
COALESCE((
|
|
SELECT jsonb_object_agg(locale, fields)
|
|
FROM (
|
|
SELECT locale, jsonb_object_agg(field_name, value) AS fields
|
|
FROM entity_translations
|
|
WHERE entity_type = ${sqlLiteral(entityType)}
|
|
AND entity_id = ${entityIdExpression}
|
|
GROUP BY locale
|
|
) translation_rows
|
|
), '{}'::jsonb)
|
|
`;
|
|
}
|
|
|
|
function cleanTranslations(value: unknown, allowedFields: TranslationField[]): TranslationInput {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
return {};
|
|
}
|
|
|
|
const translations: TranslationInput = {};
|
|
const allowedFieldSet = new Set(allowedFields);
|
|
|
|
for (const [locale, fields] of Object.entries(value as Record<string, unknown>)) {
|
|
if (!localePattern.test(locale) || locale === defaultLocale || !fields || typeof fields !== 'object' || Array.isArray(fields)) {
|
|
continue;
|
|
}
|
|
|
|
const cleanFields: Partial<Record<TranslationField, string>> = {};
|
|
for (const [fieldName, fieldValue] of Object.entries(fields as Record<string, unknown>)) {
|
|
if (!allowedFieldSet.has(fieldName as TranslationField) || typeof fieldValue !== 'string') {
|
|
continue;
|
|
}
|
|
|
|
const cleanValue = fieldValue.trim();
|
|
if (cleanValue !== '') {
|
|
cleanFields[fieldName as TranslationField] = cleanValue;
|
|
}
|
|
}
|
|
|
|
if (Object.keys(cleanFields).length > 0) {
|
|
translations[locale] = cleanFields;
|
|
}
|
|
}
|
|
|
|
return translations;
|
|
}
|
|
|
|
async function replaceEntityTranslations(
|
|
client: DbClient,
|
|
entityType: EntityType,
|
|
entityId: number,
|
|
translations: TranslationInput,
|
|
fields: TranslationField[]
|
|
): Promise<void> {
|
|
await client.query(
|
|
`
|
|
DELETE FROM entity_translations
|
|
WHERE entity_type = $1
|
|
AND entity_id = $2
|
|
AND field_name = ANY($3::text[])
|
|
`,
|
|
[entityType, entityId, fields]
|
|
);
|
|
|
|
for (const [locale, translatedFields] of Object.entries(translations)) {
|
|
for (const fieldName of fields) {
|
|
const value = translatedFields[fieldName];
|
|
if (typeof value !== 'string' || value.trim() === '') {
|
|
continue;
|
|
}
|
|
|
|
await client.query(
|
|
`
|
|
INSERT INTO entity_translations (entity_type, entity_id, locale, field_name, value)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
`,
|
|
[entityType, entityId, locale, fieldName, value.trim()]
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function deleteEntityTranslations(client: DbClient, entityType: EntityType, entityId: number): Promise<void> {
|
|
await client.query('DELETE FROM entity_translations WHERE entity_type = $1 AND entity_id = $2', [entityType, entityId]);
|
|
}
|
|
|
|
function optionSelect(
|
|
tableName: string,
|
|
entityType: EntityType,
|
|
locale: string
|
|
): Promise<Array<{ id: number; name: string }>> {
|
|
const name = localizedName(entityType, 'o', locale);
|
|
return query(`SELECT o.id, ${name} AS name FROM ${tableName} o ORDER BY ${orderByEntity('o')}`);
|
|
}
|
|
|
|
function systemListLabel(option: SystemListOption, locale: string): string {
|
|
const clean = cleanLocale(locale) as keyof SystemListOption['labels'];
|
|
return option.labels[clean] ?? option.labels[defaultLocale];
|
|
}
|
|
|
|
function systemListOptions(options: readonly SystemListOption[], locale: string): Array<{ id: number; key: string; name: string }> {
|
|
return options.map((option) => ({ id: option.id, key: option.key, name: systemListLabel(option, locale) }));
|
|
}
|
|
|
|
function systemListOptionById(
|
|
options: readonly SystemListOption[],
|
|
id: number,
|
|
message: string
|
|
): SystemListOption {
|
|
const option = options.find((item) => item.id === id);
|
|
if (!option) {
|
|
throw validationError(message);
|
|
}
|
|
return option;
|
|
}
|
|
|
|
function systemListOptionByKey(options: readonly SystemListOption[], key: string | null | undefined): SystemListOption | null {
|
|
return options.find((item) => item.key === key) ?? null;
|
|
}
|
|
|
|
function systemListNameByKey(options: readonly SystemListOption[], key: string | null | undefined, locale = defaultLocale): string | null {
|
|
const option = systemListOptionByKey(options, key);
|
|
return option ? systemListLabel(option, locale) : null;
|
|
}
|
|
|
|
function systemListIdSql(expression: string, options: readonly SystemListOption[]): string {
|
|
const cases = options.map((option) => `WHEN ${sqlLiteral(option.key)} THEN ${option.id}`).join(' ');
|
|
return `CASE ${expression} ${cases} ELSE NULL END`;
|
|
}
|
|
|
|
function systemListNameSql(expression: string, options: readonly SystemListOption[], locale: string): string {
|
|
const cases = options
|
|
.map((option) => `WHEN ${sqlLiteral(option.key)} THEN ${sqlLiteral(systemListLabel(option, locale))}`)
|
|
.join(' ');
|
|
return `CASE ${expression} ${cases} ELSE '' END`;
|
|
}
|
|
|
|
function systemListJsonSql(expression: string, options: readonly SystemListOption[], locale: string): string {
|
|
return `json_build_object('id', ${systemListIdSql(expression, options)}, 'key', ${expression}, 'name', ${systemListNameSql(expression, options, locale)})`;
|
|
}
|
|
|
|
function publicModuleSettings(row: { tradingEnabled: boolean } | null): ModuleSettings {
|
|
return {
|
|
tradingEnabled: row?.tradingEnabled ?? true
|
|
};
|
|
}
|
|
|
|
async function moduleSettingsForClient(client: DbClient): Promise<ModuleSettings> {
|
|
const result = await client.query<{ tradingEnabled: boolean }>(
|
|
'SELECT trading_enabled AS "tradingEnabled" FROM module_settings WHERE id = true'
|
|
);
|
|
return publicModuleSettings(result.rows[0] ?? null);
|
|
}
|
|
|
|
export async function getModuleSettings(): Promise<ModuleSettings> {
|
|
const row = await queryOne<{ tradingEnabled: boolean }>(
|
|
'SELECT trading_enabled AS "tradingEnabled" FROM module_settings WHERE id = true'
|
|
);
|
|
return publicModuleSettings(row);
|
|
}
|
|
|
|
export async function updateModuleSettings(payload: Record<string, unknown>, userId: number): Promise<ModuleSettings> {
|
|
const current = await getModuleSettings();
|
|
const tradingEnabled = typeof payload.tradingEnabled === 'boolean' ? payload.tradingEnabled : current.tradingEnabled;
|
|
const row = await queryOne<{ tradingEnabled: boolean }>(
|
|
`
|
|
INSERT INTO module_settings (id, trading_enabled, updated_by_user_id, updated_at)
|
|
VALUES (true, $1, $2, now())
|
|
ON CONFLICT (id) DO UPDATE SET
|
|
trading_enabled = EXCLUDED.trading_enabled,
|
|
updated_by_user_id = EXCLUDED.updated_by_user_id,
|
|
updated_at = now()
|
|
RETURNING trading_enabled AS "tradingEnabled"
|
|
`,
|
|
[tradingEnabled, userId]
|
|
);
|
|
return publicModuleSettings(row);
|
|
}
|
|
|
|
function gameVersionOptions(locale: string): Promise<Array<{ id: number; name: string; changeLog: string }>> {
|
|
const name = localizedName('game-versions', 'gv', locale);
|
|
return query(`SELECT gv.id, ${name} AS name, gv.change_log AS "changeLog" FROM game_versions gv ORDER BY ${orderByEntity('gv')}`);
|
|
}
|
|
|
|
function skillOptions(locale: string): Promise<Array<{ id: number; name: string; hasItemDrop: boolean; hasTrading: boolean }>> {
|
|
const name = localizedName('skills', 's', locale);
|
|
return query(
|
|
`SELECT s.id, ${name} AS name, s.has_item_drop AS "hasItemDrop", s.has_trading AS "hasTrading" FROM skills s ORDER BY ${orderByEntity('s')}`
|
|
);
|
|
}
|
|
|
|
function auditSelect(entityAlias: string, createdAlias = 'created_user', updatedAlias = 'updated_user'): string {
|
|
return `
|
|
${entityAlias}.created_at AS "createdAt",
|
|
${entityAlias}.updated_at AS "updatedAt",
|
|
CASE
|
|
WHEN ${createdAlias}.id IS NULL THEN NULL
|
|
ELSE json_build_object('id', ${createdAlias}.id, 'displayName', ${createdAlias}.display_name)
|
|
END AS "createdBy",
|
|
CASE
|
|
WHEN ${updatedAlias}.id IS NULL THEN NULL
|
|
ELSE json_build_object('id', ${updatedAlias}.id, 'displayName', ${updatedAlias}.display_name)
|
|
END AS "updatedBy"
|
|
`;
|
|
}
|
|
|
|
function auditJoins(entityAlias: string, createdAlias = 'created_user', updatedAlias = 'updated_user'): string {
|
|
return `
|
|
LEFT JOIN users ${createdAlias} ON ${createdAlias}.id = ${entityAlias}.created_by_user_id
|
|
LEFT JOIN users ${updatedAlias} ON ${updatedAlias}.id = ${entityAlias}.updated_by_user_id
|
|
`;
|
|
}
|
|
|
|
function configOrder(): string {
|
|
return orderByEntity('c');
|
|
}
|
|
|
|
function configSelect(definition: ConfigDefinition, locale: string): string {
|
|
const name = localizedName(definition.entityType, 'c', locale);
|
|
const oppositeName = localizedName(definition.entityType, 'opposite_config', locale);
|
|
const translations = translationsSelect(definition.entityType, 'c.id');
|
|
const columns = [`c.id`, `${name} AS name`, `c.name AS "baseName"`, `${translations} AS translations`];
|
|
if (definition.hasDescription) {
|
|
columns.push(`c.description`);
|
|
}
|
|
if (definition.oppositeColumn) {
|
|
columns.push(
|
|
`
|
|
CASE
|
|
WHEN opposite_config.id IS NULL THEN NULL
|
|
ELSE json_build_object('id', opposite_config.id, 'name', ${oppositeName})
|
|
END AS opposite
|
|
`
|
|
);
|
|
}
|
|
if (definition.hasItemDrop) {
|
|
columns.push(`c.has_item_drop AS "hasItemDrop"`);
|
|
}
|
|
if (definition.hasTrading) {
|
|
columns.push(`c.has_trading AS "hasTrading"`);
|
|
}
|
|
if (definition.hasChangeLog) {
|
|
columns.push(`c.change_log AS "changeLog"`);
|
|
}
|
|
return columns.join(', ');
|
|
}
|
|
|
|
function configRelationJoins(definition: ConfigDefinition): string {
|
|
if (!definition.oppositeColumn) {
|
|
return '';
|
|
}
|
|
|
|
return `LEFT JOIN ${definition.table} opposite_config ON opposite_config.id = c.${definition.oppositeColumn}`;
|
|
}
|
|
|
|
function validationError(message: string): ValidationError {
|
|
const error = new Error(message) as ValidationError;
|
|
error.statusCode = 400;
|
|
return error;
|
|
}
|
|
|
|
function forbiddenError(): ValidationError {
|
|
const error = new Error('server.errors.permissionDenied') as ValidationError;
|
|
error.statusCode = 403;
|
|
return error;
|
|
}
|
|
|
|
function requirePositiveInteger(value: unknown, message: string): number {
|
|
const numberValue = Number(value);
|
|
if (!Number.isInteger(numberValue) || numberValue <= 0) {
|
|
throw validationError(message);
|
|
}
|
|
return numberValue;
|
|
}
|
|
|
|
function optionalPositiveInteger(value: unknown, message: string): number | null {
|
|
if (value === undefined || value === null || value === '') {
|
|
return null;
|
|
}
|
|
|
|
return requirePositiveInteger(value, message);
|
|
}
|
|
|
|
function cleanName(value: unknown, message = 'server.validation.nameRequired'): string {
|
|
if (typeof value !== 'string' || value.trim() === '') {
|
|
throw validationError(message);
|
|
}
|
|
return value.trim();
|
|
}
|
|
|
|
function cleanOptionalText(value: unknown): string {
|
|
return typeof value === 'string' ? value.trim() : '';
|
|
}
|
|
|
|
function isStaticImageFileName(fileName: string): boolean {
|
|
return Boolean(fileName) && !fileName.includes('/') && !fileName.includes('\\') && !fileName.includes('..') && /^[A-Za-z0-9._()-]+$/.test(fileName);
|
|
}
|
|
|
|
function isItemStaticImagePath(value: string): boolean {
|
|
return isStaticImageFileName(value.startsWith(itemStaticImagePathPrefix) ? value.slice(itemStaticImagePathPrefix.length) : '');
|
|
}
|
|
|
|
function isHabitatStaticImagePath(value: string): boolean {
|
|
return isStaticImageFileName(value.startsWith(habitatStaticImagePathPrefix) ? value.slice(habitatStaticImagePathPrefix.length) : '');
|
|
}
|
|
|
|
function cleanUploadImagePath(value: unknown, entityType: 'items' | 'habitats' | 'ancient-artifacts'): string {
|
|
const imagePath = cleanOptionalText(value);
|
|
if (imagePath === '') {
|
|
return '';
|
|
}
|
|
if (isExternalImageUrl(imagePath)) {
|
|
return imagePath;
|
|
}
|
|
if (entityType === 'habitats' && isHabitatStaticImagePath(imagePath)) {
|
|
return imagePath;
|
|
}
|
|
if (!isUploadImagePath(imagePath) || !imagePath.startsWith(`${entityType}/`)) {
|
|
throw validationError('server.validation.imagePathInvalid');
|
|
}
|
|
return imagePath;
|
|
}
|
|
|
|
function cleanItemOrArtifactImagePath(value: unknown): string {
|
|
const imagePath = cleanOptionalText(value);
|
|
if (imagePath === '') {
|
|
return '';
|
|
}
|
|
if (isExternalImageUrl(imagePath)) {
|
|
return imagePath;
|
|
}
|
|
if (isItemStaticImagePath(imagePath)) {
|
|
return imagePath;
|
|
}
|
|
if (!isUploadImagePath(imagePath) || (!imagePath.startsWith('items/') && !imagePath.startsWith('ancient-artifacts/'))) {
|
|
throw validationError('server.validation.imagePathInvalid');
|
|
}
|
|
return imagePath;
|
|
}
|
|
|
|
function cleanIds(value: unknown): number[] {
|
|
if (!Array.isArray(value)) {
|
|
return [];
|
|
}
|
|
return [...new Set(value.map((item) => Number(item)).filter((item) => Number.isInteger(item) && item > 0))];
|
|
}
|
|
|
|
function cleanIdValues(value: unknown): number[] {
|
|
return cleanIds(Array.isArray(value) ? value : [value]);
|
|
}
|
|
|
|
function cleanPokemonStats(value: unknown): PokemonStats {
|
|
const row = value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
|
|
|
return pokemonStatLabels.reduce((stats, stat) => {
|
|
const numberValue = Number(row[stat.key] ?? 0);
|
|
if (!Number.isInteger(numberValue) || numberValue < 0) {
|
|
throw validationError('server.validation.statNonNegative');
|
|
}
|
|
|
|
return { ...stats, [stat.key]: numberValue };
|
|
}, {} as PokemonStats);
|
|
}
|
|
|
|
function cleanNonNegativeNumber(value: unknown, message: string): number {
|
|
const numberValue = Number(value ?? 0);
|
|
if (!Number.isFinite(numberValue) || numberValue < 0) {
|
|
throw validationError(message);
|
|
}
|
|
return numberValue;
|
|
}
|
|
|
|
function cleanOptionalNonNegativeInteger(value: unknown, message: string): number | null {
|
|
const rawValue = typeof value === 'string' ? value.trim() : value;
|
|
if (rawValue === undefined || rawValue === null || rawValue === '') {
|
|
return null;
|
|
}
|
|
|
|
const numberValue = Number(rawValue);
|
|
if (!Number.isInteger(numberValue) || numberValue < 0) {
|
|
throw validationError(message);
|
|
}
|
|
|
|
return numberValue;
|
|
}
|
|
|
|
function cleanOptionalSystemListOption(
|
|
value: unknown,
|
|
options: readonly SystemListOption[],
|
|
message: string
|
|
): SystemListOption | null {
|
|
const optionId = cleanOptionalNonNegativeInteger(value, message);
|
|
if (optionId === null) {
|
|
return null;
|
|
}
|
|
|
|
return systemListOptionById(options, optionId, message);
|
|
}
|
|
|
|
function cleanQuantities(value: unknown): IdQuantity[] {
|
|
if (!Array.isArray(value)) {
|
|
return [];
|
|
}
|
|
|
|
return value
|
|
.map((item) => {
|
|
const row = item as Partial<IdQuantity>;
|
|
return {
|
|
itemId: Number(row.itemId),
|
|
quantity: Number(row.quantity)
|
|
};
|
|
})
|
|
.filter((item) => Number.isInteger(item.itemId) && item.itemId > 0 && Number.isInteger(item.quantity) && item.quantity > 0);
|
|
}
|
|
|
|
function cleanOptions(value: unknown, allowedValues: string[]): string[] {
|
|
const values = Array.isArray(value) ? value : [value];
|
|
return [...new Set(values.map((item) => String(item ?? '')).filter((item) => allowedValues.includes(item)))];
|
|
}
|
|
|
|
function orderByEntity(entityAlias: string): string {
|
|
return `${entityAlias}.sort_order, ${entityAlias}.id`;
|
|
}
|
|
|
|
async function withTransaction<T>(callback: (client: DbClient) => Promise<T>): Promise<T> {
|
|
const client = await pool.connect();
|
|
|
|
try {
|
|
await client.query('BEGIN');
|
|
const result = await callback(client);
|
|
await client.query('COMMIT');
|
|
return result;
|
|
} catch (error) {
|
|
await client.query('ROLLBACK');
|
|
throw error;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}
|
|
|
|
async function nextSortOrder(client: DbClient, tableName: string): Promise<number> {
|
|
const result = await client.query<{ sortOrder: number }>(
|
|
`SELECT COALESCE(MAX(sort_order), 0) + 10 AS "sortOrder" FROM ${tableName}`
|
|
);
|
|
return result.rows[0]?.sortOrder ?? 10;
|
|
}
|
|
|
|
async function nextPokemonInternalId(
|
|
client: DbClient,
|
|
dataId: number | null
|
|
): Promise<number> {
|
|
if (dataId !== null) {
|
|
return dataId;
|
|
}
|
|
|
|
const result = await client.query<{ id: number }>(
|
|
'SELECT COALESCE(MAX(id), 999999) + 1 AS id FROM pokemon WHERE id >= 1000000'
|
|
);
|
|
return result.rows[0]?.id ?? 1000000;
|
|
}
|
|
|
|
async function reorderTableRows(
|
|
client: DbClient,
|
|
tableName: string,
|
|
ids: number[],
|
|
userId: number
|
|
): Promise<void> {
|
|
const existing = await client.query<{ id: number; sortOrder: number }>(
|
|
`SELECT id, sort_order AS "sortOrder" FROM ${tableName} WHERE id = ANY($1::integer[])`,
|
|
[ids]
|
|
);
|
|
|
|
if (existing.rowCount !== ids.length) {
|
|
throw validationError('server.validation.recordMissing');
|
|
}
|
|
|
|
const sortOrders = new Map(existing.rows.map((row) => [row.id, row.sortOrder]));
|
|
for (const [index, id] of ids.entries()) {
|
|
const nextSortOrder = (index + 1) * 10;
|
|
const previousSortOrder = sortOrders.get(id);
|
|
if (previousSortOrder === nextSortOrder) {
|
|
continue;
|
|
}
|
|
|
|
await client.query(
|
|
`
|
|
UPDATE ${tableName}
|
|
SET sort_order = $1, updated_by_user_id = $2, updated_at = now()
|
|
WHERE id = $3
|
|
`,
|
|
[nextSortOrder, userId, id]
|
|
);
|
|
}
|
|
}
|
|
|
|
async function recordEditLog(
|
|
client: DbClient,
|
|
entityType: string,
|
|
entityId: number,
|
|
action: EditAction,
|
|
userId: number,
|
|
changes: EditChange[] = []
|
|
): Promise<void> {
|
|
await client.query(
|
|
`
|
|
INSERT INTO wiki_edit_logs (entity_type, entity_id, action, user_id, changes)
|
|
VALUES ($1, $2, $3, $4, $5::jsonb)
|
|
`,
|
|
[entityType, entityId, action, userId, JSON.stringify(changes)]
|
|
);
|
|
}
|
|
|
|
function cleanLanguagePayload(payload: Record<string, unknown>, requireCode: boolean): LanguagePayload {
|
|
const code = typeof payload.code === 'string' ? payload.code.trim() : '';
|
|
if (requireCode && !localePattern.test(code)) {
|
|
throw validationError('server.validation.languageCodeInvalid');
|
|
}
|
|
|
|
const sortOrder = Number(payload.sortOrder ?? 0);
|
|
|
|
return {
|
|
code,
|
|
name: cleanName(payload.name, 'server.validation.languageNameRequired'),
|
|
enabled: payload.enabled !== false,
|
|
isDefault: Boolean(payload.isDefault),
|
|
sortOrder: Number.isInteger(sortOrder) && sortOrder >= 0 ? sortOrder : 0
|
|
};
|
|
}
|
|
|
|
function requireLanguageCode(value: unknown): string {
|
|
const code = typeof value === 'string' ? value.trim() : '';
|
|
if (!localePattern.test(code)) {
|
|
throw validationError('server.validation.languageCodeInvalid');
|
|
}
|
|
return code;
|
|
}
|
|
|
|
export async function listLanguages(includeDisabled = false) {
|
|
return query<LanguagePayload>(
|
|
`
|
|
SELECT code, name, enabled, is_default AS "isDefault", sort_order AS "sortOrder"
|
|
FROM languages
|
|
${includeDisabled ? '' : 'WHERE enabled = true'}
|
|
ORDER BY sort_order, name
|
|
`
|
|
);
|
|
}
|
|
|
|
export async function createLanguage(payload: Record<string, unknown>) {
|
|
const cleanPayload = cleanLanguagePayload(payload, true);
|
|
if (cleanPayload.isDefault && cleanPayload.code !== defaultLocale) {
|
|
throw validationError('server.validation.defaultLanguageMustBeEnglish');
|
|
}
|
|
if (!cleanPayload.enabled && cleanPayload.isDefault) {
|
|
throw validationError('server.validation.defaultLanguageMustBeEnabled');
|
|
}
|
|
|
|
await withTransaction(async (client) => {
|
|
if (cleanPayload.isDefault) {
|
|
await client.query('UPDATE languages SET is_default = false');
|
|
}
|
|
|
|
await client.query(
|
|
`
|
|
INSERT INTO languages (code, name, enabled, is_default, sort_order)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
`,
|
|
[cleanPayload.code, cleanPayload.name, cleanPayload.enabled, cleanPayload.isDefault, cleanPayload.sortOrder]
|
|
);
|
|
});
|
|
|
|
return listLanguages(true);
|
|
}
|
|
|
|
export async function updateLanguage(code: string, payload: Record<string, unknown>) {
|
|
const locale = requireLanguageCode(code);
|
|
const cleanPayload = cleanLanguagePayload({ ...payload, code: locale }, false);
|
|
if (cleanPayload.isDefault && locale !== defaultLocale) {
|
|
throw validationError('server.validation.defaultLanguageMustBeEnglish');
|
|
}
|
|
if (!cleanPayload.enabled && cleanPayload.isDefault) {
|
|
throw validationError('server.validation.defaultLanguageMustBeEnabled');
|
|
}
|
|
|
|
await withTransaction(async (client) => {
|
|
const current = await client.query<{ isDefault: boolean }>(
|
|
'SELECT is_default AS "isDefault" FROM languages WHERE code = $1',
|
|
[locale]
|
|
);
|
|
|
|
if (current.rowCount === 0) {
|
|
throw validationError('server.validation.languageNotFound');
|
|
}
|
|
|
|
if (!cleanPayload.enabled && current.rows[0].isDefault) {
|
|
throw validationError('server.validation.defaultLanguageMustBeEnabled');
|
|
}
|
|
|
|
if (current.rows[0].isDefault && !cleanPayload.isDefault) {
|
|
throw validationError('server.validation.defaultLanguageRequired');
|
|
}
|
|
|
|
if (cleanPayload.isDefault) {
|
|
await client.query('UPDATE languages SET is_default = false WHERE code <> $1', [locale]);
|
|
}
|
|
|
|
await client.query(
|
|
`
|
|
UPDATE languages
|
|
SET name = $1,
|
|
enabled = $2,
|
|
is_default = $3,
|
|
sort_order = $4
|
|
WHERE code = $5
|
|
`,
|
|
[cleanPayload.name, cleanPayload.enabled, cleanPayload.isDefault, cleanPayload.sortOrder, locale]
|
|
);
|
|
});
|
|
|
|
return listLanguages(true);
|
|
}
|
|
|
|
export async function deleteLanguage(code: string) {
|
|
const locale = requireLanguageCode(code);
|
|
if (locale === defaultLocale) {
|
|
throw validationError('server.validation.defaultLanguageCannotBeDeleted');
|
|
}
|
|
|
|
return withTransaction(async (client) => {
|
|
const result = await client.query<{ isDefault: boolean }>(
|
|
'DELETE FROM languages WHERE code = $1 AND is_default = false RETURNING is_default AS "isDefault"',
|
|
[locale]
|
|
);
|
|
return (result.rowCount ?? 0) > 0;
|
|
});
|
|
}
|
|
|
|
export async function reorderLanguages(payload: Record<string, unknown>) {
|
|
const codes = Array.isArray(payload.codes) ? payload.codes.map(requireLanguageCode) : [];
|
|
if (codes.length === 0) {
|
|
throw validationError('server.validation.selectLanguage');
|
|
}
|
|
|
|
await withTransaction(async (client) => {
|
|
const existing = await client.query<{ code: string }>(
|
|
'SELECT code FROM languages WHERE code = ANY($1::text[])',
|
|
[codes]
|
|
);
|
|
|
|
if (existing.rowCount !== codes.length) {
|
|
throw validationError('server.validation.languageDoesNotExist');
|
|
}
|
|
|
|
for (const [index, code] of codes.entries()) {
|
|
await client.query(
|
|
`
|
|
UPDATE languages
|
|
SET sort_order = $1
|
|
WHERE code = $2
|
|
`,
|
|
[(index + 1) * 10, code]
|
|
);
|
|
}
|
|
});
|
|
|
|
return listLanguages(true);
|
|
}
|
|
|
|
function parseCsv(content: string, fileName: string): CsvRow[] {
|
|
const rows: string[][] = [];
|
|
let row: string[] = [];
|
|
let cell = '';
|
|
let inQuotes = false;
|
|
|
|
for (let index = 0; index < content.length; index += 1) {
|
|
const char = content[index];
|
|
|
|
if (inQuotes) {
|
|
if (char === '"' && content[index + 1] === '"') {
|
|
cell += '"';
|
|
index += 1;
|
|
} else if (char === '"') {
|
|
inQuotes = false;
|
|
} else {
|
|
cell += char;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (char === '"') {
|
|
inQuotes = true;
|
|
} else if (char === ',') {
|
|
row.push(cell);
|
|
cell = '';
|
|
} else if (char === '\n') {
|
|
row.push(cell);
|
|
if (row.some((value) => value !== '')) {
|
|
rows.push(row);
|
|
}
|
|
row = [];
|
|
cell = '';
|
|
} else if (char !== '\r') {
|
|
cell += char;
|
|
}
|
|
}
|
|
|
|
if (cell !== '' || row.length > 0) {
|
|
row.push(cell);
|
|
if (row.some((value) => value !== '')) {
|
|
rows.push(row);
|
|
}
|
|
}
|
|
|
|
const headers = rows[0]?.map((header) => header.replace(/^\uFEFF/, ''));
|
|
if (!headers?.length) {
|
|
throw validationError('server.validation.pokemonDataFileEmpty');
|
|
}
|
|
|
|
return rows.slice(1).map((values) =>
|
|
headers.reduce<CsvRow>((record, header, index) => {
|
|
record[header] = values[index] ?? '';
|
|
return record;
|
|
}, {})
|
|
);
|
|
}
|
|
|
|
async function readPokemonDataFile(fileName: string): Promise<string> {
|
|
const sourceDir = dirname(fileURLToPath(import.meta.url));
|
|
const directories = [
|
|
process.env.POKOPIA_DATA_DIR ? resolve(process.env.POKOPIA_DATA_DIR) : '',
|
|
resolve(process.cwd(), 'data'),
|
|
resolve(process.cwd(), '..', 'data'),
|
|
resolve(sourceDir, '..', 'data'),
|
|
resolve(sourceDir, '..', '..', 'data')
|
|
].filter(Boolean);
|
|
const uniqueDirectories = [...new Set(directories)];
|
|
|
|
for (const directory of uniqueDirectories) {
|
|
try {
|
|
return await readFile(resolve(directory, fileName), 'utf8');
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
throw validationError('server.validation.pokemonDataFileUnavailable');
|
|
}
|
|
|
|
function csvInteger(row: CsvRow, fieldName: string): number {
|
|
const value = Number(row[fieldName]);
|
|
return Number.isInteger(value) ? value : 0;
|
|
}
|
|
|
|
function csvNumber(row: CsvRow, fieldName: string): number {
|
|
const value = Number(row[fieldName]);
|
|
return Number.isFinite(value) ? value : 0;
|
|
}
|
|
|
|
function csvText(row: CsvRow, fieldName: string): string {
|
|
return row[fieldName]?.trim() ?? '';
|
|
}
|
|
|
|
function indexRowsByNumber(rows: CsvRow[], fieldName: string): Map<number, CsvRow> {
|
|
return rows.reduce((index, row) => {
|
|
const id = csvInteger(row, fieldName);
|
|
if (id > 0) {
|
|
index.set(id, row);
|
|
}
|
|
return index;
|
|
}, new Map<number, CsvRow>());
|
|
}
|
|
|
|
async function loadPokemonCsvData(): Promise<PokemonCsvData> {
|
|
if (!pokemonCsvDataCache) {
|
|
pokemonCsvDataCache = (async () => {
|
|
const [pokemonContent, namesContent, genusContent, typesContent] = await Promise.all([
|
|
readPokemonDataFile('pokemon_data.csv'),
|
|
readPokemonDataFile('localized_pokemon_name.csv'),
|
|
readPokemonDataFile('localized_pokemon_genus.csv'),
|
|
readPokemonDataFile('localized_type_name.csv')
|
|
]);
|
|
const pokemonRows = parseCsv(pokemonContent, 'pokemon_data.csv');
|
|
const typeRows = parseCsv(typesContent, 'localized_type_name.csv');
|
|
const pokemonByLookup = new Map<string, CsvRow>();
|
|
|
|
for (const row of pokemonRows) {
|
|
const id = csvInteger(row, 'id');
|
|
const identifier = csvText(row, 'identifier').toLowerCase();
|
|
if (id > 0) {
|
|
pokemonByLookup.set(String(id), row);
|
|
}
|
|
if (identifier) {
|
|
pokemonByLookup.set(identifier, row);
|
|
}
|
|
}
|
|
|
|
return {
|
|
pokemonRows,
|
|
pokemonByLookup,
|
|
namesByPokemonId: indexRowsByNumber(parseCsv(namesContent, 'localized_pokemon_name.csv'), 'pokemon_species_id'),
|
|
genusByPokemonId: indexRowsByNumber(parseCsv(genusContent, 'localized_pokemon_genus.csv'), 'pokemon_species_id'),
|
|
typesById: indexRowsByNumber(typeRows, 'type_id'),
|
|
canonicalTypeRows: typeRows.filter((row) => pokemonTypeIconIds.has(csvInteger(row, 'type_id')))
|
|
};
|
|
})();
|
|
}
|
|
|
|
return pokemonCsvDataCache;
|
|
}
|
|
|
|
function pokemonDataLookupKey(value: unknown): string {
|
|
const rawValue = typeof value === 'number' ? String(value) : typeof value === 'string' ? value.trim() : '';
|
|
if (rawValue === '') {
|
|
throw validationError('server.validation.pokemonIdentifierRequired');
|
|
}
|
|
|
|
const numericValue = Number(rawValue);
|
|
if (Number.isInteger(numericValue) && numericValue > 0) {
|
|
return String(numericValue);
|
|
}
|
|
|
|
return rawValue.toLowerCase();
|
|
}
|
|
|
|
function languageCsvColumn(code: string): string | null {
|
|
const [language, region = ''] = code.split('-');
|
|
const languageKey = language.toLowerCase();
|
|
const regionKey = region.toUpperCase();
|
|
const directColumns: Record<string, string> = {
|
|
de: 'de',
|
|
en: 'en',
|
|
es: 'es',
|
|
fr: 'fr',
|
|
it: 'it',
|
|
ja: 'ja',
|
|
ko: 'ko'
|
|
};
|
|
|
|
if (languageKey === 'zh') {
|
|
return ['HK', 'MO', 'TW'].includes(regionKey) ? 'zh_hant' : 'zh_hans';
|
|
}
|
|
|
|
return directColumns[languageKey] ?? null;
|
|
}
|
|
|
|
function localizedCsvText(row: CsvRow, code: string): string {
|
|
const column = languageCsvColumn(code);
|
|
return column ? csvText(row, column) : '';
|
|
}
|
|
|
|
function defaultLanguage(languages: LanguagePayload[]): LanguagePayload | undefined {
|
|
return languages.find((language) => language.isDefault) ?? languages.find((language) => language.code === defaultLocale) ?? languages[0];
|
|
}
|
|
|
|
function defaultCsvText(row: CsvRow, languages: LanguagePayload[], fallback: string): string {
|
|
const defaultCode = defaultLanguage(languages)?.code ?? defaultLocale;
|
|
return localizedCsvText(row, defaultCode) || localizedCsvText(row, defaultLocale) || fallback;
|
|
}
|
|
|
|
function pokemonSpriteUrl(path: string): string {
|
|
return `${pokemonSpriteBaseUrl}${path}`;
|
|
}
|
|
|
|
function pokemonImageWithUrl(candidate: PokemonImageCandidate): PokemonImage {
|
|
return { ...candidate, url: pokemonSpriteUrl(candidate.path), source: 'sprite' };
|
|
}
|
|
|
|
function pokemonImageCandidates(id: number): PokemonImageCandidate[] {
|
|
return [
|
|
{
|
|
path: `/sprites/pokemon/other/official-artwork/${id}.png`,
|
|
style: 'Official artwork',
|
|
version: 'Official artwork',
|
|
variant: 'Default',
|
|
description: 'Large official artwork'
|
|
},
|
|
{
|
|
path: `/sprites/pokemon/other/official-artwork/shiny/${id}.png`,
|
|
style: 'Official artwork',
|
|
version: 'Official artwork',
|
|
variant: 'Shiny',
|
|
description: 'Large shiny official artwork'
|
|
},
|
|
{
|
|
path: `/sprites/pokemon/other/home/${id}.png`,
|
|
style: 'Pokemon HOME',
|
|
version: 'HOME',
|
|
variant: 'Default',
|
|
description: 'Modern HOME render'
|
|
},
|
|
{
|
|
path: `/sprites/pokemon/other/home/shiny/${id}.png`,
|
|
style: 'Pokemon HOME',
|
|
version: 'HOME',
|
|
variant: 'Shiny',
|
|
description: 'Modern shiny HOME render'
|
|
},
|
|
{
|
|
path: `/sprites/pokemon/other/home/female/${id}.png`,
|
|
style: 'Pokemon HOME',
|
|
version: 'HOME',
|
|
variant: 'Female',
|
|
description: 'Modern female HOME render'
|
|
},
|
|
{
|
|
path: `/sprites/pokemon/other/home/shiny/female/${id}.png`,
|
|
style: 'Pokemon HOME',
|
|
version: 'HOME',
|
|
variant: 'Shiny female',
|
|
description: 'Modern shiny female HOME render'
|
|
},
|
|
{
|
|
path: `/sprites/pokemon/other/dream-world/${id}.svg`,
|
|
style: 'Dream World',
|
|
version: 'Dream World',
|
|
variant: 'Default',
|
|
description: 'Dream World SVG artwork'
|
|
},
|
|
{
|
|
path: `/sprites/pokemon/other/dream-world/female/${id}.svg`,
|
|
style: 'Dream World',
|
|
version: 'Dream World',
|
|
variant: 'Female',
|
|
description: 'Dream World female SVG artwork'
|
|
},
|
|
{
|
|
path: `/sprites/pokemon/other/showdown/${id}.gif`,
|
|
style: 'Pokemon Showdown',
|
|
version: 'Showdown',
|
|
variant: 'Front animated',
|
|
description: 'Animated front battle sprite'
|
|
},
|
|
{
|
|
path: `/sprites/pokemon/other/showdown/shiny/${id}.gif`,
|
|
style: 'Pokemon Showdown',
|
|
version: 'Showdown',
|
|
variant: 'Shiny front animated',
|
|
description: 'Animated shiny front battle sprite'
|
|
},
|
|
{
|
|
path: `/sprites/pokemon/other/showdown/female/${id}.gif`,
|
|
style: 'Pokemon Showdown',
|
|
version: 'Showdown',
|
|
variant: 'Female front animated',
|
|
description: 'Animated female front battle sprite'
|
|
},
|
|
{
|
|
path: `/sprites/pokemon/other/showdown/back/${id}.gif`,
|
|
style: 'Pokemon Showdown',
|
|
version: 'Showdown',
|
|
variant: 'Back animated',
|
|
description: 'Animated back battle sprite'
|
|
},
|
|
{
|
|
path: `/sprites/pokemon/${id}.png`,
|
|
style: 'Default sprite',
|
|
version: 'PokeAPI',
|
|
variant: 'Front',
|
|
description: 'Compact front sprite'
|
|
},
|
|
{
|
|
path: `/sprites/pokemon/shiny/${id}.png`,
|
|
style: 'Default sprite',
|
|
version: 'PokeAPI',
|
|
variant: 'Shiny front',
|
|
description: 'Compact shiny front sprite'
|
|
},
|
|
{
|
|
path: `/sprites/pokemon/female/${id}.png`,
|
|
style: 'Default sprite',
|
|
version: 'PokeAPI',
|
|
variant: 'Female front',
|
|
description: 'Compact female front sprite'
|
|
},
|
|
{
|
|
path: `/sprites/pokemon/back/${id}.png`,
|
|
style: 'Default sprite',
|
|
version: 'PokeAPI',
|
|
variant: 'Back',
|
|
description: 'Compact back sprite'
|
|
},
|
|
{
|
|
path: `/sprites/pokemon/back/shiny/${id}.png`,
|
|
style: 'Default sprite',
|
|
version: 'PokeAPI',
|
|
variant: 'Shiny back',
|
|
description: 'Compact shiny back sprite'
|
|
},
|
|
{
|
|
path: `/sprites/pokemon/versions/generation-v/black-white/animated/${id}.gif`,
|
|
style: 'Game version',
|
|
version: 'Black / White',
|
|
variant: 'Animated front',
|
|
description: 'Generation V animated sprite'
|
|
},
|
|
{
|
|
path: `/sprites/pokemon/versions/generation-v/black-white/animated/shiny/${id}.gif`,
|
|
style: 'Game version',
|
|
version: 'Black / White',
|
|
variant: 'Animated shiny',
|
|
description: 'Generation V animated shiny sprite'
|
|
},
|
|
{
|
|
path: `/sprites/pokemon/versions/generation-v/black-white/${id}.png`,
|
|
style: 'Game version',
|
|
version: 'Black / White',
|
|
variant: 'Front',
|
|
description: 'Generation V front sprite'
|
|
},
|
|
{
|
|
path: `/sprites/pokemon/versions/generation-vi/x-y/${id}.png`,
|
|
style: 'Game version',
|
|
version: 'X / Y',
|
|
variant: 'Front',
|
|
description: 'Generation VI front sprite'
|
|
},
|
|
{
|
|
path: `/sprites/pokemon/versions/generation-vii/ultra-sun-ultra-moon/${id}.png`,
|
|
style: 'Game version',
|
|
version: 'Ultra Sun / Ultra Moon',
|
|
variant: 'Front',
|
|
description: 'Generation VII front sprite'
|
|
},
|
|
{
|
|
path: `/sprites/pokemon/versions/generation-ix/scarlet-violet/${id}.png`,
|
|
style: 'Game version',
|
|
version: 'Scarlet / Violet',
|
|
variant: 'Front',
|
|
description: 'Generation IX front sprite'
|
|
},
|
|
{
|
|
path: `/sprites/pokemon/versions/generation-iii/emerald/${id}.png`,
|
|
style: 'Game version',
|
|
version: 'Emerald',
|
|
variant: 'Front',
|
|
description: 'Generation III front sprite'
|
|
},
|
|
{
|
|
path: `/sprites/pokemon/versions/generation-i/red-blue/${id}.png`,
|
|
style: 'Game version',
|
|
version: 'Red / Blue',
|
|
variant: 'Front',
|
|
description: 'Generation I front sprite'
|
|
}
|
|
];
|
|
}
|
|
|
|
function pokemonImageLabel(image: PokemonImage | null | undefined): string {
|
|
if (!image) {
|
|
return '';
|
|
}
|
|
return image.source === 'upload'
|
|
|| image.source === 'external'
|
|
|| isUploadImagePath(image.path)
|
|
|| isExternalImageUrl(image.path)
|
|
? imagePathLabel(image.path)
|
|
: `${image.style} - ${image.version} - ${image.variant}`;
|
|
}
|
|
|
|
function pokemonImageDataIdFromPath(path: string): number | null {
|
|
const match = path.match(/^\/sprites\/pokemon\/(?:.+\/)?([1-9]\d*)\.(?:png|gif|svg)$/);
|
|
if (!match) {
|
|
return null;
|
|
}
|
|
|
|
const id = Number(match[1]);
|
|
return Number.isSafeInteger(id) ? id : null;
|
|
}
|
|
|
|
function pokemonImageCandidateForPath(path: string): PokemonImage | null {
|
|
const cleanPath = path.trim();
|
|
const id = pokemonImageDataIdFromPath(cleanPath);
|
|
if (!id) {
|
|
return null;
|
|
}
|
|
|
|
const candidate = pokemonImageCandidates(id).find((item) => item.path === cleanPath);
|
|
return candidate ? pokemonImageWithUrl(candidate) : null;
|
|
}
|
|
|
|
function cleanPokemonImage(value: unknown, displayId: number): PokemonImage | null {
|
|
const path = typeof value === 'string' ? value.trim() : '';
|
|
if (path === '') {
|
|
return null;
|
|
}
|
|
|
|
if (isExternalImageUrl(path)) {
|
|
return {
|
|
path,
|
|
url: path,
|
|
style: '',
|
|
version: '',
|
|
variant: `#${displayId}`,
|
|
description: '',
|
|
source: 'external'
|
|
};
|
|
}
|
|
|
|
if (isUploadImagePath(path)) {
|
|
if (!path.startsWith('pokemon/')) {
|
|
throw validationError('server.validation.imagePathInvalid');
|
|
}
|
|
return {
|
|
path,
|
|
url: uploadImageUrl(path),
|
|
style: 'Upload',
|
|
version: 'Community upload',
|
|
variant: `#${displayId}`,
|
|
description: '',
|
|
source: 'upload'
|
|
};
|
|
}
|
|
|
|
const image = pokemonImageCandidateForPath(path);
|
|
if (!image) {
|
|
throw validationError('server.validation.pokemonImagePathInvalid');
|
|
}
|
|
return image;
|
|
}
|
|
|
|
async function pokemonImageExists(candidate: PokemonImageCandidate): Promise<boolean> {
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), pokemonSpriteRequestTimeoutMs);
|
|
|
|
try {
|
|
const response = await fetch(pokemonSpriteUrl(candidate.path), { method: 'HEAD', signal: controller.signal });
|
|
return response.ok;
|
|
} catch {
|
|
return false;
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
}
|
|
|
|
function assignTranslation(translations: TranslationInput, locale: string, fieldName: TranslationField, value: string): void {
|
|
if (!value) {
|
|
return;
|
|
}
|
|
|
|
translations[locale] = {
|
|
...(translations[locale] ?? {}),
|
|
[fieldName]: value
|
|
};
|
|
}
|
|
|
|
function localizedCsvTranslations(
|
|
rows: Array<{ row: CsvRow; fieldName: TranslationField }>,
|
|
languages: LanguagePayload[]
|
|
): TranslationInput {
|
|
const translations: TranslationInput = {};
|
|
const defaultCode = defaultLanguage(languages)?.code ?? defaultLocale;
|
|
|
|
for (const language of languages) {
|
|
if (language.code === defaultCode) {
|
|
continue;
|
|
}
|
|
|
|
for (const { row, fieldName } of rows) {
|
|
assignTranslation(translations, language.code, fieldName, localizedCsvText(row, language.code));
|
|
}
|
|
}
|
|
|
|
return cleanTranslations(translations, rows.map((row) => row.fieldName));
|
|
}
|
|
|
|
function fetchedPokemonStats(row: CsvRow): PokemonStats {
|
|
return {
|
|
hp: csvInteger(row, 'hp'),
|
|
attack: csvInteger(row, 'atk'),
|
|
defense: csvInteger(row, 'def'),
|
|
specialAttack: csvInteger(row, 'sp_atk'),
|
|
specialDefense: csvInteger(row, 'sp_def'),
|
|
speed: csvInteger(row, 'spd')
|
|
};
|
|
}
|
|
|
|
function fetchedPokemonTypeIds(row: CsvRow, data: PokemonCsvData): number[] {
|
|
const typeIds = [csvInteger(row, 'type_1_id'), csvInteger(row, 'type_2_id')].filter((typeId) => typeId > 0);
|
|
|
|
if (typeIds.length === 0 || typeIds.some((typeId) => !data.typesById.has(typeId) || !pokemonTypeIconIds.has(typeId))) {
|
|
throw validationError('server.validation.pokemonTypeDataUnavailable');
|
|
}
|
|
|
|
return typeIds;
|
|
}
|
|
|
|
async function ensurePokemonTypeCatalog(
|
|
client: DbClient,
|
|
data: PokemonCsvData,
|
|
languages: LanguagePayload[],
|
|
userId: number
|
|
): Promise<void> {
|
|
for (const row of data.canonicalTypeRows) {
|
|
const typeId = csvInteger(row, 'type_id');
|
|
const name = defaultCsvText(row, languages, csvText(row, 'identifier'));
|
|
const translations = localizedCsvTranslations([{ row, fieldName: 'name' }], languages);
|
|
const existing = await client.query<ConfigChangeSource>(
|
|
`
|
|
SELECT pt.name, ${translationsSelect('pokemon-types', 'pt.id')} AS translations
|
|
FROM pokemon_types pt
|
|
WHERE pt.id = $1
|
|
`,
|
|
[typeId]
|
|
);
|
|
|
|
if (existing.rowCount === 0) {
|
|
await client.query(
|
|
`
|
|
INSERT INTO pokemon_types (
|
|
id,
|
|
name,
|
|
sort_order,
|
|
created_by_user_id,
|
|
updated_by_user_id
|
|
)
|
|
VALUES ($1, $2, $3, $4, $4)
|
|
`,
|
|
[typeId, name, typeId * 10, userId]
|
|
);
|
|
await recordEditLog(client, 'pokemon-types', typeId, 'create', userId);
|
|
} else {
|
|
const changes = configEditChanges(
|
|
{ table: 'pokemon_types', entityType: 'pokemon-types' },
|
|
existing.rows[0],
|
|
{ name, description: '', translations, oppositeName: '', hasItemDrop: false, hasTrading: false, changeLog: '' }
|
|
);
|
|
if (changes.length) {
|
|
await client.query(
|
|
`
|
|
UPDATE pokemon_types
|
|
SET name = $1,
|
|
updated_by_user_id = $2,
|
|
updated_at = now()
|
|
WHERE id = $3
|
|
`,
|
|
[name, userId, typeId]
|
|
);
|
|
await recordEditLog(client, 'pokemon-types', typeId, 'update', userId, changes);
|
|
}
|
|
}
|
|
|
|
await replaceEntityTranslations(client, 'pokemon-types', typeId, translations, ['name']);
|
|
}
|
|
|
|
await client.query(
|
|
`
|
|
SELECT setval(
|
|
pg_get_serial_sequence('pokemon_types', 'id'),
|
|
GREATEST((SELECT COALESCE(MAX(id), 1) FROM pokemon_types), 1),
|
|
true
|
|
)
|
|
`
|
|
);
|
|
}
|
|
|
|
export async function fetchPokemonData(payload: Record<string, unknown>, userId: number): Promise<PokemonFetchResult> {
|
|
const lookupKey = pokemonDataLookupKey(payload.identifier);
|
|
const [data, languages] = await Promise.all([loadPokemonCsvData(), listLanguages()]);
|
|
const pokemonRow = data.pokemonByLookup.get(lookupKey);
|
|
|
|
if (!pokemonRow) {
|
|
throw validationError('server.validation.pokemonDataNotFound');
|
|
}
|
|
|
|
const id = csvInteger(pokemonRow, 'id');
|
|
const nameRow = data.namesByPokemonId.get(id) ?? pokemonRow;
|
|
const genusRow = data.genusByPokemonId.get(id) ?? pokemonRow;
|
|
const identifier = csvText(pokemonRow, 'identifier');
|
|
const typeIds = fetchedPokemonTypeIds(pokemonRow, data);
|
|
|
|
await withTransaction((client) => ensurePokemonTypeCatalog(client, data, languages, userId));
|
|
|
|
return {
|
|
id,
|
|
identifier,
|
|
name: defaultCsvText(nameRow, languages, identifier),
|
|
genus: defaultCsvText(genusRow, languages, ''),
|
|
heightInches: Math.round(csvNumber(pokemonRow, 'height_m') * 39.37007874015748 * 100) / 100,
|
|
weightPounds: Math.round(csvNumber(pokemonRow, 'weight_kg') * 2.2046226218 * 10) / 10,
|
|
translations: localizedCsvTranslations(
|
|
[
|
|
{ row: nameRow, fieldName: 'name' },
|
|
{ row: genusRow, fieldName: 'genus' }
|
|
],
|
|
languages
|
|
),
|
|
typeIds,
|
|
stats: fetchedPokemonStats(pokemonRow)
|
|
};
|
|
}
|
|
|
|
export async function fetchPokemonImageOptions(payload: Record<string, unknown>): Promise<PokemonImageOptionsResult> {
|
|
const lookupKey = pokemonDataLookupKey(payload.identifier);
|
|
const data = await loadPokemonCsvData();
|
|
const pokemonRow = data.pokemonByLookup.get(lookupKey);
|
|
|
|
if (!pokemonRow) {
|
|
throw validationError('server.validation.pokemonDataNotFound');
|
|
}
|
|
|
|
const id = csvInteger(pokemonRow, 'id');
|
|
const images = (
|
|
await Promise.all(
|
|
pokemonImageCandidates(id).map(async (candidate) => (await pokemonImageExists(candidate) ? pokemonImageWithUrl(candidate) : null))
|
|
)
|
|
).filter((image): image is PokemonImage => image !== null);
|
|
|
|
return {
|
|
id,
|
|
identifier: csvText(pokemonRow, 'identifier'),
|
|
images
|
|
};
|
|
}
|
|
|
|
function pokemonFetchOption(row: CsvRow, data: PokemonCsvData, languages: LanguagePayload[], locale: string): PokemonFetchOption {
|
|
const id = csvInteger(row, 'id');
|
|
const identifier = csvText(row, 'identifier');
|
|
const nameRow = data.namesByPokemonId.get(id) ?? row;
|
|
|
|
return {
|
|
id,
|
|
identifier,
|
|
name: localizedCsvText(nameRow, cleanLocale(locale)) || defaultCsvText(nameRow, languages, identifier)
|
|
};
|
|
}
|
|
|
|
function pokemonFetchOptionMatches(
|
|
row: CsvRow,
|
|
data: PokemonCsvData,
|
|
languages: LanguagePayload[],
|
|
locale: string,
|
|
search: string
|
|
): boolean {
|
|
if (!search) {
|
|
return true;
|
|
}
|
|
|
|
const id = csvInteger(row, 'id');
|
|
const identifier = csvText(row, 'identifier');
|
|
const nameRow = data.namesByPokemonId.get(id) ?? row;
|
|
const defaultCode = defaultLanguage(languages)?.code ?? defaultLocale;
|
|
const searchFields = [
|
|
String(id),
|
|
identifier,
|
|
localizedCsvText(nameRow, cleanLocale(locale)),
|
|
localizedCsvText(nameRow, defaultCode),
|
|
localizedCsvText(nameRow, defaultLocale)
|
|
];
|
|
const keyword = search.toLowerCase();
|
|
|
|
return searchFields.some((field) => field.toLowerCase().includes(keyword));
|
|
}
|
|
|
|
export async function listPokemonFetchOptions(paramsQuery: QueryParams, locale = defaultLocale): Promise<PokemonFetchOption[]> {
|
|
const search = asString(paramsQuery.search)?.trim() ?? '';
|
|
const includeAll = asString(paramsQuery.all) === 'true';
|
|
const [data, languages] = await Promise.all([loadPokemonCsvData(), listLanguages()]);
|
|
const rows = data.pokemonRows.filter(
|
|
(row) => csvInteger(row, 'id') > 0 && pokemonFetchOptionMatches(row, data, languages, locale, search)
|
|
);
|
|
|
|
return (includeAll ? rows : rows.slice(0, 20)).map((row) => pokemonFetchOption(row, data, languages, locale));
|
|
}
|
|
|
|
function displayValue(value: string | null | undefined): string {
|
|
const cleanValue = value?.trim() ?? '';
|
|
return cleanValue === '' ? 'None' : cleanValue;
|
|
}
|
|
|
|
function pushChange(changes: EditChange[], label: string, before: string | null | undefined, after: string | null | undefined): void {
|
|
const beforeValue = displayValue(before);
|
|
const afterValue = displayValue(after);
|
|
|
|
if (beforeValue !== afterValue) {
|
|
changes.push({ label, before: beforeValue, after: afterValue });
|
|
}
|
|
}
|
|
|
|
const translationChangeLabels: Record<TranslationField, string> = {
|
|
name: 'Name',
|
|
title: 'Title',
|
|
details: 'Details',
|
|
genus: 'Genus',
|
|
effect: 'Effect',
|
|
mosslaxEffect: 'Mosslax effect'
|
|
};
|
|
|
|
function translationFieldValue(
|
|
translations: TranslationInput | null | undefined,
|
|
locale: string,
|
|
field: TranslationField
|
|
): string | null {
|
|
const value = translations?.[locale]?.[field];
|
|
return typeof value === 'string' && value.trim() !== '' ? value.trim() : null;
|
|
}
|
|
|
|
function pushTranslationChanges(
|
|
changes: EditChange[],
|
|
before: TranslationInput | null | undefined,
|
|
after: TranslationInput,
|
|
fields: TranslationField[]
|
|
): void {
|
|
const locales = [...new Set([...Object.keys(before ?? {}), ...Object.keys(after)])]
|
|
.filter((locale) => locale !== defaultLocale)
|
|
.sort((a, b) => a.localeCompare(b));
|
|
|
|
for (const locale of locales) {
|
|
for (const field of fields) {
|
|
pushChange(
|
|
changes,
|
|
`${translationChangeLabels[field]} (${locale})`,
|
|
translationFieldValue(before, locale, field),
|
|
translationFieldValue(after, locale, field)
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
function boolValue(value: boolean): string {
|
|
return value ? 'Yes' : 'No';
|
|
}
|
|
|
|
function namedListValue(items: Array<{ name: string }> | null | undefined): string {
|
|
if (!items?.length) {
|
|
return 'None';
|
|
}
|
|
|
|
return [...new Set(items.map((item) => item.name))]
|
|
.sort((a, b) => a.localeCompare(b))
|
|
.join(' / ');
|
|
}
|
|
|
|
function quantityListValue(items: Array<{ name: string; quantity: number }> | null | undefined): string {
|
|
if (!items?.length) {
|
|
return 'None';
|
|
}
|
|
|
|
return items
|
|
.map((item) => ({ name: item.name, value: `${item.name} x${item.quantity}` }))
|
|
.sort((a, b) => a.name.localeCompare(b.name))
|
|
.map((item) => item.value)
|
|
.join(' / ');
|
|
}
|
|
|
|
function skillDropListValue(skills: Array<{ name: string; itemDrop?: { name: string } | null }> | null | undefined): string {
|
|
const rows = skills
|
|
?.filter((skill) => skill.itemDrop)
|
|
.map((skill) => `${skill.name}: ${skill.itemDrop?.name}`)
|
|
.sort((a, b) => a.localeCompare(b)) ?? [];
|
|
|
|
return rows.length ? rows.join(' / ') : 'None';
|
|
}
|
|
|
|
function pokemonStatsValue(stats: PokemonStats | null | undefined): string {
|
|
return pokemonStatLabels.map((stat) => `${stat.label}: ${stats?.[stat.key] ?? 0}`).join(' / ');
|
|
}
|
|
|
|
function roundMeasure(value: number, precision: number): number {
|
|
const scale = 10 ** precision;
|
|
return Math.round(value * scale) / scale;
|
|
}
|
|
|
|
function formatFixedMeasure(value: number, precision: number): string {
|
|
return value.toFixed(precision);
|
|
}
|
|
|
|
function feetInchesValue(inches: number): string {
|
|
const totalInches = Math.round(inches);
|
|
const feet = Math.floor(totalInches / 12);
|
|
const remainingInches = totalInches - feet * 12;
|
|
return `${feet}'${remainingInches}"`;
|
|
}
|
|
|
|
function pokemonHeightValue(inches: number | null | undefined): string {
|
|
const value = inches ?? 0;
|
|
return `${feetInchesValue(value)} / ${formatFixedMeasure(roundMeasure(value * 0.0254, 2), 2)} m`;
|
|
}
|
|
|
|
function pokemonWeightValue(pounds: number | null | undefined): string {
|
|
const value = pounds ?? 0;
|
|
return `${formatFixedMeasure(roundMeasure(value, 1), 1)} lb / ${formatFixedMeasure(roundMeasure(value * 0.45359237, 2), 2)} kg`;
|
|
}
|
|
|
|
function appearanceListValue(
|
|
rows: Array<{ name: string; time_of_day: string; weather: string; rarity: number; map: { name: string } }> | null | undefined
|
|
): string {
|
|
if (!rows?.length) {
|
|
return 'None';
|
|
}
|
|
|
|
return rows
|
|
.map((row) => `${row.name}: ${row.time_of_day} / ${row.weather} / ${row.rarity} stars / ${row.map.name}`)
|
|
.sort((a, b) => a.localeCompare(b))
|
|
.join(' / ');
|
|
}
|
|
|
|
async function entityNameMap(client: DbClient, tableName: string, ids: number[]): Promise<Map<number, string>> {
|
|
const uniqueIds = [...new Set(ids)].filter((id) => Number.isInteger(id) && id > 0);
|
|
if (!uniqueIds.length) {
|
|
return new Map();
|
|
}
|
|
|
|
const result = await client.query<{ id: number; name: string }>(
|
|
`SELECT id, name FROM ${tableName} WHERE id = ANY($1::integer[])`,
|
|
[uniqueIds]
|
|
);
|
|
|
|
return new Map(result.rows.map((row) => [row.id, row.name]));
|
|
}
|
|
|
|
function namesFromIds(ids: number[], namesById: Map<number, string>): string {
|
|
const names = [...new Set(ids)]
|
|
.map((id) => namesById.get(id))
|
|
.filter((name): name is string => Boolean(name))
|
|
.sort((a, b) => a.localeCompare(b));
|
|
|
|
return names.length ? names.join(' / ') : 'None';
|
|
}
|
|
|
|
function namedTradingListValue(
|
|
rows: Array<{ name: string; preference: TradingPreference }> | null | undefined
|
|
): string {
|
|
if (!rows?.length) {
|
|
return 'None';
|
|
}
|
|
|
|
return rows
|
|
.map((row) => `${row.preference === 'like' ? 'Likes' : 'Neutral'}: ${row.name}`)
|
|
.sort((a, b) => a.localeCompare(b))
|
|
.join(' / ');
|
|
}
|
|
|
|
async function quantityPayloadValue(client: DbClient, rows: IdQuantity[]): Promise<string> {
|
|
const namesById = await entityNameMap(client, 'items', rows.map((row) => row.itemId));
|
|
return quantityListValue(
|
|
rows
|
|
.map((row) => {
|
|
const name = namesById.get(row.itemId);
|
|
return name ? { name, quantity: row.quantity } : null;
|
|
})
|
|
.filter((row): row is { name: string; quantity: number } => row !== null)
|
|
);
|
|
}
|
|
|
|
async function pokemonEditChanges(
|
|
client: DbClient,
|
|
before: PokemonChangeSource,
|
|
after: PokemonPayload
|
|
): Promise<EditChange[]> {
|
|
const changes: EditChange[] = [];
|
|
const environmentNames = await entityNameMap(client, 'environments', [after.environmentId]);
|
|
const typeNames = await entityNameMap(client, 'pokemon_types', after.typeIds);
|
|
const skillNames = await entityNameMap(client, 'skills', after.skillIds);
|
|
const favoriteThingNames = await entityNameMap(client, 'favorite_things', after.favoriteThingIds);
|
|
const dropSkillNames = await entityNameMap(client, 'skills', after.skillItemDrops.map((drop) => drop.skillId));
|
|
const dropItemNames = await entityNameMap(client, 'items', after.skillItemDrops.map((drop) => drop.itemId));
|
|
const tradingItemNames = await entityNameMap(client, 'items', after.tradingItems.map((item) => item.itemId));
|
|
const afterTradingItems = after.tradingItems
|
|
.map((item) => {
|
|
const itemName = tradingItemNames.get(item.itemId);
|
|
return itemName ? `${item.preference === 'like' ? 'Likes' : 'Neutral'}: ${itemName}` : null;
|
|
})
|
|
.filter((value): value is string => value !== null)
|
|
.sort((a, b) => a.localeCompare(b));
|
|
const afterTradingItemsValue = afterTradingItems.length ? afterTradingItems.join(' / ') : 'None';
|
|
const afterDrops = after.skillItemDrops
|
|
.map((drop) => {
|
|
const skillName = dropSkillNames.get(drop.skillId);
|
|
const itemName = dropItemNames.get(drop.itemId);
|
|
return skillName && itemName ? `${skillName}: ${itemName}` : null;
|
|
})
|
|
.filter((drop): drop is string => drop !== null)
|
|
.sort((a, b) => a.localeCompare(b))
|
|
.join(' / ');
|
|
|
|
pushChange(changes, 'Name', before.name, after.name);
|
|
pushChange(changes, 'Pokopia ID', String(before.displayId), String(after.displayId));
|
|
pushChange(changes, 'Event Pokemon', boolValue(before.isEventItem), boolValue(after.isEventItem));
|
|
pushChange(changes, 'Genus', before.genus, after.genus);
|
|
pushChange(changes, 'Details', before.details, after.details);
|
|
pushTranslationChanges(changes, before.translations, after.translations, ['name', 'genus', 'details']);
|
|
pushChange(changes, 'Height', pokemonHeightValue(before.heightInches), pokemonHeightValue(after.heightInches));
|
|
pushChange(changes, 'Weight', pokemonWeightValue(before.weightPounds), pokemonWeightValue(after.weightPounds));
|
|
pushChange(changes, 'Image', pokemonImageLabel(before.image), pokemonImageLabel(after.image));
|
|
pushChange(changes, 'Types', namedListValue(before.types), namesFromIds(after.typeIds, typeNames));
|
|
pushChange(changes, 'Stats', pokemonStatsValue(before.stats), pokemonStatsValue(after.stats));
|
|
pushChange(changes, 'Ideal Habitat', before.environment.name, environmentNames.get(after.environmentId));
|
|
pushChange(changes, 'Specialities', namedListValue(before.skills), namesFromIds(after.skillIds, skillNames));
|
|
pushChange(changes, 'Favourites', namedListValue(before.favorite_things), namesFromIds(after.favoriteThingIds, favoriteThingNames));
|
|
pushChange(changes, 'Trading items', namedTradingListValue(before.tradingItems), afterTradingItemsValue);
|
|
pushChange(changes, 'Speciality drops', skillDropListValue(before.skills), afterDrops);
|
|
|
|
return changes;
|
|
}
|
|
|
|
async function itemEditChanges(
|
|
client: DbClient,
|
|
before: ItemChangeSource,
|
|
after: ItemPayload
|
|
): Promise<EditChange[]> {
|
|
const changes: EditChange[] = [];
|
|
const methodNames = await entityNameMap(client, 'acquisition_methods', after.acquisitionMethodIds);
|
|
const tagNames = await entityNameMap(client, 'favorite_things', after.tagIds);
|
|
|
|
pushChange(changes, 'Name', before.name, after.name);
|
|
pushChange(changes, 'Description', before.details, after.details);
|
|
pushChange(
|
|
changes,
|
|
'Base Price',
|
|
before.basePrice === null ? null : String(before.basePrice),
|
|
after.basePrice === null ? null : String(after.basePrice)
|
|
);
|
|
pushChange(
|
|
changes,
|
|
'Ancient Artifact',
|
|
before.ancientArtifactCategory?.name ?? 'None',
|
|
systemListNameByKey(ancientArtifactCategoryOptions, after.ancientArtifactCategoryKey) ?? 'None'
|
|
);
|
|
pushTranslationChanges(changes, before.translations, after.translations, ['name', 'details']);
|
|
pushChange(changes, 'Event item', boolValue(before.isEventItem), boolValue(after.isEventItem));
|
|
pushChange(changes, 'Image', imagePathLabel(before.image?.path), imagePathLabel(after.imagePath));
|
|
pushChange(changes, 'Category', before.category.name, systemListNameByKey(itemCategoryOptions, after.categoryKey));
|
|
pushChange(changes, 'Usage', before.usage?.name, systemListNameByKey(itemUsageOptions, after.usageKey));
|
|
pushChange(changes, 'Dyeability', dyeabilityValue(before.customization.dyeability), dyeabilityValue(after.dyeability));
|
|
pushChange(changes, 'Pattern editable', boolValue(before.customization.patternEditable), boolValue(after.patternEditable));
|
|
pushChange(changes, 'No recipe', boolValue(before.noRecipe), boolValue(after.noRecipe));
|
|
pushChange(changes, 'Acquisition methods', namedListValue(before.acquisitionMethods), namesFromIds(after.acquisitionMethodIds, methodNames));
|
|
pushChange(changes, 'Tags', namedListValue(before.tags), namesFromIds(after.tagIds, tagNames));
|
|
|
|
return changes;
|
|
}
|
|
|
|
type ItemPossibleTagEntity = {
|
|
id: number;
|
|
name: string;
|
|
};
|
|
|
|
type ItemPossibleTagPokemon = {
|
|
id: number;
|
|
displayId: number;
|
|
name: string;
|
|
isEventItem: boolean;
|
|
image: EntityImageValue | null;
|
|
};
|
|
|
|
type ItemPossibleTagObservation = {
|
|
pokemon: ItemPossibleTagPokemon;
|
|
preference: TradingPreference;
|
|
tags: ItemPossibleTagEntity[];
|
|
};
|
|
|
|
type ItemPossibleTags = {
|
|
highlyLikely: ItemPossibleTagEntity[];
|
|
possible: ItemPossibleTagEntity[];
|
|
excluded: ItemPossibleTagEntity[];
|
|
evidence: {
|
|
likes: ItemPossibleTagObservation[];
|
|
neutral: ItemPossibleTagObservation[];
|
|
};
|
|
};
|
|
|
|
function inferItemPossibleTags(
|
|
allTags: ItemPossibleTagEntity[],
|
|
observations: ItemPossibleTagObservation[]
|
|
): ItemPossibleTags {
|
|
const allTagIds = new Set(allTags.map((tag) => tag.id));
|
|
const neutralExcludedTagIds = new Set<number>();
|
|
const filteredLikeSets: number[][] = [];
|
|
const likes: ItemPossibleTagObservation[] = [];
|
|
const neutral: ItemPossibleTagObservation[] = [];
|
|
|
|
for (const observation of observations) {
|
|
const filteredTagIds = [...new Set(observation.tags.map((tag) => tag.id).filter((id) => allTagIds.has(id)))];
|
|
const filteredObservation = {
|
|
...observation,
|
|
tags: observation.tags.filter((tag) => allTagIds.has(tag.id))
|
|
};
|
|
|
|
if (observation.preference === 'neutral') {
|
|
neutral.push(filteredObservation);
|
|
filteredTagIds.forEach((id) => neutralExcludedTagIds.add(id));
|
|
continue;
|
|
}
|
|
|
|
likes.push(filteredObservation);
|
|
if (filteredTagIds.length > 0) {
|
|
filteredLikeSets.push(filteredTagIds);
|
|
}
|
|
}
|
|
|
|
const allowedTagIds = allTags.map((tag) => tag.id).filter((id) => !neutralExcludedTagIds.has(id));
|
|
const conflicts = likes.some((observation) => observation.tags.length > 0 && observation.tags.every((tag) => neutralExcludedTagIds.has(tag.id)));
|
|
const unionLikeIds = new Set(filteredLikeSets.flat());
|
|
const intersectionLikeIds =
|
|
filteredLikeSets.length >= 2
|
|
? filteredLikeSets.reduce((result, current) => result.filter((id) => current.includes(id)))
|
|
: [];
|
|
const candidateTagIds = conflicts
|
|
? []
|
|
: filteredLikeSets.length > 0
|
|
? allowedTagIds.filter((id) => unionLikeIds.has(id))
|
|
: allowedTagIds;
|
|
const highlyLikelyTagIds = filteredLikeSets.length >= 2
|
|
? intersectionLikeIds.filter((id) => candidateTagIds.includes(id))
|
|
: [];
|
|
const possibleTagIds = candidateTagIds.filter((id) => !highlyLikelyTagIds.includes(id));
|
|
const excludedTagIds = allTags.map((tag) => tag.id).filter((id) => !candidateTagIds.includes(id));
|
|
|
|
const tagsById = new Map(allTags.map((tag) => [tag.id, tag]));
|
|
return {
|
|
highlyLikely: highlyLikelyTagIds.map((id) => tagsById.get(id)).filter((tag): tag is ItemPossibleTagEntity => Boolean(tag)),
|
|
possible: possibleTagIds.map((id) => tagsById.get(id)).filter((tag): tag is ItemPossibleTagEntity => Boolean(tag)),
|
|
excluded: excludedTagIds.map((id) => tagsById.get(id)).filter((tag): tag is ItemPossibleTagEntity => Boolean(tag)),
|
|
evidence: { likes, neutral }
|
|
};
|
|
}
|
|
|
|
async function ancientArtifactEditChanges(
|
|
client: DbClient,
|
|
before: AncientArtifactChangeSource,
|
|
after: AncientArtifactPayload
|
|
): Promise<EditChange[]> {
|
|
const changes: EditChange[] = [];
|
|
const tagNames = await entityNameMap(client, 'favorite_things', after.tagIds);
|
|
|
|
pushChange(changes, 'Name', before.name, after.name);
|
|
pushChange(changes, 'Description', before.details, after.details);
|
|
pushTranslationChanges(changes, before.translations, after.translations, ['name', 'details']);
|
|
pushChange(changes, 'Image', imagePathLabel(before.image?.path), imagePathLabel(after.imagePath));
|
|
pushChange(changes, 'Category', before.category.name, systemListNameByKey(ancientArtifactCategoryOptions, after.categoryKey));
|
|
pushChange(changes, 'Tags', namedListValue(before.tags), namesFromIds(after.tagIds, tagNames));
|
|
return changes;
|
|
}
|
|
|
|
async function habitatEditChanges(
|
|
client: DbClient,
|
|
before: HabitatChangeSource,
|
|
after: HabitatPayload
|
|
): Promise<EditChange[]> {
|
|
const changes: EditChange[] = [];
|
|
const pokemonNames = await entityNameMap(client, 'pokemon', after.pokemonAppearances.map((row) => row.pokemonId));
|
|
const mapNames = await entityNameMap(client, 'maps', after.pokemonAppearances.map((row) => row.mapId));
|
|
const afterAppearances = after.pokemonAppearances
|
|
.map((row) => {
|
|
const pokemonName = pokemonNames.get(row.pokemonId);
|
|
const mapName = mapNames.get(row.mapId);
|
|
return pokemonName && mapName ? `${pokemonName}: ${row.timeOfDay} / ${row.weather} / ${row.rarity} stars / ${mapName}` : null;
|
|
})
|
|
.filter((row): row is string => row !== null)
|
|
.sort((a, b) => a.localeCompare(b))
|
|
.join(' / ');
|
|
|
|
pushChange(changes, 'Name', before.name, after.name);
|
|
pushTranslationChanges(changes, before.translations, after.translations, ['name']);
|
|
pushChange(changes, 'Event Habitat', boolValue(before.isEventItem), boolValue(after.isEventItem));
|
|
pushChange(changes, 'Image', imagePathLabel(before.image?.path), imagePathLabel(after.imagePath));
|
|
pushChange(changes, 'Recipe', quantityListValue(before.recipe), await quantityPayloadValue(client, after.recipeItems));
|
|
pushChange(changes, 'Possible Pokemon', appearanceListValue(before.pokemon), afterAppearances);
|
|
|
|
return changes;
|
|
}
|
|
|
|
async function recipeEditChanges(
|
|
client: DbClient,
|
|
before: RecipeChangeSource,
|
|
after: RecipePayload
|
|
): Promise<EditChange[]> {
|
|
const changes: EditChange[] = [];
|
|
const itemNames = await entityNameMap(client, 'items', [after.itemId]);
|
|
const methodNames = await entityNameMap(client, 'acquisition_methods', after.acquisitionMethodIds);
|
|
|
|
pushChange(changes, 'Item', before.item.name, itemNames.get(after.itemId));
|
|
pushChange(changes, 'Acquisition methods', namedListValue(before.acquisition_methods), namesFromIds(after.acquisitionMethodIds, methodNames));
|
|
pushChange(changes, 'Materials', quantityListValue(before.materials), await quantityPayloadValue(client, after.materials));
|
|
|
|
return changes;
|
|
}
|
|
|
|
function dailyChecklistEditChanges(before: DailyChecklistChangeSource, after: DailyChecklistPayload): EditChange[] {
|
|
const changes: EditChange[] = [];
|
|
pushChange(changes, 'Title', before.title, after.title);
|
|
pushTranslationChanges(changes, before.translations, after.translations, ['title']);
|
|
return changes;
|
|
}
|
|
|
|
function configEditChanges(
|
|
definition: ConfigDefinition,
|
|
before: ConfigChangeSource,
|
|
after: {
|
|
name: string;
|
|
description: string;
|
|
translations: TranslationInput;
|
|
oppositeName: string;
|
|
hasItemDrop: boolean;
|
|
hasTrading: boolean;
|
|
changeLog: string;
|
|
}
|
|
): EditChange[] {
|
|
const changes: EditChange[] = [];
|
|
pushChange(changes, 'Name', before.name, after.name);
|
|
pushTranslationChanges(changes, before.translations, after.translations, ['name']);
|
|
if (definition.hasDescription) {
|
|
pushChange(changes, 'Description', before.description, after.description);
|
|
}
|
|
if (definition.oppositeColumn) {
|
|
pushChange(changes, 'Opposite', before.opposite?.name ?? '', after.oppositeName);
|
|
}
|
|
if (definition.hasItemDrop) {
|
|
pushChange(changes, 'Has item drop', boolValue(Boolean(before.hasItemDrop)), boolValue(after.hasItemDrop));
|
|
}
|
|
if (definition.hasTrading) {
|
|
pushChange(changes, 'Has trading', boolValue(Boolean(before.hasTrading)), boolValue(after.hasTrading));
|
|
}
|
|
if (definition.hasChangeLog) {
|
|
pushChange(changes, 'ChangeLog', before.changeLog, after.changeLog);
|
|
}
|
|
return changes;
|
|
}
|
|
|
|
function getEditHistory(entityType: string, entityId: number): Promise<EditHistoryEntry[]> {
|
|
return query(
|
|
`
|
|
SELECT
|
|
l.action,
|
|
COALESCE(l.changes, '[]'::jsonb) AS changes,
|
|
l.created_at AS "createdAt",
|
|
CASE
|
|
WHEN u.id IS NULL THEN NULL
|
|
ELSE json_build_object('id', u.id, 'displayName', u.display_name)
|
|
END AS user
|
|
FROM wiki_edit_logs l
|
|
LEFT JOIN users u ON u.id = l.user_id
|
|
WHERE l.entity_type = $1
|
|
AND l.entity_id = $2
|
|
ORDER BY l.created_at DESC, l.id DESC
|
|
`,
|
|
[entityType, entityId]
|
|
);
|
|
}
|
|
|
|
function pokemonProjection(locale: string): string {
|
|
const pokemonName = localizedName('pokemon', 'p', locale);
|
|
const pokemonGenus = localizedField('pokemon', 'p.id', 'p.genus', 'genus', locale);
|
|
const pokemonDetails = localizedField('pokemon', 'p.id', 'p.details', 'details', locale);
|
|
const typeName = localizedName('pokemon-types', 'pt', locale);
|
|
const environmentName = localizedName('environments', 'e', locale);
|
|
const skillName = localizedName('skills', 's', locale);
|
|
const favoriteThingName = localizedName('favorite-things', 'ft', locale);
|
|
|
|
return `
|
|
SELECT
|
|
p.id,
|
|
p.data_id AS "dataId",
|
|
p.data_identifier AS "dataIdentifier",
|
|
p.display_id AS "displayId",
|
|
${pokemonName} AS name,
|
|
p.name AS "baseName",
|
|
p.is_event_item AS "isEventItem",
|
|
${pokemonGenus} AS genus,
|
|
p.genus AS "baseGenus",
|
|
${pokemonDetails} AS details,
|
|
p.details AS "baseDetails",
|
|
p.height_inches AS "heightInches",
|
|
round((p.height_inches * 0.0254)::numeric, 2)::double precision AS "heightMeters",
|
|
p.weight_pounds AS "weightPounds",
|
|
round((p.weight_pounds * 0.45359237)::numeric, 2)::double precision AS "weightKg",
|
|
${pokemonImageJson('p')} AS image,
|
|
json_build_object(
|
|
'hp', p.hp,
|
|
'attack', p.attack,
|
|
'defense', p.defense,
|
|
'specialAttack', p.special_attack,
|
|
'specialDefense', p.special_defense,
|
|
'speed', p.speed
|
|
) AS stats,
|
|
${translationsSelect('pokemon', 'p.id')} AS translations,
|
|
${auditSelect('p', 'pokemon_created_user', 'pokemon_updated_user')},
|
|
json_build_object(
|
|
'id', e.id,
|
|
'name', ${environmentName},
|
|
'description', e.description
|
|
) AS environment,
|
|
COALESCE((
|
|
SELECT json_agg(json_build_object('id', pt.id, 'name', ${typeName}) ORDER BY ppt.slot_order)
|
|
FROM pokemon_pokemon_types ppt
|
|
JOIN pokemon_types pt ON pt.id = ppt.type_id
|
|
WHERE ppt.pokemon_id = p.id
|
|
), '[]'::json) AS types,
|
|
COALESCE((
|
|
SELECT json_agg(json_build_object('id', s.id, 'name', ${skillName}, 'hasItemDrop', s.has_item_drop, 'hasTrading', s.has_trading) ORDER BY ${orderByEntity('s')})
|
|
FROM pokemon_skills ps
|
|
JOIN skills s ON s.id = ps.skill_id
|
|
WHERE ps.pokemon_id = p.id
|
|
), '[]'::json) AS skills,
|
|
COALESCE((
|
|
SELECT json_agg(
|
|
json_build_object(
|
|
'id', ft.id,
|
|
'name', ${favoriteThingName}
|
|
)
|
|
ORDER BY ${orderByEntity('ft')}
|
|
)
|
|
FROM pokemon_favorite_things pft
|
|
JOIN favorite_things ft ON ft.id = pft.favorite_thing_id
|
|
WHERE pft.pokemon_id = p.id
|
|
), '[]'::json) AS favorite_things
|
|
FROM pokemon p
|
|
JOIN environments e ON e.id = p.environment_id
|
|
${auditJoins('p', 'pokemon_created_user', 'pokemon_updated_user')}
|
|
`;
|
|
}
|
|
|
|
export async function getOptions(locale = defaultLocale) {
|
|
const [
|
|
pokemonTypes,
|
|
skills,
|
|
environments,
|
|
favoriteThings,
|
|
acquisitionMethods,
|
|
maps,
|
|
gameVersions,
|
|
dishFlavors,
|
|
moduleSettings
|
|
] = await Promise.all([
|
|
optionSelect('pokemon_types', 'pokemon-types', locale),
|
|
skillOptions(locale),
|
|
optionSelect('environments', 'environments', locale),
|
|
optionSelect('favorite_things', 'favorite-things', locale),
|
|
optionSelect('acquisition_methods', 'acquisition-methods', locale),
|
|
optionSelect('maps', 'maps', locale),
|
|
gameVersionOptions(locale),
|
|
optionSelect('dish_flavors', 'dish-flavors', locale),
|
|
getModuleSettings()
|
|
]);
|
|
|
|
return {
|
|
pokemonTypes,
|
|
skills,
|
|
environments,
|
|
favoriteThings,
|
|
itemCategories: systemListOptions(itemCategoryOptions, locale),
|
|
itemUsages: systemListOptions(itemUsageOptions, locale),
|
|
ancientArtifactCategories: systemListOptions(ancientArtifactCategoryOptions, locale),
|
|
acquisitionMethods,
|
|
itemTags: favoriteThings,
|
|
maps,
|
|
gameVersions,
|
|
dishFlavors,
|
|
moduleSettings
|
|
};
|
|
}
|
|
|
|
function cleanDailyChecklistPayload(payload: Record<string, unknown>): DailyChecklistPayload {
|
|
return {
|
|
title: cleanName(payload.title, 'server.validation.taskRequired'),
|
|
translations: cleanTranslations(payload.translations, ['title'])
|
|
};
|
|
}
|
|
|
|
export async function listDailyChecklistItems(paramsQuery: QueryParams = {}, locale = defaultLocale) {
|
|
const title = localizedField('daily-checklist-items', 'c.id', 'c.title', 'title', locale);
|
|
return queryMaybePaged(
|
|
`
|
|
SELECT c.id, ${title} AS title, c.title AS "baseTitle", ${translationsSelect('daily-checklist-items', 'c.id')} AS translations
|
|
FROM daily_checklist_items c
|
|
ORDER BY c.sort_order, c.id
|
|
`,
|
|
[],
|
|
paramsQuery
|
|
);
|
|
}
|
|
|
|
export async function globalSearch(paramsQuery: QueryParams = {}, locale = defaultLocale): Promise<GlobalSearchResults> {
|
|
const search = asString(paramsQuery.query)?.trim() ?? asString(paramsQuery.search)?.trim() ?? '';
|
|
if (!search) {
|
|
return { query: '', groups: [] };
|
|
}
|
|
|
|
const pattern = `%${search}%`;
|
|
const limit = 5;
|
|
const pokemonName = localizedName('pokemon', 'p', locale);
|
|
const habitatName = localizedName('habitats', 'h', locale);
|
|
const itemName = localizedName('items', 'i', locale);
|
|
const itemCategoryName = systemListJsonSql('i.category_key', itemCategoryOptions, locale);
|
|
const artifactName = localizedName('items', 'artifact_item', locale);
|
|
const artifactCategoryName = systemListJsonSql('artifact_item.ancient_artifact_category_key', ancientArtifactCategoryOptions, locale);
|
|
const recipeItemName = localizedName('items', 'result_item', locale);
|
|
const recipeMaterialName = localizedName('items', 'material_item', locale);
|
|
const checklistTitle = localizedField('daily-checklist-items', 'c.id', 'c.title', 'title', locale);
|
|
const [pokemon, habitats, items, artifacts, recipes, checklist, life, users] = await Promise.all([
|
|
query<GlobalSearchItem>(
|
|
`
|
|
SELECT
|
|
p.id,
|
|
'pokemon' AS type,
|
|
${pokemonName} AS title,
|
|
'/pokemon/' || p.id AS url,
|
|
NULLIF(p.genus, '') AS summary,
|
|
'#' || p.display_id::text AS meta,
|
|
${pokemonImageJson('p')} AS image
|
|
FROM pokemon p
|
|
WHERE ${pokemonName} ILIKE $1
|
|
ORDER BY p.display_id, p.id
|
|
LIMIT $2
|
|
`,
|
|
[pattern, limit]
|
|
),
|
|
query<GlobalSearchItem>(
|
|
`
|
|
SELECT
|
|
h.id,
|
|
'habitats' AS type,
|
|
${habitatName} AS title,
|
|
'/habitats/' || h.id AS url,
|
|
NULL AS summary,
|
|
NULL AS meta,
|
|
${uploadedImageJson('h.image_path')} AS image
|
|
FROM habitats h
|
|
WHERE ${habitatName} ILIKE $1
|
|
ORDER BY ${orderByEntity('h')}
|
|
LIMIT $2
|
|
`,
|
|
[pattern, limit]
|
|
),
|
|
query<GlobalSearchItem>(
|
|
`
|
|
SELECT
|
|
i.id,
|
|
'items' AS type,
|
|
${itemName} AS title,
|
|
'/items/' || i.id AS url,
|
|
NULLIF(i.details, '') AS summary,
|
|
(${itemCategoryName}->>'name') AS meta,
|
|
${uploadedImageJson('i.image_path')} AS image
|
|
FROM items i
|
|
WHERE ${itemName} ILIKE $1
|
|
ORDER BY ${orderByEntity('i')}
|
|
LIMIT $2
|
|
`,
|
|
[pattern, limit]
|
|
),
|
|
query<GlobalSearchItem>(
|
|
`
|
|
SELECT
|
|
artifact_item.id,
|
|
'ancient-artifacts' AS type,
|
|
${artifactName} AS title,
|
|
'/ancient-artifacts/' || artifact_item.id AS url,
|
|
NULLIF(artifact_item.details, '') AS summary,
|
|
(${artifactCategoryName}->>'name') AS meta,
|
|
${uploadedImageJson('artifact_item.image_path')} AS image
|
|
FROM items artifact_item
|
|
WHERE ${artifactName} ILIKE $1
|
|
AND artifact_item.ancient_artifact_category_key IS NOT NULL
|
|
ORDER BY ${orderByEntity('artifact_item')}
|
|
LIMIT $2
|
|
`,
|
|
[pattern, limit]
|
|
),
|
|
query<GlobalSearchItem>(
|
|
`
|
|
SELECT
|
|
r.id,
|
|
'recipes' AS type,
|
|
${recipeItemName} AS title,
|
|
'/recipes/' || r.id AS url,
|
|
(
|
|
SELECT string_agg(material_rows.name, ' / ' ORDER BY material_rows.name)
|
|
FROM (
|
|
SELECT DISTINCT ${recipeMaterialName} AS name
|
|
FROM recipe_materials rm
|
|
JOIN items material_item ON material_item.id = rm.item_id
|
|
WHERE rm.recipe_id = r.id
|
|
) material_rows
|
|
) AS summary,
|
|
NULL AS meta,
|
|
${uploadedImageJson('result_item.image_path')} AS image
|
|
FROM recipes r
|
|
JOIN items result_item ON result_item.id = r.item_id
|
|
WHERE ${recipeItemName} ILIKE $1
|
|
OR EXISTS (
|
|
SELECT 1
|
|
FROM recipe_materials rm
|
|
JOIN items material_item ON material_item.id = rm.item_id
|
|
WHERE rm.recipe_id = r.id
|
|
AND ${recipeMaterialName} ILIKE $1
|
|
)
|
|
ORDER BY ${orderByEntity('r')}
|
|
LIMIT $2
|
|
`,
|
|
[pattern, limit]
|
|
),
|
|
query<GlobalSearchItem>(
|
|
`
|
|
SELECT
|
|
c.id,
|
|
'daily-checklist' AS type,
|
|
${checklistTitle} AS title,
|
|
'/checklist' AS url,
|
|
NULL AS summary,
|
|
NULL AS meta,
|
|
NULL AS image
|
|
FROM daily_checklist_items c
|
|
WHERE ${checklistTitle} ILIKE $1
|
|
ORDER BY ${orderByEntity('c')}
|
|
LIMIT $2
|
|
`,
|
|
[pattern, limit]
|
|
),
|
|
query<GlobalSearchItem>(
|
|
`
|
|
SELECT
|
|
lp.id,
|
|
'life' AS type,
|
|
LEFT(lp.body, 120) AS title,
|
|
'/life/' || lp.id AS url,
|
|
NULL AS summary,
|
|
NULL AS meta,
|
|
NULL AS image
|
|
FROM life_posts lp
|
|
WHERE lp.deleted_at IS NULL
|
|
AND lp.ai_moderation_status = 'approved'
|
|
AND lp.body ILIKE $1
|
|
ORDER BY lp.created_at DESC, lp.id DESC
|
|
LIMIT $2
|
|
`,
|
|
[pattern, limit]
|
|
),
|
|
query<GlobalSearchItem>(
|
|
`
|
|
SELECT
|
|
u.id,
|
|
'users' AS type,
|
|
u.display_name AS title,
|
|
'/profile/' || u.id AS url,
|
|
NULL AS summary,
|
|
NULL AS meta,
|
|
NULL AS image
|
|
FROM users u
|
|
WHERE u.display_name ILIKE $1
|
|
ORDER BY lower(u.display_name), u.id
|
|
LIMIT $2
|
|
`,
|
|
[pattern, limit]
|
|
)
|
|
]);
|
|
|
|
const groups: GlobalSearchGroup[] = [
|
|
{ type: 'pokemon', items: pokemon },
|
|
{ type: 'habitats', items: habitats },
|
|
{ type: 'items', items: items },
|
|
{ type: 'ancient-artifacts', items: artifacts },
|
|
{ type: 'recipes', items: recipes },
|
|
{ type: 'daily-checklist', items: checklist },
|
|
{ type: 'life', items: life },
|
|
{ type: 'users', items: users }
|
|
];
|
|
|
|
return { query: search, groups: groups.filter((group) => group.items.length > 0) };
|
|
}
|
|
|
|
async function getDailyChecklistItemById(id: number, locale = defaultLocale) {
|
|
const title = localizedField('daily-checklist-items', 'c.id', 'c.title', 'title', locale);
|
|
return queryOne(
|
|
`
|
|
SELECT c.id, ${title} AS title, c.title AS "baseTitle", ${translationsSelect('daily-checklist-items', 'c.id')} AS translations
|
|
FROM daily_checklist_items c
|
|
WHERE c.id = $1
|
|
`,
|
|
[id]
|
|
);
|
|
}
|
|
|
|
export async function createDailyChecklistItem(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
|
|
const cleanPayload = cleanDailyChecklistPayload(payload);
|
|
|
|
const id = await withTransaction(async (client) => {
|
|
const orderResult = await client.query<{ sortOrder: number }>(
|
|
'SELECT COALESCE(MAX(sort_order), 0) + 10 AS "sortOrder" FROM daily_checklist_items'
|
|
);
|
|
const sortOrder = orderResult.rows[0]?.sortOrder ?? 10;
|
|
|
|
const result = await client.query<{ id: number }>(
|
|
`
|
|
INSERT INTO daily_checklist_items (title, sort_order, created_by_user_id, updated_by_user_id)
|
|
VALUES ($1, $2, $3, $3)
|
|
RETURNING id
|
|
`,
|
|
[cleanPayload.title, sortOrder, userId]
|
|
);
|
|
|
|
const createdId = result.rows[0].id;
|
|
await replaceEntityTranslations(client, 'daily-checklist-items', createdId, cleanPayload.translations, ['title']);
|
|
await recordEditLog(client, 'daily-checklist-items', createdId, 'create', userId);
|
|
return createdId;
|
|
});
|
|
|
|
return getDailyChecklistItemById(id, locale);
|
|
}
|
|
|
|
export async function updateDailyChecklistItem(
|
|
id: number,
|
|
payload: Record<string, unknown>,
|
|
userId: number,
|
|
locale = defaultLocale
|
|
) {
|
|
const cleanPayload = cleanDailyChecklistPayload(payload);
|
|
const before = await getDailyChecklistItemById(id, defaultLocale);
|
|
|
|
const updated = await withTransaction(async (client) => {
|
|
const result = await client.query(
|
|
`
|
|
UPDATE daily_checklist_items
|
|
SET title = $1, updated_by_user_id = $2, updated_at = now()
|
|
WHERE id = $3
|
|
`,
|
|
[cleanPayload.title, userId, id]
|
|
);
|
|
|
|
if (result.rowCount === 0) {
|
|
return false;
|
|
}
|
|
|
|
await replaceEntityTranslations(client, 'daily-checklist-items', id, cleanPayload.translations, ['title']);
|
|
const changes = before ? dailyChecklistEditChanges(before as DailyChecklistChangeSource, cleanPayload) : [];
|
|
await recordEditLog(client, 'daily-checklist-items', id, 'update', userId, changes);
|
|
return true;
|
|
});
|
|
|
|
return updated ? getDailyChecklistItemById(id, locale) : null;
|
|
}
|
|
|
|
export async function reorderDailyChecklistItems(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
|
|
const ids = cleanIds(payload.ids);
|
|
if (ids.length === 0) {
|
|
throw validationError('server.validation.selectTask');
|
|
}
|
|
|
|
await withTransaction(async (client) => {
|
|
const existing = await client.query<{ id: number; sortOrder: number }>(
|
|
'SELECT id, sort_order AS "sortOrder" FROM daily_checklist_items WHERE id = ANY($1::integer[])',
|
|
[ids]
|
|
);
|
|
|
|
if (existing.rowCount !== ids.length) {
|
|
throw validationError('server.validation.taskDoesNotExist');
|
|
}
|
|
|
|
const sortOrders = new Map(existing.rows.map((row) => [row.id, row.sortOrder]));
|
|
for (const [index, id] of ids.entries()) {
|
|
const nextSortOrder = (index + 1) * 10;
|
|
const previousSortOrder = sortOrders.get(id);
|
|
if (previousSortOrder === nextSortOrder) {
|
|
continue;
|
|
}
|
|
|
|
await client.query(
|
|
`
|
|
UPDATE daily_checklist_items
|
|
SET sort_order = $1, updated_by_user_id = $2, updated_at = now()
|
|
WHERE id = $3
|
|
`,
|
|
[nextSortOrder, userId, id]
|
|
);
|
|
}
|
|
});
|
|
|
|
return listDailyChecklistItems({}, locale);
|
|
}
|
|
|
|
export async function deleteDailyChecklistItem(id: number, userId: number) {
|
|
return withTransaction(async (client) => {
|
|
const result = await client.query<{ id: number }>('DELETE FROM daily_checklist_items WHERE id = $1 RETURNING id', [id]);
|
|
if (result.rowCount === 0) {
|
|
return false;
|
|
}
|
|
|
|
await deleteEntityTranslations(client, 'daily-checklist-items', id);
|
|
await recordEditLog(client, 'daily-checklist-items', id, 'delete', userId);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
function cleanLifePostPayload(payload: Record<string, unknown>): LifePostPayload {
|
|
const body = cleanName(payload.body, 'server.validation.postRequired');
|
|
if (body.length > 2000) {
|
|
throw validationError('server.validation.postTooLong');
|
|
}
|
|
const gameVersionId = optionalPositiveInteger(payload.gameVersionId, 'server.validation.gameVersionInvalid');
|
|
|
|
return {
|
|
body,
|
|
gameVersionId,
|
|
languageCode: cleanModerationLanguageCode(payload.languageCode)
|
|
};
|
|
}
|
|
|
|
function cleanLifeCommentPayload(payload: Record<string, unknown>): LifeCommentPayload {
|
|
const body = cleanName(payload.body, 'server.validation.commentRequired');
|
|
if (body.length > 1000) {
|
|
throw validationError('server.validation.commentTooLong');
|
|
}
|
|
|
|
return { body, languageCode: cleanModerationLanguageCode(payload.languageCode) };
|
|
}
|
|
|
|
function emptyLifeReactionCounts(): LifeReactionCounts {
|
|
return {
|
|
like: 0,
|
|
helpful: 0,
|
|
fun: 0,
|
|
thanks: 0
|
|
};
|
|
}
|
|
|
|
function isLifeReactionType(value: unknown): value is LifeReactionType {
|
|
return typeof value === 'string' && lifeReactionTypes.includes(value as LifeReactionType);
|
|
}
|
|
|
|
function cleanLifeReactionType(value: unknown): LifeReactionType {
|
|
if (!isLifeReactionType(value)) {
|
|
throw validationError('server.validation.reactionInvalid');
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
function cleanLifeReactionFilter(value: QueryValue): LifeReactionType | null {
|
|
const reactionType = asString(value);
|
|
if (!reactionType) {
|
|
return null;
|
|
}
|
|
|
|
return cleanLifeReactionType(reactionType);
|
|
}
|
|
|
|
function cleanUserCommentActivitySourceFilter(value: QueryValue): UserCommentActivitySource | null {
|
|
const source = asString(value);
|
|
if (!source) {
|
|
return null;
|
|
}
|
|
|
|
if (source !== 'life' && source !== 'discussion') {
|
|
throw validationError('server.validation.invalidField');
|
|
}
|
|
|
|
return source;
|
|
}
|
|
|
|
function cleanModerationLanguageFilter(value: QueryValue): string | null {
|
|
return cleanModerationLanguageCode(asString(value));
|
|
}
|
|
|
|
function addModerationVisibilityCondition(
|
|
conditions: string[],
|
|
params: unknown[],
|
|
alias: string,
|
|
ownerColumn: string,
|
|
userId: number | null,
|
|
canViewAll: boolean
|
|
): void {
|
|
if (canViewAll) {
|
|
return;
|
|
}
|
|
|
|
if (userId !== null) {
|
|
params.push(userId);
|
|
conditions.push(`(${alias}.ai_moderation_status = 'approved' OR ${ownerColumn} = $${params.length})`);
|
|
return;
|
|
}
|
|
|
|
conditions.push(`${alias}.ai_moderation_status = 'approved'`);
|
|
}
|
|
|
|
function moderationVisibilitySql(alias: string, ownerColumn: string, userId: number | null, canViewAll: boolean): string {
|
|
if (canViewAll) {
|
|
return 'true';
|
|
}
|
|
if (userId !== null) {
|
|
return `(${alias}.ai_moderation_status = 'approved' OR ${ownerColumn} = ${userId})`;
|
|
}
|
|
return `${alias}.ai_moderation_status = 'approved'`;
|
|
}
|
|
|
|
function visibleLifeCommentSql(alias: string, ownerColumn: string, userId: number | null): string {
|
|
return userId !== null ? `(${alias}.deleted_at IS NULL OR ${ownerColumn} = ${userId})` : `${alias}.deleted_at IS NULL`;
|
|
}
|
|
|
|
function addModerationLanguageCondition(
|
|
conditions: string[],
|
|
params: unknown[],
|
|
alias: string,
|
|
languageCode: string | null
|
|
): void {
|
|
if (!languageCode) {
|
|
return;
|
|
}
|
|
|
|
params.push(languageCode);
|
|
conditions.push(`${alias}.ai_moderation_language_code = $${params.length}`);
|
|
}
|
|
|
|
function lifePostProjection(locale = defaultLocale): string {
|
|
const gameVersionName = localizedName('game-versions', 'gv', locale);
|
|
|
|
return `
|
|
SELECT
|
|
lp.id,
|
|
lp.body,
|
|
lp.ai_moderation_status AS "moderationStatus",
|
|
lp.ai_moderation_language_code AS "moderationLanguageCode",
|
|
lp.ai_moderation_reason AS "moderationReason",
|
|
lp.created_at AS "createdAt",
|
|
lp.created_at::text AS "createdAtCursor",
|
|
lp.updated_at AS "updatedAt",
|
|
CASE
|
|
WHEN created_user.id IS NULL THEN NULL
|
|
ELSE json_build_object('id', created_user.id, 'displayName', created_user.display_name)
|
|
END AS author,
|
|
CASE
|
|
WHEN updated_user.id IS NULL THEN NULL
|
|
ELSE json_build_object('id', updated_user.id, 'displayName', updated_user.display_name)
|
|
END AS "updatedBy",
|
|
CASE
|
|
WHEN gv.id IS NULL THEN NULL
|
|
ELSE json_build_object('id', gv.id, 'name', ${gameVersionName}, 'changeLog', gv.change_log)
|
|
END AS "gameVersion"
|
|
FROM life_posts lp
|
|
LEFT JOIN game_versions gv ON gv.id = lp.game_version_id
|
|
LEFT JOIN users created_user ON created_user.id = lp.created_by_user_id
|
|
LEFT JOIN users updated_user ON updated_user.id = lp.updated_by_user_id
|
|
`;
|
|
}
|
|
|
|
function cleanLifePostLimit(value: QueryValue): number {
|
|
const rawLimit = asString(value);
|
|
if (rawLimit === undefined || rawLimit === '') {
|
|
return defaultLifePostLimit;
|
|
}
|
|
|
|
const limit = Number(rawLimit);
|
|
return Number.isInteger(limit) && limit > 0 ? Math.min(limit, maxLifePostLimit) : defaultLifePostLimit;
|
|
}
|
|
|
|
function cleanLifePostSort(value: QueryValue): LifePostSort {
|
|
const sort = asString(value);
|
|
return sort === 'oldest' ? sort : 'latest';
|
|
}
|
|
|
|
function cleanCommentLimit(value: QueryValue): number {
|
|
const rawLimit = asString(value);
|
|
if (rawLimit === undefined || rawLimit === '') {
|
|
return defaultCommentLimit;
|
|
}
|
|
|
|
const limit = Number(rawLimit);
|
|
return Number.isInteger(limit) && limit > 0 ? Math.min(limit, maxCommentLimit) : defaultCommentLimit;
|
|
}
|
|
|
|
function cleanCommentSort(value: QueryValue): CommentSort {
|
|
const sort = asString(value);
|
|
return sort === 'latest' || sort === 'most-liked' || sort === 'most-replied' ? sort : 'oldest';
|
|
}
|
|
|
|
function decodeCommentCursor(value: QueryValue): CommentCursor | null {
|
|
const rawCursor = asString(value);
|
|
if (!rawCursor) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const cursor = JSON.parse(Buffer.from(rawCursor, 'base64url').toString('utf8')) as Partial<CommentCursor>;
|
|
const createdAt = typeof cursor.createdAt === 'string' ? cursor.createdAt : '';
|
|
const id = Number(cursor.id);
|
|
const count = cursor.count === undefined ? undefined : Number(cursor.count);
|
|
|
|
if (
|
|
!createdAt ||
|
|
Number.isNaN(new Date(createdAt).getTime()) ||
|
|
!Number.isInteger(id) ||
|
|
id <= 0 ||
|
|
(count !== undefined && (!Number.isInteger(count) || count < 0))
|
|
) {
|
|
throw validationError('server.validation.cursorInvalid');
|
|
}
|
|
|
|
return { createdAt, id, count };
|
|
} catch (error) {
|
|
if (error instanceof Error && 'statusCode' in error) {
|
|
throw error;
|
|
}
|
|
throw validationError('server.validation.cursorInvalid');
|
|
}
|
|
}
|
|
|
|
function encodeCommentCursor(comment: LifeCommentRow | EntityDiscussionCommentRow, sort: CommentSort): string {
|
|
const count = sort === 'most-liked' ? comment.likeCount : sort === 'most-replied' ? comment.replyCount : undefined;
|
|
return Buffer.from(JSON.stringify({ createdAt: comment.createdAtCursor, id: comment.id, count }), 'utf8').toString('base64url');
|
|
}
|
|
|
|
function commentSortOrder(alias: string, sort: CommentSort): string {
|
|
if (sort === 'latest') {
|
|
return `${alias}.created_at DESC, ${alias}.id DESC`;
|
|
}
|
|
if (sort === 'most-liked') {
|
|
return `"likeCount" DESC, ${alias}.created_at DESC, ${alias}.id DESC`;
|
|
}
|
|
if (sort === 'most-replied') {
|
|
return `"replyCount" DESC, ${alias}.created_at DESC, ${alias}.id DESC`;
|
|
}
|
|
return `${alias}.created_at, ${alias}.id`;
|
|
}
|
|
|
|
function addCommentCursorCondition(
|
|
conditions: string[],
|
|
params: unknown[],
|
|
alias: string,
|
|
cursor: CommentCursor,
|
|
sort: CommentSort
|
|
): void {
|
|
if (sort === 'latest') {
|
|
params.push(cursor.createdAt, cursor.id);
|
|
conditions.push(`(${alias}.created_at, ${alias}.id) < ($${params.length - 1}::timestamptz, $${params.length}::integer)`);
|
|
return;
|
|
}
|
|
|
|
if (sort === 'most-liked' || sort === 'most-replied') {
|
|
params.push(cursor.count ?? 0, cursor.createdAt, cursor.id);
|
|
const countExpression = sort === 'most-liked' ? 'like_stats.like_count' : 'reply_stats.reply_count';
|
|
conditions.push(`
|
|
(
|
|
${countExpression} < $${params.length - 2}::integer
|
|
OR (
|
|
${countExpression} = $${params.length - 2}::integer
|
|
AND (${alias}.created_at, ${alias}.id) < ($${params.length - 1}::timestamptz, $${params.length}::integer)
|
|
)
|
|
)
|
|
`);
|
|
return;
|
|
}
|
|
|
|
params.push(cursor.createdAt, cursor.id);
|
|
conditions.push(`(${alias}.created_at, ${alias}.id) > ($${params.length - 1}::timestamptz, $${params.length}::integer)`);
|
|
}
|
|
|
|
function decodeLifePostCursor(value: QueryValue): LifePostCursor | null {
|
|
const rawCursor = asString(value);
|
|
if (!rawCursor) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const cursor = JSON.parse(Buffer.from(rawCursor, 'base64url').toString('utf8')) as Partial<LifePostCursor>;
|
|
const createdAt = typeof cursor.createdAt === 'string' ? cursor.createdAt : '';
|
|
const id = Number(cursor.id);
|
|
|
|
if (
|
|
!createdAt ||
|
|
Number.isNaN(new Date(createdAt).getTime()) ||
|
|
!Number.isInteger(id) ||
|
|
id <= 0
|
|
) {
|
|
throw validationError('server.validation.cursorInvalid');
|
|
}
|
|
|
|
return { createdAt, id };
|
|
} catch (error) {
|
|
if (error instanceof Error && 'statusCode' in error) {
|
|
throw error;
|
|
}
|
|
throw validationError('server.validation.cursorInvalid');
|
|
}
|
|
}
|
|
|
|
function encodeLifePostCursor(post: LifePostRow): string {
|
|
return Buffer.from(JSON.stringify({ createdAt: post.createdAtCursor, id: post.id }), 'utf8').toString('base64url');
|
|
}
|
|
|
|
function encodeProfileCursor(cursor: LifePostCursor): string {
|
|
return Buffer.from(JSON.stringify(cursor), 'utf8').toString('base64url');
|
|
}
|
|
|
|
function decodeLifeReactionUserCursor(value: QueryValue): LifeReactionUserCursor | null {
|
|
const rawCursor = asString(value);
|
|
if (!rawCursor) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const cursor = JSON.parse(Buffer.from(rawCursor, 'base64url').toString('utf8')) as Partial<LifeReactionUserCursor>;
|
|
const reactedAt = typeof cursor.reactedAt === 'string' ? cursor.reactedAt : '';
|
|
const userId = Number(cursor.userId);
|
|
|
|
if (!reactedAt || Number.isNaN(new Date(reactedAt).getTime()) || !Number.isInteger(userId) || userId <= 0) {
|
|
throw validationError('server.validation.cursorInvalid');
|
|
}
|
|
|
|
return { reactedAt, userId };
|
|
} catch (error) {
|
|
if (error instanceof Error && 'statusCode' in error) {
|
|
throw error;
|
|
}
|
|
throw validationError('server.validation.cursorInvalid');
|
|
}
|
|
}
|
|
|
|
function encodeLifeReactionUserCursor(cursor: LifeReactionUserCursor): string {
|
|
return Buffer.from(JSON.stringify(cursor), 'utf8').toString('base64url');
|
|
}
|
|
|
|
function decodeUserCommentActivityCursor(value: QueryValue): UserCommentActivityCursor | null {
|
|
const rawCursor = asString(value);
|
|
if (!rawCursor) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const cursor = JSON.parse(Buffer.from(rawCursor, 'base64url').toString('utf8')) as Partial<UserCommentActivityCursor>;
|
|
const createdAt = typeof cursor.createdAt === 'string' ? cursor.createdAt : '';
|
|
const id = Number(cursor.id);
|
|
const source = cursor.source;
|
|
|
|
if (
|
|
!createdAt ||
|
|
Number.isNaN(new Date(createdAt).getTime()) ||
|
|
!Number.isInteger(id) ||
|
|
id <= 0 ||
|
|
(source !== 'life' && source !== 'discussion')
|
|
) {
|
|
throw validationError('server.validation.cursorInvalid');
|
|
}
|
|
|
|
return { createdAt, id, source };
|
|
} catch (error) {
|
|
if (error instanceof Error && 'statusCode' in error) {
|
|
throw error;
|
|
}
|
|
throw validationError('server.validation.cursorInvalid');
|
|
}
|
|
}
|
|
|
|
function encodeUserCommentActivityCursor(cursor: UserCommentActivityCursor): string {
|
|
return Buffer.from(JSON.stringify(cursor), 'utf8').toString('base64url');
|
|
}
|
|
|
|
function hydrateLifePost(
|
|
post: LifePostRow,
|
|
commentPreviewByPost: Map<number, LifeComment[]>,
|
|
commentCountsByPost: Map<number, number>,
|
|
countsByPost: Map<number, LifeReactionCounts>,
|
|
myReactionsByPost: Map<number, LifeReactionType>
|
|
): LifePost {
|
|
return {
|
|
id: post.id,
|
|
body: post.body,
|
|
moderationStatus: post.moderationStatus,
|
|
moderationLanguageCode: post.moderationLanguageCode,
|
|
moderationReason: post.moderationReason,
|
|
createdAt: post.createdAt,
|
|
updatedAt: post.updatedAt,
|
|
author: post.author,
|
|
updatedBy: post.updatedBy,
|
|
gameVersion: post.gameVersion,
|
|
commentPreview: commentPreviewByPost.get(post.id) ?? [],
|
|
commentCount: commentCountsByPost.get(post.id) ?? 0,
|
|
reactionCounts: countsByPost.get(post.id) ?? emptyLifeReactionCounts(),
|
|
myReaction: myReactionsByPost.get(post.id) ?? null
|
|
};
|
|
}
|
|
|
|
function lifeCommentProjection(whereClause: string, userId: number | null = null, canViewAll = false): string {
|
|
const myLikedExpression =
|
|
userId === null
|
|
? 'false'
|
|
: `EXISTS (SELECT 1 FROM life_comment_likes my_like WHERE my_like.comment_id = lc.id AND my_like.user_id = ${userId})`;
|
|
const replyVisibility = [
|
|
'reply.parent_comment_id = lc.id',
|
|
visibleLifeCommentSql('reply', 'reply.created_by_user_id', userId),
|
|
moderationVisibilitySql('reply', 'reply.created_by_user_id', userId, canViewAll)
|
|
].join(' AND ');
|
|
|
|
return `
|
|
SELECT
|
|
lc.id,
|
|
lc.post_id AS "postId",
|
|
lc.parent_comment_id AS "parentCommentId",
|
|
lc.body,
|
|
lc.deleted_at IS NOT NULL AS deleted,
|
|
lc.ai_moderation_status AS "moderationStatus",
|
|
lc.ai_moderation_language_code AS "moderationLanguageCode",
|
|
lc.ai_moderation_reason AS "moderationReason",
|
|
lc.created_at AS "createdAt",
|
|
lc.created_at::text AS "createdAtCursor",
|
|
lc.updated_at AS "updatedAt",
|
|
CASE WHEN comment_user.id IS NULL THEN NULL ELSE json_build_object('id', comment_user.id, 'displayName', comment_user.display_name) END AS author,
|
|
like_stats.like_count AS "likeCount",
|
|
reply_stats.reply_count AS "replyCount",
|
|
${myLikedExpression} AS "myLiked"
|
|
FROM life_post_comments lc
|
|
LEFT JOIN LATERAL (
|
|
SELECT COUNT(*)::integer AS like_count
|
|
FROM life_comment_likes lcl
|
|
WHERE lcl.comment_id = lc.id
|
|
) like_stats ON true
|
|
LEFT JOIN LATERAL (
|
|
SELECT COUNT(*)::integer AS reply_count
|
|
FROM life_post_comments reply
|
|
WHERE ${replyVisibility}
|
|
) reply_stats ON true
|
|
LEFT JOIN users comment_user ON comment_user.id = lc.created_by_user_id
|
|
${whereClause}
|
|
`;
|
|
}
|
|
|
|
function visibleLifeCommentExpression(alias: string, ownerColumn: string, userParamIndex: number | null): string {
|
|
return userParamIndex !== null ? `(${alias}.deleted_at IS NULL OR ${ownerColumn} = $${userParamIndex})` : `${alias}.deleted_at IS NULL`;
|
|
}
|
|
|
|
function addVisibleLifeCommentCondition(conditions: string[], params: unknown[], userId: number | null): void {
|
|
const userParamIndex = params.length + 1;
|
|
if (userId !== null) {
|
|
params.push(userId);
|
|
}
|
|
conditions.push(visibleLifeCommentExpression('lc', 'lc.created_by_user_id', userId === null ? null : userParamIndex));
|
|
conditions.push(`
|
|
(
|
|
lc.parent_comment_id IS NULL
|
|
OR EXISTS (
|
|
SELECT 1
|
|
FROM life_post_comments parent_comment
|
|
WHERE parent_comment.id = lc.parent_comment_id
|
|
AND ${visibleLifeCommentExpression('parent_comment', 'parent_comment.created_by_user_id', userId === null ? null : userParamIndex)}
|
|
)
|
|
)
|
|
`);
|
|
}
|
|
|
|
function buildLifeCommentTree(rows: LifeCommentRow[]): LifeComment[] {
|
|
const comments = new Map<number, LifeComment>();
|
|
const topLevelComments: LifeComment[] = [];
|
|
|
|
for (const row of rows) {
|
|
const { createdAtCursor: _createdAtCursor, ...comment } = row;
|
|
comments.set(row.id, { ...comment, replies: [] });
|
|
}
|
|
|
|
for (const comment of comments.values()) {
|
|
if (comment.parentCommentId === null) {
|
|
topLevelComments.push(comment);
|
|
continue;
|
|
}
|
|
|
|
const parent = comments.get(comment.parentCommentId);
|
|
if (parent?.parentCommentId === null) {
|
|
parent.replies.push(comment);
|
|
} else {
|
|
topLevelComments.push(comment);
|
|
}
|
|
}
|
|
|
|
return topLevelComments;
|
|
}
|
|
|
|
async function lifeCommentCountsForPosts(
|
|
postIds: number[],
|
|
userId: number | null,
|
|
canViewAll: boolean
|
|
): Promise<Map<number, number>> {
|
|
const countsByPost = new Map<number, number>();
|
|
for (const postId of postIds) {
|
|
countsByPost.set(postId, 0);
|
|
}
|
|
|
|
if (postIds.length === 0) {
|
|
return countsByPost;
|
|
}
|
|
|
|
const params: unknown[] = [postIds];
|
|
const conditions = ['lc.post_id = ANY($1::integer[])'];
|
|
addVisibleLifeCommentCondition(conditions, params, userId);
|
|
addModerationVisibilityCondition(conditions, params, 'lc', 'lc.created_by_user_id', userId, canViewAll);
|
|
|
|
const rows = await query<{ postId: number; total: number }>(
|
|
`
|
|
SELECT post_id AS "postId", COUNT(*)::integer AS total
|
|
FROM life_post_comments lc
|
|
WHERE ${conditions.join(' AND ')}
|
|
GROUP BY post_id
|
|
`,
|
|
params
|
|
);
|
|
|
|
for (const row of rows) {
|
|
countsByPost.set(row.postId, row.total);
|
|
}
|
|
|
|
return countsByPost;
|
|
}
|
|
|
|
async function lifeCommentPreviewForPosts(
|
|
postIds: number[],
|
|
userId: number | null,
|
|
canViewAll: boolean
|
|
): Promise<Map<number, LifeComment[]>> {
|
|
const commentsByPost = new Map<number, LifeComment[]>();
|
|
if (postIds.length === 0) {
|
|
return commentsByPost;
|
|
}
|
|
|
|
const params: unknown[] = [postIds];
|
|
const previewConditions = ['lc.post_id = ANY($1::integer[])', 'lc.parent_comment_id IS NULL'];
|
|
addVisibleLifeCommentCondition(previewConditions, params, userId);
|
|
addModerationVisibilityCondition(previewConditions, params, 'lc', 'lc.created_by_user_id', userId, canViewAll);
|
|
params.push(lifeCommentPreviewLimit);
|
|
|
|
const rows = await query<LifeCommentRow>(
|
|
`
|
|
WITH preview_top AS (
|
|
SELECT id
|
|
FROM (
|
|
SELECT
|
|
lc.id,
|
|
ROW_NUMBER() OVER (PARTITION BY lc.post_id ORDER BY lc.created_at DESC, lc.id DESC) AS preview_rank
|
|
FROM life_post_comments lc
|
|
WHERE ${previewConditions.join(' AND ')}
|
|
) ranked
|
|
WHERE preview_rank <= $${params.length}
|
|
)
|
|
${lifeCommentProjection('WHERE lc.id IN (SELECT id FROM preview_top)', userId, canViewAll)}
|
|
ORDER BY lc.post_id, lc.created_at, lc.id
|
|
`,
|
|
params
|
|
);
|
|
|
|
for (const postId of postIds) {
|
|
commentsByPost.set(postId, buildLifeCommentTree(rows.filter((comment) => comment.postId === postId)));
|
|
}
|
|
|
|
return commentsByPost;
|
|
}
|
|
|
|
export async function listLifeComments(
|
|
postIdValue: number,
|
|
paramsQuery: QueryParams = {},
|
|
userId: number | null = null,
|
|
canViewAll = false
|
|
): Promise<LifeCommentsPage | null> {
|
|
const postId = requirePositiveInteger(postIdValue, 'server.validation.recordInvalid');
|
|
const cursor = decodeCommentCursor(paramsQuery.cursor);
|
|
const limit = cleanCommentLimit(paramsQuery.limit);
|
|
const languageCode = cleanModerationLanguageFilter(paramsQuery.language);
|
|
const sort = cleanCommentSort(paramsQuery.sort);
|
|
const postParams: unknown[] = [postId];
|
|
const postConditions = ['lp.id = $1', 'lp.deleted_at IS NULL'];
|
|
addModerationVisibilityCondition(postConditions, postParams, 'lp', 'lp.created_by_user_id', userId, canViewAll);
|
|
const exists = await queryOne<{ exists: boolean }>(
|
|
`
|
|
SELECT EXISTS (
|
|
SELECT 1
|
|
FROM life_posts lp
|
|
WHERE ${postConditions.join(' AND ')}
|
|
) AS exists
|
|
`,
|
|
postParams
|
|
);
|
|
|
|
if (exists?.exists !== true) {
|
|
return null;
|
|
}
|
|
|
|
const params: unknown[] = [postId];
|
|
const topLevelConditions = ['lc.post_id = $1', 'lc.parent_comment_id IS NULL'];
|
|
addVisibleLifeCommentCondition(topLevelConditions, params, userId);
|
|
addModerationVisibilityCondition(topLevelConditions, params, 'lc', 'lc.created_by_user_id', userId, canViewAll);
|
|
addModerationLanguageCondition(topLevelConditions, params, 'lc', languageCode);
|
|
|
|
if (cursor) {
|
|
addCommentCursorCondition(topLevelConditions, params, 'lc', cursor, sort);
|
|
}
|
|
|
|
params.push(limit + 1);
|
|
const topLevelRows = await query<LifeCommentRow>(
|
|
`
|
|
${lifeCommentProjection(`WHERE ${topLevelConditions.join(' AND ')}`, userId, canViewAll)}
|
|
ORDER BY ${commentSortOrder('lc', sort)}
|
|
LIMIT $${params.length}
|
|
`,
|
|
params
|
|
);
|
|
const hasMore = topLevelRows.length > limit;
|
|
const topLevelComments = hasMore ? topLevelRows.slice(0, limit) : topLevelRows;
|
|
const topLevelIds = topLevelComments.map((comment) => comment.id);
|
|
const replyRows = topLevelIds.length
|
|
? await (async () => {
|
|
const replyParams: unknown[] = [topLevelIds];
|
|
const replyConditions = ['lc.parent_comment_id = ANY($1::integer[])'];
|
|
addVisibleLifeCommentCondition(replyConditions, replyParams, userId);
|
|
addModerationVisibilityCondition(replyConditions, replyParams, 'lc', 'lc.created_by_user_id', userId, canViewAll);
|
|
addModerationLanguageCondition(replyConditions, replyParams, 'lc', languageCode);
|
|
return query<LifeCommentRow>(
|
|
`
|
|
${lifeCommentProjection(`WHERE ${replyConditions.join(' AND ')}`, userId, canViewAll)}
|
|
ORDER BY lc.created_at, lc.id
|
|
`,
|
|
replyParams
|
|
);
|
|
})()
|
|
: [];
|
|
const totalParams: unknown[] = [postId];
|
|
const totalConditions = ['lc.post_id = $1'];
|
|
addVisibleLifeCommentCondition(totalConditions, totalParams, userId);
|
|
addModerationVisibilityCondition(totalConditions, totalParams, 'lc', 'lc.created_by_user_id', userId, canViewAll);
|
|
addModerationLanguageCondition(totalConditions, totalParams, 'lc', languageCode);
|
|
const total = await queryOne<{ total: number }>(
|
|
`
|
|
SELECT COUNT(*)::integer AS total
|
|
FROM life_post_comments lc
|
|
WHERE ${totalConditions.join(' AND ')}
|
|
`,
|
|
totalParams
|
|
);
|
|
|
|
return {
|
|
items: buildLifeCommentTree([...topLevelComments, ...replyRows]),
|
|
nextCursor:
|
|
hasMore && topLevelComments.length > 0
|
|
? encodeCommentCursor(topLevelComments[topLevelComments.length - 1], sort)
|
|
: null,
|
|
hasMore,
|
|
total: total?.total ?? 0
|
|
};
|
|
}
|
|
|
|
async function lifeReactionsForPosts(
|
|
postIds: number[],
|
|
userId: number | null
|
|
): Promise<{
|
|
countsByPost: Map<number, LifeReactionCounts>;
|
|
myReactionsByPost: Map<number, LifeReactionType>;
|
|
}> {
|
|
const countsByPost = new Map<number, LifeReactionCounts>();
|
|
const myReactionsByPost = new Map<number, LifeReactionType>();
|
|
|
|
for (const postId of postIds) {
|
|
countsByPost.set(postId, emptyLifeReactionCounts());
|
|
}
|
|
|
|
if (postIds.length === 0) {
|
|
return { countsByPost, myReactionsByPost };
|
|
}
|
|
|
|
const countRows = await query<{ postId: number; reactionType: LifeReactionType; count: number }>(
|
|
`
|
|
SELECT
|
|
post_id AS "postId",
|
|
reaction_type AS "reactionType",
|
|
COUNT(*)::integer AS count
|
|
FROM life_post_reactions
|
|
WHERE post_id = ANY($1::integer[])
|
|
GROUP BY post_id, reaction_type
|
|
`,
|
|
[postIds]
|
|
);
|
|
|
|
for (const row of countRows) {
|
|
const counts = countsByPost.get(row.postId);
|
|
if (counts && isLifeReactionType(row.reactionType)) {
|
|
counts[row.reactionType] = row.count;
|
|
}
|
|
}
|
|
|
|
if (userId !== null) {
|
|
const myRows = await query<{ postId: number; reactionType: LifeReactionType }>(
|
|
`
|
|
SELECT post_id AS "postId", reaction_type AS "reactionType"
|
|
FROM life_post_reactions
|
|
WHERE post_id = ANY($1::integer[])
|
|
AND user_id = $2
|
|
`,
|
|
[postIds, userId]
|
|
);
|
|
|
|
for (const row of myRows) {
|
|
if (isLifeReactionType(row.reactionType)) {
|
|
myReactionsByPost.set(row.postId, row.reactionType);
|
|
}
|
|
}
|
|
}
|
|
|
|
return { countsByPost, myReactionsByPost };
|
|
}
|
|
|
|
export async function listLifePostReactionUsers(
|
|
postIdValue: number,
|
|
paramsQuery: QueryParams = {},
|
|
userId: number | null = null,
|
|
canViewAll = false
|
|
): Promise<LifeReactionUsersPage | null> {
|
|
const postId = requirePositiveInteger(postIdValue, 'server.validation.recordInvalid');
|
|
const cursor = decodeLifeReactionUserCursor(paramsQuery.cursor);
|
|
const limit = cleanLifePostLimit(paramsQuery.limit);
|
|
const reactionType = cleanLifeReactionFilter(paramsQuery.reactionType);
|
|
const postParams: unknown[] = [postId];
|
|
const postConditions = ['lp.id = $1', 'lp.deleted_at IS NULL'];
|
|
addModerationVisibilityCondition(postConditions, postParams, 'lp', 'lp.created_by_user_id', userId, canViewAll);
|
|
const exists = await queryOne<{ exists: boolean }>(
|
|
`
|
|
SELECT EXISTS (
|
|
SELECT 1
|
|
FROM life_posts lp
|
|
WHERE ${postConditions.join(' AND ')}
|
|
) AS exists
|
|
`,
|
|
postParams
|
|
);
|
|
|
|
if (exists?.exists !== true) {
|
|
return null;
|
|
}
|
|
|
|
const params: unknown[] = [postId];
|
|
const conditions = ['lpr.post_id = $1'];
|
|
if (reactionType) {
|
|
params.push(reactionType);
|
|
conditions.push(`lpr.reaction_type = $${params.length}`);
|
|
}
|
|
if (cursor) {
|
|
params.push(cursor.reactedAt, cursor.userId);
|
|
conditions.push(`(lpr.updated_at, lpr.user_id) < ($${params.length - 1}::timestamptz, $${params.length}::integer)`);
|
|
}
|
|
|
|
params.push(limit + 1);
|
|
const rows = await query<{
|
|
userId: number;
|
|
displayName: string;
|
|
reactionType: LifeReactionType;
|
|
reactedAt: Date;
|
|
reactedAtCursor: string;
|
|
}>(
|
|
`
|
|
SELECT
|
|
u.id AS "userId",
|
|
u.display_name AS "displayName",
|
|
lpr.reaction_type AS "reactionType",
|
|
lpr.updated_at AS "reactedAt",
|
|
lpr.updated_at::text AS "reactedAtCursor"
|
|
FROM life_post_reactions lpr
|
|
JOIN users u ON u.id = lpr.user_id
|
|
WHERE ${conditions.join(' AND ')}
|
|
ORDER BY lpr.updated_at DESC, lpr.user_id DESC
|
|
LIMIT $${params.length}
|
|
`,
|
|
params
|
|
);
|
|
const hasMore = rows.length > limit;
|
|
const items = hasMore ? rows.slice(0, limit) : rows;
|
|
const totalParams: unknown[] = [postId];
|
|
const totalConditions = ['post_id = $1'];
|
|
if (reactionType) {
|
|
totalParams.push(reactionType);
|
|
totalConditions.push(`reaction_type = $${totalParams.length}`);
|
|
}
|
|
const total = await queryOne<{ total: number }>(
|
|
`
|
|
SELECT COUNT(*)::integer AS total
|
|
FROM life_post_reactions
|
|
WHERE ${totalConditions.join(' AND ')}
|
|
`,
|
|
totalParams
|
|
);
|
|
|
|
return {
|
|
items: items.map((item) => ({
|
|
user: { id: item.userId, displayName: item.displayName },
|
|
reactionType: item.reactionType,
|
|
reactedAt: item.reactedAt
|
|
})),
|
|
nextCursor:
|
|
hasMore && items.length > 0
|
|
? encodeLifeReactionUserCursor({
|
|
reactedAt: items[items.length - 1].reactedAtCursor,
|
|
userId: items[items.length - 1].userId
|
|
})
|
|
: null,
|
|
hasMore,
|
|
total: total?.total ?? 0
|
|
};
|
|
}
|
|
|
|
async function getLifeCommentById(id: number, userId: number | null = null, canViewAll = false): Promise<LifeComment | null> {
|
|
const row = await queryOne<LifeCommentRow>(
|
|
`
|
|
${lifeCommentProjection('WHERE lc.id = $1', userId, canViewAll)}
|
|
`,
|
|
[id]
|
|
);
|
|
|
|
if (!row) {
|
|
return null;
|
|
}
|
|
|
|
const { createdAtCursor: _createdAtCursor, ...comment } = row;
|
|
return { ...comment, replies: [] };
|
|
}
|
|
|
|
async function listLifePostsWithFilters(
|
|
paramsQuery: QueryParams = {},
|
|
userId: number | null = null,
|
|
locale = defaultLocale,
|
|
filters: LifePostFilters = {},
|
|
canViewAll = false
|
|
): Promise<LifePostsPage> {
|
|
const cursor = decodeLifePostCursor(paramsQuery.cursor);
|
|
const limit = cleanLifePostLimit(paramsQuery.limit);
|
|
const sort = cleanLifePostSort(paramsQuery.sort);
|
|
const search = asString(paramsQuery.search)?.trim();
|
|
const gameVersionIdValue = asString(paramsQuery.gameVersionId)?.trim();
|
|
const languageCode = cleanModerationLanguageFilter(paramsQuery.language);
|
|
const params: unknown[] = [];
|
|
const conditions: string[] = ['lp.deleted_at IS NULL'];
|
|
|
|
if (filters.authorId !== undefined) {
|
|
params.push(filters.authorId);
|
|
conditions.push(`lp.created_by_user_id = $${params.length}`);
|
|
}
|
|
|
|
if (filters.followedByUserId !== undefined) {
|
|
params.push(filters.followedByUserId);
|
|
conditions.push(`
|
|
EXISTS (
|
|
SELECT 1
|
|
FROM user_follows uf
|
|
WHERE uf.follower_user_id = $${params.length}
|
|
AND uf.followed_user_id = lp.created_by_user_id
|
|
)
|
|
`);
|
|
}
|
|
|
|
addModerationVisibilityCondition(conditions, params, 'lp', 'lp.created_by_user_id', userId, canViewAll);
|
|
addModerationLanguageCondition(conditions, params, 'lp', languageCode);
|
|
|
|
if (search) {
|
|
params.push(`%${search}%`);
|
|
conditions.push(`lp.body ILIKE $${params.length}`);
|
|
}
|
|
|
|
if (gameVersionIdValue && gameVersionIdValue !== 'all') {
|
|
const gameVersionId = requirePositiveInteger(gameVersionIdValue, 'server.validation.gameVersionInvalid');
|
|
params.push(gameVersionId);
|
|
conditions.push(`lp.game_version_id = $${params.length}`);
|
|
}
|
|
|
|
if (cursor) {
|
|
params.push(cursor.createdAt, cursor.id);
|
|
conditions.push(
|
|
`(lp.created_at, lp.id) ${sort === 'oldest' ? '>' : '<'} ($${params.length - 1}::timestamptz, $${params.length}::integer)`
|
|
);
|
|
}
|
|
|
|
const orderClause = `ORDER BY lp.created_at ${sort === 'oldest' ? 'ASC' : 'DESC'}, lp.id ${sort === 'oldest' ? 'ASC' : 'DESC'}`;
|
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
|
params.push(limit + 1);
|
|
const rows = await query<LifePostRow>(
|
|
`
|
|
${lifePostProjection(locale)}
|
|
${whereClause}
|
|
${orderClause}
|
|
LIMIT $${params.length}
|
|
`,
|
|
params
|
|
);
|
|
const hasMore = rows.length > limit;
|
|
const posts = hasMore ? rows.slice(0, limit) : rows;
|
|
|
|
const postIds = posts.map((post) => post.id);
|
|
const commentPreviewByPost = await lifeCommentPreviewForPosts(postIds, userId, canViewAll);
|
|
const commentCountsByPost = await lifeCommentCountsForPosts(postIds, userId, canViewAll);
|
|
const { countsByPost, myReactionsByPost } = await lifeReactionsForPosts(postIds, userId);
|
|
|
|
return {
|
|
items: posts.map((post) =>
|
|
hydrateLifePost(post, commentPreviewByPost, commentCountsByPost, countsByPost, myReactionsByPost)
|
|
),
|
|
nextCursor: hasMore && posts.length > 0 ? encodeLifePostCursor(posts[posts.length - 1]) : null,
|
|
hasMore
|
|
};
|
|
}
|
|
|
|
export async function listLifePosts(
|
|
paramsQuery: QueryParams = {},
|
|
userId: number | null = null,
|
|
locale = defaultLocale,
|
|
canViewAll = false
|
|
): Promise<LifePostsPage> {
|
|
return listLifePostsWithFilters(paramsQuery, userId, locale, {}, canViewAll);
|
|
}
|
|
|
|
export async function listFollowingLifePosts(
|
|
userId: number,
|
|
paramsQuery: QueryParams = {},
|
|
locale = defaultLocale,
|
|
canViewAll = false
|
|
): Promise<LifePostsPage> {
|
|
return listLifePostsWithFilters(paramsQuery, userId, locale, { followedByUserId: userId }, canViewAll);
|
|
}
|
|
|
|
async function getPublicProfileUser(userIdValue: number): Promise<PublicProfileUser | null> {
|
|
const userId = requirePositiveInteger(userIdValue, 'server.validation.recordInvalid');
|
|
return queryOne<PublicProfileUser>(
|
|
`
|
|
SELECT
|
|
id,
|
|
display_name AS "displayName",
|
|
created_at AS "joinedAt"
|
|
FROM users
|
|
WHERE id = $1
|
|
`,
|
|
[userId]
|
|
);
|
|
}
|
|
|
|
function publicContributionType(entityType: string): string {
|
|
return entityType === 'daily-checklist-items' ? 'daily-checklist' : entityType;
|
|
}
|
|
|
|
async function getPublicProfileSocial(userId: number, viewerUserId: number | null): Promise<PublicProfileSocial> {
|
|
const social = await queryOne<
|
|
Omit<PublicProfileSocial, 'viewerRelation'> & {
|
|
viewerFollows: boolean;
|
|
targetFollowsViewer: boolean;
|
|
}
|
|
>(
|
|
`
|
|
SELECT
|
|
COALESCE((SELECT COUNT(*)::integer FROM user_follows WHERE followed_user_id = $1), 0) AS "followerCount",
|
|
COALESCE((SELECT COUNT(*)::integer FROM user_follows WHERE follower_user_id = $1), 0) AS "followingCount",
|
|
COALESCE((
|
|
SELECT COUNT(*)::integer
|
|
FROM user_follows outgoing
|
|
WHERE outgoing.follower_user_id = $1
|
|
AND EXISTS (
|
|
SELECT 1
|
|
FROM user_follows incoming
|
|
WHERE incoming.follower_user_id = outgoing.followed_user_id
|
|
AND incoming.followed_user_id = $1
|
|
)
|
|
), 0) AS "friendCount",
|
|
CASE
|
|
WHEN $2::integer IS NULL OR $2::integer = $1 THEN false
|
|
ELSE EXISTS (
|
|
SELECT 1
|
|
FROM user_follows
|
|
WHERE follower_user_id = $2::integer
|
|
AND followed_user_id = $1
|
|
)
|
|
END AS "viewerFollows",
|
|
CASE
|
|
WHEN $2::integer IS NULL OR $2::integer = $1 THEN false
|
|
ELSE EXISTS (
|
|
SELECT 1
|
|
FROM user_follows
|
|
WHERE follower_user_id = $1
|
|
AND followed_user_id = $2::integer
|
|
)
|
|
END AS "targetFollowsViewer"
|
|
`,
|
|
[userId, viewerUserId]
|
|
);
|
|
|
|
const viewerRelation =
|
|
social?.viewerFollows && social.targetFollowsViewer
|
|
? 'friends'
|
|
: social?.viewerFollows
|
|
? 'following'
|
|
: social?.targetFollowsViewer
|
|
? 'followed-by'
|
|
: 'none';
|
|
|
|
return {
|
|
followerCount: social?.followerCount ?? 0,
|
|
followingCount: social?.followingCount ?? 0,
|
|
friendCount: social?.friendCount ?? 0,
|
|
viewerRelation
|
|
};
|
|
}
|
|
|
|
export async function getPublicUserProfile(userIdValue: number, viewerUserId: number | null = null): Promise<PublicUserProfile | null> {
|
|
const user = await getPublicProfileUser(userIdValue);
|
|
if (!user) {
|
|
return null;
|
|
}
|
|
|
|
const stats = await queryOne<PublicProfileStats>(
|
|
`
|
|
SELECT
|
|
COALESCE((SELECT COUNT(*)::integer FROM wiki_edit_logs WHERE user_id = $1), 0) AS "wikiEdits",
|
|
COALESCE((SELECT COUNT(*)::integer FROM wiki_edit_logs WHERE user_id = $1 AND action = 'create'), 0) AS "wikiCreates",
|
|
COALESCE((SELECT COUNT(*)::integer FROM wiki_edit_logs WHERE user_id = $1 AND action = 'update'), 0) AS "wikiUpdates",
|
|
COALESCE((SELECT COUNT(*)::integer FROM wiki_edit_logs WHERE user_id = $1 AND action = 'delete'), 0) AS "wikiDeletes",
|
|
COALESCE((SELECT COUNT(*)::integer FROM entity_image_uploads WHERE created_by_user_id = $1), 0) AS "imageUploads",
|
|
COALESCE((
|
|
SELECT COUNT(*)::integer
|
|
FROM life_posts
|
|
WHERE created_by_user_id = $1
|
|
AND deleted_at IS NULL
|
|
AND ai_moderation_status = 'approved'
|
|
), 0) AS "lifePosts",
|
|
COALESCE((
|
|
SELECT COUNT(*)::integer
|
|
FROM life_post_comments lc
|
|
JOIN life_posts lp ON lp.id = lc.post_id
|
|
WHERE lc.created_by_user_id = $1
|
|
AND lc.deleted_at IS NULL
|
|
AND lp.deleted_at IS NULL
|
|
AND lc.ai_moderation_status = 'approved'
|
|
AND lp.ai_moderation_status = 'approved'
|
|
), 0) AS "lifeComments",
|
|
COALESCE((
|
|
SELECT COUNT(*)::integer
|
|
FROM life_post_reactions lpr
|
|
JOIN life_posts lp ON lp.id = lpr.post_id
|
|
WHERE lpr.user_id = $1
|
|
AND lp.deleted_at IS NULL
|
|
AND lp.ai_moderation_status = 'approved'
|
|
), 0) AS "lifeReactions",
|
|
COALESCE((
|
|
SELECT COUNT(*)::integer
|
|
FROM entity_discussion_comments
|
|
WHERE created_by_user_id = $1
|
|
AND deleted_at IS NULL
|
|
AND ai_moderation_status = 'approved'
|
|
), 0) AS "discussionComments"
|
|
`,
|
|
[user.id]
|
|
);
|
|
|
|
const contributions = await query<PublicProfileContribution>(
|
|
`
|
|
SELECT
|
|
entity_type AS "contentType",
|
|
COUNT(*)::integer AS total,
|
|
COUNT(*) FILTER (WHERE action = 'create')::integer AS creates,
|
|
COUNT(*) FILTER (WHERE action = 'update')::integer AS updates,
|
|
COUNT(*) FILTER (WHERE action = 'delete')::integer AS deletes,
|
|
MAX(created_at) AS "lastContributedAt"
|
|
FROM wiki_edit_logs
|
|
WHERE user_id = $1
|
|
GROUP BY entity_type
|
|
ORDER BY total DESC, "lastContributedAt" DESC, entity_type
|
|
`,
|
|
[user.id]
|
|
);
|
|
|
|
const social = await getPublicProfileSocial(user.id, viewerUserId);
|
|
|
|
return {
|
|
user,
|
|
stats: stats ?? {
|
|
wikiEdits: 0,
|
|
wikiCreates: 0,
|
|
wikiUpdates: 0,
|
|
wikiDeletes: 0,
|
|
imageUploads: 0,
|
|
lifePosts: 0,
|
|
lifeComments: 0,
|
|
lifeReactions: 0,
|
|
discussionComments: 0
|
|
},
|
|
social,
|
|
contributions: contributions.map((item) => ({
|
|
...item,
|
|
contentType: publicContributionType(item.contentType)
|
|
}))
|
|
};
|
|
}
|
|
|
|
export async function followUser(followerUserId: number, followedUserIdValue: number): Promise<PublicUserProfile | null> {
|
|
const followedUserId = requirePositiveInteger(followedUserIdValue, 'server.validation.recordInvalid');
|
|
if (followerUserId === followedUserId) {
|
|
throw validationError('server.validation.cannotFollowSelf');
|
|
}
|
|
|
|
const followedUser = await getPublicProfileUser(followedUserId);
|
|
if (!followedUser) {
|
|
return null;
|
|
}
|
|
|
|
const inserted = await queryOne<{ inserted: boolean }>(
|
|
`
|
|
INSERT INTO user_follows (follower_user_id, followed_user_id)
|
|
VALUES ($1, $2)
|
|
ON CONFLICT (follower_user_id, followed_user_id) DO NOTHING
|
|
RETURNING true AS inserted
|
|
`,
|
|
[followerUserId, followedUser.id]
|
|
);
|
|
|
|
if (inserted?.inserted === true) {
|
|
await createUserFollowNotification(followerUserId, followedUser.id);
|
|
}
|
|
|
|
return getPublicUserProfile(followedUser.id, followerUserId);
|
|
}
|
|
|
|
export async function unfollowUser(followerUserId: number, followedUserIdValue: number): Promise<PublicUserProfile | null> {
|
|
const followedUserId = requirePositiveInteger(followedUserIdValue, 'server.validation.recordInvalid');
|
|
if (followerUserId === followedUserId) {
|
|
throw validationError('server.validation.cannotFollowSelf');
|
|
}
|
|
|
|
const followedUser = await getPublicProfileUser(followedUserId);
|
|
if (!followedUser) {
|
|
return null;
|
|
}
|
|
|
|
await query(
|
|
`
|
|
DELETE FROM user_follows
|
|
WHERE follower_user_id = $1
|
|
AND followed_user_id = $2
|
|
`,
|
|
[followerUserId, followedUser.id]
|
|
);
|
|
|
|
return getPublicUserProfile(followedUser.id, followerUserId);
|
|
}
|
|
|
|
export async function listUserLifePosts(
|
|
userIdValue: number,
|
|
paramsQuery: QueryParams = {},
|
|
viewerUserId: number | null = null,
|
|
locale = defaultLocale,
|
|
canViewAll = false
|
|
): Promise<LifePostsPage | null> {
|
|
const user = await getPublicProfileUser(userIdValue);
|
|
if (!user) {
|
|
return null;
|
|
}
|
|
|
|
return listLifePostsWithFilters(paramsQuery, viewerUserId, locale, { authorId: user.id }, canViewAll);
|
|
}
|
|
|
|
async function hydrateLifePostsById(
|
|
postIds: number[],
|
|
viewerUserId: number | null,
|
|
locale: string,
|
|
canViewAll = false
|
|
): Promise<Map<number, LifePost>> {
|
|
const postById = new Map<number, LifePost>();
|
|
if (postIds.length === 0) {
|
|
return postById;
|
|
}
|
|
|
|
const params: unknown[] = [postIds];
|
|
const conditions = ['lp.id = ANY($1::integer[])', 'lp.deleted_at IS NULL'];
|
|
addModerationVisibilityCondition(conditions, params, 'lp', 'lp.created_by_user_id', viewerUserId, canViewAll);
|
|
const posts = await query<LifePostRow>(
|
|
`
|
|
${lifePostProjection(locale)}
|
|
WHERE ${conditions.join(' AND ')}
|
|
`,
|
|
params
|
|
);
|
|
const commentPreviewByPost = await lifeCommentPreviewForPosts(postIds, viewerUserId, canViewAll);
|
|
const commentCountsByPost = await lifeCommentCountsForPosts(postIds, viewerUserId, canViewAll);
|
|
const { countsByPost, myReactionsByPost } = await lifeReactionsForPosts(postIds, viewerUserId);
|
|
|
|
for (const post of posts) {
|
|
postById.set(post.id, hydrateLifePost(post, commentPreviewByPost, commentCountsByPost, countsByPost, myReactionsByPost));
|
|
}
|
|
|
|
return postById;
|
|
}
|
|
|
|
export async function listUserReactionActivities(
|
|
userIdValue: number,
|
|
paramsQuery: QueryParams = {},
|
|
viewerUserId: number | null = null,
|
|
locale = defaultLocale
|
|
): Promise<UserReactionActivityPage | null> {
|
|
const user = await getPublicProfileUser(userIdValue);
|
|
if (!user) {
|
|
return null;
|
|
}
|
|
|
|
const cursor = decodeLifePostCursor(paramsQuery.cursor);
|
|
const limit = cleanLifePostLimit(paramsQuery.limit);
|
|
const reactionType = cleanLifeReactionFilter(paramsQuery.reactionType);
|
|
const params: unknown[] = [user.id];
|
|
const conditions = ['lpr.user_id = $1', 'lp.deleted_at IS NULL', "lp.ai_moderation_status = 'approved'"];
|
|
|
|
if (reactionType) {
|
|
params.push(reactionType);
|
|
conditions.push(`lpr.reaction_type = $${params.length}`);
|
|
}
|
|
|
|
if (cursor) {
|
|
params.push(cursor.createdAt, cursor.id);
|
|
conditions.push(`(lpr.updated_at, lpr.post_id) < ($${params.length - 1}::timestamptz, $${params.length}::integer)`);
|
|
}
|
|
|
|
params.push(limit + 1);
|
|
const rows = await query<{
|
|
postId: number;
|
|
reactionType: LifeReactionType;
|
|
reactedAt: Date;
|
|
reactedAtCursor: string;
|
|
}>(
|
|
`
|
|
SELECT
|
|
lpr.post_id AS "postId",
|
|
lpr.reaction_type AS "reactionType",
|
|
lpr.updated_at AS "reactedAt",
|
|
lpr.updated_at::text AS "reactedAtCursor"
|
|
FROM life_post_reactions lpr
|
|
JOIN life_posts lp ON lp.id = lpr.post_id
|
|
WHERE ${conditions.join(' AND ')}
|
|
ORDER BY lpr.updated_at DESC, lpr.post_id DESC
|
|
LIMIT $${params.length}
|
|
`,
|
|
params
|
|
);
|
|
const hasMore = rows.length > limit;
|
|
const activities = hasMore ? rows.slice(0, limit) : rows;
|
|
const postById = await hydrateLifePostsById(
|
|
activities.map((activity) => activity.postId),
|
|
viewerUserId,
|
|
locale
|
|
);
|
|
|
|
return {
|
|
items: activities.flatMap((activity) => {
|
|
const post = postById.get(activity.postId);
|
|
return post
|
|
? [
|
|
{
|
|
postId: activity.postId,
|
|
reactionType: activity.reactionType,
|
|
reactedAt: activity.reactedAt,
|
|
post
|
|
}
|
|
]
|
|
: [];
|
|
}),
|
|
nextCursor:
|
|
hasMore && activities.length > 0
|
|
? encodeProfileCursor({
|
|
createdAt: activities[activities.length - 1].reactedAtCursor,
|
|
id: activities[activities.length - 1].postId
|
|
})
|
|
: null,
|
|
hasMore
|
|
};
|
|
}
|
|
|
|
export async function listUserCommentActivities(
|
|
userIdValue: number,
|
|
paramsQuery: QueryParams = {},
|
|
locale = defaultLocale
|
|
): Promise<UserCommentActivityPage | null> {
|
|
const user = await getPublicProfileUser(userIdValue);
|
|
if (!user) {
|
|
return null;
|
|
}
|
|
|
|
const cursor = decodeUserCommentActivityCursor(paramsQuery.cursor);
|
|
const limit = cleanLifePostLimit(paramsQuery.limit);
|
|
const sourceFilter = cleanUserCommentActivitySourceFilter(paramsQuery.source);
|
|
const pokemonName = localizedName('pokemon', 'p', locale);
|
|
const itemName = localizedName('items', 'i', locale);
|
|
const recipeItemName = localizedName('items', 'recipe_item', locale);
|
|
const habitatName = localizedName('habitats', 'h', locale);
|
|
const artifactName = localizedName('items', 'artifact_item', locale);
|
|
const params: unknown[] = [user.id];
|
|
const outerConditions: string[] = [];
|
|
|
|
if (sourceFilter) {
|
|
params.push(sourceFilter);
|
|
outerConditions.push(`source = $${params.length}`);
|
|
}
|
|
|
|
if (cursor) {
|
|
params.push(cursor.createdAt, cursor.source, cursor.id);
|
|
outerConditions.push(
|
|
`(created_at, source, id) < ($${params.length - 2}::timestamptz, $${params.length - 1}::text, $${params.length}::integer)`
|
|
);
|
|
}
|
|
|
|
params.push(limit + 1);
|
|
const outerWhere = outerConditions.length ? `WHERE ${outerConditions.join(' AND ')}` : '';
|
|
const rows = await query<{
|
|
id: number;
|
|
source: UserCommentActivitySource;
|
|
body: string;
|
|
createdAt: Date;
|
|
createdAtCursor: string;
|
|
targetType: 'life-post' | DiscussionEntityType;
|
|
targetId: number;
|
|
targetTitle: string;
|
|
targetExcerpt: string;
|
|
}>(
|
|
`
|
|
WITH activity AS (
|
|
SELECT
|
|
'life'::text AS source,
|
|
lc.id,
|
|
lc.body,
|
|
lc.created_at,
|
|
lc.created_at::text AS cursor_at,
|
|
'life-post'::text AS target_type,
|
|
lp.id AS target_id,
|
|
COALESCE(post_user.display_name, '') AS target_title,
|
|
lp.body AS target_excerpt
|
|
FROM life_post_comments lc
|
|
JOIN life_posts lp ON lp.id = lc.post_id
|
|
LEFT JOIN users post_user ON post_user.id = lp.created_by_user_id
|
|
WHERE lc.created_by_user_id = $1
|
|
AND lc.deleted_at IS NULL
|
|
AND lp.deleted_at IS NULL
|
|
AND lc.ai_moderation_status = 'approved'
|
|
AND lp.ai_moderation_status = 'approved'
|
|
|
|
UNION ALL
|
|
|
|
SELECT
|
|
'discussion'::text AS source,
|
|
edc.id,
|
|
edc.body,
|
|
edc.created_at,
|
|
edc.created_at::text AS cursor_at,
|
|
edc.entity_type AS target_type,
|
|
edc.entity_id AS target_id,
|
|
COALESCE(
|
|
CASE edc.entity_type
|
|
WHEN 'pokemon' THEN ${pokemonName}
|
|
WHEN 'items' THEN ${itemName}
|
|
WHEN 'recipes' THEN ${recipeItemName}
|
|
WHEN 'habitats' THEN ${habitatName}
|
|
WHEN 'ancient-artifacts' THEN ${artifactName}
|
|
ELSE ''
|
|
END,
|
|
''
|
|
) AS target_title,
|
|
''::text AS target_excerpt
|
|
FROM entity_discussion_comments edc
|
|
LEFT JOIN pokemon p ON edc.entity_type = 'pokemon' AND p.id = edc.entity_id
|
|
LEFT JOIN items i ON edc.entity_type = 'items' AND i.id = edc.entity_id
|
|
LEFT JOIN recipes r ON edc.entity_type = 'recipes' AND r.id = edc.entity_id
|
|
LEFT JOIN items recipe_item ON recipe_item.id = r.item_id
|
|
LEFT JOIN habitats h ON edc.entity_type = 'habitats' AND h.id = edc.entity_id
|
|
LEFT JOIN items artifact_item ON edc.entity_type = 'ancient-artifacts' AND artifact_item.id = edc.entity_id
|
|
WHERE edc.created_by_user_id = $1
|
|
AND edc.deleted_at IS NULL
|
|
AND edc.ai_moderation_status = 'approved'
|
|
)
|
|
SELECT
|
|
source,
|
|
id,
|
|
body,
|
|
created_at AS "createdAt",
|
|
cursor_at AS "createdAtCursor",
|
|
target_type AS "targetType",
|
|
target_id AS "targetId",
|
|
target_title AS "targetTitle",
|
|
target_excerpt AS "targetExcerpt"
|
|
FROM activity
|
|
${outerWhere}
|
|
ORDER BY created_at DESC, source DESC, id DESC
|
|
LIMIT $${params.length}
|
|
`,
|
|
params
|
|
);
|
|
const hasMore = rows.length > limit;
|
|
const activities = hasMore ? rows.slice(0, limit) : rows;
|
|
|
|
return {
|
|
items: activities.map((activity) => ({
|
|
id: activity.id,
|
|
source: activity.source,
|
|
body: activity.body,
|
|
createdAt: activity.createdAt,
|
|
target: {
|
|
type: activity.targetType,
|
|
id: activity.targetId,
|
|
title: activity.targetTitle,
|
|
excerpt: activity.targetExcerpt
|
|
}
|
|
})),
|
|
nextCursor:
|
|
hasMore && activities.length > 0
|
|
? encodeUserCommentActivityCursor({
|
|
createdAt: activities[activities.length - 1].createdAtCursor,
|
|
id: activities[activities.length - 1].id,
|
|
source: activities[activities.length - 1].source
|
|
})
|
|
: null,
|
|
hasMore
|
|
};
|
|
}
|
|
|
|
async function getLifePostById(
|
|
id: number,
|
|
userId: number | null = null,
|
|
locale = defaultLocale,
|
|
options: { enforceVisibility?: boolean; canViewAll?: boolean } = {}
|
|
): Promise<LifePost | null> {
|
|
const params: unknown[] = [id];
|
|
const conditions = ['lp.id = $1', 'lp.deleted_at IS NULL'];
|
|
|
|
if (options.enforceVisibility) {
|
|
addModerationVisibilityCondition(conditions, params, 'lp', 'lp.created_by_user_id', userId, options.canViewAll === true);
|
|
}
|
|
|
|
const post = await queryOne<LifePostRow>(
|
|
`
|
|
${lifePostProjection(locale)}
|
|
WHERE ${conditions.join(' AND ')}
|
|
`,
|
|
params
|
|
);
|
|
|
|
if (!post) {
|
|
return null;
|
|
}
|
|
|
|
const canViewAll = options.canViewAll === true;
|
|
const commentPreviewByPost = await lifeCommentPreviewForPosts([post.id], userId, canViewAll);
|
|
const commentCountsByPost = await lifeCommentCountsForPosts([post.id], userId, canViewAll);
|
|
const { countsByPost, myReactionsByPost } = await lifeReactionsForPosts([post.id], userId);
|
|
return hydrateLifePost(post, commentPreviewByPost, commentCountsByPost, countsByPost, myReactionsByPost);
|
|
}
|
|
|
|
export async function getLifePost(
|
|
idValue: number,
|
|
userId: number | null = null,
|
|
locale = defaultLocale,
|
|
canViewAll = false
|
|
): Promise<LifePost | null> {
|
|
const id = requirePositiveInteger(idValue, 'server.validation.recordInvalid');
|
|
return getLifePostById(id, userId, locale, { enforceVisibility: true, canViewAll });
|
|
}
|
|
|
|
async function ensureGameVersion(client: DbClient, gameVersionId: number | null): Promise<void> {
|
|
if (gameVersionId === null) {
|
|
return;
|
|
}
|
|
|
|
const result = await client.query<{ id: number }>('SELECT id FROM game_versions WHERE id = $1', [gameVersionId]);
|
|
if (result.rowCount === 0) {
|
|
throw validationError('server.validation.gameVersionInvalid');
|
|
}
|
|
}
|
|
|
|
export async function createLifePost(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
|
|
const cleanPayload = cleanLifePostPayload(payload);
|
|
|
|
const id = await withTransaction(async (client) => {
|
|
await ensureGameVersion(client, cleanPayload.gameVersionId);
|
|
const result = await client.query<{ id: number }>(
|
|
`
|
|
INSERT INTO life_posts (
|
|
body,
|
|
game_version_id,
|
|
ai_moderation_status,
|
|
ai_moderation_language_code,
|
|
created_by_user_id,
|
|
updated_by_user_id
|
|
)
|
|
VALUES ($1, $2, 'reviewing', NULL, $3, $3)
|
|
RETURNING id
|
|
`,
|
|
[cleanPayload.body, cleanPayload.gameVersionId, userId]
|
|
);
|
|
|
|
return result.rows[0].id;
|
|
});
|
|
|
|
await requestAiModerationReview({ type: 'life-post', id }, { languageCode: cleanPayload.languageCode, resetRetries: true });
|
|
return getLifePostById(id, userId, locale);
|
|
}
|
|
|
|
export async function updateLifePost(
|
|
id: number,
|
|
payload: Record<string, unknown>,
|
|
userId: number,
|
|
locale = defaultLocale,
|
|
allowAny = false
|
|
) {
|
|
const cleanPayload = cleanLifePostPayload(payload);
|
|
|
|
const updatedId = await withTransaction(async (client) => {
|
|
await ensureGameVersion(client, cleanPayload.gameVersionId);
|
|
const result = await client.query<{ id: number }>(
|
|
`
|
|
UPDATE life_posts
|
|
SET body = $1,
|
|
game_version_id = $2,
|
|
ai_moderation_status = 'reviewing',
|
|
ai_moderation_language_code = NULL,
|
|
ai_moderation_content_hash = NULL,
|
|
ai_moderation_checked_at = NULL,
|
|
ai_moderation_retry_count = 0,
|
|
ai_moderation_updated_at = now(),
|
|
updated_by_user_id = $3,
|
|
updated_at = now()
|
|
WHERE id = $4
|
|
AND ($5 = true OR created_by_user_id = $3)
|
|
AND deleted_at IS NULL
|
|
RETURNING id
|
|
`,
|
|
[cleanPayload.body, cleanPayload.gameVersionId, userId, id, allowAny]
|
|
);
|
|
|
|
return result.rows[0]?.id ?? null;
|
|
});
|
|
|
|
if (updatedId) {
|
|
await requestAiModerationReview(
|
|
{ type: 'life-post', id: updatedId },
|
|
{ languageCode: cleanPayload.languageCode, resetRetries: true }
|
|
);
|
|
}
|
|
|
|
return updatedId ? getLifePostById(updatedId, userId, locale) : null;
|
|
}
|
|
|
|
export async function deleteLifePost(id: number, userId: number, allowAny = false) {
|
|
const result = await queryOne<{ id: number }>(
|
|
`
|
|
UPDATE life_posts
|
|
SET deleted_at = now(),
|
|
deleted_by_user_id = $2,
|
|
updated_by_user_id = $2,
|
|
updated_at = now()
|
|
WHERE id = $1
|
|
AND ($3 = true OR created_by_user_id = $2)
|
|
AND deleted_at IS NULL
|
|
RETURNING id
|
|
`,
|
|
[id, userId, allowAny]
|
|
);
|
|
|
|
return Boolean(result);
|
|
}
|
|
|
|
export async function retryLifePostModeration(id: number, userId: number, locale = defaultLocale, allowAny = false) {
|
|
const postId = requirePositiveInteger(id, 'server.validation.recordInvalid');
|
|
const row = await queryOne<{ id: number }>(
|
|
`
|
|
SELECT id
|
|
FROM life_posts
|
|
WHERE id = $1
|
|
AND ($3 = true OR created_by_user_id = $2)
|
|
AND deleted_at IS NULL
|
|
AND ai_moderation_status IN ('unreviewed', 'rejected', 'failed')
|
|
`,
|
|
[postId, userId, allowAny]
|
|
);
|
|
|
|
if (!row) {
|
|
return null;
|
|
}
|
|
|
|
await requestAiModerationReview({ type: 'life-post', id: postId }, { incrementRetries: true });
|
|
return getLifePostById(postId, userId, locale);
|
|
}
|
|
|
|
export async function setLifePostReaction(
|
|
postId: number,
|
|
payload: Record<string, unknown>,
|
|
userId: number,
|
|
locale = defaultLocale
|
|
) {
|
|
const reactionType = cleanLifeReactionType(payload.reactionType);
|
|
|
|
const result = await queryOne<{ postId: number }>(
|
|
`
|
|
INSERT INTO life_post_reactions (post_id, user_id, reaction_type)
|
|
SELECT $1, $2, $3
|
|
WHERE EXISTS (
|
|
SELECT 1
|
|
FROM life_posts
|
|
WHERE id = $1
|
|
AND deleted_at IS NULL
|
|
AND ai_moderation_status = 'approved'
|
|
)
|
|
ON CONFLICT (post_id, user_id)
|
|
DO UPDATE SET reaction_type = EXCLUDED.reaction_type, updated_at = now()
|
|
RETURNING post_id AS "postId"
|
|
`,
|
|
[postId, userId, reactionType]
|
|
);
|
|
|
|
if (result) {
|
|
await createLifePostReactionNotification(result.postId, userId);
|
|
}
|
|
|
|
return result ? getLifePostById(result.postId, userId, locale) : null;
|
|
}
|
|
|
|
export async function deleteLifePostReaction(postId: number, userId: number, locale = defaultLocale) {
|
|
await queryOne<{ postId: number }>(
|
|
`
|
|
DELETE FROM life_post_reactions
|
|
WHERE post_id = $1
|
|
AND user_id = $2
|
|
AND EXISTS (
|
|
SELECT 1
|
|
FROM life_posts
|
|
WHERE id = $1
|
|
AND deleted_at IS NULL
|
|
AND ai_moderation_status = 'approved'
|
|
)
|
|
RETURNING post_id AS "postId"
|
|
`,
|
|
[postId, userId]
|
|
);
|
|
|
|
return getLifePostById(postId, userId, locale);
|
|
}
|
|
|
|
export async function createLifeComment(postId: number, payload: Record<string, unknown>, userId: number) {
|
|
const cleanPayload = cleanLifeCommentPayload(payload);
|
|
|
|
const result = await queryOne<{ id: number }>(
|
|
`
|
|
INSERT INTO life_post_comments (post_id, body, ai_moderation_status, ai_moderation_language_code, created_by_user_id)
|
|
SELECT $1, $2, 'reviewing', NULL, $3
|
|
WHERE EXISTS (
|
|
SELECT 1
|
|
FROM life_posts
|
|
WHERE id = $1
|
|
AND deleted_at IS NULL
|
|
AND ai_moderation_status = 'approved'
|
|
)
|
|
RETURNING id
|
|
`,
|
|
[postId, cleanPayload.body, userId]
|
|
);
|
|
|
|
if (result) {
|
|
await requestAiModerationReview(
|
|
{ type: 'life-comment', id: result.id },
|
|
{ languageCode: cleanPayload.languageCode, resetRetries: true }
|
|
);
|
|
}
|
|
|
|
return result ? getLifeCommentById(result.id, userId, false) : null;
|
|
}
|
|
|
|
export async function createLifeCommentReply(
|
|
postId: number,
|
|
commentId: number,
|
|
payload: Record<string, unknown>,
|
|
userId: number
|
|
) {
|
|
const cleanPayload = cleanLifeCommentPayload(payload);
|
|
|
|
const result = await queryOne<{ id: number }>(
|
|
`
|
|
INSERT INTO life_post_comments (
|
|
post_id,
|
|
parent_comment_id,
|
|
body,
|
|
ai_moderation_status,
|
|
ai_moderation_language_code,
|
|
created_by_user_id
|
|
)
|
|
SELECT lc.post_id, lc.id, $3, 'reviewing', NULL, $4
|
|
FROM life_post_comments lc
|
|
JOIN life_posts lp ON lp.id = lc.post_id
|
|
WHERE lc.post_id = $1
|
|
AND lc.id = $2
|
|
AND lc.parent_comment_id IS NULL
|
|
AND lc.deleted_at IS NULL
|
|
AND lc.ai_moderation_status = 'approved'
|
|
AND lp.deleted_at IS NULL
|
|
AND lp.ai_moderation_status = 'approved'
|
|
RETURNING id
|
|
`,
|
|
[postId, commentId, cleanPayload.body, userId]
|
|
);
|
|
|
|
if (result) {
|
|
await requestAiModerationReview(
|
|
{ type: 'life-comment', id: result.id },
|
|
{ languageCode: cleanPayload.languageCode, resetRetries: true }
|
|
);
|
|
}
|
|
|
|
return result ? getLifeCommentById(result.id, userId, false) : null;
|
|
}
|
|
|
|
export async function deleteLifeComment(id: number, userId: number, allowAny = false) {
|
|
const result = await queryOne<{ id: number }>(
|
|
`
|
|
UPDATE life_post_comments
|
|
SET deleted_at = now(), deleted_by_user_id = $2, updated_at = now()
|
|
WHERE id = $1
|
|
AND ($3 = true OR created_by_user_id = $2)
|
|
AND deleted_at IS NULL
|
|
RETURNING id
|
|
`,
|
|
[id, userId, allowAny]
|
|
);
|
|
|
|
return Boolean(result);
|
|
}
|
|
|
|
export async function restoreLifeComment(id: number, userId: number) {
|
|
const commentId = requirePositiveInteger(id, 'server.validation.commentInvalid');
|
|
const result = await queryOne<{ id: number }>(
|
|
`
|
|
UPDATE life_post_comments
|
|
SET deleted_at = NULL, deleted_by_user_id = NULL, updated_at = now()
|
|
WHERE id = $1
|
|
AND created_by_user_id = $2
|
|
AND deleted_at IS NOT NULL
|
|
AND EXISTS (
|
|
SELECT 1
|
|
FROM life_posts lp
|
|
WHERE lp.id = life_post_comments.post_id
|
|
AND lp.deleted_at IS NULL
|
|
)
|
|
RETURNING id
|
|
`,
|
|
[commentId, userId]
|
|
);
|
|
|
|
return result ? getLifeCommentById(result.id, userId, false) : null;
|
|
}
|
|
|
|
export async function retryLifeCommentModeration(id: number, userId: number, allowAny = false) {
|
|
const commentId = requirePositiveInteger(id, 'server.validation.commentInvalid');
|
|
const row = await queryOne<{ id: number }>(
|
|
`
|
|
SELECT id
|
|
FROM life_post_comments
|
|
WHERE id = $1
|
|
AND ($3 = true OR created_by_user_id = $2)
|
|
AND deleted_at IS NULL
|
|
AND ai_moderation_status IN ('unreviewed', 'rejected', 'failed')
|
|
`,
|
|
[commentId, userId, allowAny]
|
|
);
|
|
|
|
if (!row) {
|
|
return null;
|
|
}
|
|
|
|
await requestAiModerationReview({ type: 'life-comment', id: commentId }, { incrementRetries: true });
|
|
return getLifeCommentById(commentId, userId, allowAny);
|
|
}
|
|
|
|
async function approvedLifeCommentExists(commentId: number): Promise<boolean> {
|
|
const row = await queryOne<{ id: number }>(
|
|
`
|
|
SELECT lc.id
|
|
FROM life_post_comments lc
|
|
JOIN life_posts lp ON lp.id = lc.post_id
|
|
WHERE lc.id = $1
|
|
AND lc.deleted_at IS NULL
|
|
AND lc.ai_moderation_status = 'approved'
|
|
AND lp.deleted_at IS NULL
|
|
AND lp.ai_moderation_status = 'approved'
|
|
`,
|
|
[commentId]
|
|
);
|
|
|
|
return Boolean(row);
|
|
}
|
|
|
|
export async function setLifeCommentLike(id: number, userId: number): Promise<LifeComment | null> {
|
|
const commentId = requirePositiveInteger(id, 'server.validation.commentInvalid');
|
|
if (!(await approvedLifeCommentExists(commentId))) {
|
|
return null;
|
|
}
|
|
|
|
await queryOne<{ commentId: number }>(
|
|
`
|
|
INSERT INTO life_comment_likes (comment_id, user_id)
|
|
VALUES ($1, $2)
|
|
ON CONFLICT (comment_id, user_id) DO NOTHING
|
|
RETURNING comment_id AS "commentId"
|
|
`,
|
|
[commentId, userId]
|
|
);
|
|
|
|
return getLifeCommentById(commentId, userId);
|
|
}
|
|
|
|
export async function deleteLifeCommentLike(id: number, userId: number): Promise<LifeComment | null> {
|
|
const commentId = requirePositiveInteger(id, 'server.validation.commentInvalid');
|
|
if (!(await approvedLifeCommentExists(commentId))) {
|
|
return null;
|
|
}
|
|
|
|
await queryOne<{ commentId: number }>(
|
|
`
|
|
DELETE FROM life_comment_likes
|
|
WHERE comment_id = $1
|
|
AND user_id = $2
|
|
RETURNING comment_id AS "commentId"
|
|
`,
|
|
[commentId, userId]
|
|
);
|
|
|
|
return getLifeCommentById(commentId, userId);
|
|
}
|
|
|
|
function cleanDiscussionEntityType(value: unknown): DiscussionEntityType {
|
|
if (typeof value !== 'string' || !Object.hasOwn(discussionEntityDefinitions, value)) {
|
|
throw validationError('server.validation.entityTypeInvalid');
|
|
}
|
|
|
|
return value as DiscussionEntityType;
|
|
}
|
|
|
|
function cleanEntityDiscussionCommentPayload(payload: Record<string, unknown>): EntityDiscussionCommentPayload {
|
|
const body = cleanName(payload.body, 'server.validation.commentRequired');
|
|
if (body.length > 1000) {
|
|
throw validationError('server.validation.commentTooLong');
|
|
}
|
|
|
|
return { body, languageCode: cleanModerationLanguageCode(payload.languageCode) };
|
|
}
|
|
|
|
async function entityDiscussionExists(
|
|
client: Pick<DbClient, 'query'>,
|
|
entityType: DiscussionEntityType,
|
|
entityId: number
|
|
): Promise<boolean> {
|
|
const definition = discussionEntityDefinitions[entityType];
|
|
const result = await client.query<{ exists: boolean }>(
|
|
`SELECT EXISTS (SELECT 1 FROM ${definition.table} WHERE id = $1) AS exists`,
|
|
[entityId]
|
|
);
|
|
|
|
return result.rows[0]?.exists === true;
|
|
}
|
|
|
|
function entityDiscussionCommentProjection(whereClause: string, userId: number | null = null, canViewAll = false): string {
|
|
const myLikedExpression =
|
|
userId === null
|
|
? 'false'
|
|
: `EXISTS (
|
|
SELECT 1
|
|
FROM entity_discussion_comment_likes my_like
|
|
WHERE my_like.comment_id = edc.id AND my_like.user_id = ${userId}
|
|
)`;
|
|
const replyVisibility = [
|
|
'reply.parent_comment_id = edc.id',
|
|
'reply.deleted_at IS NULL',
|
|
moderationVisibilitySql('reply', 'reply.created_by_user_id', userId, canViewAll)
|
|
].join(' AND ');
|
|
|
|
return `
|
|
SELECT
|
|
edc.id,
|
|
edc.entity_type AS "entityType",
|
|
edc.entity_id AS "entityId",
|
|
edc.parent_comment_id AS "parentCommentId",
|
|
CASE WHEN edc.deleted_at IS NULL THEN edc.body ELSE '' END AS body,
|
|
edc.deleted_at IS NOT NULL AS deleted,
|
|
edc.ai_moderation_status AS "moderationStatus",
|
|
edc.ai_moderation_language_code AS "moderationLanguageCode",
|
|
edc.ai_moderation_reason AS "moderationReason",
|
|
edc.created_at AS "createdAt",
|
|
edc.created_at::text AS "createdAtCursor",
|
|
edc.updated_at AS "updatedAt",
|
|
CASE
|
|
WHEN edc.deleted_at IS NOT NULL OR comment_user.id IS NULL THEN NULL
|
|
ELSE json_build_object('id', comment_user.id, 'displayName', comment_user.display_name)
|
|
END AS author,
|
|
like_stats.like_count AS "likeCount",
|
|
reply_stats.reply_count AS "replyCount",
|
|
${myLikedExpression} AS "myLiked"
|
|
FROM entity_discussion_comments edc
|
|
LEFT JOIN LATERAL (
|
|
SELECT COUNT(*)::integer AS like_count
|
|
FROM entity_discussion_comment_likes edcl
|
|
WHERE edcl.comment_id = edc.id
|
|
) like_stats ON true
|
|
LEFT JOIN LATERAL (
|
|
SELECT COUNT(*)::integer AS reply_count
|
|
FROM entity_discussion_comments reply
|
|
WHERE ${replyVisibility}
|
|
) reply_stats ON true
|
|
LEFT JOIN users comment_user ON comment_user.id = edc.created_by_user_id
|
|
${whereClause}
|
|
`;
|
|
}
|
|
|
|
function buildEntityDiscussionCommentTree(rows: EntityDiscussionCommentRow[]): EntityDiscussionComment[] {
|
|
const comments = new Map<number, EntityDiscussionComment>();
|
|
const topLevelComments: EntityDiscussionComment[] = [];
|
|
|
|
for (const row of rows) {
|
|
const { createdAtCursor: _createdAtCursor, ...comment } = row;
|
|
comments.set(row.id, { ...comment, replies: [] });
|
|
}
|
|
|
|
for (const comment of comments.values()) {
|
|
if (comment.parentCommentId === null) {
|
|
topLevelComments.push(comment);
|
|
continue;
|
|
}
|
|
|
|
const parent = comments.get(comment.parentCommentId);
|
|
if (parent?.parentCommentId === null) {
|
|
parent.replies.push(comment);
|
|
} else {
|
|
topLevelComments.push(comment);
|
|
}
|
|
}
|
|
|
|
return topLevelComments;
|
|
}
|
|
|
|
async function getEntityDiscussionCommentById(
|
|
id: number,
|
|
userId: number | null = null,
|
|
canViewAll = false
|
|
): Promise<EntityDiscussionComment | null> {
|
|
const row = await queryOne<EntityDiscussionCommentRow>(
|
|
`
|
|
${entityDiscussionCommentProjection('WHERE edc.id = $1', userId, canViewAll)}
|
|
`,
|
|
[id]
|
|
);
|
|
|
|
if (!row) {
|
|
return null;
|
|
}
|
|
|
|
const { createdAtCursor: _createdAtCursor, ...comment } = row;
|
|
return { ...comment, replies: [] };
|
|
}
|
|
|
|
export async function listEntityDiscussionComments(
|
|
entityTypeValue: string,
|
|
entityIdValue: number,
|
|
paramsQuery: QueryParams = {},
|
|
userId: number | null = null,
|
|
canViewAll = false
|
|
): Promise<EntityDiscussionCommentsPage | null> {
|
|
const entityType = cleanDiscussionEntityType(entityTypeValue);
|
|
const entityId = requirePositiveInteger(entityIdValue, 'server.validation.recordInvalid');
|
|
const cursor = decodeCommentCursor(paramsQuery.cursor);
|
|
const limit = cleanCommentLimit(paramsQuery.limit);
|
|
const languageCode = cleanModerationLanguageFilter(paramsQuery.language);
|
|
const sort = cleanCommentSort(paramsQuery.sort);
|
|
|
|
if (!(await entityDiscussionExists(pool, entityType, entityId))) {
|
|
return null;
|
|
}
|
|
|
|
const params: unknown[] = [entityType, entityId];
|
|
const topLevelConditions = ['edc.entity_type = $1', 'edc.entity_id = $2', 'edc.parent_comment_id IS NULL'];
|
|
addModerationVisibilityCondition(topLevelConditions, params, 'edc', 'edc.created_by_user_id', userId, canViewAll);
|
|
addModerationLanguageCondition(topLevelConditions, params, 'edc', languageCode);
|
|
|
|
if (cursor) {
|
|
addCommentCursorCondition(topLevelConditions, params, 'edc', cursor, sort);
|
|
}
|
|
|
|
params.push(limit + 1);
|
|
const topLevelRows = await query<EntityDiscussionCommentRow>(
|
|
`
|
|
${entityDiscussionCommentProjection(`WHERE ${topLevelConditions.join(' AND ')}`, userId, canViewAll)}
|
|
ORDER BY ${commentSortOrder('edc', sort)}
|
|
LIMIT $${params.length}
|
|
`,
|
|
params
|
|
);
|
|
const hasMore = topLevelRows.length > limit;
|
|
const topLevelComments = hasMore ? topLevelRows.slice(0, limit) : topLevelRows;
|
|
const topLevelIds = topLevelComments.map((comment) => comment.id);
|
|
const replyRows = topLevelIds.length
|
|
? await (async () => {
|
|
const replyParams: unknown[] = [topLevelIds];
|
|
const replyConditions = ['edc.parent_comment_id = ANY($1::integer[])'];
|
|
addModerationVisibilityCondition(replyConditions, replyParams, 'edc', 'edc.created_by_user_id', userId, canViewAll);
|
|
addModerationLanguageCondition(replyConditions, replyParams, 'edc', languageCode);
|
|
return query<EntityDiscussionCommentRow>(
|
|
`
|
|
${entityDiscussionCommentProjection(`WHERE ${replyConditions.join(' AND ')}`, userId, canViewAll)}
|
|
ORDER BY edc.created_at, edc.id
|
|
`,
|
|
replyParams
|
|
);
|
|
})()
|
|
: [];
|
|
const totalParams: unknown[] = [entityType, entityId];
|
|
const totalConditions = ['edc.entity_type = $1', 'edc.entity_id = $2'];
|
|
addModerationVisibilityCondition(totalConditions, totalParams, 'edc', 'edc.created_by_user_id', userId, canViewAll);
|
|
addModerationLanguageCondition(totalConditions, totalParams, 'edc', languageCode);
|
|
const total = await queryOne<{ total: number }>(
|
|
`
|
|
SELECT COUNT(*)::integer AS total
|
|
FROM entity_discussion_comments edc
|
|
WHERE ${totalConditions.join(' AND ')}
|
|
`,
|
|
totalParams
|
|
);
|
|
|
|
return {
|
|
items: buildEntityDiscussionCommentTree([...topLevelComments, ...replyRows]),
|
|
nextCursor:
|
|
hasMore && topLevelComments.length > 0
|
|
? encodeCommentCursor(topLevelComments[topLevelComments.length - 1], sort)
|
|
: null,
|
|
hasMore,
|
|
total: total?.total ?? 0
|
|
};
|
|
}
|
|
|
|
export async function createEntityDiscussionComment(
|
|
entityTypeValue: string,
|
|
entityIdValue: number,
|
|
payload: Record<string, unknown>,
|
|
userId: number
|
|
): Promise<EntityDiscussionComment | null> {
|
|
const entityType = cleanDiscussionEntityType(entityTypeValue);
|
|
const entityId = requirePositiveInteger(entityIdValue, 'server.validation.recordInvalid');
|
|
const cleanPayload = cleanEntityDiscussionCommentPayload(payload);
|
|
|
|
const id = await withTransaction(async (client) => {
|
|
if (!(await entityDiscussionExists(client, entityType, entityId))) {
|
|
return null;
|
|
}
|
|
|
|
const result = await client.query<{ id: number }>(
|
|
`
|
|
INSERT INTO entity_discussion_comments (
|
|
entity_type,
|
|
entity_id,
|
|
body,
|
|
ai_moderation_status,
|
|
ai_moderation_language_code,
|
|
created_by_user_id
|
|
)
|
|
VALUES ($1, $2, $3, 'reviewing', NULL, $4)
|
|
RETURNING id
|
|
`,
|
|
[entityType, entityId, cleanPayload.body, userId]
|
|
);
|
|
|
|
return result.rows[0].id;
|
|
});
|
|
|
|
if (id) {
|
|
await requestAiModerationReview(
|
|
{ type: 'discussion-comment', id },
|
|
{ languageCode: cleanPayload.languageCode, resetRetries: true }
|
|
);
|
|
}
|
|
|
|
return id ? getEntityDiscussionCommentById(id, userId, false) : null;
|
|
}
|
|
|
|
export async function createEntityDiscussionReply(
|
|
entityTypeValue: string,
|
|
entityIdValue: number,
|
|
commentIdValue: number,
|
|
payload: Record<string, unknown>,
|
|
userId: number
|
|
): Promise<EntityDiscussionComment | null> {
|
|
const entityType = cleanDiscussionEntityType(entityTypeValue);
|
|
const entityId = requirePositiveInteger(entityIdValue, 'server.validation.recordInvalid');
|
|
const commentId = requirePositiveInteger(commentIdValue, 'server.validation.commentInvalid');
|
|
const cleanPayload = cleanEntityDiscussionCommentPayload(payload);
|
|
|
|
const id = await withTransaction(async (client) => {
|
|
if (!(await entityDiscussionExists(client, entityType, entityId))) {
|
|
return null;
|
|
}
|
|
|
|
const result = await client.query<{ id: number }>(
|
|
`
|
|
INSERT INTO entity_discussion_comments (
|
|
entity_type,
|
|
entity_id,
|
|
parent_comment_id,
|
|
body,
|
|
ai_moderation_status,
|
|
ai_moderation_language_code,
|
|
created_by_user_id
|
|
)
|
|
SELECT edc.entity_type, edc.entity_id, edc.id, $4, 'reviewing', NULL, $5
|
|
FROM entity_discussion_comments edc
|
|
WHERE edc.entity_type = $1
|
|
AND edc.entity_id = $2
|
|
AND edc.id = $3
|
|
AND edc.parent_comment_id IS NULL
|
|
AND edc.deleted_at IS NULL
|
|
AND edc.ai_moderation_status = 'approved'
|
|
RETURNING id
|
|
`,
|
|
[entityType, entityId, commentId, cleanPayload.body, userId]
|
|
);
|
|
|
|
return result.rows[0]?.id ?? null;
|
|
});
|
|
|
|
if (id) {
|
|
await requestAiModerationReview(
|
|
{ type: 'discussion-comment', id },
|
|
{ languageCode: cleanPayload.languageCode, resetRetries: true }
|
|
);
|
|
}
|
|
|
|
return id ? getEntityDiscussionCommentById(id, userId, false) : null;
|
|
}
|
|
|
|
export async function deleteEntityDiscussionComment(id: number, userId: number, allowAny = false): Promise<boolean> {
|
|
const commentId = requirePositiveInteger(id, 'server.validation.commentInvalid');
|
|
const result = await queryOne<{ id: number }>(
|
|
`
|
|
UPDATE entity_discussion_comments
|
|
SET deleted_at = now(),
|
|
deleted_by_user_id = $2,
|
|
updated_at = now()
|
|
WHERE id = $1
|
|
AND ($3 = true OR created_by_user_id = $2)
|
|
AND deleted_at IS NULL
|
|
RETURNING id
|
|
`,
|
|
[commentId, userId, allowAny]
|
|
);
|
|
|
|
return Boolean(result);
|
|
}
|
|
|
|
export async function retryEntityDiscussionCommentModeration(
|
|
id: number,
|
|
userId: number,
|
|
allowAny = false
|
|
): Promise<EntityDiscussionComment | null> {
|
|
const commentId = requirePositiveInteger(id, 'server.validation.commentInvalid');
|
|
const row = await queryOne<{ id: number }>(
|
|
`
|
|
SELECT id
|
|
FROM entity_discussion_comments
|
|
WHERE id = $1
|
|
AND ($3 = true OR created_by_user_id = $2)
|
|
AND deleted_at IS NULL
|
|
AND ai_moderation_status IN ('unreviewed', 'rejected', 'failed')
|
|
`,
|
|
[commentId, userId, allowAny]
|
|
);
|
|
|
|
if (!row) {
|
|
return null;
|
|
}
|
|
|
|
await requestAiModerationReview({ type: 'discussion-comment', id: commentId }, { incrementRetries: true });
|
|
return getEntityDiscussionCommentById(commentId, userId, allowAny);
|
|
}
|
|
|
|
async function approvedEntityDiscussionCommentExists(commentId: number): Promise<boolean> {
|
|
const row = await queryOne<{ id: number }>(
|
|
`
|
|
SELECT id
|
|
FROM entity_discussion_comments
|
|
WHERE id = $1
|
|
AND deleted_at IS NULL
|
|
AND ai_moderation_status = 'approved'
|
|
`,
|
|
[commentId]
|
|
);
|
|
|
|
return Boolean(row);
|
|
}
|
|
|
|
export async function setEntityDiscussionCommentLike(id: number, userId: number): Promise<EntityDiscussionComment | null> {
|
|
const commentId = requirePositiveInteger(id, 'server.validation.commentInvalid');
|
|
if (!(await approvedEntityDiscussionCommentExists(commentId))) {
|
|
return null;
|
|
}
|
|
|
|
await queryOne<{ commentId: number }>(
|
|
`
|
|
INSERT INTO entity_discussion_comment_likes (comment_id, user_id)
|
|
VALUES ($1, $2)
|
|
ON CONFLICT (comment_id, user_id) DO NOTHING
|
|
RETURNING comment_id AS "commentId"
|
|
`,
|
|
[commentId, userId]
|
|
);
|
|
|
|
return getEntityDiscussionCommentById(commentId, userId);
|
|
}
|
|
|
|
export async function deleteEntityDiscussionCommentLike(id: number, userId: number): Promise<EntityDiscussionComment | null> {
|
|
const commentId = requirePositiveInteger(id, 'server.validation.commentInvalid');
|
|
if (!(await approvedEntityDiscussionCommentExists(commentId))) {
|
|
return null;
|
|
}
|
|
|
|
await queryOne<{ commentId: number }>(
|
|
`
|
|
DELETE FROM entity_discussion_comment_likes
|
|
WHERE comment_id = $1
|
|
AND user_id = $2
|
|
RETURNING comment_id AS "commentId"
|
|
`,
|
|
[commentId, userId]
|
|
);
|
|
|
|
return getEntityDiscussionCommentById(commentId, userId);
|
|
}
|
|
|
|
async function deleteEntityDiscussionCommentsForEntity(
|
|
client: DbClient,
|
|
entityType: DiscussionEntityType,
|
|
entityId: number
|
|
): Promise<void> {
|
|
await client.query(
|
|
`
|
|
DELETE FROM entity_discussion_comments
|
|
WHERE entity_type = $1
|
|
AND entity_id = $2
|
|
`,
|
|
[entityType, entityId]
|
|
);
|
|
}
|
|
|
|
export function isConfigType(type: string): type is ConfigType {
|
|
return Object.hasOwn(configDefinitions, type);
|
|
}
|
|
|
|
async function syncConfigOpposite(
|
|
client: DbClient,
|
|
definition: ConfigDefinition,
|
|
id: number,
|
|
oppositeId: number | null,
|
|
userId: number
|
|
): Promise<void> {
|
|
const oppositeColumn = definition.oppositeColumn;
|
|
if (!oppositeColumn) {
|
|
return;
|
|
}
|
|
|
|
if (oppositeId === id) {
|
|
throw validationError('server.validation.invalidField');
|
|
}
|
|
|
|
if (oppositeId !== null) {
|
|
const opposite = await client.query<{ id: number }>(
|
|
`SELECT id FROM ${definition.table} WHERE id = $1 FOR UPDATE`,
|
|
[oppositeId]
|
|
);
|
|
if (opposite.rowCount === 0) {
|
|
throw validationError('server.validation.selectRecord');
|
|
}
|
|
}
|
|
|
|
await client.query(
|
|
`
|
|
UPDATE ${definition.table}
|
|
SET ${oppositeColumn} = NULL,
|
|
updated_by_user_id = $2,
|
|
updated_at = now()
|
|
WHERE id <> $1
|
|
AND (${oppositeColumn} = $1 OR (${oppositeId === null ? 'FALSE' : `${oppositeColumn} = $3`}))
|
|
`,
|
|
oppositeId === null ? [id, userId] : [id, userId, oppositeId]
|
|
);
|
|
|
|
await client.query(
|
|
`
|
|
UPDATE ${definition.table}
|
|
SET ${oppositeColumn} = $1,
|
|
updated_by_user_id = $2,
|
|
updated_at = now()
|
|
WHERE id = $3
|
|
`,
|
|
[oppositeId, userId, id]
|
|
);
|
|
|
|
if (oppositeId !== null) {
|
|
await client.query(
|
|
`
|
|
UPDATE ${definition.table}
|
|
SET ${oppositeColumn} = $1,
|
|
updated_by_user_id = $2,
|
|
updated_at = now()
|
|
WHERE id = $3
|
|
`,
|
|
[id, userId, oppositeId]
|
|
);
|
|
}
|
|
}
|
|
|
|
export async function listConfig(type: ConfigType, locale = defaultLocale) {
|
|
const definition = configDefinitions[type];
|
|
return query(
|
|
`
|
|
SELECT ${configSelect(definition, locale)}, ${auditSelect('c')}
|
|
FROM ${definition.table} c
|
|
${configRelationJoins(definition)}
|
|
${auditJoins('c')}
|
|
ORDER BY ${configOrder()}
|
|
`
|
|
);
|
|
}
|
|
|
|
async function getConfigById(type: ConfigType, id: number, locale = defaultLocale) {
|
|
const definition = configDefinitions[type];
|
|
return queryOne(
|
|
`
|
|
SELECT ${configSelect(definition, locale)}, ${auditSelect('c')}
|
|
FROM ${definition.table} c
|
|
${configRelationJoins(definition)}
|
|
${auditJoins('c')}
|
|
WHERE c.id = $1
|
|
`,
|
|
[id]
|
|
);
|
|
}
|
|
|
|
export async function createConfig(type: ConfigType, payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
|
|
const definition = configDefinitions[type];
|
|
const name = cleanName(payload.name);
|
|
const translations = cleanTranslations(payload.translations, ['name']);
|
|
const description = definition.hasDescription ? cleanOptionalText(payload.description) : '';
|
|
const oppositeId = definition.oppositeColumn ? cleanOptionalPositiveInteger(payload.oppositeId) : null;
|
|
const hasItemDrop = definition.hasItemDrop ? Boolean(payload.hasItemDrop) : false;
|
|
const hasTrading = definition.hasTrading ? Boolean(payload.hasTrading) : false;
|
|
const changeLog = definition.hasChangeLog ? cleanOptionalText(payload.changeLog) : '';
|
|
|
|
const id = await withTransaction(async (client) => {
|
|
const sortOrder = await nextSortOrder(client, definition.table);
|
|
const columns = ['name'];
|
|
const values: unknown[] = [name];
|
|
if (definition.hasDescription) {
|
|
columns.push('description');
|
|
values.push(description);
|
|
}
|
|
if (definition.hasItemDrop) {
|
|
columns.push('has_item_drop');
|
|
values.push(hasItemDrop);
|
|
}
|
|
if (definition.hasTrading) {
|
|
columns.push('has_trading');
|
|
values.push(hasTrading);
|
|
}
|
|
if (definition.hasChangeLog) {
|
|
columns.push('change_log');
|
|
values.push(changeLog);
|
|
}
|
|
columns.push('sort_order', 'created_by_user_id', 'updated_by_user_id');
|
|
values.push(sortOrder, userId, userId);
|
|
const placeholders = values.map((_, index) => `$${index + 1}`).join(', ');
|
|
const result = await client.query<{ id: number }>(
|
|
`
|
|
INSERT INTO ${definition.table} (${columns.join(', ')})
|
|
VALUES (${placeholders})
|
|
RETURNING id
|
|
`,
|
|
values
|
|
);
|
|
|
|
const createdId = result.rows[0].id;
|
|
await syncConfigOpposite(client, definition, createdId, oppositeId, userId);
|
|
await replaceEntityTranslations(client, definition.entityType, createdId, translations, ['name']);
|
|
await recordEditLog(client, type, createdId, 'create', userId);
|
|
return createdId;
|
|
});
|
|
|
|
return getConfigById(type, id, locale);
|
|
}
|
|
|
|
export async function reorderConfig(type: ConfigType, payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
|
|
const definition = configDefinitions[type];
|
|
const ids = cleanIds(payload.ids);
|
|
if (ids.length === 0) {
|
|
throw validationError('server.validation.selectRecord');
|
|
}
|
|
|
|
await withTransaction(async (client) => {
|
|
await reorderTableRows(client, definition.table, ids, userId);
|
|
});
|
|
|
|
return listConfig(type, locale);
|
|
}
|
|
|
|
export async function updateConfig(
|
|
type: ConfigType,
|
|
id: number,
|
|
payload: Record<string, unknown>,
|
|
userId: number,
|
|
locale = defaultLocale
|
|
) {
|
|
const definition = configDefinitions[type];
|
|
const name = cleanName(payload.name);
|
|
const translations = cleanTranslations(payload.translations, ['name']);
|
|
const description = definition.hasDescription ? cleanOptionalText(payload.description) : '';
|
|
const oppositeId = definition.oppositeColumn ? cleanOptionalPositiveInteger(payload.oppositeId) : null;
|
|
const before = await getConfigById(type, id, defaultLocale);
|
|
const hasItemDrop = definition.hasItemDrop ? Boolean(payload.hasItemDrop) : false;
|
|
const hasTrading = definition.hasTrading
|
|
? Object.hasOwn(payload, 'hasTrading')
|
|
? Boolean(payload.hasTrading)
|
|
: Boolean((before as { hasTrading?: boolean } | null)?.hasTrading)
|
|
: false;
|
|
const changeLog = definition.hasChangeLog ? cleanOptionalText(payload.changeLog) : '';
|
|
|
|
if (oppositeId === id) {
|
|
throw validationError('server.validation.invalidField');
|
|
}
|
|
|
|
const updated = await withTransaction(async (client) => {
|
|
const assignments = ['name = $1'];
|
|
const values: unknown[] = [name];
|
|
if (definition.hasDescription) {
|
|
values.push(description);
|
|
assignments.push(`description = $${values.length}`);
|
|
}
|
|
if (definition.hasItemDrop) {
|
|
values.push(hasItemDrop);
|
|
assignments.push(`has_item_drop = $${values.length}`);
|
|
}
|
|
if (definition.hasTrading) {
|
|
values.push(hasTrading);
|
|
assignments.push(`has_trading = $${values.length}`);
|
|
}
|
|
if (definition.hasChangeLog) {
|
|
values.push(changeLog);
|
|
assignments.push(`change_log = $${values.length}`);
|
|
}
|
|
values.push(userId);
|
|
assignments.push(`updated_by_user_id = $${values.length}`, 'updated_at = now()');
|
|
values.push(id);
|
|
const result = await client.query(
|
|
`
|
|
UPDATE ${definition.table}
|
|
SET ${assignments.join(', ')}
|
|
WHERE id = $${values.length}
|
|
`,
|
|
values
|
|
);
|
|
|
|
if (result.rowCount === 0) {
|
|
return false;
|
|
}
|
|
|
|
await syncConfigOpposite(client, definition, id, oppositeId, userId);
|
|
|
|
if (definition.hasItemDrop && !hasItemDrop) {
|
|
await client.query('DELETE FROM pokemon_skill_item_drops WHERE skill_id = $1', [id]);
|
|
}
|
|
if (definition.hasTrading && !hasTrading) {
|
|
await client.query(
|
|
`
|
|
DELETE FROM pokemon_trading_items pti
|
|
WHERE EXISTS (
|
|
SELECT 1
|
|
FROM pokemon_skills ps
|
|
WHERE ps.pokemon_id = pti.pokemon_id
|
|
AND ps.skill_id = $1
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM pokemon_skills ps
|
|
JOIN skills s ON s.id = ps.skill_id
|
|
WHERE ps.pokemon_id = pti.pokemon_id
|
|
AND s.has_trading = true
|
|
)
|
|
`,
|
|
[id]
|
|
);
|
|
}
|
|
|
|
await replaceEntityTranslations(client, definition.entityType, id, translations, ['name']);
|
|
const oppositeNames = definition.oppositeColumn && oppositeId ? await entityNameMap(client, definition.table, [oppositeId]) : new Map<number, string>();
|
|
const changes = before
|
|
? configEditChanges(definition, before as ConfigChangeSource, {
|
|
name,
|
|
description,
|
|
translations,
|
|
oppositeName: oppositeId ? oppositeNames.get(oppositeId) ?? '' : '',
|
|
hasItemDrop,
|
|
hasTrading,
|
|
changeLog
|
|
})
|
|
: [];
|
|
await recordEditLog(client, type, id, 'update', userId, changes);
|
|
return true;
|
|
});
|
|
|
|
return updated ? getConfigById(type, id, locale) : null;
|
|
}
|
|
|
|
export async function deleteConfig(type: ConfigType, id: number, userId: number) {
|
|
const definition = configDefinitions[type];
|
|
return withTransaction(async (client) => {
|
|
const result = await client.query<{ id: number }>(`DELETE FROM ${definition.table} WHERE id = $1 RETURNING id`, [id]);
|
|
if (result.rowCount === 0) {
|
|
return false;
|
|
}
|
|
|
|
await deleteEntityTranslations(client, definition.entityType, id);
|
|
await recordEditLog(client, type, id, 'delete', userId);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
async function reorderContent(type: SortableContentType, payload: Record<string, unknown>, userId: number): Promise<void> {
|
|
const definition = sortableContentDefinitions[type];
|
|
const ids = cleanIds(payload.ids);
|
|
if (ids.length === 0) {
|
|
throw validationError('server.validation.selectRecord');
|
|
}
|
|
|
|
await withTransaction(async (client) => {
|
|
await reorderTableRows(client, definition.table, ids, userId);
|
|
});
|
|
}
|
|
|
|
export async function reorderItems(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
|
|
await reorderContent('items', payload, userId);
|
|
return listItems({}, locale);
|
|
}
|
|
|
|
export async function reorderAncientArtifacts(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
|
|
await reorderContent('ancient-artifacts', payload, userId);
|
|
return listAncientArtifacts({}, locale);
|
|
}
|
|
|
|
export async function reorderRecipes(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
|
|
await reorderContent('recipes', payload, userId);
|
|
return listRecipes({}, locale);
|
|
}
|
|
|
|
export async function reorderHabitats(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
|
|
await reorderContent('habitats', payload, userId);
|
|
return listHabitats({}, locale);
|
|
}
|
|
|
|
export async function listPokemon(paramsQuery: QueryParams, locale = defaultLocale) {
|
|
const params: unknown[] = [];
|
|
const conditions: string[] = [];
|
|
const search = asString(paramsQuery.search)?.trim();
|
|
const isEventItem = asString(paramsQuery.isEventItem);
|
|
const environmentId = Number(asString(paramsQuery.environmentId));
|
|
const skillIds = parseIdList(asString(paramsQuery.skillIds));
|
|
const favoriteThingIds = parseIdList(asString(paramsQuery.favoriteThingIds));
|
|
|
|
if (isEventItem === 'true' || isEventItem === 'false') {
|
|
params.push(isEventItem === 'true');
|
|
conditions.push(`p.is_event_item = $${params.length}`);
|
|
}
|
|
|
|
if (search) {
|
|
params.push(`%${search}%`);
|
|
conditions.push(`${localizedName('pokemon', 'p', locale)} ILIKE $${params.length}`);
|
|
}
|
|
|
|
if (Number.isInteger(environmentId) && environmentId > 0) {
|
|
params.push(environmentId);
|
|
conditions.push(`p.environment_id = $${params.length}`);
|
|
}
|
|
|
|
const skillFilter = sqlForRelationFilter(
|
|
skillIds,
|
|
parseMatchMode(asString(paramsQuery.skillMode)),
|
|
'pokemon_skills',
|
|
'pokemon_id',
|
|
'skill_id',
|
|
'p.id',
|
|
params
|
|
);
|
|
if (skillFilter) {
|
|
conditions.push(skillFilter);
|
|
}
|
|
|
|
const favoriteThingFilter = sqlForRelationFilter(
|
|
favoriteThingIds,
|
|
parseMatchMode(asString(paramsQuery.favoriteThingMode)),
|
|
'pokemon_favorite_things',
|
|
'pokemon_id',
|
|
'favorite_thing_id',
|
|
'p.id',
|
|
params
|
|
);
|
|
if (favoriteThingFilter) {
|
|
conditions.push(favoriteThingFilter);
|
|
}
|
|
|
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
|
return queryMaybePaged(`${pokemonProjection(locale)} ${whereClause} ORDER BY p.display_id, p.id`, params, paramsQuery);
|
|
}
|
|
|
|
export async function getPokemon(id: number, locale = defaultLocale) {
|
|
const pokemon = await queryOne(`${pokemonProjection(locale)} WHERE p.id = $1`, [id]);
|
|
if (!pokemon) {
|
|
return null;
|
|
}
|
|
|
|
const habitatName = localizedName('habitats', 'h', locale);
|
|
const mapName = localizedName('maps', 'm', locale);
|
|
const itemName = localizedName('items', 'i', locale);
|
|
const tagName = localizedName('favorite-things', 'ft', locale);
|
|
const relatedPokemonName = localizedName('pokemon', 'related_pokemon', locale);
|
|
const relatedEnvironmentName = localizedName('environments', 'related_environment', locale);
|
|
const relatedSkillName = localizedName('skills', 'related_skill', locale);
|
|
const relatedFavoriteThingName = localizedName('favorite-things', 'related_favorite_thing', locale);
|
|
const tradingItemName = localizedName('items', 'trading_item', locale);
|
|
const moduleSettings = await getModuleSettings();
|
|
|
|
const [habitats, itemDrops, favoriteThingItems, tradingItems, relatedPokemon, editHistory, imageHistory] = await Promise.all([
|
|
query(
|
|
`
|
|
SELECT
|
|
h.id,
|
|
${habitatName} AS name,
|
|
${uploadedImageJson('h.image_path')} AS image,
|
|
hp.time_of_day,
|
|
hp.weather,
|
|
hp.rarity,
|
|
json_build_object('id', m.id, 'name', ${mapName}) AS map
|
|
FROM habitat_pokemon hp
|
|
JOIN habitats h ON h.id = hp.habitat_id
|
|
JOIN maps m ON m.id = hp.map_id
|
|
WHERE hp.pokemon_id = $1
|
|
ORDER BY ${orderByEntity('h')}, hp.rarity, ${orderByEntity('m')}
|
|
`,
|
|
[id]
|
|
),
|
|
query<{ skillId: number; id: number; name: string; image: EntityImageValue | null }>(
|
|
`
|
|
SELECT psid.skill_id AS "skillId", i.id, ${itemName} AS name, ${uploadedImageJson('i.image_path')} AS image
|
|
FROM pokemon_skill_item_drops psid
|
|
JOIN skills s ON s.id = psid.skill_id
|
|
JOIN items i ON i.id = psid.item_id
|
|
WHERE psid.pokemon_id = $1
|
|
AND s.has_item_drop = true
|
|
ORDER BY ${orderByEntity('s')}, ${orderByEntity('i')}
|
|
`,
|
|
[id]
|
|
),
|
|
query(
|
|
`
|
|
SELECT
|
|
i.id,
|
|
${itemName} AS name,
|
|
${uploadedImageJson('i.image_path')} AS image,
|
|
${systemListJsonSql('i.category_key', itemCategoryOptions, locale)} AS category,
|
|
json_agg(json_build_object('id', ft.id, 'name', ${tagName}) ORDER BY ${orderByEntity('ft')}) AS tags
|
|
FROM pokemon_favorite_things pft
|
|
JOIN item_favorite_things ift ON ift.favorite_thing_id = pft.favorite_thing_id
|
|
JOIN favorite_things ft ON ft.id = pft.favorite_thing_id
|
|
JOIN items i ON i.id = ift.item_id
|
|
WHERE pft.pokemon_id = $1
|
|
GROUP BY i.id, i.name, i.image_path, i.category_key, i.sort_order
|
|
ORDER BY i.category_key, ${orderByEntity('i')}
|
|
`,
|
|
[id]
|
|
),
|
|
moduleSettings.tradingEnabled
|
|
? query(
|
|
`
|
|
SELECT
|
|
ti.item_id AS "itemId",
|
|
ti.preference,
|
|
trading_item.id,
|
|
${tradingItemName} AS name,
|
|
${uploadedImageJson('trading_item.image_path')} AS image
|
|
FROM pokemon_trading_items ti
|
|
JOIN items trading_item ON trading_item.id = ti.item_id
|
|
WHERE ti.pokemon_id = $1
|
|
AND EXISTS (
|
|
SELECT 1
|
|
FROM pokemon_skills ps
|
|
JOIN skills trading_skill ON trading_skill.id = ps.skill_id
|
|
WHERE ps.pokemon_id = ti.pokemon_id
|
|
AND trading_skill.has_trading = true
|
|
)
|
|
ORDER BY ti.preference DESC, ${orderByEntity('trading_item')}
|
|
`,
|
|
[id]
|
|
)
|
|
: Promise.resolve([]),
|
|
query(
|
|
`
|
|
WITH current_pokemon AS (
|
|
SELECT p.id, p.environment_id
|
|
FROM pokemon p
|
|
WHERE p.id = $1
|
|
),
|
|
current_favourites AS (
|
|
SELECT
|
|
pft.favorite_thing_id,
|
|
ft.opposite_favorite_thing_id
|
|
FROM pokemon_favorite_things pft
|
|
JOIN favorite_things ft ON ft.id = pft.favorite_thing_id
|
|
WHERE pft.pokemon_id = $1
|
|
),
|
|
scored_pokemon AS (
|
|
SELECT
|
|
related_pokemon.id,
|
|
(related_pokemon.environment_id = current_pokemon.environment_id) AS "environmentMatches",
|
|
(
|
|
related_pokemon.environment_id = current_environment.opposite_environment_id
|
|
OR related_environment.opposite_environment_id = current_pokemon.environment_id
|
|
) AS "environmentIsOpposite",
|
|
COUNT(current_favourites.favorite_thing_id) FILTER (
|
|
WHERE current_favourites.favorite_thing_id = related_pokemon_favourite.favorite_thing_id
|
|
)::integer AS "favoriteThingMatchCount",
|
|
COUNT(current_favourites.favorite_thing_id) FILTER (
|
|
WHERE current_favourites.opposite_favorite_thing_id = related_pokemon_favourite.favorite_thing_id
|
|
OR related_favorite_thing.opposite_favorite_thing_id = current_favourites.favorite_thing_id
|
|
)::integer AS "favoriteThingOppositeCount"
|
|
FROM current_pokemon
|
|
JOIN environments current_environment ON current_environment.id = current_pokemon.environment_id
|
|
JOIN pokemon related_pokemon ON related_pokemon.id <> current_pokemon.id
|
|
JOIN environments related_environment ON related_environment.id = related_pokemon.environment_id
|
|
LEFT JOIN pokemon_favorite_things related_pokemon_favourite
|
|
ON related_pokemon_favourite.pokemon_id = related_pokemon.id
|
|
LEFT JOIN favorite_things related_favorite_thing
|
|
ON related_favorite_thing.id = related_pokemon_favourite.favorite_thing_id
|
|
LEFT JOIN current_favourites
|
|
ON current_favourites.favorite_thing_id = related_pokemon_favourite.favorite_thing_id
|
|
OR current_favourites.opposite_favorite_thing_id = related_pokemon_favourite.favorite_thing_id
|
|
OR related_favorite_thing.opposite_favorite_thing_id = current_favourites.favorite_thing_id
|
|
GROUP BY
|
|
related_pokemon.id,
|
|
related_pokemon.environment_id,
|
|
related_environment.opposite_environment_id,
|
|
current_pokemon.environment_id,
|
|
current_environment.opposite_environment_id
|
|
HAVING related_pokemon.environment_id = current_pokemon.environment_id
|
|
OR related_pokemon.environment_id = current_environment.opposite_environment_id
|
|
OR related_environment.opposite_environment_id = current_pokemon.environment_id
|
|
OR COUNT(current_favourites.favorite_thing_id) FILTER (
|
|
WHERE current_favourites.favorite_thing_id = related_pokemon_favourite.favorite_thing_id
|
|
) > 0
|
|
OR COUNT(current_favourites.favorite_thing_id) FILTER (
|
|
WHERE current_favourites.opposite_favorite_thing_id = related_pokemon_favourite.favorite_thing_id
|
|
OR related_favorite_thing.opposite_favorite_thing_id = current_favourites.favorite_thing_id
|
|
) > 0
|
|
)
|
|
SELECT
|
|
related_pokemon.id,
|
|
related_pokemon.display_id AS "displayId",
|
|
${relatedPokemonName} AS name,
|
|
related_pokemon.is_event_item AS "isEventItem",
|
|
${pokemonImageJson('related_pokemon')} AS image,
|
|
json_build_object(
|
|
'id', related_environment.id,
|
|
'name', ${relatedEnvironmentName},
|
|
'matches', scored_pokemon."environmentMatches",
|
|
'isOpposite', scored_pokemon."environmentIsOpposite"
|
|
) AS environment,
|
|
COALESCE((
|
|
SELECT json_agg(
|
|
json_build_object(
|
|
'id', related_skill.id,
|
|
'name', ${relatedSkillName},
|
|
'hasItemDrop', related_skill.has_item_drop,
|
|
'hasTrading', related_skill.has_trading
|
|
)
|
|
ORDER BY ${orderByEntity('related_skill')}
|
|
)
|
|
FROM pokemon_skills related_pokemon_skill
|
|
JOIN skills related_skill ON related_skill.id = related_pokemon_skill.skill_id
|
|
WHERE related_pokemon_skill.pokemon_id = related_pokemon.id
|
|
), '[]'::json) AS skills,
|
|
COALESCE((
|
|
SELECT json_agg(
|
|
json_build_object(
|
|
'id', related_favorite_thing.id,
|
|
'name', ${relatedFavoriteThingName},
|
|
'matches', EXISTS (
|
|
SELECT 1
|
|
FROM current_favourites
|
|
WHERE current_favourites.favorite_thing_id = related_favorite_thing.id
|
|
),
|
|
'isOpposite', EXISTS (
|
|
SELECT 1
|
|
FROM current_favourites
|
|
WHERE current_favourites.opposite_favorite_thing_id = related_favorite_thing.id
|
|
OR related_favorite_thing.opposite_favorite_thing_id = current_favourites.favorite_thing_id
|
|
)
|
|
)
|
|
ORDER BY ${orderByEntity('related_favorite_thing')}
|
|
)
|
|
FROM pokemon_favorite_things related_pokemon_favourite
|
|
JOIN favorite_things related_favorite_thing ON related_favorite_thing.id = related_pokemon_favourite.favorite_thing_id
|
|
WHERE related_pokemon_favourite.pokemon_id = related_pokemon.id
|
|
), '[]'::json) AS favorite_things
|
|
FROM scored_pokemon
|
|
JOIN pokemon related_pokemon ON related_pokemon.id = scored_pokemon.id
|
|
JOIN environments related_environment ON related_environment.id = related_pokemon.environment_id
|
|
ORDER BY
|
|
scored_pokemon."environmentMatches" DESC,
|
|
scored_pokemon."favoriteThingMatchCount" DESC,
|
|
scored_pokemon."environmentIsOpposite" DESC,
|
|
scored_pokemon."favoriteThingOppositeCount" DESC,
|
|
related_pokemon.display_id,
|
|
related_pokemon.id
|
|
`,
|
|
[id]
|
|
),
|
|
getEditHistory('pokemon', id),
|
|
listEntityImageUploads('pokemon', id)
|
|
]);
|
|
|
|
const dropsBySkill = itemDrops.reduce((itemsBySkill, item) => {
|
|
itemsBySkill.set(item.skillId, { id: item.id, name: item.name, image: item.image });
|
|
return itemsBySkill;
|
|
}, new Map<number, { id: number; name: string; image: EntityImageValue | null }>());
|
|
|
|
const tradingItemsByPreference = tradingItems.map((item) => ({
|
|
itemId: item.itemId,
|
|
preference: item.preference,
|
|
id: item.id,
|
|
name: item.name,
|
|
image: item.image
|
|
}));
|
|
|
|
const skills = Array.isArray(pokemon.skills)
|
|
? pokemon.skills.map((skill: { id: number; name: string }) => ({
|
|
...skill,
|
|
itemDrop: dropsBySkill.get(skill.id) ?? null
|
|
}))
|
|
: [];
|
|
|
|
return { ...pokemon, skills, habitats, favoriteThingItems, tradingItems: tradingItemsByPreference, relatedPokemon, editHistory, imageHistory };
|
|
}
|
|
|
|
function cleanPokemonPayload(payload: Record<string, unknown>): PokemonPayload {
|
|
const cleanTypeIds = cleanIds(payload.typeIds);
|
|
const typeIds = cleanTypeIds.slice(0, 2);
|
|
const skillIds = cleanIds(payload.skillIds);
|
|
const favoriteThingIds = cleanIds(payload.favoriteThingIds);
|
|
const selectedSkillIds = new Set(skillIds);
|
|
const skillItemDrops = new Map<string, SkillItemDrop>();
|
|
const tradingItems = new Map<string, PokemonTradingItemPayload>();
|
|
|
|
if (typeIds.length === 0) {
|
|
throw validationError('server.validation.typeMin');
|
|
}
|
|
if (cleanTypeIds.length > 2) {
|
|
throw validationError('server.validation.typeMax');
|
|
}
|
|
if (skillIds.length > 2) {
|
|
throw validationError('server.validation.skillMax');
|
|
}
|
|
if (favoriteThingIds.length > 6) {
|
|
throw validationError('server.validation.favoriteMax');
|
|
}
|
|
|
|
if (Array.isArray(payload.tradingItems)) {
|
|
for (const item of payload.tradingItems) {
|
|
const row = item as Record<string, unknown>;
|
|
const itemId = Number(row.itemId);
|
|
const preference = row.preference;
|
|
|
|
if (!Number.isInteger(itemId) || itemId <= 0 || (preference !== 'like' && preference !== 'neutral')) {
|
|
throw validationError('server.validation.invalidField');
|
|
}
|
|
|
|
tradingItems.set(String(itemId), { itemId, preference });
|
|
}
|
|
}
|
|
|
|
if (Array.isArray(payload.skillItemDrops)) {
|
|
for (const item of payload.skillItemDrops) {
|
|
const row = item as Record<string, unknown>;
|
|
const skillId = Number(row.skillId);
|
|
const itemId = Number(row.itemId);
|
|
|
|
if (!Number.isInteger(itemId) || itemId <= 0) {
|
|
continue;
|
|
}
|
|
|
|
if (!Number.isInteger(skillId) || skillId <= 0 || !selectedSkillIds.has(skillId)) {
|
|
throw validationError('server.validation.dropItemSelectedSkill');
|
|
}
|
|
|
|
skillItemDrops.set(String(skillId), { skillId, itemId });
|
|
}
|
|
}
|
|
|
|
const displayId = requirePositiveInteger(payload.displayId, 'server.validation.pokemonIdRequired');
|
|
|
|
return {
|
|
dataId: optionalPositiveInteger(payload.dataId, 'server.validation.pokemonIdRequired'),
|
|
dataIdentifier: cleanOptionalText(payload.dataIdentifier),
|
|
displayId,
|
|
isEventItem: Boolean(payload.isEventItem),
|
|
name: cleanName(payload.name, 'server.validation.pokemonNameRequired'),
|
|
genus: cleanOptionalText(payload.genus),
|
|
details: cleanOptionalText(payload.details),
|
|
heightInches: cleanNonNegativeNumber(payload.heightInches, 'server.validation.heightNonNegative'),
|
|
weightPounds: cleanNonNegativeNumber(payload.weightPounds, 'server.validation.weightNonNegative'),
|
|
translations: cleanTranslations(payload.translations, ['name', 'details', 'genus']),
|
|
typeIds,
|
|
stats: cleanPokemonStats(payload.stats),
|
|
environmentId: requirePositiveInteger(payload.environmentId, 'server.validation.environmentRequired'),
|
|
skillIds,
|
|
favoriteThingIds,
|
|
skillItemDrops: [...skillItemDrops.values()],
|
|
tradingItems: [...tradingItems.values()],
|
|
image: cleanPokemonImage(payload.imagePath, displayId)
|
|
};
|
|
}
|
|
|
|
async function normalizePokemonDataIdentity(payload: PokemonPayload): Promise<void> {
|
|
if (payload.dataId === null) {
|
|
payload.dataIdentifier = '';
|
|
return;
|
|
}
|
|
|
|
const data = await loadPokemonCsvData();
|
|
const pokemonRow = data.pokemonByLookup.get(String(payload.dataId));
|
|
if (!pokemonRow) {
|
|
throw validationError('server.validation.pokemonDataNotFound');
|
|
}
|
|
|
|
payload.dataIdentifier = csvText(pokemonRow, 'identifier');
|
|
}
|
|
|
|
async function replacePokemonRelations(client: DbClient, pokemonId: number, payload: PokemonPayload, replaceTrading: boolean): Promise<void> {
|
|
await client.query('DELETE FROM pokemon_skill_item_drops WHERE pokemon_id = $1', [pokemonId]);
|
|
await client.query('DELETE FROM pokemon_pokemon_types WHERE pokemon_id = $1', [pokemonId]);
|
|
await client.query('DELETE FROM pokemon_skills WHERE pokemon_id = $1', [pokemonId]);
|
|
await client.query('DELETE FROM pokemon_favorite_things WHERE pokemon_id = $1', [pokemonId]);
|
|
if (replaceTrading) {
|
|
await client.query('DELETE FROM pokemon_trading_items WHERE pokemon_id = $1', [pokemonId]);
|
|
}
|
|
|
|
for (const [index, typeId] of payload.typeIds.entries()) {
|
|
await client.query('INSERT INTO pokemon_pokemon_types (pokemon_id, type_id, slot_order) VALUES ($1, $2, $3)', [
|
|
pokemonId,
|
|
typeId,
|
|
index + 1
|
|
]);
|
|
}
|
|
|
|
for (const skillId of payload.skillIds) {
|
|
await client.query('INSERT INTO pokemon_skills (pokemon_id, skill_id) VALUES ($1, $2)', [pokemonId, skillId]);
|
|
}
|
|
|
|
for (const favoriteThingId of payload.favoriteThingIds) {
|
|
await client.query('INSERT INTO pokemon_favorite_things (pokemon_id, favorite_thing_id) VALUES ($1, $2)', [
|
|
pokemonId,
|
|
favoriteThingId
|
|
]);
|
|
}
|
|
|
|
const tradingSkillResult = replaceTrading && payload.skillIds.length
|
|
? await client.query<{ id: number }>('SELECT id FROM skills WHERE id = ANY($1::integer[]) AND has_trading = true', [payload.skillIds])
|
|
: { rows: [] };
|
|
const hasTradingSkill = replaceTrading && tradingSkillResult.rows.length > 0;
|
|
|
|
if (hasTradingSkill) {
|
|
for (const tradingItem of payload.tradingItems) {
|
|
await client.query('INSERT INTO pokemon_trading_items (pokemon_id, item_id, preference) VALUES ($1, $2, $3)', [
|
|
pokemonId,
|
|
tradingItem.itemId,
|
|
tradingItem.preference
|
|
]);
|
|
}
|
|
}
|
|
|
|
if (payload.skillItemDrops.length > 0) {
|
|
const allowedDrops = await client.query<{ id: number }>(
|
|
'SELECT id FROM skills WHERE id = ANY($1::integer[]) AND has_item_drop = true',
|
|
[payload.skillItemDrops.map((drop) => drop.skillId)]
|
|
);
|
|
const allowedDropSkillIds = new Set(allowedDrops.rows.map((row) => row.id));
|
|
|
|
if (payload.skillItemDrops.some((drop) => !allowedDropSkillIds.has(drop.skillId))) {
|
|
throw validationError('server.validation.skillNoDrop');
|
|
}
|
|
}
|
|
|
|
for (const drop of payload.skillItemDrops) {
|
|
await client.query(
|
|
'INSERT INTO pokemon_skill_item_drops (pokemon_id, skill_id, item_id) VALUES ($1, $2, $3)',
|
|
[pokemonId, drop.skillId, drop.itemId]
|
|
);
|
|
}
|
|
}
|
|
|
|
export async function createPokemon(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
|
|
const cleanPayload = cleanPokemonPayload(payload);
|
|
await normalizePokemonDataIdentity(cleanPayload);
|
|
|
|
const id = await withTransaction(async (client) => {
|
|
const pokemonId = await nextPokemonInternalId(client, cleanPayload.dataId);
|
|
const sortOrder = await nextSortOrder(client, 'pokemon');
|
|
await client.query(
|
|
`
|
|
INSERT INTO pokemon (
|
|
id,
|
|
data_id,
|
|
data_identifier,
|
|
display_id,
|
|
name,
|
|
is_event_item,
|
|
genus,
|
|
details,
|
|
height_inches,
|
|
weight_pounds,
|
|
environment_id,
|
|
hp,
|
|
attack,
|
|
defense,
|
|
special_attack,
|
|
special_defense,
|
|
speed,
|
|
image_path,
|
|
image_style,
|
|
image_version,
|
|
image_variant,
|
|
image_description,
|
|
sort_order,
|
|
created_by_user_id,
|
|
updated_by_user_id
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $24)
|
|
`,
|
|
[
|
|
pokemonId,
|
|
cleanPayload.dataId,
|
|
cleanPayload.dataIdentifier,
|
|
cleanPayload.displayId,
|
|
cleanPayload.name,
|
|
cleanPayload.isEventItem,
|
|
cleanPayload.genus,
|
|
cleanPayload.details,
|
|
cleanPayload.heightInches,
|
|
cleanPayload.weightPounds,
|
|
cleanPayload.environmentId,
|
|
cleanPayload.stats.hp,
|
|
cleanPayload.stats.attack,
|
|
cleanPayload.stats.defense,
|
|
cleanPayload.stats.specialAttack,
|
|
cleanPayload.stats.specialDefense,
|
|
cleanPayload.stats.speed,
|
|
cleanPayload.image?.path ?? '',
|
|
cleanPayload.image?.style ?? '',
|
|
cleanPayload.image?.version ?? '',
|
|
cleanPayload.image?.variant ?? '',
|
|
cleanPayload.image?.description ?? '',
|
|
sortOrder,
|
|
userId
|
|
]
|
|
);
|
|
await linkEntityImageUpload(client, 'pokemon', pokemonId, cleanPayload.image?.path, cleanPayload.name);
|
|
const moduleSettings = await moduleSettingsForClient(client);
|
|
await replacePokemonRelations(client, pokemonId, cleanPayload, moduleSettings.tradingEnabled);
|
|
await replaceEntityTranslations(client, 'pokemon', pokemonId, cleanPayload.translations, ['name', 'details', 'genus']);
|
|
await recordEditLog(client, 'pokemon', pokemonId, 'create', userId);
|
|
return pokemonId;
|
|
});
|
|
return getPokemon(id, locale);
|
|
}
|
|
|
|
export async function updatePokemon(id: number, payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
|
|
const cleanPayload = cleanPokemonPayload(payload);
|
|
await normalizePokemonDataIdentity(cleanPayload);
|
|
if (cleanPayload.dataId !== null && cleanPayload.dataId !== id) {
|
|
throw validationError('server.validation.pokemonDataIdMismatch');
|
|
}
|
|
const before = await getPokemon(id, defaultLocale);
|
|
|
|
const updated = await withTransaction(async (client) => {
|
|
const result = await client.query(
|
|
`
|
|
UPDATE pokemon
|
|
SET
|
|
data_id = $1,
|
|
data_identifier = $2,
|
|
display_id = $3,
|
|
name = $4,
|
|
is_event_item = $5,
|
|
genus = $6,
|
|
details = $7,
|
|
height_inches = $8,
|
|
weight_pounds = $9,
|
|
environment_id = $10,
|
|
hp = $11,
|
|
attack = $12,
|
|
defense = $13,
|
|
special_attack = $14,
|
|
special_defense = $15,
|
|
speed = $16,
|
|
image_path = $17,
|
|
image_style = $18,
|
|
image_version = $19,
|
|
image_variant = $20,
|
|
image_description = $21,
|
|
updated_by_user_id = $22,
|
|
updated_at = now()
|
|
WHERE id = $23
|
|
`,
|
|
[
|
|
cleanPayload.dataId,
|
|
cleanPayload.dataIdentifier,
|
|
cleanPayload.displayId,
|
|
cleanPayload.name,
|
|
cleanPayload.isEventItem,
|
|
cleanPayload.genus,
|
|
cleanPayload.details,
|
|
cleanPayload.heightInches,
|
|
cleanPayload.weightPounds,
|
|
cleanPayload.environmentId,
|
|
cleanPayload.stats.hp,
|
|
cleanPayload.stats.attack,
|
|
cleanPayload.stats.defense,
|
|
cleanPayload.stats.specialAttack,
|
|
cleanPayload.stats.specialDefense,
|
|
cleanPayload.stats.speed,
|
|
cleanPayload.image?.path ?? '',
|
|
cleanPayload.image?.style ?? '',
|
|
cleanPayload.image?.version ?? '',
|
|
cleanPayload.image?.variant ?? '',
|
|
cleanPayload.image?.description ?? '',
|
|
userId,
|
|
id
|
|
]
|
|
);
|
|
if (result.rowCount === 0) {
|
|
return false;
|
|
}
|
|
await linkEntityImageUpload(client, 'pokemon', id, cleanPayload.image?.path, cleanPayload.name);
|
|
const moduleSettings = await moduleSettingsForClient(client);
|
|
await replacePokemonRelations(client, id, cleanPayload, moduleSettings.tradingEnabled);
|
|
await replaceEntityTranslations(client, 'pokemon', id, cleanPayload.translations, ['name', 'details', 'genus']);
|
|
const changes = before ? await pokemonEditChanges(client, before as unknown as PokemonChangeSource, cleanPayload) : [];
|
|
await recordEditLog(client, 'pokemon', id, 'update', userId, changes);
|
|
return true;
|
|
});
|
|
return updated ? getPokemon(id, locale) : null;
|
|
}
|
|
|
|
export async function deletePokemon(id: number, userId: number) {
|
|
return withTransaction(async (client) => {
|
|
const result = await client.query<{ id: number }>('DELETE FROM pokemon WHERE id = $1 RETURNING id', [id]);
|
|
if (result.rowCount === 0) {
|
|
return false;
|
|
}
|
|
|
|
await deleteEntityDiscussionCommentsForEntity(client, 'pokemon', id);
|
|
await deleteEntityTranslations(client, 'pokemon', id);
|
|
await recordEditLog(client, 'pokemon', id, 'delete', userId);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
export async function listHabitats(paramsQuery: QueryParams = {}, locale = defaultLocale) {
|
|
const habitatName = localizedName('habitats', 'h', locale);
|
|
const itemName = localizedName('items', 'i', locale);
|
|
const pokemonName = localizedName('pokemon', 'p', locale);
|
|
const params: unknown[] = [];
|
|
const conditions: string[] = [];
|
|
const isEventItem = asString(paramsQuery.isEventItem);
|
|
|
|
if (isEventItem === 'true' || isEventItem === 'false') {
|
|
params.push(isEventItem === 'true');
|
|
conditions.push(`h.is_event_item = $${params.length}`);
|
|
}
|
|
|
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
|
|
|
return queryMaybePaged(`
|
|
SELECT
|
|
h.id,
|
|
${habitatName} AS name,
|
|
h.name AS "baseName",
|
|
h.is_event_item AS "isEventItem",
|
|
${translationsSelect('habitats', 'h.id')} AS translations,
|
|
${auditSelect('h', 'habitat_created_user', 'habitat_updated_user')},
|
|
${uploadedImageJson('h.image_path')} AS image,
|
|
COALESCE((
|
|
SELECT json_agg(json_build_object('id', i.id, 'name', ${itemName}, 'quantity', hri.quantity) ORDER BY ${orderByEntity('i')})
|
|
FROM habitat_recipe_items hri
|
|
JOIN items i ON i.id = hri.item_id
|
|
WHERE hri.habitat_id = h.id
|
|
), '[]'::json) AS recipe,
|
|
COALESCE((
|
|
SELECT json_agg(
|
|
json_build_object(
|
|
'id', pokemon_rows.id,
|
|
'displayId', pokemon_rows.display_id,
|
|
'name', pokemon_rows.name,
|
|
'isEventItem', pokemon_rows.is_event_item
|
|
)
|
|
ORDER BY pokemon_rows.display_id, pokemon_rows.id
|
|
)
|
|
FROM (
|
|
SELECT DISTINCT p.id, p.display_id, ${pokemonName} AS name, p.is_event_item
|
|
FROM habitat_pokemon hp
|
|
JOIN pokemon p ON p.id = hp.pokemon_id
|
|
WHERE hp.habitat_id = h.id
|
|
) pokemon_rows
|
|
), '[]'::json) AS pokemon
|
|
FROM habitats h
|
|
${auditJoins('h', 'habitat_created_user', 'habitat_updated_user')}
|
|
${whereClause}
|
|
ORDER BY ${orderByEntity('h')}
|
|
`, params, paramsQuery);
|
|
}
|
|
|
|
export async function getHabitat(id: number, locale = defaultLocale) {
|
|
const habitatName = localizedName('habitats', 'h', locale);
|
|
const itemName = localizedName('items', 'i', locale);
|
|
const pokemonName = localizedName('pokemon', 'p', locale);
|
|
const mapName = localizedName('maps', 'm', locale);
|
|
|
|
const habitat = await queryOne(
|
|
`
|
|
SELECT
|
|
h.id,
|
|
${habitatName} AS name,
|
|
h.name AS "baseName",
|
|
h.is_event_item AS "isEventItem",
|
|
${translationsSelect('habitats', 'h.id')} AS translations,
|
|
${auditSelect('h', 'habitat_created_user', 'habitat_updated_user')},
|
|
${uploadedImageJson('h.image_path')} AS image,
|
|
COALESCE((
|
|
SELECT json_agg(
|
|
json_build_object(
|
|
'id', i.id,
|
|
'name', ${itemName},
|
|
'image', ${uploadedImageJson('i.image_path')},
|
|
'quantity', hri.quantity
|
|
)
|
|
ORDER BY ${orderByEntity('i')}
|
|
)
|
|
FROM habitat_recipe_items hri
|
|
JOIN items i ON i.id = hri.item_id
|
|
WHERE hri.habitat_id = h.id
|
|
), '[]'::json) AS recipe
|
|
FROM habitats h
|
|
${auditJoins('h', 'habitat_created_user', 'habitat_updated_user')}
|
|
WHERE h.id = $1
|
|
`,
|
|
[id]
|
|
);
|
|
|
|
if (!habitat) {
|
|
return null;
|
|
}
|
|
|
|
const [pokemon, editHistory, imageHistory] = await Promise.all([
|
|
query(
|
|
`
|
|
SELECT
|
|
p.id,
|
|
p.display_id AS "displayId",
|
|
${pokemonName} AS name,
|
|
p.is_event_item AS "isEventItem",
|
|
${pokemonImageJson('p')} AS image,
|
|
hp.time_of_day,
|
|
hp.weather,
|
|
hp.rarity,
|
|
json_build_object('id', m.id, 'name', ${mapName}) AS map
|
|
FROM habitat_pokemon hp
|
|
JOIN pokemon p ON p.id = hp.pokemon_id
|
|
JOIN maps m ON m.id = hp.map_id
|
|
WHERE hp.habitat_id = $1
|
|
ORDER BY hp.rarity, p.display_id, p.id, ${orderByEntity('m')}
|
|
`,
|
|
[id]
|
|
),
|
|
getEditHistory('habitats', id),
|
|
listEntityImageUploads('habitats', id)
|
|
]);
|
|
|
|
return { ...habitat, pokemon, editHistory, imageHistory };
|
|
}
|
|
|
|
function cleanHabitatPayload(payload: Record<string, unknown>): HabitatPayload {
|
|
const appearances = Array.isArray(payload.pokemonAppearances) ? payload.pokemonAppearances : [];
|
|
const pokemonAppearances = new Map<string, HabitatPayload['pokemonAppearances'][number]>();
|
|
|
|
for (const item of appearances) {
|
|
const row = item as Record<string, unknown>;
|
|
const pokemonId = Number(row.pokemonId);
|
|
const mapIds = cleanIdValues(row.mapIds);
|
|
const selectedTimeOfDays = cleanOptions(row.timeOfDays, timeOfDays);
|
|
const selectedWeathers = cleanOptions(row.weathers, weathers);
|
|
const rarity = Number(row.rarity);
|
|
|
|
if (!Number.isInteger(pokemonId) || pokemonId <= 0 || !Number.isInteger(rarity) || rarity < 1 || rarity > 3) {
|
|
continue;
|
|
}
|
|
|
|
for (const mapId of mapIds) {
|
|
for (const timeOfDay of selectedTimeOfDays) {
|
|
for (const weather of selectedWeathers) {
|
|
pokemonAppearances.set(`${pokemonId}:${mapId}:${timeOfDay}:${weather}`, {
|
|
pokemonId,
|
|
mapId,
|
|
timeOfDay,
|
|
weather,
|
|
rarity
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
name: cleanName(payload.name, 'server.validation.habitatNameRequired'),
|
|
translations: cleanTranslations(payload.translations, ['name']),
|
|
isEventItem: Boolean(payload.isEventItem),
|
|
imagePath: cleanUploadImagePath(payload.imagePath, 'habitats'),
|
|
recipeItems: cleanQuantities(payload.recipeItems),
|
|
pokemonAppearances: [...pokemonAppearances.values()]
|
|
};
|
|
}
|
|
|
|
async function replaceHabitatRelations(client: DbClient, habitatId: number, payload: HabitatPayload): Promise<void> {
|
|
await client.query('DELETE FROM habitat_recipe_items WHERE habitat_id = $1', [habitatId]);
|
|
await client.query('DELETE FROM habitat_pokemon WHERE habitat_id = $1', [habitatId]);
|
|
|
|
for (const item of payload.recipeItems) {
|
|
await client.query('INSERT INTO habitat_recipe_items (habitat_id, item_id, quantity) VALUES ($1, $2, $3)', [
|
|
habitatId,
|
|
item.itemId,
|
|
item.quantity
|
|
]);
|
|
}
|
|
|
|
for (const item of payload.pokemonAppearances) {
|
|
await client.query(
|
|
`
|
|
INSERT INTO habitat_pokemon (habitat_id, pokemon_id, map_id, time_of_day, weather, rarity)
|
|
VALUES ($1, $2, $3, $4, $5, $6)
|
|
`,
|
|
[habitatId, item.pokemonId, item.mapId, item.timeOfDay, item.weather, item.rarity]
|
|
);
|
|
}
|
|
}
|
|
|
|
export async function createHabitat(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
|
|
const cleanPayload = cleanHabitatPayload(payload);
|
|
|
|
const id = await withTransaction(async (client) => {
|
|
const sortOrder = await nextSortOrder(client, 'habitats');
|
|
const result = await client.query<{ id: number }>(
|
|
`
|
|
INSERT INTO habitats (name, is_event_item, image_path, sort_order, created_by_user_id, updated_by_user_id)
|
|
VALUES ($1, $2, $3, $4, $5, $5)
|
|
RETURNING id
|
|
`,
|
|
[cleanPayload.name, cleanPayload.isEventItem, cleanPayload.imagePath, sortOrder, userId]
|
|
);
|
|
const habitatId = result.rows[0].id;
|
|
await linkEntityImageUpload(client, 'habitats', habitatId, cleanPayload.imagePath, cleanPayload.name);
|
|
await replaceHabitatRelations(client, habitatId, cleanPayload);
|
|
await replaceEntityTranslations(client, 'habitats', habitatId, cleanPayload.translations, ['name']);
|
|
await recordEditLog(client, 'habitats', habitatId, 'create', userId);
|
|
return habitatId;
|
|
});
|
|
return getHabitat(id, locale);
|
|
}
|
|
|
|
export async function updateHabitat(id: number, payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
|
|
const cleanPayload = cleanHabitatPayload(payload);
|
|
const before = await getHabitat(id, defaultLocale);
|
|
|
|
const updated = await withTransaction(async (client) => {
|
|
const result = await client.query(
|
|
'UPDATE habitats SET name = $1, is_event_item = $2, image_path = $3, updated_by_user_id = $4, updated_at = now() WHERE id = $5',
|
|
[cleanPayload.name, cleanPayload.isEventItem, cleanPayload.imagePath, userId, id]
|
|
);
|
|
if (result.rowCount === 0) {
|
|
return false;
|
|
}
|
|
await linkEntityImageUpload(client, 'habitats', id, cleanPayload.imagePath, cleanPayload.name);
|
|
await replaceHabitatRelations(client, id, cleanPayload);
|
|
await replaceEntityTranslations(client, 'habitats', id, cleanPayload.translations, ['name']);
|
|
const changes = before ? await habitatEditChanges(client, before as unknown as HabitatChangeSource, cleanPayload) : [];
|
|
await recordEditLog(client, 'habitats', id, 'update', userId, changes);
|
|
return true;
|
|
});
|
|
return updated ? getHabitat(id, locale) : null;
|
|
}
|
|
|
|
export async function deleteHabitat(id: number, userId: number) {
|
|
return withTransaction(async (client) => {
|
|
const result = await client.query<{ id: number }>('DELETE FROM habitats WHERE id = $1 RETURNING id', [id]);
|
|
if (result.rowCount === 0) {
|
|
return false;
|
|
}
|
|
|
|
await deleteEntityDiscussionCommentsForEntity(client, 'habitats', id);
|
|
await deleteEntityTranslations(client, 'habitats', id);
|
|
await recordEditLog(client, 'habitats', id, 'delete', userId);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
function itemProjection(locale: string): string {
|
|
const itemName = localizedName('items', 'i', locale);
|
|
const itemDetails = localizedField('items', 'i.id', 'i.details', 'details', locale);
|
|
const tagName = localizedName('favorite-things', 't', locale);
|
|
|
|
return `
|
|
SELECT
|
|
i.id,
|
|
${itemName} AS name,
|
|
i.name AS "baseName",
|
|
${itemDetails} AS details,
|
|
i.details AS "baseDetails",
|
|
i.base_price AS "basePrice",
|
|
CASE
|
|
WHEN i.ancient_artifact_category_key IS NULL THEN NULL
|
|
ELSE ${systemListJsonSql('i.ancient_artifact_category_key', ancientArtifactCategoryOptions, locale)}
|
|
END AS "ancientArtifactCategory",
|
|
i.is_event_item AS "isEventItem",
|
|
${translationsSelect('items', 'i.id')} AS translations,
|
|
${auditSelect('i', 'item_created_user', 'item_updated_user')},
|
|
${uploadedImageJson('i.image_path')} AS image,
|
|
${systemListJsonSql('i.category_key', itemCategoryOptions, locale)} AS category,
|
|
CASE
|
|
WHEN i.usage_key IS NULL THEN NULL
|
|
ELSE ${systemListJsonSql('i.usage_key', itemUsageOptions, locale)}
|
|
END AS usage,
|
|
json_build_object(
|
|
'dyeability', i.dyeability,
|
|
'patternEditable', i.pattern_editable
|
|
) AS customization,
|
|
i.no_recipe AS "noRecipe",
|
|
COALESCE((
|
|
SELECT json_agg(json_build_object('id', t.id, 'name', ${tagName}) ORDER BY ${orderByEntity('t')})
|
|
FROM item_favorite_things ift
|
|
JOIN favorite_things t ON t.id = ift.favorite_thing_id
|
|
WHERE ift.item_id = i.id
|
|
), '[]'::json) AS tags,
|
|
CASE
|
|
WHEN item_recipe.id IS NULL THEN NULL
|
|
ELSE json_build_object(
|
|
'id', item_recipe.id,
|
|
'createdAt', item_recipe.created_at,
|
|
'updatedAt', item_recipe.updated_at,
|
|
'createdBy', CASE
|
|
WHEN recipe_created_user.id IS NULL THEN NULL
|
|
ELSE json_build_object('id', recipe_created_user.id, 'displayName', recipe_created_user.display_name)
|
|
END,
|
|
'updatedBy', CASE
|
|
WHEN recipe_updated_user.id IS NULL THEN NULL
|
|
ELSE json_build_object('id', recipe_updated_user.id, 'displayName', recipe_updated_user.display_name)
|
|
END
|
|
)
|
|
END AS recipe
|
|
FROM items i
|
|
LEFT JOIN recipes item_recipe ON item_recipe.item_id = i.id
|
|
LEFT JOIN users recipe_created_user ON recipe_created_user.id = item_recipe.created_by_user_id
|
|
LEFT JOIN users recipe_updated_user ON recipe_updated_user.id = item_recipe.updated_by_user_id
|
|
${auditJoins('i', 'item_created_user', 'item_updated_user')}
|
|
`;
|
|
}
|
|
|
|
export async function listItems(paramsQuery: QueryParams, locale = defaultLocale) {
|
|
const params: unknown[] = [];
|
|
const conditions: string[] = [];
|
|
const categoryId = Number(asString(paramsQuery.categoryId));
|
|
const usageId = Number(asString(paramsQuery.usageId));
|
|
const ancientArtifactCategoryId = Number(asString(paramsQuery.ancientArtifactCategoryId));
|
|
const isEventItem = asString(paramsQuery.isEventItem);
|
|
const tagIds = parseIdList(asString(paramsQuery.tagIds));
|
|
const search = asString(paramsQuery.search)?.trim();
|
|
const recipeOrder = asString(paramsQuery.recipeOrder) === '1';
|
|
const categoryOption = Number.isInteger(categoryId) && categoryId > 0
|
|
? systemListOptionById(itemCategoryOptions, categoryId, 'server.validation.categoryRequired')
|
|
: null;
|
|
const usageOption = Number.isInteger(usageId) && usageId > 0
|
|
? systemListOptionById(itemUsageOptions, usageId, 'server.validation.usageRequired')
|
|
: null;
|
|
const ancientArtifactCategoryOption = Number.isInteger(ancientArtifactCategoryId) && ancientArtifactCategoryId > 0
|
|
? systemListOptionById(ancientArtifactCategoryOptions, ancientArtifactCategoryId, 'server.validation.invalidField')
|
|
: null;
|
|
|
|
if (search) {
|
|
params.push(`%${search}%`);
|
|
conditions.push(`${localizedName('items', 'i', locale)} ILIKE $${params.length}`);
|
|
}
|
|
|
|
if (isEventItem === 'true' || isEventItem === 'false') {
|
|
params.push(isEventItem === 'true');
|
|
conditions.push(`i.is_event_item = $${params.length}`);
|
|
}
|
|
|
|
if (categoryOption) {
|
|
params.push(categoryOption.key);
|
|
conditions.push(`i.category_key = $${params.length}`);
|
|
}
|
|
|
|
if (usageOption) {
|
|
params.push(usageOption.key);
|
|
conditions.push(`i.usage_key = $${params.length}`);
|
|
}
|
|
|
|
if (ancientArtifactCategoryOption) {
|
|
params.push(ancientArtifactCategoryOption.key);
|
|
conditions.push(`i.ancient_artifact_category_key = $${params.length}`);
|
|
}
|
|
|
|
const tagFilter = sqlForRelationFilter(
|
|
tagIds,
|
|
'any',
|
|
'item_favorite_things',
|
|
'item_id',
|
|
'favorite_thing_id',
|
|
'i.id',
|
|
params
|
|
);
|
|
if (tagFilter) {
|
|
conditions.push(tagFilter);
|
|
}
|
|
|
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
|
const orderClause = recipeOrder
|
|
? `ORDER BY CASE WHEN item_recipe.id IS NULL THEN 1 ELSE 0 END, item_recipe.sort_order, item_recipe.id, ${orderByEntity('i')}`
|
|
: `ORDER BY ${orderByEntity('i')}`;
|
|
return queryMaybePaged(`${itemProjection(locale)} ${whereClause} ${orderClause}`, params, paramsQuery);
|
|
}
|
|
|
|
export async function getItem(id: number, locale = defaultLocale) {
|
|
const item = await queryOne(`${itemProjection(locale)} WHERE i.id = $1`, [id]);
|
|
if (!item) {
|
|
return null;
|
|
}
|
|
|
|
const acquisitionMethodName = localizedName('acquisition-methods', 'am', locale);
|
|
const resultItemName = localizedName('items', 'result_item', locale);
|
|
const materialItemName = localizedName('items', 'mi', locale);
|
|
const habitatName = localizedName('habitats', 'h', locale);
|
|
const recipeItemName = localizedName('items', 'recipe_item', locale);
|
|
const pokemonName = localizedName('pokemon', 'p', locale);
|
|
const skillName = localizedName('skills', 's', locale);
|
|
const possibleTagName = localizedName('favorite-things', 'possible_tag', locale);
|
|
const evidenceTagName = localizedName('favorite-things', 'evidence_tag', locale);
|
|
const moduleSettings = await getModuleSettings();
|
|
|
|
const [
|
|
acquisitionMethods,
|
|
recipe,
|
|
relatedRecipes,
|
|
relatedHabitats,
|
|
droppedByPokemon,
|
|
allPossibleTags,
|
|
possibleTagObservations,
|
|
editHistory,
|
|
imageHistory
|
|
] = await Promise.all([
|
|
query(
|
|
`
|
|
SELECT am.id, ${acquisitionMethodName} AS name
|
|
FROM item_acquisition_methods iam
|
|
JOIN acquisition_methods am ON am.id = iam.acquisition_method_id
|
|
WHERE iam.item_id = $1
|
|
ORDER BY ${orderByEntity('am')}
|
|
`,
|
|
[id]
|
|
),
|
|
queryOne(
|
|
`
|
|
SELECT
|
|
r.id,
|
|
${resultItemName} AS name,
|
|
${auditSelect('r', 'recipe_created_user', 'recipe_updated_user')},
|
|
COALESCE((
|
|
SELECT json_agg(json_build_object('id', am.id, 'name', ${acquisitionMethodName}) ORDER BY ${orderByEntity('am')})
|
|
FROM recipe_acquisition_methods ram
|
|
JOIN acquisition_methods am ON am.id = ram.acquisition_method_id
|
|
WHERE ram.recipe_id = r.id
|
|
), '[]'::json) AS acquisition_methods,
|
|
COALESCE((
|
|
SELECT json_agg(
|
|
json_build_object(
|
|
'id', mi.id,
|
|
'name', ${materialItemName},
|
|
'image', ${uploadedImageJson('mi.image_path')},
|
|
'quantity', rm.quantity
|
|
)
|
|
ORDER BY ${orderByEntity('mi')}
|
|
)
|
|
FROM recipe_materials rm
|
|
JOIN items mi ON mi.id = rm.item_id
|
|
WHERE rm.recipe_id = r.id
|
|
), '[]'::json) AS materials,
|
|
json_build_object(
|
|
'id', result_item.id,
|
|
'name', ${resultItemName},
|
|
'image', ${uploadedImageJson('result_item.image_path')},
|
|
'category', ${systemListJsonSql('result_item.category_key', itemCategoryOptions, locale)},
|
|
'usage', CASE
|
|
WHEN result_item.usage_key IS NULL THEN NULL
|
|
ELSE ${systemListJsonSql('result_item.usage_key', itemUsageOptions, locale)}
|
|
END
|
|
) AS item
|
|
FROM recipes r
|
|
JOIN items result_item ON result_item.id = r.item_id
|
|
${auditJoins('r', 'recipe_created_user', 'recipe_updated_user')}
|
|
WHERE r.item_id = $1
|
|
`,
|
|
[id]
|
|
),
|
|
query(
|
|
`
|
|
SELECT
|
|
r.id,
|
|
${resultItemName} AS name,
|
|
${uploadedImageJson('result_item.image_path')} AS image,
|
|
COALESCE((
|
|
SELECT json_agg(
|
|
json_build_object(
|
|
'id', mi.id,
|
|
'name', ${materialItemName},
|
|
'image', ${uploadedImageJson('mi.image_path')},
|
|
'quantity', recipe_material.quantity
|
|
)
|
|
ORDER BY ${orderByEntity('mi')}
|
|
)
|
|
FROM recipe_materials recipe_material
|
|
JOIN items mi ON mi.id = recipe_material.item_id
|
|
WHERE recipe_material.recipe_id = r.id
|
|
), '[]'::json) AS materials
|
|
FROM recipe_materials used_material
|
|
JOIN recipes r ON r.id = used_material.recipe_id
|
|
JOIN items result_item ON result_item.id = r.item_id
|
|
WHERE used_material.item_id = $1
|
|
ORDER BY ${orderByEntity('r')}
|
|
`,
|
|
[id]
|
|
),
|
|
query(
|
|
`
|
|
SELECT
|
|
h.id,
|
|
${habitatName} AS name,
|
|
${uploadedImageJson('h.image_path')} AS image,
|
|
COALESCE((
|
|
SELECT json_agg(
|
|
json_build_object(
|
|
'id', recipe_item.id,
|
|
'name', ${recipeItemName},
|
|
'image', ${uploadedImageJson('recipe_item.image_path')},
|
|
'quantity', recipe_item_row.quantity
|
|
)
|
|
ORDER BY ${orderByEntity('recipe_item')}
|
|
)
|
|
FROM habitat_recipe_items recipe_item_row
|
|
JOIN items recipe_item ON recipe_item.id = recipe_item_row.item_id
|
|
WHERE recipe_item_row.habitat_id = h.id
|
|
), '[]'::json) AS recipe
|
|
FROM habitat_recipe_items used_item
|
|
JOIN habitats h ON h.id = used_item.habitat_id
|
|
WHERE used_item.item_id = $1
|
|
ORDER BY ${orderByEntity('h')}
|
|
`,
|
|
[id]
|
|
),
|
|
query(
|
|
`
|
|
SELECT
|
|
json_build_object(
|
|
'id', p.id,
|
|
'displayId', p.display_id,
|
|
'name', ${pokemonName},
|
|
'isEventItem', p.is_event_item,
|
|
'image', ${pokemonImageJson('p')}
|
|
) AS pokemon,
|
|
json_build_object('id', s.id, 'name', ${skillName}) AS skill
|
|
FROM pokemon_skill_item_drops psid
|
|
JOIN pokemon p ON p.id = psid.pokemon_id
|
|
JOIN skills s ON s.id = psid.skill_id
|
|
WHERE psid.item_id = $1
|
|
AND s.has_item_drop = true
|
|
ORDER BY p.display_id, p.id, ${orderByEntity('s')}
|
|
`,
|
|
[id]
|
|
),
|
|
moduleSettings.tradingEnabled
|
|
? query<ItemPossibleTagEntity>(
|
|
`
|
|
SELECT possible_tag.id, ${possibleTagName} AS name
|
|
FROM favorite_things possible_tag
|
|
ORDER BY ${orderByEntity('possible_tag')}
|
|
`
|
|
)
|
|
: Promise.resolve([]),
|
|
moduleSettings.tradingEnabled
|
|
? query<ItemPossibleTagObservation>(
|
|
`
|
|
SELECT
|
|
json_build_object(
|
|
'id', p.id,
|
|
'displayId', p.display_id,
|
|
'name', ${pokemonName},
|
|
'isEventItem', p.is_event_item,
|
|
'image', ${pokemonImageJson('p')}
|
|
) AS pokemon,
|
|
pti.preference,
|
|
COALESCE((
|
|
SELECT json_agg(json_build_object('id', evidence_tag.id, 'name', ${evidenceTagName}) ORDER BY ${orderByEntity('evidence_tag')})
|
|
FROM pokemon_favorite_things pft
|
|
JOIN favorite_things evidence_tag ON evidence_tag.id = pft.favorite_thing_id
|
|
WHERE pft.pokemon_id = p.id
|
|
), '[]'::json) AS tags
|
|
FROM pokemon_trading_items pti
|
|
JOIN pokemon p ON p.id = pti.pokemon_id
|
|
WHERE pti.item_id = $1
|
|
AND EXISTS (
|
|
SELECT 1
|
|
FROM pokemon_skills ps
|
|
JOIN skills trading_skill ON trading_skill.id = ps.skill_id
|
|
WHERE ps.pokemon_id = p.id
|
|
AND trading_skill.has_trading = true
|
|
)
|
|
ORDER BY pti.preference DESC, p.display_id, p.id
|
|
`,
|
|
[id]
|
|
)
|
|
: Promise.resolve([]),
|
|
getEditHistory('items', id),
|
|
listEntityImageUploads('items', id)
|
|
]);
|
|
|
|
const possibleTags = inferItemPossibleTags(allPossibleTags, possibleTagObservations);
|
|
return { ...item, acquisitionMethods, recipe, relatedRecipes, relatedHabitats, droppedByPokemon, possibleTags, editHistory, imageHistory };
|
|
}
|
|
|
|
function cleanItemPayload(payload: Record<string, unknown>): ItemPayload {
|
|
const categoryId = requirePositiveInteger(payload.categoryId, 'server.validation.categoryRequired');
|
|
const usageId = payload.usageId === null || payload.usageId === '' || payload.usageId === undefined
|
|
? null
|
|
: requirePositiveInteger(payload.usageId, 'server.validation.usageRequired');
|
|
const ancientArtifactCategory = cleanOptionalSystemListOption(
|
|
payload.ancientArtifactCategoryId,
|
|
ancientArtifactCategoryOptions,
|
|
'server.validation.invalidField'
|
|
);
|
|
const insertBeforeItemId = cleanOptionalPositiveInteger(payload.insertBeforeItemId);
|
|
const insertAfterItemId = cleanOptionalPositiveInteger(payload.insertAfterItemId);
|
|
const category = systemListOptionById(itemCategoryOptions, categoryId, 'server.validation.categoryRequired');
|
|
const usage = usageId === null ? null : systemListOptionById(itemUsageOptions, usageId, 'server.validation.usageRequired');
|
|
|
|
if (insertBeforeItemId !== null && insertAfterItemId !== null) {
|
|
throw validationError('server.validation.invalidField');
|
|
}
|
|
|
|
return {
|
|
name: cleanName(payload.name, 'server.validation.itemNameRequired'),
|
|
details: cleanOptionalText(payload.details),
|
|
basePrice: cleanOptionalNonNegativeInteger(payload.basePrice, 'server.validation.invalidField'),
|
|
ancientArtifactCategoryId: ancientArtifactCategory?.id ?? null,
|
|
ancientArtifactCategoryKey: ancientArtifactCategory?.key ?? null,
|
|
translations: cleanTranslations(payload.translations, ['name', 'details']),
|
|
categoryId,
|
|
categoryKey: category.key,
|
|
usageId,
|
|
usageKey: usage?.key ?? null,
|
|
dyeability: cleanDyeability(payload),
|
|
patternEditable: Boolean(payload.patternEditable),
|
|
noRecipe: Boolean(payload.noRecipe),
|
|
isEventItem: Boolean(payload.isEventItem),
|
|
acquisitionMethodIds: cleanIds(payload.acquisitionMethodIds),
|
|
tagIds: cleanIds(payload.tagIds),
|
|
imagePath: cleanItemOrArtifactImagePath(payload.imagePath),
|
|
insertBeforeItemId,
|
|
insertAfterItemId
|
|
};
|
|
}
|
|
|
|
function cleanOptionalPositiveInteger(value: unknown): number | null {
|
|
if (value === null || value === '' || value === undefined) {
|
|
return null;
|
|
}
|
|
|
|
return requirePositiveInteger(value, 'server.validation.invalidField');
|
|
}
|
|
|
|
function cleanDyeability(payload: Record<string, unknown>): number {
|
|
if (payload.dyeability === undefined || payload.dyeability === null || payload.dyeability === '') {
|
|
if (payload.dualDyeable === true) {
|
|
return 2;
|
|
}
|
|
if (payload.dyeable === true) {
|
|
return 1;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
const dyeability = Number(payload.dyeability);
|
|
if (!Number.isInteger(dyeability) || dyeability < 0 || dyeability > 3) {
|
|
throw validationError('server.validation.invalidField');
|
|
}
|
|
|
|
return dyeability;
|
|
}
|
|
|
|
function dyeabilityValue(value: number): string {
|
|
if (value === 3) {
|
|
return 'Triple dyeable';
|
|
}
|
|
if (value === 2) {
|
|
return 'Dual dyeable';
|
|
}
|
|
if (value === 1) {
|
|
return 'Dyeable';
|
|
}
|
|
return 'Not dyeable';
|
|
}
|
|
|
|
async function orderedItemIds(client: DbClient, isEventItem: boolean): Promise<number[]> {
|
|
const rows = await client.query<{ id: number }>(
|
|
'SELECT id FROM items WHERE is_event_item = $1 ORDER BY sort_order, id',
|
|
[isEventItem]
|
|
);
|
|
return rows.rows.map((row) => row.id);
|
|
}
|
|
|
|
async function ensureItemCanDisableRecipe(client: DbClient, itemId: number, noRecipe: boolean): Promise<void> {
|
|
if (!noRecipe) {
|
|
return;
|
|
}
|
|
|
|
const result = await client.query('SELECT 1 FROM recipes WHERE item_id = $1', [itemId]);
|
|
if (result.rowCount && result.rowCount > 0) {
|
|
throw validationError('server.validation.recipeFreeWithRecipe');
|
|
}
|
|
}
|
|
|
|
async function replaceItemRelations(client: DbClient, itemId: number, payload: ItemPayload): Promise<void> {
|
|
await client.query('DELETE FROM item_acquisition_methods WHERE item_id = $1', [itemId]);
|
|
await client.query('DELETE FROM item_favorite_things WHERE item_id = $1', [itemId]);
|
|
|
|
for (const methodId of payload.acquisitionMethodIds) {
|
|
await client.query('INSERT INTO item_acquisition_methods (item_id, acquisition_method_id) VALUES ($1, $2)', [
|
|
itemId,
|
|
methodId
|
|
]);
|
|
}
|
|
|
|
for (const tagId of payload.tagIds) {
|
|
await client.query('INSERT INTO item_favorite_things (item_id, favorite_thing_id) VALUES ($1, $2)', [
|
|
itemId,
|
|
tagId
|
|
]);
|
|
}
|
|
}
|
|
|
|
export async function createItem(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
|
|
const cleanPayload = cleanItemPayload(payload);
|
|
|
|
const id = await withTransaction(async (client) => {
|
|
const sortOrder = await nextSortOrder(client, 'items');
|
|
const result = await client.query<{ id: number }>(
|
|
`
|
|
INSERT INTO items (
|
|
name,
|
|
details,
|
|
ancient_artifact_category_key,
|
|
base_price,
|
|
category_key,
|
|
usage_key,
|
|
dyeability,
|
|
dyeable,
|
|
dual_dyeable,
|
|
pattern_editable,
|
|
no_recipe,
|
|
is_event_item,
|
|
image_path,
|
|
sort_order,
|
|
created_by_user_id,
|
|
updated_by_user_id
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $15)
|
|
RETURNING id
|
|
`,
|
|
[
|
|
cleanPayload.name,
|
|
cleanPayload.details,
|
|
cleanPayload.ancientArtifactCategoryKey,
|
|
cleanPayload.basePrice,
|
|
cleanPayload.categoryKey,
|
|
cleanPayload.usageKey,
|
|
cleanPayload.dyeability,
|
|
cleanPayload.dyeability >= 1,
|
|
cleanPayload.dyeability >= 2,
|
|
cleanPayload.patternEditable,
|
|
cleanPayload.noRecipe,
|
|
cleanPayload.isEventItem,
|
|
cleanPayload.imagePath,
|
|
sortOrder,
|
|
userId
|
|
]
|
|
);
|
|
const itemId = result.rows[0].id;
|
|
await linkEntityImageUpload(client, 'items', itemId, cleanPayload.imagePath, cleanPayload.name);
|
|
await replaceItemRelations(client, itemId, cleanPayload);
|
|
await replaceEntityTranslations(client, 'items', itemId, cleanPayload.translations, ['name', 'details']);
|
|
|
|
if (cleanPayload.insertBeforeItemId !== null || cleanPayload.insertAfterItemId !== null) {
|
|
const targetId = cleanPayload.insertBeforeItemId ?? cleanPayload.insertAfterItemId;
|
|
if (targetId === null) {
|
|
throw validationError('server.validation.invalidField');
|
|
}
|
|
|
|
const orderedIds = await orderedItemIds(client, cleanPayload.isEventItem);
|
|
const targetIndex = orderedIds.indexOf(targetId);
|
|
if (targetIndex < 0) {
|
|
throw validationError('server.validation.recordMissing');
|
|
}
|
|
|
|
const insertedIndex = orderedIds.indexOf(itemId);
|
|
if (insertedIndex >= 0) {
|
|
orderedIds.splice(insertedIndex, 1);
|
|
}
|
|
|
|
orderedIds.splice(targetIndex + (cleanPayload.insertAfterItemId !== null ? 1 : 0), 0, itemId);
|
|
await reorderTableRows(client, 'items', orderedIds, userId);
|
|
}
|
|
|
|
await recordEditLog(client, 'items', itemId, 'create', userId);
|
|
return itemId;
|
|
});
|
|
return getItem(id, locale);
|
|
}
|
|
|
|
export async function updateItem(id: number, payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
|
|
const cleanPayload = cleanItemPayload(payload);
|
|
const before = await getItem(id, defaultLocale);
|
|
|
|
const updated = await withTransaction(async (client) => {
|
|
await ensureItemCanDisableRecipe(client, id, cleanPayload.noRecipe);
|
|
const result = await client.query(
|
|
`
|
|
UPDATE items
|
|
SET name = $1,
|
|
details = $2,
|
|
ancient_artifact_category_key = $3,
|
|
base_price = $4,
|
|
category_key = $5,
|
|
usage_key = $6,
|
|
dyeability = $7,
|
|
dyeable = $8,
|
|
dual_dyeable = $9,
|
|
pattern_editable = $10,
|
|
no_recipe = $11,
|
|
is_event_item = $12,
|
|
image_path = $13,
|
|
updated_by_user_id = $14,
|
|
updated_at = now()
|
|
WHERE id = $15
|
|
`,
|
|
[
|
|
cleanPayload.name,
|
|
cleanPayload.details,
|
|
cleanPayload.ancientArtifactCategoryKey,
|
|
cleanPayload.basePrice,
|
|
cleanPayload.categoryKey,
|
|
cleanPayload.usageKey,
|
|
cleanPayload.dyeability,
|
|
cleanPayload.dyeability >= 1,
|
|
cleanPayload.dyeability >= 2,
|
|
cleanPayload.patternEditable,
|
|
cleanPayload.noRecipe,
|
|
cleanPayload.isEventItem,
|
|
cleanPayload.imagePath,
|
|
userId,
|
|
id
|
|
]
|
|
);
|
|
if (result.rowCount === 0) {
|
|
return false;
|
|
}
|
|
await linkEntityImageUpload(client, 'items', id, cleanPayload.imagePath, cleanPayload.name);
|
|
await replaceItemRelations(client, id, cleanPayload);
|
|
await replaceEntityTranslations(client, 'items', id, cleanPayload.translations, ['name', 'details']);
|
|
const changes = before ? await itemEditChanges(client, before as unknown as ItemChangeSource, cleanPayload) : [];
|
|
await recordEditLog(client, 'items', id, 'update', userId, changes);
|
|
return true;
|
|
});
|
|
return updated ? getItem(id, locale) : null;
|
|
}
|
|
|
|
export async function deleteItem(id: number, userId: number) {
|
|
return withTransaction(async (client) => {
|
|
const result = await client.query<{ id: number }>('DELETE FROM items WHERE id = $1 RETURNING id', [id]);
|
|
if (result.rowCount === 0) {
|
|
return false;
|
|
}
|
|
|
|
await deleteEntityDiscussionCommentsForEntity(client, 'items', id);
|
|
await deleteEntityDiscussionCommentsForEntity(client, 'ancient-artifacts', id);
|
|
await deleteEntityTranslations(client, 'items', id);
|
|
await recordEditLog(client, 'items', id, 'delete', userId);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
function ancientArtifactProjection(locale: string): string {
|
|
const artifactName = localizedName('items', 'i', locale);
|
|
const artifactDetails = localizedField('items', 'i.id', 'i.details', 'details', locale);
|
|
const tagName = localizedName('favorite-things', 't', locale);
|
|
|
|
return `
|
|
SELECT
|
|
i.id,
|
|
${artifactName} AS name,
|
|
i.name AS "baseName",
|
|
${artifactDetails} AS details,
|
|
i.details AS "baseDetails",
|
|
${translationsSelect('items', 'i.id')} AS translations,
|
|
${systemListJsonSql('i.ancient_artifact_category_key', ancientArtifactCategoryOptions, locale)} AS category,
|
|
${uploadedImageJson('i.image_path')} AS image,
|
|
COALESCE((
|
|
SELECT json_agg(json_build_object('id', t.id, 'name', ${tagName}) ORDER BY ${orderByEntity('t')})
|
|
FROM item_favorite_things ift
|
|
JOIN favorite_things t ON t.id = ift.favorite_thing_id
|
|
WHERE ift.item_id = i.id
|
|
), '[]'::json) AS tags,
|
|
${auditSelect('i', 'item_created_user', 'item_updated_user')}
|
|
FROM items i
|
|
${auditJoins('i', 'item_created_user', 'item_updated_user')}
|
|
`;
|
|
}
|
|
|
|
export async function listAncientArtifacts(paramsQuery: QueryParams = {}, locale = defaultLocale) {
|
|
const params: unknown[] = [];
|
|
const conditions: string[] = [];
|
|
const search = asString(paramsQuery.search)?.trim();
|
|
const categoryId = Number(asString(paramsQuery.categoryId));
|
|
const tagIds = parseIdList(asString(paramsQuery.tagIds));
|
|
const categoryOption = Number.isInteger(categoryId) && categoryId > 0
|
|
? systemListOptionById(ancientArtifactCategoryOptions, categoryId, 'server.validation.categoryRequired')
|
|
: null;
|
|
|
|
conditions.push('i.ancient_artifact_category_key IS NOT NULL');
|
|
|
|
if (search) {
|
|
params.push(`%${search}%`);
|
|
conditions.push(`${localizedName('items', 'i', locale)} ILIKE $${params.length}`);
|
|
}
|
|
|
|
if (categoryOption) {
|
|
params.push(categoryOption.key);
|
|
conditions.push(`i.ancient_artifact_category_key = $${params.length}`);
|
|
}
|
|
|
|
const tagFilter = sqlForRelationFilter(
|
|
tagIds,
|
|
'any',
|
|
'item_favorite_things',
|
|
'item_id',
|
|
'favorite_thing_id',
|
|
'i.id',
|
|
params
|
|
);
|
|
if (tagFilter) {
|
|
conditions.push(tagFilter);
|
|
}
|
|
|
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
|
return queryMaybePaged(`${ancientArtifactProjection(locale)} ${whereClause} ORDER BY ${orderByEntity('i')}`, params, paramsQuery);
|
|
}
|
|
|
|
export async function getAncientArtifact(id: number, locale = defaultLocale) {
|
|
const artifact = await queryOne(`${ancientArtifactProjection(locale)} WHERE i.id = $1 AND i.ancient_artifact_category_key IS NOT NULL`, [id]);
|
|
if (!artifact) {
|
|
return null;
|
|
}
|
|
|
|
const editHistory = await getEditHistory('items', id);
|
|
const imageHistory = await listEntityImageUploads('items', id);
|
|
return { ...artifact, editHistory, imageHistory };
|
|
}
|
|
|
|
function cleanAncientArtifactPayload(payload: Record<string, unknown>): AncientArtifactPayload {
|
|
const categoryId = requirePositiveInteger(payload.categoryId, 'server.validation.categoryRequired');
|
|
const category = systemListOptionById(ancientArtifactCategoryOptions, categoryId, 'server.validation.categoryRequired');
|
|
|
|
return {
|
|
name: cleanName(payload.name, 'server.validation.artifactNameRequired'),
|
|
details: cleanOptionalText(payload.details),
|
|
translations: cleanTranslations(payload.translations, ['name', 'details']),
|
|
categoryId,
|
|
categoryKey: category.key,
|
|
tagIds: cleanIds(payload.tagIds),
|
|
imagePath: cleanItemOrArtifactImagePath(payload.imagePath)
|
|
};
|
|
}
|
|
|
|
async function replaceAncientArtifactRelations(client: DbClient, artifactId: number, payload: AncientArtifactPayload): Promise<void> {
|
|
await client.query('DELETE FROM item_favorite_things WHERE item_id = $1', [artifactId]);
|
|
|
|
for (const tagId of payload.tagIds) {
|
|
await client.query(
|
|
'INSERT INTO item_favorite_things (item_id, favorite_thing_id) VALUES ($1, $2)',
|
|
[artifactId, tagId]
|
|
);
|
|
}
|
|
}
|
|
|
|
export async function createAncientArtifact(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
|
|
const cleanPayload = cleanAncientArtifactPayload(payload);
|
|
|
|
const id = await withTransaction(async (client) => {
|
|
const sortOrder = await nextSortOrder(client, 'items');
|
|
const result = await client.query<{ id: number }>(
|
|
`
|
|
INSERT INTO items (
|
|
name,
|
|
details,
|
|
ancient_artifact_category_key,
|
|
image_path,
|
|
sort_order,
|
|
created_by_user_id,
|
|
updated_by_user_id
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $6)
|
|
RETURNING id
|
|
`,
|
|
[
|
|
cleanPayload.name,
|
|
cleanPayload.details,
|
|
cleanPayload.categoryKey,
|
|
cleanPayload.imagePath,
|
|
sortOrder,
|
|
userId
|
|
]
|
|
);
|
|
const artifactId = result.rows[0].id;
|
|
await linkEntityImageUpload(client, 'items', artifactId, cleanPayload.imagePath, cleanPayload.name);
|
|
await replaceAncientArtifactRelations(client, artifactId, cleanPayload);
|
|
await replaceEntityTranslations(client, 'items', artifactId, cleanPayload.translations, ['name', 'details']);
|
|
await recordEditLog(client, 'items', artifactId, 'create', userId);
|
|
return artifactId;
|
|
});
|
|
|
|
return getAncientArtifact(id, locale);
|
|
}
|
|
|
|
export async function updateAncientArtifact(id: number, payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
|
|
const cleanPayload = cleanAncientArtifactPayload(payload);
|
|
const before = await getAncientArtifact(id, defaultLocale);
|
|
|
|
const updated = await withTransaction(async (client) => {
|
|
const result = await client.query(
|
|
`
|
|
UPDATE items
|
|
SET name = $1,
|
|
details = $2,
|
|
ancient_artifact_category_key = $3,
|
|
image_path = $4,
|
|
updated_by_user_id = $5,
|
|
updated_at = now()
|
|
WHERE id = $6
|
|
AND ancient_artifact_category_key IS NOT NULL
|
|
`,
|
|
[cleanPayload.name, cleanPayload.details, cleanPayload.categoryKey, cleanPayload.imagePath, userId, id]
|
|
);
|
|
if (result.rowCount === 0) {
|
|
return false;
|
|
}
|
|
|
|
await linkEntityImageUpload(client, 'items', id, cleanPayload.imagePath, cleanPayload.name);
|
|
await replaceAncientArtifactRelations(client, id, cleanPayload);
|
|
await replaceEntityTranslations(client, 'items', id, cleanPayload.translations, ['name', 'details']);
|
|
const changes = before ? await ancientArtifactEditChanges(client, before as unknown as AncientArtifactChangeSource, cleanPayload) : [];
|
|
await recordEditLog(client, 'items', id, 'update', userId, changes);
|
|
return true;
|
|
});
|
|
|
|
return updated ? getAncientArtifact(id, locale) : null;
|
|
}
|
|
|
|
export async function deleteAncientArtifact(id: number, userId: number) {
|
|
return withTransaction(async (client) => {
|
|
const result = await client.query<{ id: number }>('DELETE FROM items WHERE id = $1 AND ancient_artifact_category_key IS NOT NULL RETURNING id', [id]);
|
|
if (result.rowCount === 0) {
|
|
return false;
|
|
}
|
|
|
|
await deleteEntityDiscussionCommentsForEntity(client, 'ancient-artifacts', id);
|
|
await deleteEntityTranslations(client, 'items', id);
|
|
await recordEditLog(client, 'items', id, 'delete', userId);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
export async function listRecipes(paramsQuery: QueryParams = {}, locale = defaultLocale) {
|
|
const params: unknown[] = [];
|
|
const conditions: string[] = [];
|
|
const categoryId = Number(asString(paramsQuery.categoryId));
|
|
const categoryOption = Number.isInteger(categoryId) && categoryId > 0
|
|
? systemListOptionById(itemCategoryOptions, categoryId, 'server.validation.categoryRequired')
|
|
: null;
|
|
const resultItemName = localizedName('items', 'result_item', locale);
|
|
const materialItemName = localizedName('items', 'i', locale);
|
|
|
|
if (categoryOption) {
|
|
params.push(categoryOption.key);
|
|
conditions.push(`result_item.category_key = $${params.length}`);
|
|
}
|
|
|
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
|
return queryMaybePaged(`
|
|
SELECT
|
|
r.id,
|
|
${resultItemName} AS name,
|
|
${auditSelect('r', 'recipe_created_user', 'recipe_updated_user')},
|
|
COALESCE((
|
|
SELECT json_agg(json_build_object('id', i.id, 'name', ${materialItemName}, 'quantity', rm.quantity) ORDER BY ${orderByEntity('i')})
|
|
FROM recipe_materials rm
|
|
JOIN items i ON i.id = rm.item_id
|
|
WHERE rm.recipe_id = r.id
|
|
), '[]'::json) AS materials
|
|
FROM recipes r
|
|
JOIN items result_item ON result_item.id = r.item_id
|
|
${auditJoins('r', 'recipe_created_user', 'recipe_updated_user')}
|
|
${whereClause}
|
|
ORDER BY ${orderByEntity('r')}
|
|
`, params, paramsQuery);
|
|
}
|
|
|
|
export async function getRecipe(id: number, locale = defaultLocale) {
|
|
const resultItemName = localizedName('items', 'result_item', locale);
|
|
const acquisitionMethodName = localizedName('acquisition-methods', 'am', locale);
|
|
const materialItemName = localizedName('items', 'i', locale);
|
|
|
|
const recipe = await queryOne(
|
|
`
|
|
SELECT
|
|
r.id,
|
|
${resultItemName} AS name,
|
|
${auditSelect('r', 'recipe_created_user', 'recipe_updated_user')},
|
|
COALESCE((
|
|
SELECT json_agg(json_build_object('id', am.id, 'name', ${acquisitionMethodName}) ORDER BY ${orderByEntity('am')})
|
|
FROM recipe_acquisition_methods ram
|
|
JOIN acquisition_methods am ON am.id = ram.acquisition_method_id
|
|
WHERE ram.recipe_id = r.id
|
|
), '[]'::json) AS acquisition_methods,
|
|
COALESCE((
|
|
SELECT json_agg(
|
|
json_build_object(
|
|
'id', i.id,
|
|
'name', ${materialItemName},
|
|
'image', ${uploadedImageJson('i.image_path')},
|
|
'quantity', rm.quantity
|
|
)
|
|
ORDER BY ${orderByEntity('i')}
|
|
)
|
|
FROM recipe_materials rm
|
|
JOIN items i ON i.id = rm.item_id
|
|
WHERE rm.recipe_id = r.id
|
|
), '[]'::json) AS materials,
|
|
json_build_object(
|
|
'id', result_item.id,
|
|
'name', ${resultItemName},
|
|
'image', ${uploadedImageJson('result_item.image_path')},
|
|
'category', ${systemListJsonSql('result_item.category_key', itemCategoryOptions, locale)},
|
|
'usage', CASE
|
|
WHEN result_item.usage_key IS NULL THEN NULL
|
|
ELSE ${systemListJsonSql('result_item.usage_key', itemUsageOptions, locale)}
|
|
END
|
|
) AS item
|
|
FROM recipes r
|
|
JOIN items result_item ON result_item.id = r.item_id
|
|
${auditJoins('r', 'recipe_created_user', 'recipe_updated_user')}
|
|
WHERE r.id = $1
|
|
`,
|
|
[id]
|
|
);
|
|
|
|
if (!recipe) {
|
|
return null;
|
|
}
|
|
|
|
const editHistory = await getEditHistory('recipes', id);
|
|
return { ...recipe, editHistory };
|
|
}
|
|
|
|
function cleanRecipePayload(payload: Record<string, unknown>): RecipePayload {
|
|
return {
|
|
itemId: requirePositiveInteger(payload.itemId, 'server.validation.itemRequired'),
|
|
acquisitionMethodIds: cleanIds(payload.acquisitionMethodIds),
|
|
materials: cleanQuantities(payload.materials)
|
|
};
|
|
}
|
|
|
|
async function replaceRecipeRelations(client: DbClient, recipeId: number, payload: RecipePayload): Promise<void> {
|
|
await client.query('DELETE FROM recipe_acquisition_methods WHERE recipe_id = $1', [recipeId]);
|
|
await client.query('DELETE FROM recipe_materials WHERE recipe_id = $1', [recipeId]);
|
|
|
|
for (const methodId of payload.acquisitionMethodIds) {
|
|
await client.query('INSERT INTO recipe_acquisition_methods (recipe_id, acquisition_method_id) VALUES ($1, $2)', [
|
|
recipeId,
|
|
methodId
|
|
]);
|
|
}
|
|
|
|
for (const material of payload.materials) {
|
|
await client.query('INSERT INTO recipe_materials (recipe_id, item_id, quantity) VALUES ($1, $2, $3)', [
|
|
recipeId,
|
|
material.itemId,
|
|
material.quantity
|
|
]);
|
|
}
|
|
}
|
|
|
|
async function ensureItemCanHaveRecipe(client: DbClient, itemId: number): Promise<void> {
|
|
const result = await client.query<{ no_recipe: boolean }>('SELECT no_recipe FROM items WHERE id = $1', [itemId]);
|
|
if (result.rowCount === 0) {
|
|
throw validationError('server.validation.itemRequired');
|
|
}
|
|
|
|
if (result.rows[0].no_recipe) {
|
|
throw validationError('server.validation.recipeFreeItem');
|
|
}
|
|
}
|
|
|
|
export async function createRecipe(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
|
|
const cleanPayload = cleanRecipePayload(payload);
|
|
|
|
const id = await withTransaction(async (client) => {
|
|
await ensureItemCanHaveRecipe(client, cleanPayload.itemId);
|
|
const sortOrder = await nextSortOrder(client, 'recipes');
|
|
const result = await client.query<{ id: number }>(
|
|
`
|
|
INSERT INTO recipes (item_id, sort_order, created_by_user_id, updated_by_user_id)
|
|
VALUES ($1, $2, $3, $3)
|
|
RETURNING id
|
|
`,
|
|
[cleanPayload.itemId, sortOrder, userId]
|
|
);
|
|
const recipeId = result.rows[0].id;
|
|
await replaceRecipeRelations(client, recipeId, cleanPayload);
|
|
await recordEditLog(client, 'recipes', recipeId, 'create', userId);
|
|
return recipeId;
|
|
});
|
|
return getRecipe(id, locale);
|
|
}
|
|
|
|
export async function updateRecipe(id: number, payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
|
|
const cleanPayload = cleanRecipePayload(payload);
|
|
const before = await getRecipe(id, defaultLocale);
|
|
|
|
const updated = await withTransaction(async (client) => {
|
|
await ensureItemCanHaveRecipe(client, cleanPayload.itemId);
|
|
const result = await client.query(
|
|
'UPDATE recipes SET item_id = $1, updated_by_user_id = $2, updated_at = now() WHERE id = $3',
|
|
[cleanPayload.itemId, userId, id]
|
|
);
|
|
if (result.rowCount === 0) {
|
|
return false;
|
|
}
|
|
await replaceRecipeRelations(client, id, cleanPayload);
|
|
const changes = before ? await recipeEditChanges(client, before as unknown as RecipeChangeSource, cleanPayload) : [];
|
|
await recordEditLog(client, 'recipes', id, 'update', userId, changes);
|
|
return true;
|
|
});
|
|
return updated ? getRecipe(id, locale) : null;
|
|
}
|
|
|
|
export async function deleteRecipe(id: number, userId: number) {
|
|
return withTransaction(async (client) => {
|
|
const result = await client.query<{ id: number }>('DELETE FROM recipes WHERE id = $1 RETURNING id', [id]);
|
|
if (result.rowCount === 0) {
|
|
return false;
|
|
}
|
|
|
|
await deleteEntityDiscussionCommentsForEntity(client, 'recipes', id);
|
|
await recordEditLog(client, 'recipes', id, 'delete', userId);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
function dishCategoryProjection(locale: string): string {
|
|
const categoryName = localizedName('dish-categories', 'dc', locale);
|
|
const categoryEffect = localizedField('dish-categories', 'dc.id', 'dc.effect', 'effect', locale);
|
|
const cookwareName = localizedName('items', 'cookware_item', locale);
|
|
const mainMaterialName = localizedName('items', 'main_material_item', locale);
|
|
const dishItemName = localizedName('items', 'dish_item', locale);
|
|
const flavorName = localizedName('dish-flavors', 'dish_flavor', locale);
|
|
const secondaryMaterialName = localizedName('items', 'secondary_material_item', locale);
|
|
const skillName = localizedName('skills', 'dish_skill', locale);
|
|
const mosslaxEffect = localizedField('dishes', 'd.id', 'd.mosslax_effect', 'mosslaxEffect', locale);
|
|
|
|
return `
|
|
SELECT
|
|
dc.id,
|
|
${categoryName} AS name,
|
|
dc.name AS "baseName",
|
|
${categoryEffect} AS effect,
|
|
dc.effect AS "baseEffect",
|
|
dc.total_material_quantity AS "totalMaterialQuantity",
|
|
${translationsSelect('dish-categories', 'dc.id')} AS translations,
|
|
${auditSelect('dc', 'category_created_user', 'category_updated_user')},
|
|
json_build_object(
|
|
'id', cookware_item.id,
|
|
'name', ${cookwareName},
|
|
'image', ${uploadedImageJson('cookware_item.image_path')},
|
|
'category', ${systemListJsonSql('cookware_item.category_key', itemCategoryOptions, locale)}
|
|
) AS cookware,
|
|
json_build_object(
|
|
'id', main_material_item.id,
|
|
'name', ${mainMaterialName},
|
|
'image', ${uploadedImageJson('main_material_item.image_path')},
|
|
'category', ${systemListJsonSql('main_material_item.category_key', itemCategoryOptions, locale)}
|
|
) AS "mainMaterial",
|
|
COALESCE((
|
|
SELECT json_agg(
|
|
json_build_object(
|
|
'id', d.id,
|
|
'flavor', json_build_object('id', dish_flavor.id, 'name', ${flavorName}),
|
|
'mosslaxEffect', ${mosslaxEffect},
|
|
'baseMosslaxEffect', d.mosslax_effect,
|
|
'translations', ${translationsSelect('dishes', 'd.id')},
|
|
'createdAt', d.created_at,
|
|
'updatedAt', d.updated_at,
|
|
'createdBy', CASE
|
|
WHEN dish_created_user.id IS NULL THEN NULL
|
|
ELSE json_build_object('id', dish_created_user.id, 'displayName', dish_created_user.display_name)
|
|
END,
|
|
'updatedBy', CASE
|
|
WHEN dish_updated_user.id IS NULL THEN NULL
|
|
ELSE json_build_object('id', dish_updated_user.id, 'displayName', dish_updated_user.display_name)
|
|
END,
|
|
'category', json_build_object('id', dc.id, 'name', ${categoryName}),
|
|
'item', json_build_object(
|
|
'id', dish_item.id,
|
|
'name', ${dishItemName},
|
|
'image', ${uploadedImageJson('dish_item.image_path')},
|
|
'category', ${systemListJsonSql('dish_item.category_key', itemCategoryOptions, locale)}
|
|
),
|
|
'secondaryMaterials', COALESCE((
|
|
SELECT json_agg(
|
|
json_build_object(
|
|
'id', secondary_material_item.id,
|
|
'name', ${secondaryMaterialName},
|
|
'image', ${uploadedImageJson('secondary_material_item.image_path')},
|
|
'category', ${systemListJsonSql('secondary_material_item.category_key', itemCategoryOptions, locale)}
|
|
)
|
|
ORDER BY secondary_slots.slot
|
|
)
|
|
FROM (VALUES (d.secondary_material_1_item_id, 1), (d.secondary_material_2_item_id, 2)) AS secondary_slots(item_id, slot)
|
|
JOIN items secondary_material_item ON secondary_material_item.id = secondary_slots.item_id
|
|
), '[]'::json),
|
|
'pokemonSkill', CASE
|
|
WHEN dish_skill.id IS NULL THEN NULL
|
|
ELSE json_build_object('id', dish_skill.id, 'name', ${skillName}, 'hasItemDrop', dish_skill.has_item_drop, 'hasTrading', dish_skill.has_trading)
|
|
END
|
|
)
|
|
ORDER BY d.sort_order, d.id
|
|
)
|
|
FROM dishes d
|
|
JOIN items dish_item ON dish_item.id = d.item_id
|
|
JOIN dish_flavors dish_flavor ON dish_flavor.id = d.flavor_id
|
|
LEFT JOIN skills dish_skill ON dish_skill.id = d.pokemon_skill_id
|
|
LEFT JOIN users dish_created_user ON dish_created_user.id = d.created_by_user_id
|
|
LEFT JOIN users dish_updated_user ON dish_updated_user.id = d.updated_by_user_id
|
|
WHERE d.category_id = dc.id
|
|
), '[]'::json) AS dishes
|
|
FROM dish_categories dc
|
|
JOIN items cookware_item ON cookware_item.id = dc.cookware_item_id
|
|
JOIN items main_material_item ON main_material_item.id = dc.main_material_item_id
|
|
${auditJoins('dc', 'category_created_user', 'category_updated_user')}
|
|
`;
|
|
}
|
|
|
|
function dishProjection(locale: string): string {
|
|
const categoryName = localizedName('dish-categories', 'dc', locale);
|
|
const dishItemName = localizedName('items', 'dish_item', locale);
|
|
const flavorName = localizedName('dish-flavors', 'dish_flavor', locale);
|
|
const secondaryMaterialName = localizedName('items', 'secondary_material_item', locale);
|
|
const skillName = localizedName('skills', 'dish_skill', locale);
|
|
const mosslaxEffect = localizedField('dishes', 'd.id', 'd.mosslax_effect', 'mosslaxEffect', locale);
|
|
|
|
return `
|
|
SELECT
|
|
d.id,
|
|
json_build_object('id', dish_flavor.id, 'name', ${flavorName}) AS flavor,
|
|
${mosslaxEffect} AS "mosslaxEffect",
|
|
d.mosslax_effect AS "baseMosslaxEffect",
|
|
${translationsSelect('dishes', 'd.id')} AS translations,
|
|
${auditSelect('d', 'dish_created_user', 'dish_updated_user')},
|
|
json_build_object('id', dc.id, 'name', ${categoryName}) AS category,
|
|
json_build_object(
|
|
'id', dish_item.id,
|
|
'name', ${dishItemName},
|
|
'image', ${uploadedImageJson('dish_item.image_path')},
|
|
'category', ${systemListJsonSql('dish_item.category_key', itemCategoryOptions, locale)}
|
|
) AS item,
|
|
COALESCE((
|
|
SELECT json_agg(
|
|
json_build_object(
|
|
'id', secondary_material_item.id,
|
|
'name', ${secondaryMaterialName},
|
|
'image', ${uploadedImageJson('secondary_material_item.image_path')},
|
|
'category', ${systemListJsonSql('secondary_material_item.category_key', itemCategoryOptions, locale)}
|
|
)
|
|
ORDER BY secondary_slots.slot
|
|
)
|
|
FROM (VALUES (d.secondary_material_1_item_id, 1), (d.secondary_material_2_item_id, 2)) AS secondary_slots(item_id, slot)
|
|
JOIN items secondary_material_item ON secondary_material_item.id = secondary_slots.item_id
|
|
), '[]'::json) AS "secondaryMaterials",
|
|
CASE
|
|
WHEN dish_skill.id IS NULL THEN NULL
|
|
ELSE json_build_object('id', dish_skill.id, 'name', ${skillName}, 'hasItemDrop', dish_skill.has_item_drop, 'hasTrading', dish_skill.has_trading)
|
|
END AS "pokemonSkill"
|
|
FROM dishes d
|
|
JOIN dish_categories dc ON dc.id = d.category_id
|
|
JOIN items dish_item ON dish_item.id = d.item_id
|
|
JOIN dish_flavors dish_flavor ON dish_flavor.id = d.flavor_id
|
|
LEFT JOIN skills dish_skill ON dish_skill.id = d.pokemon_skill_id
|
|
${auditJoins('d', 'dish_created_user', 'dish_updated_user')}
|
|
`;
|
|
}
|
|
|
|
export async function listDish(locale = defaultLocale) {
|
|
return query(`${dishCategoryProjection(locale)} ORDER BY ${orderByEntity('dc')}`);
|
|
}
|
|
|
|
async function getDishCategory(id: number, locale = defaultLocale) {
|
|
return queryOne(`${dishCategoryProjection(locale)} WHERE dc.id = $1`, [id]);
|
|
}
|
|
|
|
async function getDish(id: number, locale = defaultLocale) {
|
|
return queryOne(`${dishProjection(locale)} WHERE d.id = $1`, [id]);
|
|
}
|
|
|
|
function cleanDishCategoryPayload(payload: Record<string, unknown>): DishCategoryPayload {
|
|
const totalMaterialQuantity = requirePositiveInteger(payload.totalMaterialQuantity, 'server.validation.invalidField');
|
|
if (totalMaterialQuantity < 2) {
|
|
throw validationError('server.validation.invalidField');
|
|
}
|
|
|
|
return {
|
|
name: cleanName(payload.name),
|
|
effect: cleanName(payload.effect, 'server.validation.invalidField'),
|
|
translations: cleanTranslations(payload.translations, ['name', 'effect']),
|
|
cookwareItemId: requirePositiveInteger(payload.cookwareItemId, 'server.validation.itemRequired'),
|
|
mainMaterialItemId: requirePositiveInteger(payload.mainMaterialItemId, 'server.validation.itemRequired'),
|
|
totalMaterialQuantity
|
|
};
|
|
}
|
|
|
|
function cleanDishPayload(payload: Record<string, unknown>): DishPayload {
|
|
const secondaryMaterialItemIds = cleanIds(payload.secondaryMaterialItemIds).slice(0, 2);
|
|
|
|
return {
|
|
categoryId: requirePositiveInteger(payload.categoryId, 'server.validation.categoryRequired'),
|
|
itemId: requirePositiveInteger(payload.itemId, 'server.validation.itemRequired'),
|
|
flavorId: requirePositiveInteger(payload.flavorId, 'server.validation.invalidField'),
|
|
secondaryMaterialItemIds,
|
|
pokemonSkillId: optionalPositiveInteger(payload.pokemonSkillId, 'server.validation.invalidField'),
|
|
mosslaxEffect: cleanName(payload.mosslaxEffect, 'server.validation.invalidField'),
|
|
translations: cleanTranslations(payload.translations, ['mosslaxEffect'])
|
|
};
|
|
}
|
|
|
|
async function ensureDishMaterialSlots(client: DbClient, payload: DishPayload): Promise<void> {
|
|
const result = await client.query<{ totalMaterialQuantity: number; mainMaterialItemId: number }>(
|
|
`
|
|
SELECT
|
|
total_material_quantity AS "totalMaterialQuantity",
|
|
main_material_item_id AS "mainMaterialItemId"
|
|
FROM dish_categories
|
|
WHERE id = $1
|
|
`,
|
|
[payload.categoryId]
|
|
);
|
|
if (result.rowCount === 0) {
|
|
throw validationError('server.validation.categoryRequired');
|
|
}
|
|
|
|
if (payload.secondaryMaterialItemIds.length > 1 && result.rows[0].totalMaterialQuantity <= 2) {
|
|
throw validationError('server.validation.invalidField');
|
|
}
|
|
|
|
if (payload.secondaryMaterialItemIds.includes(result.rows[0].mainMaterialItemId)) {
|
|
throw validationError('server.validation.invalidField');
|
|
}
|
|
}
|
|
|
|
async function ensureDishCategoryMaterialSlots(client: DbClient, id: number, payload: DishCategoryPayload): Promise<void> {
|
|
const result = await client.query<{ id: number }>(
|
|
`
|
|
SELECT id
|
|
FROM dishes
|
|
WHERE category_id = $1
|
|
AND (
|
|
($2::integer <= 2 AND secondary_material_2_item_id IS NOT NULL)
|
|
OR secondary_material_1_item_id = $3
|
|
OR secondary_material_2_item_id = $3
|
|
)
|
|
LIMIT 1
|
|
`,
|
|
[id, payload.totalMaterialQuantity, payload.mainMaterialItemId]
|
|
);
|
|
if ((result.rowCount ?? 0) > 0) {
|
|
throw validationError('server.validation.invalidField');
|
|
}
|
|
}
|
|
|
|
async function dishCategoryEditChanges(
|
|
client: DbClient,
|
|
before: DishCategoryChangeSource,
|
|
after: DishCategoryPayload
|
|
): Promise<EditChange[]> {
|
|
const changes: EditChange[] = [];
|
|
const itemNames = await entityNameMap(client, 'items', [after.cookwareItemId, after.mainMaterialItemId]);
|
|
pushChange(changes, 'Name', before.name, after.name);
|
|
pushTranslationChanges(changes, before.translations, after.translations, ['name', 'effect']);
|
|
pushChange(changes, 'Cookware', before.cookware.name, itemNames.get(after.cookwareItemId));
|
|
pushChange(changes, 'Main material', before.mainMaterial.name, itemNames.get(after.mainMaterialItemId));
|
|
pushChange(changes, 'Total material quantity', String(before.totalMaterialQuantity), String(after.totalMaterialQuantity));
|
|
pushChange(changes, 'Effect', before.effect, after.effect);
|
|
return changes;
|
|
}
|
|
|
|
async function dishEditChanges(client: DbClient, before: DishChangeSource, after: DishPayload): Promise<EditChange[]> {
|
|
const changes: EditChange[] = [];
|
|
const categoryNames = await entityNameMap(client, 'dish_categories', [after.categoryId]);
|
|
const itemNames = await entityNameMap(client, 'items', [after.itemId, ...after.secondaryMaterialItemIds]);
|
|
const flavorNames = await entityNameMap(client, 'dish_flavors', [after.flavorId]);
|
|
const skillNames = await entityNameMap(client, 'skills', after.pokemonSkillId ? [after.pokemonSkillId] : []);
|
|
|
|
pushChange(changes, 'Category', before.category.name, categoryNames.get(after.categoryId));
|
|
pushChange(changes, 'Dish item', before.item.name, itemNames.get(after.itemId));
|
|
pushChange(changes, 'Flavor', before.flavor.name, flavorNames.get(after.flavorId));
|
|
pushTranslationChanges(changes, before.translations, after.translations, ['mosslaxEffect']);
|
|
pushChange(changes, 'Secondary materials', namedListValue(before.secondaryMaterials), namesFromIds(after.secondaryMaterialItemIds, itemNames));
|
|
pushChange(changes, 'Pokemon speciality', before.pokemonSkill?.name, after.pokemonSkillId ? skillNames.get(after.pokemonSkillId) : null);
|
|
pushChange(changes, 'Mosslax effect', before.mosslaxEffect, after.mosslaxEffect);
|
|
return changes;
|
|
}
|
|
|
|
export async function createDishCategory(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
|
|
const cleanPayload = cleanDishCategoryPayload(payload);
|
|
const id = await withTransaction(async (client) => {
|
|
const sortOrder = await nextSortOrder(client, 'dish_categories');
|
|
const result = await client.query<{ id: number }>(
|
|
`
|
|
INSERT INTO dish_categories (
|
|
name,
|
|
cookware_item_id,
|
|
main_material_item_id,
|
|
total_material_quantity,
|
|
effect,
|
|
sort_order,
|
|
created_by_user_id,
|
|
updated_by_user_id
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $7)
|
|
RETURNING id
|
|
`,
|
|
[
|
|
cleanPayload.name,
|
|
cleanPayload.cookwareItemId,
|
|
cleanPayload.mainMaterialItemId,
|
|
cleanPayload.totalMaterialQuantity,
|
|
cleanPayload.effect,
|
|
sortOrder,
|
|
userId
|
|
]
|
|
);
|
|
const categoryId = result.rows[0].id;
|
|
await replaceEntityTranslations(client, 'dish-categories', categoryId, cleanPayload.translations, ['name', 'effect']);
|
|
await recordEditLog(client, 'dish-categories', categoryId, 'create', userId);
|
|
return categoryId;
|
|
});
|
|
return getDishCategory(id, locale);
|
|
}
|
|
|
|
export async function updateDishCategory(id: number, payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
|
|
const cleanPayload = cleanDishCategoryPayload(payload);
|
|
const before = await getDishCategory(id, defaultLocale);
|
|
const updated = await withTransaction(async (client) => {
|
|
await ensureDishCategoryMaterialSlots(client, id, cleanPayload);
|
|
const result = await client.query(
|
|
`
|
|
UPDATE dish_categories
|
|
SET name = $1,
|
|
cookware_item_id = $2,
|
|
main_material_item_id = $3,
|
|
total_material_quantity = $4,
|
|
effect = $5,
|
|
updated_by_user_id = $6,
|
|
updated_at = now()
|
|
WHERE id = $7
|
|
`,
|
|
[
|
|
cleanPayload.name,
|
|
cleanPayload.cookwareItemId,
|
|
cleanPayload.mainMaterialItemId,
|
|
cleanPayload.totalMaterialQuantity,
|
|
cleanPayload.effect,
|
|
userId,
|
|
id
|
|
]
|
|
);
|
|
if (result.rowCount === 0) {
|
|
return false;
|
|
}
|
|
await replaceEntityTranslations(client, 'dish-categories', id, cleanPayload.translations, ['name', 'effect']);
|
|
const changes = before ? await dishCategoryEditChanges(client, before as unknown as DishCategoryChangeSource, cleanPayload) : [];
|
|
await recordEditLog(client, 'dish-categories', id, 'update', userId, changes);
|
|
return true;
|
|
});
|
|
return updated ? getDishCategory(id, locale) : null;
|
|
}
|
|
|
|
export async function deleteDishCategory(id: number, userId: number) {
|
|
return withTransaction(async (client) => {
|
|
const childRows = await client.query<{ id: number }>('SELECT id FROM dishes WHERE category_id = $1', [id]);
|
|
const result = await client.query<{ id: number }>('DELETE FROM dish_categories WHERE id = $1 RETURNING id', [id]);
|
|
if (result.rowCount === 0) {
|
|
return false;
|
|
}
|
|
|
|
await client.query('DELETE FROM entity_translations WHERE entity_type = $1 AND entity_id = $2', ['dish-categories', id]);
|
|
const childIds = childRows.rows.map((row) => row.id);
|
|
if (childIds.length) {
|
|
await client.query('DELETE FROM entity_translations WHERE entity_type = $1 AND entity_id = ANY($2::integer[])', [
|
|
'dishes',
|
|
childIds
|
|
]);
|
|
}
|
|
await recordEditLog(client, 'dish-categories', id, 'delete', userId);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
export async function createDish(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
|
|
const cleanPayload = cleanDishPayload(payload);
|
|
const id = await withTransaction(async (client) => {
|
|
await ensureDishMaterialSlots(client, cleanPayload);
|
|
const sortOrder = await nextSortOrder(client, 'dishes');
|
|
const result = await client.query<{ id: number }>(
|
|
`
|
|
INSERT INTO dishes (
|
|
category_id,
|
|
item_id,
|
|
flavor_id,
|
|
secondary_material_1_item_id,
|
|
secondary_material_2_item_id,
|
|
pokemon_skill_id,
|
|
mosslax_effect,
|
|
sort_order,
|
|
created_by_user_id,
|
|
updated_by_user_id
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $9)
|
|
RETURNING id
|
|
`,
|
|
[
|
|
cleanPayload.categoryId,
|
|
cleanPayload.itemId,
|
|
cleanPayload.flavorId,
|
|
cleanPayload.secondaryMaterialItemIds[0] ?? null,
|
|
cleanPayload.secondaryMaterialItemIds[1] ?? null,
|
|
cleanPayload.pokemonSkillId,
|
|
cleanPayload.mosslaxEffect,
|
|
sortOrder,
|
|
userId
|
|
]
|
|
);
|
|
const dishId = result.rows[0].id;
|
|
await replaceEntityTranslations(client, 'dishes', dishId, cleanPayload.translations, ['mosslaxEffect']);
|
|
await recordEditLog(client, 'dishes', dishId, 'create', userId);
|
|
return dishId;
|
|
});
|
|
return getDish(id, locale);
|
|
}
|
|
|
|
export async function updateDish(id: number, payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
|
|
const cleanPayload = cleanDishPayload(payload);
|
|
const before = await getDish(id, defaultLocale);
|
|
const updated = await withTransaction(async (client) => {
|
|
await ensureDishMaterialSlots(client, cleanPayload);
|
|
const result = await client.query(
|
|
`
|
|
UPDATE dishes
|
|
SET category_id = $1,
|
|
item_id = $2,
|
|
flavor_id = $3,
|
|
secondary_material_1_item_id = $4,
|
|
secondary_material_2_item_id = $5,
|
|
pokemon_skill_id = $6,
|
|
mosslax_effect = $7,
|
|
updated_by_user_id = $8,
|
|
updated_at = now()
|
|
WHERE id = $9
|
|
`,
|
|
[
|
|
cleanPayload.categoryId,
|
|
cleanPayload.itemId,
|
|
cleanPayload.flavorId,
|
|
cleanPayload.secondaryMaterialItemIds[0] ?? null,
|
|
cleanPayload.secondaryMaterialItemIds[1] ?? null,
|
|
cleanPayload.pokemonSkillId,
|
|
cleanPayload.mosslaxEffect,
|
|
userId,
|
|
id
|
|
]
|
|
);
|
|
if (result.rowCount === 0) {
|
|
return false;
|
|
}
|
|
await replaceEntityTranslations(client, 'dishes', id, cleanPayload.translations, ['mosslaxEffect']);
|
|
const changes = before ? await dishEditChanges(client, before as unknown as DishChangeSource, cleanPayload) : [];
|
|
await recordEditLog(client, 'dishes', id, 'update', userId, changes);
|
|
return true;
|
|
});
|
|
return updated ? getDish(id, locale) : null;
|
|
}
|
|
|
|
export async function deleteDish(id: number, userId: number) {
|
|
return withTransaction(async (client) => {
|
|
const result = await client.query<{ id: number }>('DELETE FROM dishes WHERE id = $1 RETURNING id', [id]);
|
|
if (result.rowCount === 0) {
|
|
return false;
|
|
}
|
|
|
|
await deleteEntityTranslations(client, 'dishes', id);
|
|
await recordEditLog(client, 'dishes', id, 'delete', userId);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
export async function reorderDishCategories(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
|
|
const ids = cleanIds(payload.ids);
|
|
if (ids.length === 0) {
|
|
throw validationError('server.validation.selectRecord');
|
|
}
|
|
await withTransaction(async (client) => {
|
|
await reorderTableRows(client, 'dish_categories', ids, userId);
|
|
});
|
|
return listDish(locale);
|
|
}
|
|
|
|
export async function reorderDishes(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
|
|
const ids = cleanIds(payload.ids);
|
|
if (ids.length === 0) {
|
|
throw validationError('server.validation.selectRecord');
|
|
}
|
|
await withTransaction(async (client) => {
|
|
await reorderTableRows(client, 'dishes', ids, userId);
|
|
});
|
|
return listDish(locale);
|
|
}
|
|
|
|
const dataToolScopes = ['pokemon', 'habitats', 'items', 'artifacts', 'recipes', 'checklist'] as const satisfies readonly DataToolScope[];
|
|
const dataToolMainTables: Record<Exclude<DataToolScope, 'artifacts'>, string> = {
|
|
pokemon: 'pokemon',
|
|
habitats: 'habitats',
|
|
items: 'items',
|
|
recipes: 'recipes',
|
|
checklist: 'daily_checklist_items'
|
|
};
|
|
|
|
const dataToolColumns = {
|
|
pokemon: [
|
|
'id',
|
|
'data_id',
|
|
'data_identifier',
|
|
'display_id',
|
|
'name',
|
|
'is_event_item',
|
|
'genus',
|
|
'details',
|
|
'height_inches',
|
|
'weight_pounds',
|
|
'environment_id',
|
|
'hp',
|
|
'attack',
|
|
'defense',
|
|
'special_attack',
|
|
'special_defense',
|
|
'speed',
|
|
'image_path',
|
|
'image_style',
|
|
'image_version',
|
|
'image_variant',
|
|
'image_description',
|
|
'sort_order',
|
|
'created_by_user_id',
|
|
'updated_by_user_id',
|
|
'created_at',
|
|
'updated_at'
|
|
],
|
|
pokemonTypeLinks: ['pokemon_id', 'type_id', 'slot_order'],
|
|
pokemonSkills: ['pokemon_id', 'skill_id'],
|
|
pokemonFavoriteThings: ['pokemon_id', 'favorite_thing_id'],
|
|
pokemonSkillItemDrops: ['pokemon_id', 'skill_id', 'item_id'],
|
|
pokemonTradingItems: ['pokemon_id', 'item_id', 'preference'],
|
|
habitats: ['id', 'name', 'is_event_item', 'image_path', 'sort_order', 'created_by_user_id', 'updated_by_user_id', 'created_at', 'updated_at'],
|
|
habitatRecipeItems: ['habitat_id', 'item_id', 'quantity'],
|
|
habitatPokemon: ['habitat_id', 'pokemon_id', 'map_id', 'time_of_day', 'weather', 'rarity'],
|
|
items: [
|
|
'id',
|
|
'name',
|
|
'details',
|
|
'base_price',
|
|
'ancient_artifact_category_key',
|
|
'category_key',
|
|
'usage_key',
|
|
'dyeability',
|
|
'dyeable',
|
|
'dual_dyeable',
|
|
'pattern_editable',
|
|
'no_recipe',
|
|
'is_event_item',
|
|
'image_path',
|
|
'sort_order',
|
|
'created_by_user_id',
|
|
'updated_by_user_id',
|
|
'created_at',
|
|
'updated_at'
|
|
],
|
|
itemAcquisitionMethods: ['item_id', 'acquisition_method_id'],
|
|
itemFavoriteThings: ['item_id', 'favorite_thing_id'],
|
|
recipes: ['id', 'item_id', 'sort_order', 'created_by_user_id', 'updated_by_user_id', 'created_at', 'updated_at'],
|
|
recipeAcquisitionMethods: ['recipe_id', 'acquisition_method_id'],
|
|
recipeMaterials: ['recipe_id', 'item_id', 'quantity'],
|
|
checklist: ['id', 'title', 'sort_order', 'created_by_user_id', 'updated_by_user_id', 'created_at', 'updated_at'],
|
|
translations: ['entity_type', 'entity_id', 'locale', 'field_name', 'value'],
|
|
editLogs: ['id', 'entity_type', 'entity_id', 'action', 'user_id', 'changes', 'created_at'],
|
|
imageUploads: [
|
|
'id',
|
|
'entity_type',
|
|
'entity_id',
|
|
'entity_name',
|
|
'path',
|
|
'original_filename',
|
|
'mime_type',
|
|
'byte_size',
|
|
'created_by_user_id',
|
|
'created_at'
|
|
],
|
|
discussionComments: [
|
|
'id',
|
|
'entity_type',
|
|
'entity_id',
|
|
'parent_comment_id',
|
|
'body',
|
|
'ai_moderation_status',
|
|
'ai_moderation_language_code',
|
|
'ai_moderation_content_hash',
|
|
'ai_moderation_checked_at',
|
|
'ai_moderation_retry_count',
|
|
'ai_moderation_updated_at',
|
|
'created_by_user_id',
|
|
'deleted_by_user_id',
|
|
'deleted_at',
|
|
'created_at',
|
|
'updated_at'
|
|
],
|
|
discussionCommentLikes: ['comment_id', 'user_id', 'created_at']
|
|
} as const;
|
|
const itemsCsvColumns = [
|
|
'name',
|
|
'category',
|
|
'description',
|
|
'image_file_name',
|
|
'not_registered_in_collection',
|
|
'cannot_grow_again_today'
|
|
] as const;
|
|
const habitatsCsvColumns = ['id', 'name', 'image_file_name'] as const;
|
|
const itemsCsvCategoryAliases = new Map<string, string>(
|
|
itemCategoryOptions.flatMap((option) => [
|
|
[option.key, option.key],
|
|
[option.labels.en.toLowerCase(), option.key],
|
|
[option.labels.en.toLowerCase().replaceAll(' ', '-'), option.key],
|
|
[option.labels.en.toLowerCase().replace(/\.$/, ''), option.key]
|
|
])
|
|
);
|
|
|
|
itemsCsvCategoryAliases.set('misc.', 'misc');
|
|
|
|
function normalizeItemsCsvCategory(value: string): string {
|
|
return value.trim().toLowerCase().replace(/\s+/g, ' ');
|
|
}
|
|
|
|
function itemsCsvCategoryKey(value: string): string {
|
|
const categoryKey = itemsCsvCategoryAliases.get(normalizeItemsCsvCategory(value));
|
|
if (!categoryKey) {
|
|
throw validationError('server.validation.dataToolItemsCsvInvalid');
|
|
}
|
|
return categoryKey;
|
|
}
|
|
|
|
function itemsCsvBoolean(row: CsvRow, fieldName: string): boolean {
|
|
const value = csvText(row, fieldName).toLowerCase();
|
|
if (value === '' || value === 'false' || value === '0' || value === 'no') {
|
|
return false;
|
|
}
|
|
if (value === 'true' || value === '1' || value === 'yes') {
|
|
return true;
|
|
}
|
|
throw validationError('server.validation.dataToolItemsCsvInvalid');
|
|
}
|
|
|
|
function appendItemsCsvNote(details: string, note: string): string {
|
|
return details ? `${details}\n${note}` : note;
|
|
}
|
|
|
|
function itemsCsvDetails(row: CsvRow): string {
|
|
let details = csvText(row, 'description');
|
|
if (itemsCsvBoolean(row, 'not_registered_in_collection')) {
|
|
details = appendItemsCsvNote(details, 'Note: Not registered in collection');
|
|
}
|
|
if (itemsCsvBoolean(row, 'cannot_grow_again_today')) {
|
|
details = appendItemsCsvNote(details, 'Note: Cannot have Grow used on it again today');
|
|
}
|
|
return details;
|
|
}
|
|
|
|
function itemsCsvImagePath(value: string): string {
|
|
const fileName = value.trim();
|
|
const imagePath = `${itemStaticImagePathPrefix}${fileName}`;
|
|
if (!isItemStaticImagePath(imagePath)) {
|
|
throw validationError('server.validation.dataToolItemsCsvInvalid');
|
|
}
|
|
return imagePath;
|
|
}
|
|
|
|
function habitatsCsvId(value: string): { normalizedId: string; isEventItem: boolean } {
|
|
const id = value.trim();
|
|
const eventMatch = id.match(/^E-?(\d+)$/i);
|
|
if (eventMatch) {
|
|
return { normalizedId: `E${eventMatch[1]}`, isEventItem: true };
|
|
}
|
|
if (!/^\d+$/.test(id)) {
|
|
throw validationError('server.validation.dataToolHabitatsCsvInvalid');
|
|
}
|
|
return { normalizedId: id, isEventItem: false };
|
|
}
|
|
|
|
function habitatsCsvImagePath(value: string): string {
|
|
const fileName = value.trim();
|
|
const imagePath = `${habitatStaticImagePathPrefix}${fileName}`;
|
|
if (!isHabitatStaticImagePath(imagePath)) {
|
|
throw validationError('server.validation.dataToolHabitatsCsvInvalid');
|
|
}
|
|
return imagePath;
|
|
}
|
|
|
|
function cleanItemsCsvRows(value: unknown): CsvRow[] {
|
|
if (typeof value !== 'string' || value.trim() === '') {
|
|
throw validationError('server.validation.dataToolItemsCsvInvalid');
|
|
}
|
|
|
|
const rows = parseCsv(value, 'items.csv');
|
|
if (!rows.length || rows.some((row) => itemsCsvColumns.some((column) => !(column in row)))) {
|
|
throw validationError('server.validation.dataToolItemsCsvInvalid');
|
|
}
|
|
|
|
const names = new Set<string>();
|
|
for (const row of rows) {
|
|
const name = csvText(row, 'name');
|
|
if (!name || names.has(name)) {
|
|
throw validationError('server.validation.dataToolItemsCsvInvalid');
|
|
}
|
|
names.add(name);
|
|
}
|
|
|
|
return rows;
|
|
}
|
|
|
|
function cleanHabitatsCsvRows(value: unknown): CsvRow[] {
|
|
if (typeof value !== 'string' || value.trim() === '') {
|
|
throw validationError('server.validation.dataToolHabitatsCsvInvalid');
|
|
}
|
|
|
|
const rows = parseCsv(value, 'habitats.csv');
|
|
if (!rows.length || rows.some((row) => habitatsCsvColumns.some((column) => !(column in row)))) {
|
|
throw validationError('server.validation.dataToolHabitatsCsvInvalid');
|
|
}
|
|
|
|
const ids = new Set<string>();
|
|
const names = new Set<string>();
|
|
for (const row of rows) {
|
|
const id = habitatsCsvId(csvText(row, 'id')).normalizedId;
|
|
const name = csvText(row, 'name');
|
|
habitatsCsvImagePath(csvText(row, 'image_file_name'));
|
|
if (ids.has(id) || !name || names.has(name)) {
|
|
throw validationError('server.validation.dataToolHabitatsCsvInvalid');
|
|
}
|
|
ids.add(id);
|
|
names.add(name);
|
|
}
|
|
|
|
return rows;
|
|
}
|
|
|
|
function isDataToolScope(value: unknown): value is DataToolScope {
|
|
return typeof value === 'string' && dataToolScopes.includes(value as DataToolScope);
|
|
}
|
|
|
|
function normalizeDataToolScopes(scopes: DataToolScope[]): DataToolScope[] {
|
|
const scopeSet = new Set(scopes);
|
|
if (scopeSet.has('items')) {
|
|
scopeSet.add('recipes');
|
|
scopeSet.delete('artifacts');
|
|
}
|
|
return dataToolScopes.filter((scope) => scopeSet.has(scope));
|
|
}
|
|
|
|
function cleanDataToolScopes(value: unknown): DataToolScope[] {
|
|
if (!Array.isArray(value)) {
|
|
throw validationError('server.validation.dataToolScopeRequired');
|
|
}
|
|
|
|
const scopes: DataToolScope[] = [];
|
|
for (const scope of value) {
|
|
if (!isDataToolScope(scope)) {
|
|
throw validationError('server.validation.dataToolScopeInvalid');
|
|
}
|
|
if (!scopes.includes(scope)) {
|
|
scopes.push(scope);
|
|
}
|
|
}
|
|
|
|
if (scopes.length === 0) {
|
|
throw validationError('server.validation.dataToolScopeRequired');
|
|
}
|
|
return normalizeDataToolScopes(scopes);
|
|
}
|
|
|
|
function cleanDataToolsBundle(value: unknown): DataToolsBundle {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
throw validationError('server.validation.dataToolBundleInvalid');
|
|
}
|
|
|
|
const bundle = value as Record<string, unknown>;
|
|
if (bundle.version !== 1 || !bundle.data || typeof bundle.data !== 'object' || Array.isArray(bundle.data)) {
|
|
throw validationError('server.validation.dataToolBundleInvalid');
|
|
}
|
|
|
|
return {
|
|
version: 1,
|
|
exportedAt: typeof bundle.exportedAt === 'string' ? bundle.exportedAt : new Date().toISOString(),
|
|
scopes: cleanDataToolScopes(bundle.scopes),
|
|
data: bundle.data as DataToolsBundle['data']
|
|
};
|
|
}
|
|
|
|
function dataToolTableRows(data: DataToolScopeData | undefined, key: string): DataToolRows {
|
|
const rows = data?.[key];
|
|
if (rows === undefined) {
|
|
return [];
|
|
}
|
|
if (!Array.isArray(rows) || rows.some((row) => !row || typeof row !== 'object' || Array.isArray(row))) {
|
|
throw validationError('server.validation.dataToolBundleInvalid');
|
|
}
|
|
return rows as DataToolRows;
|
|
}
|
|
|
|
function dataToolDataWithRows(key: string, ...sources: Array<DataToolScopeData | undefined>): DataToolScopeData | undefined {
|
|
return sources.find((source) => source?.[key] !== undefined);
|
|
}
|
|
|
|
async function tableRows(client: DbClient, sql: string, params: unknown[] = []): Promise<DataToolRows> {
|
|
const result = await client.query<Record<string, unknown>>(sql, params);
|
|
return result.rows;
|
|
}
|
|
|
|
function normalizeImportValue(value: unknown): unknown {
|
|
return value === undefined ? null : value;
|
|
}
|
|
|
|
function normalizeImportColumnValue(row: Record<string, unknown>, column: string): unknown {
|
|
if (column === 'dyeability' && row[column] === undefined) {
|
|
if (row.dual_dyeable === true) {
|
|
return 2;
|
|
}
|
|
if (row.dyeable === true) {
|
|
return 1;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
return normalizeImportValue(row[column]);
|
|
}
|
|
|
|
async function insertRows(client: DbClient, tableName: string, columns: readonly string[], rows: DataToolRows): Promise<void> {
|
|
for (const row of rows) {
|
|
const placeholders = columns.map((_, index) => `$${index + 1}`).join(', ');
|
|
const values = columns.map((column) => normalizeImportColumnValue(row, column));
|
|
await client.query(`INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${placeholders})`, values);
|
|
}
|
|
}
|
|
|
|
async function upsertRowsById(client: DbClient, tableName: string, columns: readonly string[], rows: DataToolRows): Promise<void> {
|
|
const updateColumns = columns.filter((column) => column !== 'id');
|
|
for (const row of rows) {
|
|
const placeholders = columns.map((_, index) => `$${index + 1}`).join(', ');
|
|
const assignments = updateColumns.map((column) => `${column} = EXCLUDED.${column}`).join(', ');
|
|
const values = columns.map((column) => normalizeImportColumnValue(row, column));
|
|
await client.query(
|
|
`INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${placeholders}) ON CONFLICT (id) DO UPDATE SET ${assignments}`,
|
|
values
|
|
);
|
|
}
|
|
}
|
|
|
|
async function insertRowsIgnoreConflicts(client: DbClient, tableName: string, columns: readonly string[], rows: DataToolRows): Promise<void> {
|
|
for (const row of rows) {
|
|
const placeholders = columns.map((_, index) => `$${index + 1}`).join(', ');
|
|
const values = columns.map((column) => normalizeImportColumnValue(row, column));
|
|
await client.query(`INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${placeholders}) ON CONFLICT DO NOTHING`, values);
|
|
}
|
|
}
|
|
|
|
async function resetIdentity(client: DbClient, tableName: string): Promise<void> {
|
|
const result = await client.query<{ maxId: number | null }>(`SELECT MAX(id)::integer AS "maxId" FROM ${tableName}`);
|
|
const maxId = result.rows[0]?.maxId ?? null;
|
|
if (maxId === null) {
|
|
await client.query(`ALTER TABLE ${tableName} ALTER COLUMN id RESTART WITH 1`);
|
|
return;
|
|
}
|
|
|
|
await client.query('SELECT setval(pg_get_serial_sequence($1, $2), $3, true)', [tableName, 'id', maxId]);
|
|
}
|
|
|
|
async function resetDataToolIdentities(client: DbClient): Promise<void> {
|
|
for (const tableName of [
|
|
'daily_checklist_items',
|
|
'items',
|
|
'recipes',
|
|
'habitats',
|
|
'wiki_edit_logs',
|
|
'entity_image_uploads',
|
|
'entity_discussion_comments'
|
|
]) {
|
|
await resetIdentity(client, tableName);
|
|
}
|
|
}
|
|
|
|
async function deleteGenericEntityRows(client: DbClient, entityTypes: string[]): Promise<void> {
|
|
await client.query('DELETE FROM entity_discussion_comments WHERE entity_type = ANY($1::text[])', [entityTypes]);
|
|
await client.query('DELETE FROM entity_image_uploads WHERE entity_type = ANY($1::text[])', [entityTypes]);
|
|
await client.query('DELETE FROM wiki_edit_logs WHERE entity_type = ANY($1::text[])', [entityTypes]);
|
|
await client.query('DELETE FROM entity_translations WHERE entity_type = ANY($1::text[])', [entityTypes]);
|
|
}
|
|
|
|
async function wipeRecipesData(client: DbClient): Promise<void> {
|
|
await deleteGenericEntityRows(client, ['recipes']);
|
|
await client.query('DELETE FROM recipe_acquisition_methods');
|
|
await client.query('DELETE FROM recipe_materials');
|
|
await client.query('DELETE FROM recipes');
|
|
}
|
|
|
|
async function wipeItemsData(client: DbClient): Promise<void> {
|
|
await wipeRecipesData(client);
|
|
await deleteGenericEntityRows(client, ['items']);
|
|
await client.query('DELETE FROM item_acquisition_methods');
|
|
await client.query('DELETE FROM item_favorite_things');
|
|
await client.query('DELETE FROM habitat_recipe_items');
|
|
await client.query('DELETE FROM pokemon_skill_item_drops');
|
|
await client.query('DELETE FROM pokemon_trading_items');
|
|
await client.query('DELETE FROM items');
|
|
}
|
|
|
|
async function wipeAncientArtifactsData(client: DbClient): Promise<void> {
|
|
await client.query(`
|
|
DELETE FROM entity_discussion_comments
|
|
WHERE entity_type = 'ancient-artifacts'
|
|
AND entity_id IN (SELECT id FROM items WHERE ancient_artifact_category_key IS NOT NULL)
|
|
`);
|
|
await client.query(`
|
|
UPDATE items
|
|
SET ancient_artifact_category_key = NULL,
|
|
updated_at = now()
|
|
WHERE ancient_artifact_category_key IS NOT NULL
|
|
`);
|
|
}
|
|
|
|
async function wipePokemonData(client: DbClient): Promise<void> {
|
|
await deleteGenericEntityRows(client, ['pokemon']);
|
|
await client.query('DELETE FROM habitat_pokemon');
|
|
await client.query('DELETE FROM pokemon_skill_item_drops');
|
|
await client.query('DELETE FROM pokemon_pokemon_types');
|
|
await client.query('DELETE FROM pokemon_skills');
|
|
await client.query('DELETE FROM pokemon_favorite_things');
|
|
await client.query('DELETE FROM pokemon_trading_items');
|
|
await client.query('DELETE FROM pokemon');
|
|
}
|
|
|
|
async function wipeHabitatsData(client: DbClient): Promise<void> {
|
|
await deleteGenericEntityRows(client, ['habitats']);
|
|
await client.query('DELETE FROM habitat_recipe_items');
|
|
await client.query('DELETE FROM habitat_pokemon');
|
|
await client.query('DELETE FROM habitats');
|
|
}
|
|
|
|
async function wipeChecklistData(client: DbClient): Promise<void> {
|
|
await deleteGenericEntityRows(client, ['daily-checklist-items']);
|
|
await client.query('DELETE FROM daily_checklist_items');
|
|
}
|
|
|
|
async function wipeDataToolScopes(client: DbClient, scopes: DataToolScope[], resetIdentities = true): Promise<void> {
|
|
const scopeSet = new Set(scopes);
|
|
if (scopeSet.has('items')) {
|
|
await wipeItemsData(client);
|
|
} else if (scopeSet.has('recipes')) {
|
|
await wipeRecipesData(client);
|
|
}
|
|
if (scopeSet.has('artifacts')) {
|
|
await wipeAncientArtifactsData(client);
|
|
}
|
|
if (scopeSet.has('pokemon')) {
|
|
await wipePokemonData(client);
|
|
}
|
|
if (scopeSet.has('habitats')) {
|
|
await wipeHabitatsData(client);
|
|
}
|
|
if (scopeSet.has('checklist')) {
|
|
await wipeChecklistData(client);
|
|
}
|
|
if (resetIdentities) {
|
|
await resetDataToolIdentities(client);
|
|
}
|
|
}
|
|
|
|
async function exportGenericScopeData(client: DbClient, entityType: string, includeImages: boolean): Promise<DataToolScopeData> {
|
|
const data: DataToolScopeData = {
|
|
translations: await tableRows(client, 'SELECT * FROM entity_translations WHERE entity_type = $1 ORDER BY entity_id, locale, field_name', [entityType]),
|
|
editLogs: await tableRows(client, 'SELECT * FROM wiki_edit_logs WHERE entity_type = $1 ORDER BY id', [entityType]),
|
|
discussionComments: await tableRows(
|
|
client,
|
|
`
|
|
SELECT *
|
|
FROM entity_discussion_comments
|
|
WHERE entity_type = $1
|
|
ORDER BY parent_comment_id NULLS FIRST, id
|
|
`,
|
|
[entityType]
|
|
),
|
|
discussionCommentLikes: await tableRows(
|
|
client,
|
|
`
|
|
SELECT edcl.*
|
|
FROM entity_discussion_comment_likes edcl
|
|
JOIN entity_discussion_comments edc ON edc.id = edcl.comment_id
|
|
WHERE edc.entity_type = $1
|
|
ORDER BY edcl.comment_id, edcl.user_id
|
|
`,
|
|
[entityType]
|
|
)
|
|
};
|
|
|
|
if (includeImages) {
|
|
data.imageUploads = await tableRows(client, 'SELECT * FROM entity_image_uploads WHERE entity_type = $1 ORDER BY id', [entityType]);
|
|
}
|
|
|
|
return data;
|
|
}
|
|
|
|
async function exportScopeData(client: DbClient, scope: DataToolScope): Promise<DataToolScopeData> {
|
|
if (scope === 'pokemon') {
|
|
return {
|
|
pokemon: await tableRows(client, 'SELECT * FROM pokemon ORDER BY id'),
|
|
pokemonTypeLinks: await tableRows(client, 'SELECT * FROM pokemon_pokemon_types ORDER BY pokemon_id, slot_order'),
|
|
pokemonSkills: await tableRows(client, 'SELECT * FROM pokemon_skills ORDER BY pokemon_id, skill_id'),
|
|
pokemonFavoriteThings: await tableRows(client, 'SELECT * FROM pokemon_favorite_things ORDER BY pokemon_id, favorite_thing_id'),
|
|
pokemonSkillItemDrops: await tableRows(client, 'SELECT * FROM pokemon_skill_item_drops ORDER BY pokemon_id, skill_id'),
|
|
pokemonTradingItems: await tableRows(client, 'SELECT * FROM pokemon_trading_items ORDER BY pokemon_id, preference, item_id'),
|
|
habitatPokemon: await tableRows(client, 'SELECT * FROM habitat_pokemon ORDER BY habitat_id, pokemon_id, map_id, time_of_day, weather'),
|
|
...(await exportGenericScopeData(client, 'pokemon', true))
|
|
};
|
|
}
|
|
|
|
if (scope === 'habitats') {
|
|
return {
|
|
habitats: await tableRows(client, 'SELECT * FROM habitats ORDER BY sort_order, id'),
|
|
habitatRecipeItems: await tableRows(client, 'SELECT * FROM habitat_recipe_items ORDER BY habitat_id, item_id'),
|
|
habitatPokemon: await tableRows(client, 'SELECT * FROM habitat_pokemon ORDER BY habitat_id, pokemon_id, map_id, time_of_day, weather'),
|
|
...(await exportGenericScopeData(client, 'habitats', true))
|
|
};
|
|
}
|
|
|
|
if (scope === 'items') {
|
|
return {
|
|
items: await tableRows(client, 'SELECT * FROM items ORDER BY sort_order, id'),
|
|
itemAcquisitionMethods: await tableRows(client, 'SELECT * FROM item_acquisition_methods ORDER BY item_id, acquisition_method_id'),
|
|
itemFavoriteThings: await tableRows(client, 'SELECT * FROM item_favorite_things ORDER BY item_id, favorite_thing_id'),
|
|
pokemonSkillItemDrops: await tableRows(client, 'SELECT * FROM pokemon_skill_item_drops ORDER BY pokemon_id, skill_id'),
|
|
pokemonTradingItems: await tableRows(client, 'SELECT * FROM pokemon_trading_items ORDER BY pokemon_id, preference, item_id'),
|
|
habitatRecipeItems: await tableRows(client, 'SELECT * FROM habitat_recipe_items ORDER BY habitat_id, item_id'),
|
|
...(await exportGenericScopeData(client, 'items', true))
|
|
};
|
|
}
|
|
|
|
if (scope === 'artifacts') {
|
|
return {
|
|
artifacts: await tableRows(client, 'SELECT * FROM items WHERE ancient_artifact_category_key IS NOT NULL ORDER BY sort_order, id'),
|
|
itemFavoriteThings: await tableRows(
|
|
client,
|
|
`
|
|
SELECT ift.*
|
|
FROM item_favorite_things ift
|
|
JOIN items i ON i.id = ift.item_id
|
|
WHERE i.ancient_artifact_category_key IS NOT NULL
|
|
ORDER BY ift.item_id, ift.favorite_thing_id
|
|
`
|
|
),
|
|
translations: await tableRows(
|
|
client,
|
|
`
|
|
SELECT et.*
|
|
FROM entity_translations et
|
|
JOIN items i ON i.id = et.entity_id
|
|
WHERE et.entity_type = 'items'
|
|
AND i.ancient_artifact_category_key IS NOT NULL
|
|
ORDER BY et.entity_id, et.locale, et.field_name
|
|
`
|
|
),
|
|
editLogs: await tableRows(
|
|
client,
|
|
`
|
|
SELECT wel.*
|
|
FROM wiki_edit_logs wel
|
|
JOIN items i ON i.id = wel.entity_id
|
|
WHERE wel.entity_type = 'items'
|
|
AND i.ancient_artifact_category_key IS NOT NULL
|
|
ORDER BY wel.id
|
|
`
|
|
),
|
|
imageUploads: await tableRows(
|
|
client,
|
|
`
|
|
SELECT eiu.*
|
|
FROM entity_image_uploads eiu
|
|
JOIN items i ON i.id = eiu.entity_id
|
|
WHERE eiu.entity_type = 'items'
|
|
AND i.ancient_artifact_category_key IS NOT NULL
|
|
ORDER BY eiu.id
|
|
`
|
|
),
|
|
discussionComments: await tableRows(
|
|
client,
|
|
`
|
|
SELECT edc.*
|
|
FROM entity_discussion_comments edc
|
|
JOIN items i ON i.id = edc.entity_id
|
|
WHERE edc.entity_type = 'ancient-artifacts'
|
|
AND i.ancient_artifact_category_key IS NOT NULL
|
|
ORDER BY edc.parent_comment_id NULLS FIRST, edc.id
|
|
`
|
|
),
|
|
discussionCommentLikes: await tableRows(
|
|
client,
|
|
`
|
|
SELECT edcl.*
|
|
FROM entity_discussion_comment_likes edcl
|
|
JOIN entity_discussion_comments edc ON edc.id = edcl.comment_id
|
|
JOIN items i ON i.id = edc.entity_id
|
|
WHERE edc.entity_type = 'ancient-artifacts'
|
|
AND i.ancient_artifact_category_key IS NOT NULL
|
|
ORDER BY edcl.comment_id, edcl.user_id
|
|
`
|
|
)
|
|
};
|
|
}
|
|
|
|
if (scope === 'recipes') {
|
|
return {
|
|
recipes: await tableRows(client, 'SELECT * FROM recipes ORDER BY sort_order, id'),
|
|
recipeAcquisitionMethods: await tableRows(client, 'SELECT * FROM recipe_acquisition_methods ORDER BY recipe_id, acquisition_method_id'),
|
|
recipeMaterials: await tableRows(client, 'SELECT * FROM recipe_materials ORDER BY recipe_id, item_id'),
|
|
...(await exportGenericScopeData(client, 'recipes', false))
|
|
};
|
|
}
|
|
|
|
return {
|
|
checklist: await tableRows(client, 'SELECT * FROM daily_checklist_items ORDER BY sort_order, id'),
|
|
translations: await tableRows(
|
|
client,
|
|
'SELECT * FROM entity_translations WHERE entity_type = $1 ORDER BY entity_id, locale, field_name',
|
|
['daily-checklist-items']
|
|
),
|
|
editLogs: await tableRows(client, 'SELECT * FROM wiki_edit_logs WHERE entity_type = $1 ORDER BY id', ['daily-checklist-items'])
|
|
};
|
|
}
|
|
|
|
async function importScopeMainRows(client: DbClient, bundle: DataToolsBundle): Promise<void> {
|
|
const itemData = bundle.data.items;
|
|
const artifactData = bundle.data.artifacts;
|
|
const pokemonData = bundle.data.pokemon;
|
|
const habitatData = bundle.data.habitats;
|
|
const checklistData = bundle.data.checklist;
|
|
const recipeData = bundle.data.recipes;
|
|
|
|
await insertRows(client, 'items', dataToolColumns.items, dataToolTableRows(itemData, 'items'));
|
|
await upsertRowsById(client, 'items', dataToolColumns.items, dataToolTableRows(artifactData, 'artifacts'));
|
|
await insertRows(client, 'pokemon', dataToolColumns.pokemon, dataToolTableRows(pokemonData, 'pokemon'));
|
|
await insertRows(client, 'habitats', dataToolColumns.habitats, dataToolTableRows(habitatData, 'habitats'));
|
|
await insertRows(client, 'daily_checklist_items', dataToolColumns.checklist, dataToolTableRows(checklistData, 'checklist'));
|
|
await insertRows(client, 'recipes', dataToolColumns.recipes, dataToolTableRows(recipeData, 'recipes'));
|
|
}
|
|
|
|
async function importScopeRelationRows(client: DbClient, bundle: DataToolsBundle): Promise<void> {
|
|
const itemData = bundle.data.items;
|
|
const artifactData = bundle.data.artifacts;
|
|
const pokemonData = bundle.data.pokemon;
|
|
const habitatData = bundle.data.habitats;
|
|
const recipeData = bundle.data.recipes;
|
|
const pokemonDropData = dataToolDataWithRows('pokemonSkillItemDrops', pokemonData, itemData);
|
|
const pokemonTradingData = dataToolDataWithRows('pokemonTradingItems', pokemonData, itemData);
|
|
const habitatRecipeData = dataToolDataWithRows('habitatRecipeItems', habitatData, itemData);
|
|
const habitatPokemonData = dataToolDataWithRows('habitatPokemon', habitatData, pokemonData);
|
|
|
|
await insertRows(client, 'item_acquisition_methods', dataToolColumns.itemAcquisitionMethods, dataToolTableRows(itemData, 'itemAcquisitionMethods'));
|
|
await insertRows(client, 'item_favorite_things', dataToolColumns.itemFavoriteThings, dataToolTableRows(itemData, 'itemFavoriteThings'));
|
|
await insertRowsIgnoreConflicts(client, 'item_favorite_things', dataToolColumns.itemFavoriteThings, dataToolTableRows(artifactData, 'itemFavoriteThings'));
|
|
await insertRows(client, 'pokemon_pokemon_types', dataToolColumns.pokemonTypeLinks, dataToolTableRows(pokemonData, 'pokemonTypeLinks'));
|
|
await insertRows(client, 'pokemon_skills', dataToolColumns.pokemonSkills, dataToolTableRows(pokemonData, 'pokemonSkills'));
|
|
await insertRows(client, 'pokemon_favorite_things', dataToolColumns.pokemonFavoriteThings, dataToolTableRows(pokemonData, 'pokemonFavoriteThings'));
|
|
await insertRows(client, 'pokemon_skill_item_drops', dataToolColumns.pokemonSkillItemDrops, dataToolTableRows(pokemonDropData, 'pokemonSkillItemDrops'));
|
|
await insertRows(client, 'pokemon_trading_items', dataToolColumns.pokemonTradingItems, dataToolTableRows(pokemonTradingData, 'pokemonTradingItems'));
|
|
await insertRows(client, 'recipe_acquisition_methods', dataToolColumns.recipeAcquisitionMethods, dataToolTableRows(recipeData, 'recipeAcquisitionMethods'));
|
|
await insertRows(client, 'recipe_materials', dataToolColumns.recipeMaterials, dataToolTableRows(recipeData, 'recipeMaterials'));
|
|
await insertRows(client, 'habitat_recipe_items', dataToolColumns.habitatRecipeItems, dataToolTableRows(habitatRecipeData, 'habitatRecipeItems'));
|
|
await insertRows(client, 'habitat_pokemon', dataToolColumns.habitatPokemon, dataToolTableRows(habitatPokemonData, 'habitatPokemon'));
|
|
}
|
|
|
|
async function importGenericScopeRows(client: DbClient, bundle: DataToolsBundle): Promise<void> {
|
|
for (const scope of bundle.scopes) {
|
|
const data = bundle.data[scope];
|
|
await insertRows(client, 'entity_translations', dataToolColumns.translations, dataToolTableRows(data, 'translations'));
|
|
await insertRows(client, 'wiki_edit_logs', dataToolColumns.editLogs, dataToolTableRows(data, 'editLogs'));
|
|
await insertRows(client, 'entity_image_uploads', dataToolColumns.imageUploads, dataToolTableRows(data, 'imageUploads'));
|
|
await insertRows(client, 'entity_discussion_comments', dataToolColumns.discussionComments, dataToolTableRows(data, 'discussionComments'));
|
|
await insertRows(
|
|
client,
|
|
'entity_discussion_comment_likes',
|
|
dataToolColumns.discussionCommentLikes,
|
|
dataToolTableRows(data, 'discussionCommentLikes')
|
|
);
|
|
}
|
|
}
|
|
|
|
async function importDataToolsBundle(client: DbClient, bundle: DataToolsBundle): Promise<void> {
|
|
await importScopeMainRows(client, bundle);
|
|
await importScopeRelationRows(client, bundle);
|
|
await importGenericScopeRows(client, bundle);
|
|
await resetDataToolIdentities(client);
|
|
}
|
|
|
|
export async function getAdminDataToolsSummary(): Promise<{ scopes: DataToolScopeSummary[] }> {
|
|
const scopes: DataToolScopeSummary[] = [];
|
|
for (const scope of dataToolScopes) {
|
|
const result = await queryOne<{ count: number }>(
|
|
scope === 'artifacts'
|
|
? 'SELECT COUNT(*)::integer AS count FROM items WHERE ancient_artifact_category_key IS NOT NULL'
|
|
: `SELECT COUNT(*)::integer AS count FROM ${dataToolMainTables[scope]}`
|
|
);
|
|
scopes.push({ scope, count: result?.count ?? 0 });
|
|
}
|
|
return { scopes };
|
|
}
|
|
|
|
export async function exportAdminData(payload: Record<string, unknown>): Promise<DataToolsBundle> {
|
|
const scopes = cleanDataToolScopes(payload.scopes);
|
|
return withTransaction(async (client) => {
|
|
const data: DataToolsBundle['data'] = {};
|
|
for (const scope of scopes) {
|
|
data[scope] = await exportScopeData(client, scope);
|
|
}
|
|
return { version: 1, exportedAt: new Date().toISOString(), scopes, data };
|
|
});
|
|
}
|
|
|
|
export async function importAdminData(payload: Record<string, unknown>): Promise<{ scopes: DataToolScopeSummary[] }> {
|
|
const bundle = cleanDataToolsBundle(payload.bundle);
|
|
await withTransaction(async (client) => {
|
|
await wipeDataToolScopes(client, bundle.scopes, false);
|
|
await importDataToolsBundle(client, bundle);
|
|
});
|
|
return getAdminDataToolsSummary();
|
|
}
|
|
|
|
export async function importAdminItemsCsv(payload: Record<string, unknown>, userId: number): Promise<{ scopes: DataToolScopeSummary[] }> {
|
|
const rows = cleanItemsCsvRows(payload.csv);
|
|
const names = rows.map((row) => csvText(row, 'name'));
|
|
|
|
await withTransaction(async (client) => {
|
|
const existing = await client.query<{ name: string }>('SELECT name FROM items WHERE name = ANY($1::text[])', [names]);
|
|
if (existing.rowCount && existing.rowCount > 0) {
|
|
throw validationError('server.validation.dataToolItemsCsvInvalid');
|
|
}
|
|
|
|
const firstSortOrder = await nextSortOrder(client, 'items');
|
|
for (const [index, row] of rows.entries()) {
|
|
const result = await client.query<{ id: number }>(
|
|
`
|
|
INSERT INTO items (
|
|
name,
|
|
details,
|
|
category_key,
|
|
image_path,
|
|
sort_order,
|
|
created_by_user_id,
|
|
updated_by_user_id
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $6)
|
|
RETURNING id
|
|
`,
|
|
[
|
|
csvText(row, 'name'),
|
|
itemsCsvDetails(row),
|
|
itemsCsvCategoryKey(csvText(row, 'category')),
|
|
itemsCsvImagePath(csvText(row, 'image_file_name')),
|
|
firstSortOrder + index * 10,
|
|
userId
|
|
]
|
|
);
|
|
await recordEditLog(client, 'items', result.rows[0].id, 'create', userId);
|
|
}
|
|
|
|
await resetIdentity(client, 'items');
|
|
});
|
|
|
|
return getAdminDataToolsSummary();
|
|
}
|
|
|
|
export async function importAdminHabitatsCsv(payload: Record<string, unknown>, userId: number): Promise<{ scopes: DataToolScopeSummary[] }> {
|
|
const rows = cleanHabitatsCsvRows(payload.csv);
|
|
const names = rows.map((row) => csvText(row, 'name'));
|
|
|
|
await withTransaction(async (client) => {
|
|
const existing = await client.query<{ name: string }>('SELECT name FROM habitats WHERE name = ANY($1::text[])', [names]);
|
|
if (existing.rowCount && existing.rowCount > 0) {
|
|
throw validationError('server.validation.dataToolHabitatsCsvInvalid');
|
|
}
|
|
|
|
const firstSortOrder = await nextSortOrder(client, 'habitats');
|
|
for (const [index, row] of rows.entries()) {
|
|
const { isEventItem } = habitatsCsvId(csvText(row, 'id'));
|
|
const result = await client.query<{ id: number }>(
|
|
`
|
|
INSERT INTO habitats (
|
|
name,
|
|
is_event_item,
|
|
image_path,
|
|
sort_order,
|
|
created_by_user_id,
|
|
updated_by_user_id
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5, $5)
|
|
RETURNING id
|
|
`,
|
|
[
|
|
csvText(row, 'name'),
|
|
isEventItem,
|
|
habitatsCsvImagePath(csvText(row, 'image_file_name')),
|
|
firstSortOrder + index * 10,
|
|
userId
|
|
]
|
|
);
|
|
await recordEditLog(client, 'habitats', result.rows[0].id, 'create', userId);
|
|
}
|
|
|
|
await resetIdentity(client, 'habitats');
|
|
});
|
|
|
|
return getAdminDataToolsSummary();
|
|
}
|
|
|
|
const defaultThreadLimit = 20;
|
|
const maxThreadLimit = 50;
|
|
const defaultThreadMessageLimit = 30;
|
|
const maxThreadMessageLimit = 80;
|
|
const threadEmojiReactionPattern = /(?:\p{Extended_Pictographic}|\p{Regional_Indicator})/u;
|
|
|
|
type ThreadCursor = { value: string; id: number };
|
|
type ThreadMessageCursor = { createdAt: string; id: number };
|
|
|
|
function emptyThreadReactionCounts(): ThreadReactionCounts {
|
|
return {};
|
|
}
|
|
|
|
function cleanThreadReactionType(value: unknown): ThreadReactionType {
|
|
const reactionType = typeof value === 'string' ? value.trim() : '';
|
|
if (
|
|
!reactionType ||
|
|
reactionType.length > 24 ||
|
|
/\s/.test(reactionType) ||
|
|
/[\p{Letter}\p{Number}]/u.test(reactionType) ||
|
|
!threadEmojiReactionPattern.test(reactionType)
|
|
) {
|
|
throw validationError('server.validation.reactionInvalid');
|
|
}
|
|
return reactionType;
|
|
}
|
|
|
|
function cleanThreadLimit(value: QueryValue, fallback = defaultThreadLimit, max = maxThreadLimit): number {
|
|
const raw = Number(asString(value));
|
|
return Number.isInteger(raw) && raw > 0 ? Math.min(raw, max) : fallback;
|
|
}
|
|
|
|
function encodeCursor(value: unknown): string {
|
|
return Buffer.from(JSON.stringify(value), 'utf8').toString('base64url');
|
|
}
|
|
|
|
function decodeThreadCursor(value: QueryValue): ThreadCursor | null {
|
|
const cursor = asString(value);
|
|
if (!cursor) return null;
|
|
try {
|
|
const payload = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as unknown;
|
|
if (payload && typeof payload === 'object') {
|
|
const record = payload as Record<string, unknown>;
|
|
if (typeof record.value === 'string' && Number.isInteger(Number(record.id))) {
|
|
return { value: record.value, id: Number(record.id) };
|
|
}
|
|
}
|
|
} catch {
|
|
return null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function decodeThreadMessageCursor(value: QueryValue): ThreadMessageCursor | null {
|
|
const cursor = asString(value);
|
|
if (!cursor) return null;
|
|
try {
|
|
const payload = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as unknown;
|
|
if (payload && typeof payload === 'object') {
|
|
const record = payload as Record<string, unknown>;
|
|
if (typeof record.createdAt === 'string' && Number.isInteger(Number(record.id))) {
|
|
return { createdAt: record.createdAt, id: Number(record.id) };
|
|
}
|
|
}
|
|
} catch {
|
|
return null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function cleanThreadTitle(value: unknown): string {
|
|
const title = cleanName(value, 'server.validation.titleRequired');
|
|
if (title.length > 140) {
|
|
throw validationError('server.validation.valueTooLong');
|
|
}
|
|
return title;
|
|
}
|
|
|
|
function cleanThreadMessageBody(value: unknown): string {
|
|
const body = cleanName(value, 'server.validation.commentRequired');
|
|
if (body.length > 2000) {
|
|
throw validationError('server.validation.commentTooLong');
|
|
}
|
|
return body;
|
|
}
|
|
|
|
function cleanThreadLanguageCode(value: unknown): string {
|
|
const languageCode = cleanModerationLanguageCode(value);
|
|
if (!languageCode) {
|
|
throw validationError('server.validation.languageInvalid');
|
|
}
|
|
return languageCode;
|
|
}
|
|
|
|
function cleanThreadTagIds(value: unknown): number[] {
|
|
if (!Array.isArray(value)) {
|
|
return [];
|
|
}
|
|
return cleanIds(value).slice(0, 8);
|
|
}
|
|
|
|
async function publicThreadChannels(userId: number | null): Promise<ThreadChannel[]> {
|
|
const rows = await query<{
|
|
id: number;
|
|
name: string;
|
|
allowUserThreads: boolean;
|
|
sortOrder: number;
|
|
tags: ThreadChannelTag[] | null;
|
|
languages: Array<{ code: string; name: string }> | null;
|
|
unreadCount: number;
|
|
}>(
|
|
`
|
|
SELECT
|
|
tc.id,
|
|
tc.name,
|
|
tc.allow_user_threads AS "allowUserThreads",
|
|
tc.sort_order AS "sortOrder",
|
|
COALESCE(tags.items, '[]'::json) AS tags,
|
|
COALESCE(channel_languages.items, fallback_languages.items, '[]'::json) AS languages,
|
|
CASE
|
|
WHEN $1::integer IS NULL THEN 0
|
|
ELSE COALESCE(unread.count, 0)
|
|
END AS "unreadCount"
|
|
FROM thread_channels tc
|
|
LEFT JOIN LATERAL (
|
|
SELECT json_agg(json_build_object('id', tct.id, 'name', tct.name, 'sortOrder', tct.sort_order) ORDER BY tct.sort_order, tct.id) AS items
|
|
FROM thread_channel_tags tct
|
|
WHERE tct.channel_id = tc.id
|
|
) tags ON true
|
|
LEFT JOIN LATERAL (
|
|
SELECT json_agg(json_build_object('code', l.code, 'name', l.name) ORDER BY tcl.sort_order, l.sort_order, l.code) AS items
|
|
FROM thread_channel_languages tcl
|
|
JOIN languages l ON l.code = tcl.language_code
|
|
WHERE tcl.channel_id = tc.id
|
|
AND l.enabled = true
|
|
) channel_languages ON true
|
|
LEFT JOIN LATERAL (
|
|
SELECT json_agg(json_build_object('code', l.code, 'name', l.name) ORDER BY l.sort_order, l.code) AS items
|
|
FROM languages l
|
|
WHERE l.enabled = true
|
|
) fallback_languages ON true
|
|
LEFT JOIN LATERAL (
|
|
SELECT COUNT(*)::integer AS count
|
|
FROM thread_follows tf
|
|
JOIN threads t ON t.id = tf.thread_id
|
|
LEFT JOIN thread_reads tr ON tr.thread_id = t.id AND tr.user_id = tf.user_id
|
|
WHERE tf.user_id = $1::integer
|
|
AND t.channel_id = tc.id
|
|
AND t.deleted_at IS NULL
|
|
AND t.last_message_id IS NOT NULL
|
|
AND (tr.last_read_message_id IS NULL OR t.last_message_id > tr.last_read_message_id)
|
|
) unread ON true
|
|
ORDER BY tc.sort_order, tc.id
|
|
`,
|
|
[userId]
|
|
);
|
|
|
|
return rows.map((row) => ({
|
|
id: row.id,
|
|
name: row.name,
|
|
allowUserThreads: row.allowUserThreads,
|
|
sortOrder: row.sortOrder,
|
|
tags: row.tags ?? [],
|
|
languages: row.languages ?? [],
|
|
unreadCount: row.unreadCount
|
|
}));
|
|
}
|
|
|
|
export async function listThreadChannels(userId: number | null): Promise<ThreadChannel[]> {
|
|
return publicThreadChannels(userId);
|
|
}
|
|
|
|
export async function listAdminThreadChannels(): Promise<ThreadChannel[]> {
|
|
return publicThreadChannels(null);
|
|
}
|
|
|
|
async function channelAllowsLanguage(channelId: number, languageCode: string): Promise<boolean> {
|
|
const row = await queryOne<{ allowed: boolean }>(
|
|
`
|
|
SELECT CASE
|
|
WHEN EXISTS (SELECT 1 FROM thread_channel_languages WHERE channel_id = $1) THEN EXISTS (
|
|
SELECT 1
|
|
FROM thread_channel_languages tcl
|
|
JOIN languages l ON l.code = tcl.language_code
|
|
WHERE tcl.channel_id = $1 AND tcl.language_code = $2 AND l.enabled = true
|
|
)
|
|
ELSE EXISTS (SELECT 1 FROM languages WHERE code = $2 AND enabled = true)
|
|
END AS allowed
|
|
`,
|
|
[channelId, languageCode]
|
|
);
|
|
return row?.allowed === true;
|
|
}
|
|
|
|
async function validateThreadTags(channelId: number, tagIds: number[]): Promise<void> {
|
|
if (!tagIds.length) return;
|
|
const rows = await query<{ id: number }>(
|
|
'SELECT id FROM thread_channel_tags WHERE channel_id = $1 AND id = ANY($2::integer[])',
|
|
[channelId, tagIds]
|
|
);
|
|
if (rows.length !== tagIds.length) {
|
|
throw validationError('server.validation.invalidField');
|
|
}
|
|
}
|
|
|
|
async function threadReactionCounts(threadIds: number[], userId: number | null): Promise<{
|
|
counts: Map<number, ThreadReactionCounts>;
|
|
mine: Map<number, ThreadReactionType[]>;
|
|
}> {
|
|
const counts = new Map<number, ThreadReactionCounts>();
|
|
const mine = new Map<number, ThreadReactionType[]>();
|
|
for (const id of threadIds) counts.set(id, emptyThreadReactionCounts());
|
|
if (!threadIds.length) return { counts, mine };
|
|
|
|
const countRows = await query<{ threadId: number; reactionType: ThreadReactionType; count: number }>(
|
|
`
|
|
SELECT thread_id AS "threadId", reaction_type AS "reactionType", COUNT(*)::integer AS count
|
|
FROM thread_reactions
|
|
WHERE thread_id = ANY($1::integer[])
|
|
GROUP BY thread_id, reaction_type
|
|
`,
|
|
[threadIds]
|
|
);
|
|
for (const row of countRows) {
|
|
const item = counts.get(row.threadId);
|
|
if (item) {
|
|
item[row.reactionType] = row.count;
|
|
}
|
|
}
|
|
|
|
if (userId !== null) {
|
|
const myRows = await query<{ threadId: number; reactionType: ThreadReactionType }>(
|
|
`
|
|
SELECT thread_id AS "threadId", reaction_type AS "reactionType"
|
|
FROM thread_reactions
|
|
WHERE user_id = $1
|
|
AND thread_id = ANY($2::integer[])
|
|
`,
|
|
[userId, threadIds]
|
|
);
|
|
for (const row of myRows) {
|
|
mine.set(row.threadId, [...(mine.get(row.threadId) ?? []), row.reactionType]);
|
|
}
|
|
}
|
|
|
|
return { counts, mine };
|
|
}
|
|
|
|
async function threadMessageReactionCounts(messageIds: number[], userId: number | null): Promise<{
|
|
counts: Map<number, ThreadReactionCounts>;
|
|
mine: Map<number, ThreadReactionType[]>;
|
|
}> {
|
|
const counts = new Map<number, ThreadReactionCounts>();
|
|
const mine = new Map<number, ThreadReactionType[]>();
|
|
for (const id of messageIds) counts.set(id, emptyThreadReactionCounts());
|
|
if (!messageIds.length) return { counts, mine };
|
|
|
|
const countRows = await query<{ messageId: number; reactionType: ThreadReactionType; count: number }>(
|
|
`
|
|
SELECT message_id AS "messageId", reaction_type AS "reactionType", COUNT(*)::integer AS count
|
|
FROM thread_message_reactions
|
|
WHERE message_id = ANY($1::integer[])
|
|
GROUP BY message_id, reaction_type
|
|
`,
|
|
[messageIds]
|
|
);
|
|
for (const row of countRows) {
|
|
const item = counts.get(row.messageId);
|
|
if (item) {
|
|
item[row.reactionType] = row.count;
|
|
}
|
|
}
|
|
|
|
if (userId !== null) {
|
|
const myRows = await query<{ messageId: number; reactionType: ThreadReactionType }>(
|
|
`
|
|
SELECT message_id AS "messageId", reaction_type AS "reactionType"
|
|
FROM thread_message_reactions
|
|
WHERE user_id = $1
|
|
AND message_id = ANY($2::integer[])
|
|
`,
|
|
[userId, messageIds]
|
|
);
|
|
for (const row of myRows) {
|
|
mine.set(row.messageId, [...(mine.get(row.messageId) ?? []), row.reactionType]);
|
|
}
|
|
}
|
|
|
|
return { counts, mine };
|
|
}
|
|
|
|
async function hydrateThreads(rows: Array<ThreadSummary & { lastActiveCursor: string }>, userId: number | null): Promise<ThreadSummary[]> {
|
|
const ids = rows.map((row) => row.id);
|
|
const tags = await query<{ threadId: number; id: number; name: string; sortOrder: number }>(
|
|
`
|
|
SELECT ttl.thread_id AS "threadId", tct.id, tct.name, tct.sort_order AS "sortOrder"
|
|
FROM thread_tag_links ttl
|
|
JOIN thread_channel_tags tct ON tct.id = ttl.tag_id
|
|
WHERE ttl.thread_id = ANY($1::integer[])
|
|
ORDER BY tct.sort_order, tct.id
|
|
`,
|
|
[ids]
|
|
);
|
|
const tagsByThread = new Map<number, ThreadChannelTag[]>();
|
|
for (const tag of tags) {
|
|
tagsByThread.set(tag.threadId, [...(tagsByThread.get(tag.threadId) ?? []), { id: tag.id, name: tag.name, sortOrder: tag.sortOrder }]);
|
|
}
|
|
const reactions = await threadReactionCounts(ids, userId);
|
|
return rows.map((row) => ({
|
|
id: row.id,
|
|
channelId: row.channelId,
|
|
title: row.title,
|
|
languageCode: row.languageCode,
|
|
tags: tagsByThread.get(row.id) ?? [],
|
|
locked: row.locked,
|
|
messageCount: row.messageCount,
|
|
lastActiveAt: row.lastActiveAt,
|
|
createdAt: row.createdAt,
|
|
author: row.author,
|
|
reactionCounts: reactions.counts.get(row.id) ?? emptyThreadReactionCounts(),
|
|
myReactions: reactions.mine.get(row.id) ?? [],
|
|
followed: row.followed,
|
|
unread: row.unread
|
|
}));
|
|
}
|
|
|
|
export async function listThreads(paramsQuery: QueryParams, userId: number | null): Promise<ThreadsPage> {
|
|
const limit = cleanThreadLimit(paramsQuery.limit);
|
|
const channelId = optionalPositiveInteger(asString(paramsQuery.channelId), 'server.validation.invalidField');
|
|
const tagId = optionalPositiveInteger(asString(paramsQuery.tagId), 'server.validation.invalidField');
|
|
const language = asString(paramsQuery.language);
|
|
const sort = asString(paramsQuery.sort) ?? 'last-active';
|
|
const cursor = decodeThreadCursor(paramsQuery.cursor);
|
|
const conditions = ['t.deleted_at IS NULL'];
|
|
const params: unknown[] = [];
|
|
|
|
if (channelId !== null) {
|
|
params.push(channelId);
|
|
conditions.push(`t.channel_id = $${params.length}`);
|
|
}
|
|
if (tagId !== null) {
|
|
params.push(tagId);
|
|
conditions.push(`EXISTS (SELECT 1 FROM thread_tag_links ttl WHERE ttl.thread_id = t.id AND ttl.tag_id = $${params.length})`);
|
|
}
|
|
if (language && language !== 'all') {
|
|
params.push(cleanThreadLanguageCode(language));
|
|
conditions.push(`t.language_code = $${params.length}`);
|
|
}
|
|
|
|
const orderField = sort === 'latest' ? 't.created_at' : sort === 'most-discussed' ? 't.message_count' : 't.last_active_at';
|
|
const orderCursorField = sort === 'latest' ? 'created_at' : sort === 'most-discussed' ? 'message_count' : 'last_active_at';
|
|
if (cursor) {
|
|
params.push(cursor.value, cursor.id);
|
|
conditions.push(`(${orderField}, t.id) < ($${params.length - 1}::${sort === 'most-discussed' ? 'integer' : 'timestamptz'}, $${params.length}::integer)`);
|
|
}
|
|
params.push(limit + 1, userId);
|
|
const limitParam = params.length - 1;
|
|
const userParam = params.length;
|
|
|
|
const rows = await query<ThreadSummary & { lastActiveCursor: string; createdAtCursor: string; messageCountCursor: number }>(
|
|
`
|
|
SELECT
|
|
t.id,
|
|
t.channel_id AS "channelId",
|
|
t.title,
|
|
t.language_code AS "languageCode",
|
|
t.locked,
|
|
t.message_count AS "messageCount",
|
|
t.last_active_at AS "lastActiveAt",
|
|
t.last_active_at::text AS "lastActiveCursor",
|
|
t.created_at AS "createdAt",
|
|
t.created_at::text AS "createdAtCursor",
|
|
t.message_count AS "messageCountCursor",
|
|
CASE WHEN u.id IS NULL THEN NULL ELSE json_build_object('id', u.id, 'displayName', u.display_name) END AS author,
|
|
($${userParam}::integer IS NOT NULL AND tf.user_id IS NOT NULL) AS followed,
|
|
($${userParam}::integer IS NOT NULL AND t.last_message_id IS NOT NULL AND (tr.last_read_message_id IS NULL OR t.last_message_id > tr.last_read_message_id)) AS unread
|
|
FROM threads t
|
|
LEFT JOIN users u ON u.id = t.created_by_user_id
|
|
LEFT JOIN thread_follows tf ON tf.thread_id = t.id AND tf.user_id = $${userParam}::integer
|
|
LEFT JOIN thread_reads tr ON tr.thread_id = t.id AND tr.user_id = $${userParam}::integer
|
|
WHERE ${conditions.join(' AND ')}
|
|
ORDER BY ${orderField} DESC, t.id DESC
|
|
LIMIT $${limitParam}
|
|
`,
|
|
params
|
|
);
|
|
const items = await hydrateThreads(rows.slice(0, limit), userId);
|
|
const last = rows.slice(0, limit).at(-1) as (typeof rows)[number] | undefined;
|
|
const nextValue = last ? (sort === 'latest' ? last.createdAtCursor : sort === 'most-discussed' ? String(last.messageCountCursor) : last.lastActiveCursor) : null;
|
|
return {
|
|
items,
|
|
nextCursor: rows.length > limit && last && nextValue ? encodeCursor({ value: nextValue, id: last.id }) : null,
|
|
hasMore: rows.length > limit
|
|
};
|
|
}
|
|
|
|
export async function getThread(threadIdValue: number, userId: number | null): Promise<ThreadSummary | null> {
|
|
const threadId = requirePositiveInteger(threadIdValue, 'server.validation.recordInvalid');
|
|
const rows = await query<ThreadSummary & { lastActiveCursor: string }>(
|
|
`
|
|
SELECT
|
|
t.id,
|
|
t.channel_id AS "channelId",
|
|
t.title,
|
|
t.language_code AS "languageCode",
|
|
t.locked,
|
|
t.message_count AS "messageCount",
|
|
t.last_active_at AS "lastActiveAt",
|
|
t.last_active_at::text AS "lastActiveCursor",
|
|
t.created_at AS "createdAt",
|
|
CASE WHEN u.id IS NULL THEN NULL ELSE json_build_object('id', u.id, 'displayName', u.display_name) END AS author,
|
|
($2::integer IS NOT NULL AND tf.user_id IS NOT NULL) AS followed,
|
|
($2::integer IS NOT NULL AND t.last_message_id IS NOT NULL AND (tr.last_read_message_id IS NULL OR t.last_message_id > tr.last_read_message_id)) AS unread
|
|
FROM threads t
|
|
LEFT JOIN users u ON u.id = t.created_by_user_id
|
|
LEFT JOIN thread_follows tf ON tf.thread_id = t.id AND tf.user_id = $2::integer
|
|
LEFT JOIN thread_reads tr ON tr.thread_id = t.id AND tr.user_id = $2::integer
|
|
WHERE t.id = $1
|
|
AND t.deleted_at IS NULL
|
|
`,
|
|
[threadId, userId]
|
|
);
|
|
return (await hydrateThreads(rows, userId))[0] ?? null;
|
|
}
|
|
|
|
async function getThreadMessageById(messageId: number, userId: number | null, canViewAll = false): Promise<ThreadMessage | null> {
|
|
const rows = await query<ThreadMessage>(
|
|
`
|
|
SELECT
|
|
tm.id,
|
|
tm.thread_id AS "threadId",
|
|
tm.body,
|
|
tm.ai_moderation_status AS "moderationStatus",
|
|
tm.ai_moderation_language_code AS "moderationLanguageCode",
|
|
tm.ai_moderation_reason AS "moderationReason",
|
|
tm.created_at AS "createdAt",
|
|
tm.updated_at AS "updatedAt",
|
|
CASE WHEN u.id IS NULL THEN NULL ELSE json_build_object('id', u.id, 'displayName', u.display_name) END AS author
|
|
FROM thread_messages tm
|
|
LEFT JOIN users u ON u.id = tm.created_by_user_id
|
|
WHERE tm.id = $1
|
|
AND tm.deleted_at IS NULL
|
|
AND ${moderationVisibilitySql('tm', 'tm.created_by_user_id', userId, canViewAll)}
|
|
`,
|
|
[messageId]
|
|
);
|
|
const row = rows[0];
|
|
if (!row) return null;
|
|
const reactions = await threadMessageReactionCounts([row.id], userId);
|
|
return {
|
|
...row,
|
|
reactionCounts: reactions.counts.get(row.id) ?? emptyThreadReactionCounts(),
|
|
myReactions: reactions.mine.get(row.id) ?? []
|
|
};
|
|
}
|
|
|
|
export async function listThreadMessages(
|
|
threadIdValue: number,
|
|
paramsQuery: QueryParams,
|
|
userId: number | null,
|
|
canViewAll = false
|
|
): Promise<ThreadMessagesPage | null> {
|
|
const threadId = requirePositiveInteger(threadIdValue, 'server.validation.recordInvalid');
|
|
const thread = await getThread(threadId, userId);
|
|
if (!thread) return null;
|
|
const limit = cleanThreadLimit(paramsQuery.limit, defaultThreadMessageLimit, maxThreadMessageLimit);
|
|
const before = decodeThreadMessageCursor(paramsQuery.before);
|
|
const conditions = ['tm.thread_id = $1', 'tm.deleted_at IS NULL'];
|
|
const params: unknown[] = [threadId];
|
|
if (before) {
|
|
params.push(before.createdAt, before.id);
|
|
conditions.push(`(tm.created_at, tm.id) < ($${params.length - 1}::timestamptz, $${params.length}::integer)`);
|
|
}
|
|
if (!canViewAll) {
|
|
if (userId !== null) {
|
|
params.push(userId);
|
|
conditions.push(`(tm.ai_moderation_status = 'approved' OR tm.created_by_user_id = $${params.length})`);
|
|
} else {
|
|
conditions.push("tm.ai_moderation_status = 'approved'");
|
|
}
|
|
}
|
|
params.push(limit + 1);
|
|
|
|
const rows = await query<ThreadMessage & { createdAtCursor: string }>(
|
|
`
|
|
SELECT
|
|
tm.id,
|
|
tm.thread_id AS "threadId",
|
|
tm.body,
|
|
tm.ai_moderation_status AS "moderationStatus",
|
|
tm.ai_moderation_language_code AS "moderationLanguageCode",
|
|
tm.ai_moderation_reason AS "moderationReason",
|
|
tm.created_at AS "createdAt",
|
|
tm.created_at::text AS "createdAtCursor",
|
|
tm.updated_at AS "updatedAt",
|
|
CASE WHEN u.id IS NULL THEN NULL ELSE json_build_object('id', u.id, 'displayName', u.display_name) END AS author
|
|
FROM thread_messages tm
|
|
LEFT JOIN users u ON u.id = tm.created_by_user_id
|
|
WHERE ${conditions.join(' AND ')}
|
|
ORDER BY tm.created_at DESC, tm.id DESC
|
|
LIMIT $${params.length}
|
|
`,
|
|
params
|
|
);
|
|
const pageRows = rows.slice(0, limit).reverse();
|
|
const reactions = await threadMessageReactionCounts(pageRows.map((row) => row.id), userId);
|
|
const items = pageRows.map((row) => ({
|
|
id: row.id,
|
|
threadId: row.threadId,
|
|
body: row.body,
|
|
moderationStatus: row.moderationStatus,
|
|
moderationLanguageCode: row.moderationLanguageCode,
|
|
moderationReason: row.moderationReason,
|
|
createdAt: row.createdAt,
|
|
updatedAt: row.updatedAt,
|
|
author: row.author,
|
|
reactionCounts: reactions.counts.get(row.id) ?? emptyThreadReactionCounts(),
|
|
myReactions: reactions.mine.get(row.id) ?? []
|
|
}));
|
|
const oldest = rows.slice(0, limit).at(-1);
|
|
return {
|
|
items,
|
|
beforeCursor: rows.length > limit && oldest ? encodeCursor({ createdAt: oldest.createdAtCursor, id: oldest.id }) : null,
|
|
hasMoreBefore: rows.length > limit
|
|
};
|
|
}
|
|
|
|
export async function createThread(payload: Record<string, unknown>, userId: number): Promise<ThreadSummary> {
|
|
const channelId = requirePositiveInteger(payload.channelId, 'server.validation.invalidField');
|
|
const title = cleanThreadTitle(payload.title);
|
|
const languageCode = cleanThreadLanguageCode(payload.languageCode);
|
|
const tagIds = cleanThreadTagIds(payload.tagIds);
|
|
const messageBody = cleanThreadMessageBody(payload.body);
|
|
const channel = await queryOne<{ id: number; allowUserThreads: boolean }>(
|
|
'SELECT id, allow_user_threads AS "allowUserThreads" FROM thread_channels WHERE id = $1',
|
|
[channelId]
|
|
);
|
|
if (!channel || !channel.allowUserThreads) {
|
|
throw validationError('server.validation.invalidField');
|
|
}
|
|
if (!(await channelAllowsLanguage(channelId, languageCode))) {
|
|
throw validationError('server.validation.languageInvalid');
|
|
}
|
|
await validateThreadTags(channelId, tagIds);
|
|
|
|
const ids = await withTransaction(async (client) => {
|
|
const threadResult = await client.query<{ id: number }>(
|
|
`
|
|
INSERT INTO threads (channel_id, title, language_code, created_by_user_id, updated_by_user_id)
|
|
VALUES ($1, $2, $3, $4, $4)
|
|
RETURNING id
|
|
`,
|
|
[channelId, title, languageCode, userId]
|
|
);
|
|
const threadId = threadResult.rows[0].id;
|
|
for (const tagId of tagIds) {
|
|
await client.query('INSERT INTO thread_tag_links (thread_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING', [threadId, tagId]);
|
|
}
|
|
const messageResult = await client.query<{ id: number }>(
|
|
`
|
|
INSERT INTO thread_messages (thread_id, body, ai_moderation_status, ai_moderation_language_code, created_by_user_id)
|
|
VALUES ($1, $2, 'unreviewed', $3, $4)
|
|
RETURNING id
|
|
`,
|
|
[threadId, messageBody, languageCode, userId]
|
|
);
|
|
await client.query('INSERT INTO thread_follows (thread_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING', [threadId, userId]);
|
|
return { threadId, messageId: messageResult.rows[0].id };
|
|
});
|
|
|
|
await requestAiModerationReview({ type: 'thread-message', id: ids.messageId }, { languageCode, resetRetries: true });
|
|
return (await getThread(ids.threadId, userId)) as ThreadSummary;
|
|
}
|
|
|
|
export async function updateThread(
|
|
threadIdValue: number,
|
|
payload: Record<string, unknown>,
|
|
userId: number,
|
|
canUpdateAny = false
|
|
): Promise<ThreadSummary | null> {
|
|
const threadId = requirePositiveInteger(threadIdValue, 'server.validation.recordInvalid');
|
|
const title = cleanThreadTitle(payload.title);
|
|
const tagIds = cleanThreadTagIds(payload.tagIds);
|
|
const thread = await queryOne<{ id: number; channelId: number; createdByUserId: number }>(
|
|
`
|
|
SELECT id, channel_id AS "channelId", created_by_user_id AS "createdByUserId"
|
|
FROM threads
|
|
WHERE id = $1
|
|
AND deleted_at IS NULL
|
|
`,
|
|
[threadId]
|
|
);
|
|
if (!thread) return null;
|
|
if (!canUpdateAny && thread.createdByUserId !== userId) {
|
|
throw forbiddenError();
|
|
}
|
|
await validateThreadTags(thread.channelId, tagIds);
|
|
|
|
await withTransaction(async (client) => {
|
|
await client.query('UPDATE threads SET title = $1, updated_by_user_id = $2, updated_at = now() WHERE id = $3', [title, userId, threadId]);
|
|
await client.query('DELETE FROM thread_tag_links WHERE thread_id = $1', [threadId]);
|
|
for (const tagId of tagIds) {
|
|
await client.query('INSERT INTO thread_tag_links (thread_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING', [threadId, tagId]);
|
|
}
|
|
});
|
|
|
|
return getThread(threadId, userId);
|
|
}
|
|
|
|
async function refreshThreadMessageAggregates(threadId: number): Promise<void> {
|
|
await pool.query(
|
|
`
|
|
UPDATE threads t
|
|
SET message_count = (
|
|
SELECT COUNT(*)::integer
|
|
FROM thread_messages tm
|
|
WHERE tm.thread_id = t.id
|
|
AND tm.deleted_at IS NULL
|
|
AND tm.ai_moderation_status = 'approved'
|
|
),
|
|
last_message_id = (
|
|
SELECT tm.id
|
|
FROM thread_messages tm
|
|
WHERE tm.thread_id = t.id
|
|
AND tm.deleted_at IS NULL
|
|
AND tm.ai_moderation_status = 'approved'
|
|
ORDER BY tm.created_at DESC, tm.id DESC
|
|
LIMIT 1
|
|
),
|
|
updated_at = now()
|
|
WHERE t.id = $1
|
|
`,
|
|
[threadId]
|
|
);
|
|
}
|
|
|
|
export async function createThreadMessage(threadIdValue: number, payload: Record<string, unknown>, userId: number): Promise<ThreadMessage | null> {
|
|
const threadId = requirePositiveInteger(threadIdValue, 'server.validation.recordInvalid');
|
|
const body = cleanThreadMessageBody(payload.body);
|
|
const thread = await queryOne<{ id: number; locked: boolean; languageCode: string }>(
|
|
'SELECT id, locked, language_code AS "languageCode" FROM threads WHERE id = $1 AND deleted_at IS NULL',
|
|
[threadId]
|
|
);
|
|
if (!thread) return null;
|
|
if (thread.locked) {
|
|
throw validationError('server.validation.invalidField');
|
|
}
|
|
const result = await queryOne<{ id: number }>(
|
|
`
|
|
INSERT INTO thread_messages (thread_id, body, ai_moderation_status, ai_moderation_language_code, created_by_user_id)
|
|
VALUES ($1, $2, 'unreviewed', $3, $4)
|
|
RETURNING id
|
|
`,
|
|
[threadId, body, thread.languageCode, userId]
|
|
);
|
|
if (!result) return null;
|
|
await requestAiModerationReview({ type: 'thread-message', id: result.id }, { languageCode: thread.languageCode, resetRetries: true });
|
|
return getThreadMessageById(result.id, userId, false);
|
|
}
|
|
|
|
export async function updateThreadMessage(
|
|
messageIdValue: number,
|
|
payload: Record<string, unknown>,
|
|
userId: number,
|
|
canUpdateAny = false
|
|
): Promise<ThreadMessage | null> {
|
|
const messageId = requirePositiveInteger(messageIdValue, 'server.validation.recordInvalid');
|
|
const body = cleanThreadMessageBody(payload.body);
|
|
const message = await queryOne<{ id: number; threadId: number; languageCode: string; createdByUserId: number }>(
|
|
`
|
|
SELECT
|
|
tm.id,
|
|
tm.thread_id AS "threadId",
|
|
t.language_code AS "languageCode",
|
|
tm.created_by_user_id AS "createdByUserId"
|
|
FROM thread_messages tm
|
|
JOIN threads t ON t.id = tm.thread_id
|
|
WHERE tm.id = $1
|
|
AND tm.deleted_at IS NULL
|
|
AND t.deleted_at IS NULL
|
|
`,
|
|
[messageId]
|
|
);
|
|
if (!message) return null;
|
|
if (!canUpdateAny && message.createdByUserId !== userId) {
|
|
throw forbiddenError();
|
|
}
|
|
|
|
const result = await queryOne<{ id: number }>(
|
|
`
|
|
UPDATE thread_messages
|
|
SET body = $1,
|
|
ai_moderation_status = 'reviewing',
|
|
ai_moderation_language_code = NULL,
|
|
ai_moderation_reason = NULL,
|
|
ai_moderation_content_hash = NULL,
|
|
ai_moderation_checked_at = NULL,
|
|
ai_moderation_retry_count = 0,
|
|
ai_moderation_updated_at = now(),
|
|
updated_at = now()
|
|
WHERE id = $2
|
|
AND deleted_at IS NULL
|
|
RETURNING id
|
|
`,
|
|
[body, messageId]
|
|
);
|
|
if (!result) return null;
|
|
|
|
await refreshThreadMessageAggregates(message.threadId);
|
|
await publishThreadMessageModeration(message.threadId, messageId, null);
|
|
await requestAiModerationReview({ type: 'thread-message', id: messageId }, { languageCode: message.languageCode, resetRetries: true });
|
|
return getThreadMessageById(messageId, userId, canUpdateAny);
|
|
}
|
|
|
|
export async function retryThreadMessageModeration(
|
|
messageIdValue: number,
|
|
userId: number,
|
|
canRetryAny = false
|
|
): Promise<ThreadMessage | null> {
|
|
const messageId = requirePositiveInteger(messageIdValue, 'server.validation.recordInvalid');
|
|
const message = await queryOne<{ id: number; createdByUserId: number }>(
|
|
`
|
|
SELECT tm.id, tm.created_by_user_id AS "createdByUserId"
|
|
FROM thread_messages tm
|
|
JOIN threads t ON t.id = tm.thread_id
|
|
WHERE tm.id = $1
|
|
AND tm.deleted_at IS NULL
|
|
AND t.deleted_at IS NULL
|
|
AND tm.ai_moderation_status IN ('unreviewed', 'rejected', 'failed')
|
|
`,
|
|
[messageId]
|
|
);
|
|
if (!message) return null;
|
|
if (!canRetryAny && message.createdByUserId !== userId) {
|
|
throw forbiddenError();
|
|
}
|
|
|
|
await requestAiModerationReview({ type: 'thread-message', id: messageId }, { incrementRetries: true });
|
|
return getThreadMessageById(messageId, userId, canRetryAny);
|
|
}
|
|
|
|
export async function markThreadRead(threadIdValue: number, userId: number): Promise<ThreadSummary | null> {
|
|
const threadId = requirePositiveInteger(threadIdValue, 'server.validation.recordInvalid');
|
|
const row = await queryOne<{ lastMessageId: number | null }>(
|
|
'SELECT last_message_id AS "lastMessageId" FROM threads WHERE id = $1 AND deleted_at IS NULL',
|
|
[threadId]
|
|
);
|
|
if (!row) return null;
|
|
await pool.query(
|
|
`
|
|
INSERT INTO thread_reads (thread_id, user_id, last_read_message_id, last_read_at)
|
|
VALUES ($1, $2, $3, now())
|
|
ON CONFLICT (thread_id, user_id)
|
|
DO UPDATE SET last_read_message_id = EXCLUDED.last_read_message_id,
|
|
last_read_at = now()
|
|
`,
|
|
[threadId, userId, row.lastMessageId]
|
|
);
|
|
const thread = await getThread(threadId, userId);
|
|
await publishThreadReadUpdated(userId, threadId, thread?.unread ?? false, 0);
|
|
return thread;
|
|
}
|
|
|
|
export async function followThread(threadIdValue: number, userId: number): Promise<ThreadSummary | null> {
|
|
const threadId = requirePositiveInteger(threadIdValue, 'server.validation.recordInvalid');
|
|
const thread = await getThread(threadId, userId);
|
|
if (!thread) return null;
|
|
await pool.query('INSERT INTO thread_follows (thread_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING', [threadId, userId]);
|
|
return getThread(threadId, userId);
|
|
}
|
|
|
|
export async function unfollowThread(threadIdValue: number, userId: number): Promise<ThreadSummary | null> {
|
|
const threadId = requirePositiveInteger(threadIdValue, 'server.validation.recordInvalid');
|
|
const thread = await getThread(threadId, userId);
|
|
if (!thread) return null;
|
|
await pool.query('DELETE FROM thread_follows WHERE thread_id = $1 AND user_id = $2', [threadId, userId]);
|
|
return getThread(threadId, userId);
|
|
}
|
|
|
|
export async function setThreadReaction(threadIdValue: number, payload: Record<string, unknown>, userId: number): Promise<ThreadSummary | null> {
|
|
const threadId = requirePositiveInteger(threadIdValue, 'server.validation.recordInvalid');
|
|
const reactionType = cleanThreadReactionType(payload.reactionType);
|
|
const thread = await getThread(threadId, userId);
|
|
if (!thread) return null;
|
|
await pool.query(
|
|
`
|
|
INSERT INTO thread_reactions (thread_id, user_id, reaction_type)
|
|
VALUES ($1, $2, $3)
|
|
ON CONFLICT (thread_id, user_id, reaction_type)
|
|
DO UPDATE SET updated_at = now()
|
|
`,
|
|
[threadId, userId, reactionType]
|
|
);
|
|
const updated = await getThread(threadId, userId);
|
|
if (updated) {
|
|
await publishThreadReactionUpdated(userId, {
|
|
type: 'thread.reactions.updated',
|
|
target: 'thread',
|
|
threadId,
|
|
messageId: null,
|
|
reactionCounts: updated.reactionCounts,
|
|
myReactions: updated.myReactions
|
|
});
|
|
}
|
|
return updated;
|
|
}
|
|
|
|
export async function deleteThreadReaction(threadIdValue: number, payload: Record<string, unknown>, userId: number): Promise<ThreadSummary | null> {
|
|
const threadId = requirePositiveInteger(threadIdValue, 'server.validation.recordInvalid');
|
|
const reactionType = cleanThreadReactionType(payload.reactionType);
|
|
const thread = await getThread(threadId, userId);
|
|
if (!thread) return null;
|
|
await pool.query('DELETE FROM thread_reactions WHERE thread_id = $1 AND user_id = $2 AND reaction_type = $3', [threadId, userId, reactionType]);
|
|
const updated = await getThread(threadId, userId);
|
|
if (updated) {
|
|
await publishThreadReactionUpdated(userId, {
|
|
type: 'thread.reactions.updated',
|
|
target: 'thread',
|
|
threadId,
|
|
messageId: null,
|
|
reactionCounts: updated.reactionCounts,
|
|
myReactions: updated.myReactions
|
|
});
|
|
}
|
|
return updated;
|
|
}
|
|
|
|
export async function setThreadMessageReaction(messageIdValue: number, payload: Record<string, unknown>, userId: number): Promise<ThreadMessage | null> {
|
|
const messageId = requirePositiveInteger(messageIdValue, 'server.validation.recordInvalid');
|
|
const reactionType = cleanThreadReactionType(payload.reactionType);
|
|
const message = await getThreadMessageById(messageId, userId);
|
|
if (!message || message.moderationStatus !== 'approved') return null;
|
|
await pool.query(
|
|
`
|
|
INSERT INTO thread_message_reactions (message_id, user_id, reaction_type)
|
|
VALUES ($1, $2, $3)
|
|
ON CONFLICT (message_id, user_id, reaction_type)
|
|
DO UPDATE SET updated_at = now()
|
|
`,
|
|
[messageId, userId, reactionType]
|
|
);
|
|
const updated = await getThreadMessageById(messageId, userId);
|
|
if (updated) {
|
|
await publishThreadReactionUpdated(userId, {
|
|
type: 'thread.reactions.updated',
|
|
target: 'message',
|
|
threadId: updated.threadId,
|
|
messageId,
|
|
reactionCounts: updated.reactionCounts,
|
|
myReactions: updated.myReactions
|
|
});
|
|
}
|
|
return updated;
|
|
}
|
|
|
|
export async function deleteThreadMessageReaction(messageIdValue: number, payload: Record<string, unknown>, userId: number): Promise<ThreadMessage | null> {
|
|
const messageId = requirePositiveInteger(messageIdValue, 'server.validation.recordInvalid');
|
|
const reactionType = cleanThreadReactionType(payload.reactionType);
|
|
const message = await getThreadMessageById(messageId, userId);
|
|
if (!message) return null;
|
|
await pool.query('DELETE FROM thread_message_reactions WHERE message_id = $1 AND user_id = $2 AND reaction_type = $3', [messageId, userId, reactionType]);
|
|
const updated = await getThreadMessageById(messageId, userId);
|
|
if (updated) {
|
|
await publishThreadReactionUpdated(userId, {
|
|
type: 'thread.reactions.updated',
|
|
target: 'message',
|
|
threadId: updated.threadId,
|
|
messageId,
|
|
reactionCounts: updated.reactionCounts,
|
|
myReactions: updated.myReactions
|
|
});
|
|
}
|
|
return updated;
|
|
}
|
|
|
|
export async function applyApprovedThreadMessage(messageId: number): Promise<void> {
|
|
const row = await queryOne<{ threadId: number }>(
|
|
`
|
|
UPDATE threads t
|
|
SET last_message_id = tm.id,
|
|
message_count = (
|
|
SELECT COUNT(*)::integer
|
|
FROM thread_messages visible_message
|
|
WHERE visible_message.thread_id = t.id
|
|
AND visible_message.deleted_at IS NULL
|
|
AND visible_message.ai_moderation_status = 'approved'
|
|
),
|
|
last_active_at = GREATEST(t.last_active_at, tm.created_at),
|
|
updated_at = now()
|
|
FROM thread_messages tm
|
|
WHERE tm.id = $1
|
|
AND tm.thread_id = t.id
|
|
AND tm.deleted_at IS NULL
|
|
AND tm.ai_moderation_status = 'approved'
|
|
RETURNING t.id AS "threadId"
|
|
`,
|
|
[messageId]
|
|
);
|
|
if (!row) return;
|
|
const message = await getThreadMessageById(messageId, null, true);
|
|
const thread = await getThread(row.threadId, null);
|
|
if (message && thread) {
|
|
await publishThreadMessageCreated(thread, message);
|
|
} else {
|
|
await publishThreadMessageModeration(row.threadId, messageId, message);
|
|
}
|
|
}
|
|
|
|
export async function createAdminThreadChannel(payload: Record<string, unknown>, userId: number): Promise<ThreadChannel[]> {
|
|
const name = cleanName(payload.name, 'server.validation.nameRequired');
|
|
const allowUserThreads = payload.allowUserThreads !== false;
|
|
const tagNames = Array.isArray(payload.tags) ? payload.tags.map((tag) => cleanName(tag, 'server.validation.nameRequired')).slice(0, 20) : [];
|
|
const languageCodes = Array.isArray(payload.languages) ? payload.languages.map(cleanThreadLanguageCode).slice(0, 20) : [];
|
|
await withTransaction(async (client) => {
|
|
const sortOrder = await nextSortOrder(client, 'thread_channels');
|
|
const result = await client.query<{ id: number }>(
|
|
`
|
|
INSERT INTO thread_channels (name, allow_user_threads, sort_order, created_by_user_id, updated_by_user_id)
|
|
VALUES ($1, $2, $3, $4, $4)
|
|
RETURNING id
|
|
`,
|
|
[name, allowUserThreads, sortOrder, userId]
|
|
);
|
|
await replaceThreadChannelConfig(client, result.rows[0].id, tagNames, languageCodes);
|
|
});
|
|
return listAdminThreadChannels();
|
|
}
|
|
|
|
async function replaceThreadChannelConfig(client: DbClient, channelId: number, tagNames: string[], languageCodes: string[]): Promise<void> {
|
|
await client.query('DELETE FROM thread_channel_tags WHERE channel_id = $1', [channelId]);
|
|
await client.query('DELETE FROM thread_channel_languages WHERE channel_id = $1', [channelId]);
|
|
for (const [index, tagName] of [...new Set(tagNames)].entries()) {
|
|
await client.query('INSERT INTO thread_channel_tags (channel_id, name, sort_order) VALUES ($1, $2, $3)', [channelId, tagName, (index + 1) * 10]);
|
|
}
|
|
for (const [index, languageCode] of [...new Set(languageCodes)].entries()) {
|
|
await client.query('INSERT INTO thread_channel_languages (channel_id, language_code, sort_order) VALUES ($1, $2, $3)', [
|
|
channelId,
|
|
languageCode,
|
|
(index + 1) * 10
|
|
]);
|
|
}
|
|
}
|
|
|
|
export async function updateAdminThreadChannel(channelIdValue: number, payload: Record<string, unknown>, userId: number): Promise<ThreadChannel[] | null> {
|
|
const channelId = requirePositiveInteger(channelIdValue, 'server.validation.recordInvalid');
|
|
const name = cleanName(payload.name, 'server.validation.nameRequired');
|
|
const allowUserThreads = payload.allowUserThreads !== false;
|
|
const tagNames = Array.isArray(payload.tags) ? payload.tags.map((tag) => cleanName(tag, 'server.validation.nameRequired')).slice(0, 20) : [];
|
|
const languageCodes = Array.isArray(payload.languages) ? payload.languages.map(cleanThreadLanguageCode).slice(0, 20) : [];
|
|
const updated = await withTransaction(async (client) => {
|
|
const result = await client.query(
|
|
`
|
|
UPDATE thread_channels
|
|
SET name = $1, allow_user_threads = $2, updated_by_user_id = $3, updated_at = now()
|
|
WHERE id = $4
|
|
`,
|
|
[name, allowUserThreads, userId, channelId]
|
|
);
|
|
if (!result.rowCount) return false;
|
|
await replaceThreadChannelConfig(client, channelId, tagNames, languageCodes);
|
|
return true;
|
|
});
|
|
return updated ? listAdminThreadChannels() : null;
|
|
}
|
|
|
|
export async function deleteAdminThreadChannel(channelIdValue: number): Promise<boolean> {
|
|
const channelId = requirePositiveInteger(channelIdValue, 'server.validation.recordInvalid');
|
|
const result = await pool.query('DELETE FROM thread_channels WHERE id = $1', [channelId]);
|
|
return Boolean(result.rowCount);
|
|
}
|
|
|
|
export async function updateThreadLock(threadIdValue: number, locked: boolean, userId: number): Promise<ThreadSummary | null> {
|
|
const threadId = requirePositiveInteger(threadIdValue, 'server.validation.recordInvalid');
|
|
const result = await pool.query(
|
|
'UPDATE threads SET locked = $1, updated_by_user_id = $2, updated_at = now() WHERE id = $3 AND deleted_at IS NULL',
|
|
[locked, userId, threadId]
|
|
);
|
|
return result.rowCount ? getThread(threadId, userId) : null;
|
|
}
|
|
|
|
export async function deleteThread(threadIdValue: number, userId: number): Promise<boolean> {
|
|
const threadId = requirePositiveInteger(threadIdValue, 'server.validation.recordInvalid');
|
|
const result = await pool.query(
|
|
'UPDATE threads SET deleted_at = now(), deleted_by_user_id = $1, updated_at = now() WHERE id = $2 AND deleted_at IS NULL',
|
|
[userId, threadId]
|
|
);
|
|
return Boolean(result.rowCount);
|
|
}
|
|
|
|
export async function deleteThreadMessage(messageIdValue: number, userId: number): Promise<boolean> {
|
|
const messageId = requirePositiveInteger(messageIdValue, 'server.validation.recordInvalid');
|
|
const result = await pool.query<{ threadId: number }>(
|
|
`
|
|
UPDATE thread_messages
|
|
SET deleted_at = now(), deleted_by_user_id = $1, updated_at = now()
|
|
WHERE id = $2
|
|
AND deleted_at IS NULL
|
|
RETURNING thread_id AS "threadId"
|
|
`,
|
|
[userId, messageId]
|
|
);
|
|
if (!result.rowCount) return false;
|
|
await pool.query(
|
|
`
|
|
UPDATE threads t
|
|
SET message_count = (
|
|
SELECT COUNT(*)::integer
|
|
FROM thread_messages tm
|
|
WHERE tm.thread_id = t.id
|
|
AND tm.deleted_at IS NULL
|
|
AND tm.ai_moderation_status = 'approved'
|
|
),
|
|
last_message_id = (
|
|
SELECT tm.id
|
|
FROM thread_messages tm
|
|
WHERE tm.thread_id = t.id
|
|
AND tm.deleted_at IS NULL
|
|
AND tm.ai_moderation_status = 'approved'
|
|
ORDER BY tm.created_at DESC, tm.id DESC
|
|
LIMIT 1
|
|
),
|
|
updated_at = now()
|
|
WHERE t.id = $1
|
|
`,
|
|
[result.rows[0].threadId]
|
|
);
|
|
return true;
|
|
}
|
|
|
|
export async function createThreadsWsTicketForUser(userId: number): Promise<{ ticket: string; expiresAt: Date }> {
|
|
return createThreadWebSocketTicket(userId);
|
|
}
|
|
|
|
export async function wipeAdminData(payload: Record<string, unknown>): Promise<{ scopes: DataToolScopeSummary[] }> {
|
|
const scopes = cleanDataToolScopes(payload.scopes);
|
|
await withTransaction(async (client) => {
|
|
await wipeDataToolScopes(client, scopes);
|
|
});
|
|
return getAdminDataToolsSummary();
|
|
}
|