feat(life): replace multiple tags with single category for posts

Add default category support and enforce one category per Life Post
Update UI filters, forms, and translations to reflect category semantics
This commit is contained in:
2026-05-03 17:34:32 +08:00
parent 18baf7b513
commit 6782ddd101
8 changed files with 264 additions and 172 deletions

View File

@@ -523,6 +523,7 @@ CREATE INDEX IF NOT EXISTS daily_checklist_items_sort_order_idx
CREATE TABLE IF NOT EXISTS life_tags (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name text NOT NULL UNIQUE,
is_default boolean NOT NULL DEFAULT false,
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
@@ -533,6 +534,7 @@ CREATE TABLE IF NOT EXISTS life_tags (
CREATE TABLE IF NOT EXISTS life_posts (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
body text NOT NULL CHECK (length(body) BETWEEN 1 AND 2000),
category_id integer REFERENCES life_tags(id) ON DELETE RESTRICT,
ai_moderation_status text NOT NULL DEFAULT 'unreviewed' CHECK (ai_moderation_status IN ('unreviewed', 'reviewing', 'approved', 'rejected', 'failed')),
ai_moderation_language_code text REFERENCES languages(code) ON DELETE SET NULL,
ai_moderation_content_hash text,
@@ -818,6 +820,9 @@ CREATE TABLE IF NOT EXISTS habitat_pokemon (
PRIMARY KEY (habitat_id, pokemon_id, map_id, time_of_day, weather)
);
ALTER TABLE life_tags
ADD COLUMN IF NOT EXISTS is_default boolean NOT NULL DEFAULT false;
CREATE INDEX IF NOT EXISTS environments_sort_order_idx ON environments(sort_order, id);
CREATE INDEX IF NOT EXISTS skills_sort_order_idx ON skills(sort_order, id);
CREATE INDEX IF NOT EXISTS favorite_things_sort_order_idx ON favorite_things(sort_order, id);
@@ -825,6 +830,7 @@ CREATE INDEX IF NOT EXISTS pokemon_types_sort_order_idx ON pokemon_types(sort_or
CREATE INDEX IF NOT EXISTS pokemon_sort_order_idx ON pokemon(sort_order, id);
CREATE UNIQUE INDEX IF NOT EXISTS pokemon_display_event_item_key ON pokemon(display_id, is_event_item);
CREATE INDEX IF NOT EXISTS life_tags_sort_order_idx ON life_tags(sort_order, id);
CREATE UNIQUE INDEX IF NOT EXISTS life_tags_single_default_idx ON life_tags(is_default) WHERE is_default = true;
CREATE INDEX IF NOT EXISTS item_categories_sort_order_idx ON item_categories(sort_order, id);
CREATE INDEX IF NOT EXISTS item_usages_sort_order_idx ON item_usages(sort_order, id);
CREATE INDEX IF NOT EXISTS acquisition_methods_sort_order_idx ON acquisition_methods(sort_order, id);
@@ -900,12 +906,28 @@ CREATE INDEX IF NOT EXISTS entity_discussion_comments_user_idx
ALTER TABLE life_posts
ADD COLUMN IF NOT EXISTS ai_moderation_status text NOT NULL DEFAULT 'unreviewed' CHECK (ai_moderation_status IN ('unreviewed', 'reviewing', 'approved', 'rejected', 'failed')),
ADD COLUMN IF NOT EXISTS category_id integer REFERENCES life_tags(id) ON DELETE RESTRICT,
ADD COLUMN IF NOT EXISTS ai_moderation_language_code text REFERENCES languages(code) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS ai_moderation_content_hash text,
ADD COLUMN IF NOT EXISTS ai_moderation_checked_at timestamptz,
ADD COLUMN IF NOT EXISTS ai_moderation_retry_count integer NOT NULL DEFAULT 0 CHECK (ai_moderation_retry_count >= 0),
ADD COLUMN IF NOT EXISTS ai_moderation_updated_at timestamptz NOT NULL DEFAULT now();
UPDATE life_posts lp
SET category_id = selected.tag_id
FROM (
SELECT DISTINCT ON (lpt.post_id) lpt.post_id, lpt.tag_id
FROM life_post_tags lpt
JOIN life_tags lt ON lt.id = lpt.tag_id
ORDER BY lpt.post_id, lt.sort_order, lt.id
) selected
WHERE lp.id = selected.post_id
AND lp.category_id IS NULL;
CREATE INDEX IF NOT EXISTS life_posts_category_idx
ON life_posts(category_id, created_at DESC, id DESC)
WHERE deleted_at IS NULL;
ALTER TABLE life_post_comments
ADD COLUMN IF NOT EXISTS ai_moderation_status text NOT NULL DEFAULT 'unreviewed' CHECK (ai_moderation_status IN ('unreviewed', 'reviewing', 'approved', 'rejected', 'failed')),
ADD COLUMN IF NOT EXISTS ai_moderation_language_code text REFERENCES languages(code) ON DELETE SET NULL,

View File

@@ -55,6 +55,7 @@ type ConfigDefinition = {
table: string;
entityType: EntityType;
hasItemDrop?: boolean;
hasDefault?: boolean;
};
type SortableContentType = 'pokemon' | 'items' | 'recipes' | 'habitats';
type SortableContentDefinition = {
@@ -178,7 +179,7 @@ type DailyChecklistPayload = {
type LifePostPayload = {
body: string;
tagIds: number[];
categoryId: number;
languageCode: string | null;
};
@@ -250,7 +251,7 @@ type LifePostRow = {
updatedAt: Date;
author: { id: number; displayName: string } | null;
updatedBy: { id: number; displayName: string } | null;
tags: Array<{ id: number; name: string }>;
category: { id: number; name: string } | null;
};
type LifePost = Omit<LifePostRow, 'createdAtCursor'> & {
@@ -459,7 +460,7 @@ const configDefinitions: Record<ConfigType, ConfigDefinition> = {
'item-usages': { table: 'item_usages', entityType: 'item-usages' },
'acquisition-methods': { table: 'acquisition_methods', entityType: 'acquisition-methods' },
maps: { table: 'maps', entityType: 'maps' },
'life-tags': { table: 'life_tags', entityType: 'life-tags' }
'life-tags': { table: 'life_tags', entityType: 'life-tags', hasDefault: true }
};
const sortableContentDefinitions: Record<SortableContentType, SortableContentDefinition> = {
@@ -684,6 +685,11 @@ function optionSelect(
return query(`SELECT o.id, ${name} AS name FROM ${tableName} o ORDER BY ${orderByEntity('o')}`);
}
function lifeCategoryOptions(locale: string): Promise<Array<{ id: number; name: string; isDefault: boolean }>> {
const name = localizedName('life-tags', 'lc', locale);
return query(`SELECT lc.id, ${name} AS name, lc.is_default AS "isDefault" FROM life_tags lc ORDER BY ${orderByEntity('lc')}`);
}
function skillOptions(locale: string): Promise<Array<{ id: number; name: string; hasItemDrop: boolean }>> {
const name = localizedName('skills', 's', locale);
return query(`SELECT s.id, ${name} AS name, s.has_item_drop AS "hasItemDrop" FROM skills s ORDER BY ${orderByEntity('s')}`);
@@ -718,9 +724,14 @@ function configOrder(): string {
function configSelect(definition: ConfigDefinition, locale: string): string {
const name = localizedName(definition.entityType, 'c', locale);
const translations = translationsSelect(definition.entityType, 'c.id');
return definition.hasItemDrop
? `c.id, ${name} AS name, c.name AS "baseName", ${translations} AS translations, c.has_item_drop AS "hasItemDrop"`
: `c.id, ${name} AS name, c.name AS "baseName", ${translations} AS translations`;
const columns = [`c.id`, `${name} AS name`, `c.name AS "baseName"`, `${translations} AS translations`];
if (definition.hasItemDrop) {
columns.push(`c.has_item_drop AS "hasItemDrop"`);
}
if (definition.hasDefault) {
columns.push(`c.is_default AS "isDefault"`);
}
return columns.join(', ');
}
function validationError(message: string): ValidationError {
@@ -2045,7 +2056,7 @@ export async function getOptions(locale = defaultLocale) {
itemUsages,
acquisitionMethods,
maps,
lifeTags
lifeCategories
] = await Promise.all([
optionSelect('pokemon_types', 'pokemon-types', locale),
skillOptions(locale),
@@ -2055,7 +2066,7 @@ export async function getOptions(locale = defaultLocale) {
optionSelect('item_usages', 'item-usages', locale),
optionSelect('acquisition_methods', 'acquisition-methods', locale),
optionSelect('maps', 'maps', locale),
optionSelect('life_tags', 'life-tags', locale)
lifeCategoryOptions(locale)
]);
return {
@@ -2068,7 +2079,7 @@ export async function getOptions(locale = defaultLocale) {
acquisitionMethods,
itemTags: favoriteThings,
maps,
lifeTags
lifeCategories
};
}
@@ -2209,14 +2220,11 @@ function cleanLifePostPayload(payload: Record<string, unknown>): LifePostPayload
if (body.length > 2000) {
throw validationError('server.validation.postTooLong');
}
const tagIds = cleanIds(payload.tagIds);
if (tagIds.length === 0) {
throw validationError('server.validation.lifeTagRequired');
}
const categoryId = requirePositiveInteger(payload.categoryId, 'server.validation.lifeCategoryRequired');
return {
body,
tagIds,
categoryId,
languageCode: cleanModerationLanguageCode(payload.languageCode)
};
}
@@ -2313,7 +2321,7 @@ function addModerationLanguageCondition(
}
function lifePostProjection(locale = defaultLocale): string {
const tagName = localizedName('life-tags', 'lt', locale);
const categoryName = localizedName('life-tags', 'lc', locale);
return `
SELECT
@@ -2332,13 +2340,12 @@ function lifePostProjection(locale = defaultLocale): string {
WHEN updated_user.id IS NULL THEN NULL
ELSE json_build_object('id', updated_user.id, 'displayName', updated_user.display_name)
END AS "updatedBy",
COALESCE((
SELECT json_agg(json_build_object('id', lt.id, 'name', ${tagName}) ORDER BY ${orderByEntity('lt')})
FROM life_post_tags lpt
JOIN life_tags lt ON lt.id = lpt.tag_id
WHERE lpt.post_id = lp.id
), '[]'::json) AS tags
CASE
WHEN lc.id IS NULL THEN NULL
ELSE json_build_object('id', lc.id, 'name', ${categoryName})
END AS category
FROM life_posts lp
LEFT JOIN life_tags lc ON lc.id = lp.category_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
`;
@@ -2447,7 +2454,7 @@ function hydrateLifePost(
updatedAt: post.updatedAt,
author: post.author,
updatedBy: post.updatedBy,
tags: post.tags,
category: post.category,
commentPreview: commentPreviewByPost.get(post.id) ?? [],
commentCount: commentCountsByPost.get(post.id) ?? 0,
reactionCounts: countsByPost.get(post.id) ?? emptyLifeReactionCounts(),
@@ -2759,7 +2766,7 @@ async function listLifePostsWithFilters(
const cursor = decodeLifePostCursor(paramsQuery.cursor);
const limit = cleanLifePostLimit(paramsQuery.limit);
const search = asString(paramsQuery.search)?.trim();
const tagIdValue = asString(paramsQuery.tagId)?.trim();
const categoryIdValue = asString(paramsQuery.categoryId)?.trim();
const languageCode = cleanModerationLanguageFilter(paramsQuery.language);
const params: unknown[] = [];
const conditions: string[] = ['lp.deleted_at IS NULL'];
@@ -2777,15 +2784,10 @@ async function listLifePostsWithFilters(
conditions.push(`lp.body ILIKE $${params.length}`);
}
if (tagIdValue) {
const tagId = requirePositiveInteger(tagIdValue, 'server.validation.tagInvalid');
params.push(tagId);
conditions.push(`EXISTS (
SELECT 1
FROM life_post_tags lpt_filter
WHERE lpt_filter.post_id = lp.id
AND lpt_filter.tag_id = $${params.length}
)`);
if (categoryIdValue) {
const categoryId = requirePositiveInteger(categoryIdValue, 'server.validation.lifeCategoryInvalid');
params.push(categoryId);
conditions.push(`lp.category_id = $${params.length}`);
}
if (cursor) {
@@ -3224,35 +3226,40 @@ async function getLifePostById(id: number, userId: number | null = null, locale
return hydrateLifePost(post, commentPreviewByPost, commentCountsByPost, countsByPost, myReactionsByPost);
}
async function replaceLifePostTags(client: DbClient, postId: number, tagIds: number[]): Promise<void> {
await client.query('DELETE FROM life_post_tags WHERE post_id = $1', [postId]);
for (const tagId of tagIds) {
await client.query(
`
INSERT INTO life_post_tags (post_id, tag_id)
VALUES ($1, $2)
`,
[postId, tagId]
);
async function ensureLifeCategory(client: DbClient, categoryId: number): Promise<void> {
const result = await client.query<{ id: number }>('SELECT id FROM life_tags WHERE id = $1', [categoryId]);
if (result.rowCount === 0) {
throw validationError('server.validation.lifeCategoryInvalid');
}
}
async function replaceLifePostCategoryLink(client: DbClient, postId: number, categoryId: number): Promise<void> {
await client.query('DELETE FROM life_post_tags WHERE post_id = $1', [postId]);
await client.query(
`
INSERT INTO life_post_tags (post_id, tag_id)
VALUES ($1, $2)
`,
[postId, categoryId]
);
}
export async function createLifePost(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
const cleanPayload = cleanLifePostPayload(payload);
const id = await withTransaction(async (client) => {
await ensureLifeCategory(client, cleanPayload.categoryId);
const result = await client.query<{ id: number }>(
`
INSERT INTO life_posts (body, ai_moderation_status, ai_moderation_language_code, created_by_user_id, updated_by_user_id)
VALUES ($1, 'reviewing', NULL, $2, $2)
INSERT INTO life_posts (body, category_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, userId]
[cleanPayload.body, cleanPayload.categoryId, userId]
);
const createdId = result.rows[0].id;
await replaceLifePostTags(client, createdId, cleanPayload.tagIds);
await replaceLifePostCategoryLink(client, createdId, cleanPayload.categoryId);
return createdId;
});
@@ -3270,24 +3277,26 @@ export async function updateLifePost(
const cleanPayload = cleanLifePostPayload(payload);
const updatedId = await withTransaction(async (client) => {
await ensureLifeCategory(client, cleanPayload.categoryId);
const result = await client.query<{ id: number }>(
`
UPDATE life_posts
SET body = $1,
category_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 = $2,
updated_by_user_id = $3,
updated_at = now()
WHERE id = $3
AND ($4 = true OR created_by_user_id = $2)
WHERE id = $4
AND ($5 = true OR created_by_user_id = $3)
AND deleted_at IS NULL
RETURNING id
`,
[cleanPayload.body, userId, id, allowAny]
[cleanPayload.body, cleanPayload.categoryId, userId, id, allowAny]
);
const resultId = result.rows[0]?.id ?? null;
@@ -3295,7 +3304,7 @@ export async function updateLifePost(
return null;
}
await replaceLifePostTags(client, resultId, cleanPayload.tagIds);
await replaceLifePostCategoryLink(client, resultId, cleanPayload.categoryId);
return resultId;
});
@@ -3879,26 +3888,37 @@ export async function createConfig(type: ConfigType, payload: Record<string, unk
const name = cleanName(payload.name);
const translations = cleanTranslations(payload.translations, ['name']);
const hasItemDrop = definition.hasItemDrop ? Boolean(payload.hasItemDrop) : false;
const isDefault = definition.hasDefault ? Boolean(payload.isDefault) : false;
const id = await withTransaction(async (client) => {
const sortOrder = await nextSortOrder(client, definition.table);
const result = definition.hasItemDrop
? await client.query<{ id: number }>(
`
INSERT INTO ${definition.table} (name, has_item_drop, sort_order, created_by_user_id, updated_by_user_id)
VALUES ($1, $2, $3, $4, $4)
RETURNING id
`,
[name, hasItemDrop, sortOrder, userId]
)
: await client.query<{ id: number }>(
`
INSERT INTO ${definition.table} (name, sort_order, created_by_user_id, updated_by_user_id)
VALUES ($1, $2, $3, $3)
RETURNING id
`,
[name, sortOrder, userId]
);
if (definition.hasDefault && isDefault) {
await client.query(
`UPDATE ${definition.table} SET is_default = false, updated_by_user_id = $1, updated_at = now() WHERE is_default = true`,
[userId]
);
}
const columns = ['name'];
const values: unknown[] = [name];
if (definition.hasItemDrop) {
columns.push('has_item_drop');
values.push(hasItemDrop);
}
if (definition.hasDefault) {
columns.push('is_default');
values.push(isDefault);
}
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 replaceEntityTranslations(client, definition.entityType, createdId, translations, ['name']);
@@ -3934,25 +3954,37 @@ export async function updateConfig(
const name = cleanName(payload.name);
const translations = cleanTranslations(payload.translations, ['name']);
const hasItemDrop = definition.hasItemDrop ? Boolean(payload.hasItemDrop) : false;
const isDefault = definition.hasDefault ? Boolean(payload.isDefault) : false;
const updated = await withTransaction(async (client) => {
const result = definition.hasItemDrop
? await client.query(
`
UPDATE ${definition.table}
SET name = $1, has_item_drop = $2, updated_by_user_id = $3, updated_at = now()
WHERE id = $4
`,
[name, hasItemDrop, userId, id]
)
: await client.query(
`
UPDATE ${definition.table}
SET name = $1, updated_by_user_id = $2, updated_at = now()
WHERE id = $3
`,
[name, userId, id]
);
if (definition.hasDefault && isDefault) {
await client.query(
`UPDATE ${definition.table} SET is_default = false, updated_by_user_id = $2, updated_at = now() WHERE id <> $1 AND is_default = true`,
[id, userId]
);
}
const assignments = ['name = $1'];
const values: unknown[] = [name];
if (definition.hasItemDrop) {
values.push(hasItemDrop);
assignments.push(`has_item_drop = $${values.length}`);
}
if (definition.hasDefault) {
values.push(isDefault);
assignments.push(`is_default = $${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;