feat(life): add tags to life posts and feed filtering

Allow users to select tags when creating or editing life posts
Add tag tabs to the life feed for filtering posts by tag
This commit is contained in:
2026-05-02 00:16:30 +08:00
parent 866d7add16
commit 433b19eb67
10 changed files with 411 additions and 66 deletions

View File

@@ -38,7 +38,8 @@ CREATE TABLE IF NOT EXISTS entity_translations (
'items',
'maps',
'habitats',
'daily-checklist-items'
'daily-checklist-items',
'life-tags'
)
),
entity_id integer NOT NULL,
@@ -65,7 +66,8 @@ ALTER TABLE entity_translations ADD CONSTRAINT entity_translations_entity_type_c
'items',
'maps',
'habitats',
'daily-checklist-items'
'daily-checklist-items',
'life-tags'
)
);
ALTER TABLE entity_translations DROP CONSTRAINT IF EXISTS entity_translations_field_name_check;
@@ -119,6 +121,16 @@ CREATE TABLE IF NOT EXISTS daily_checklist_items (
CREATE INDEX IF NOT EXISTS daily_checklist_items_sort_order_idx
ON daily_checklist_items(sort_order, id);
CREATE TABLE IF NOT EXISTS life_tags (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name text NOT NULL UNIQUE,
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,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
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),
@@ -134,6 +146,15 @@ ALTER TABLE life_posts DROP COLUMN IF EXISTS link_title;
CREATE INDEX IF NOT EXISTS life_posts_created_at_idx
ON life_posts(created_at DESC, id DESC);
CREATE TABLE IF NOT EXISTS life_post_tags (
post_id integer NOT NULL REFERENCES life_posts(id) ON DELETE CASCADE,
tag_id integer NOT NULL REFERENCES life_tags(id) ON DELETE CASCADE,
PRIMARY KEY (post_id, tag_id)
);
CREATE INDEX IF NOT EXISTS life_post_tags_tag_idx
ON life_post_tags(tag_id, post_id);
CREATE TABLE IF NOT EXISTS life_post_comments (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
post_id integer NOT NULL REFERENCES life_posts(id) ON DELETE CASCADE,
@@ -401,6 +422,12 @@ ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS special_attack integer NOT NULL DEF
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS special_defense integer NOT NULL DEFAULT 0 CHECK (special_defense >= 0);
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS speed integer NOT NULL DEFAULT 0 CHECK (speed >= 0);
ALTER TABLE life_tags ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
ALTER TABLE life_tags ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
ALTER TABLE life_tags ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now();
ALTER TABLE life_tags ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
ALTER TABLE life_tags ADD COLUMN IF NOT EXISTS sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0);
ALTER TABLE item_categories ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
ALTER TABLE item_categories ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
ALTER TABLE item_categories ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now();
@@ -493,6 +520,16 @@ SET sort_order = ordered.next_sort_order
FROM ordered
WHERE target.id = ordered.id;
WITH ordered AS (
SELECT id, (row_number() OVER (ORDER BY created_at, id) * 10)::integer AS next_sort_order
FROM life_tags
WHERE sort_order = 0
)
UPDATE life_tags target
SET sort_order = ordered.next_sort_order
FROM ordered
WHERE target.id = ordered.id;
WITH ordered AS (
SELECT id, (row_number() OVER (ORDER BY created_at, id) * 10)::integer AS next_sort_order
FROM item_categories
@@ -568,6 +605,7 @@ 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);
CREATE INDEX IF NOT EXISTS pokemon_types_sort_order_idx ON pokemon_types(sort_order, id);
CREATE INDEX IF NOT EXISTS pokemon_sort_order_idx ON pokemon(sort_order, id);
CREATE INDEX IF NOT EXISTS life_tags_sort_order_idx ON life_tags(sort_order, id);
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);

View File

@@ -23,7 +23,8 @@ type EntityType =
| 'items'
| 'maps'
| 'habitats'
| 'daily-checklist-items';
| 'daily-checklist-items'
| 'life-tags';
type ConfigType =
| 'pokemon-types'
@@ -33,7 +34,8 @@ type ConfigType =
| 'item-categories'
| 'item-usages'
| 'acquisition-methods'
| 'maps';
| 'maps'
| 'life-tags';
type ConfigDefinition = {
table: string;
@@ -107,6 +109,7 @@ type DailyChecklistPayload = {
type LifePostPayload = {
body: string;
tagIds: number[];
};
type LifeCommentPayload = {
@@ -139,6 +142,7 @@ type LifePostRow = {
updatedAt: Date;
author: { id: number; displayName: string } | null;
updatedBy: { id: number; displayName: string } | null;
tags: Array<{ id: number; name: string }>;
};
type LifePost = Omit<LifePostRow, 'createdAtCursor'> & {
@@ -248,7 +252,8 @@ const configDefinitions: Record<ConfigType, ConfigDefinition> = {
'item-categories': { table: 'item_categories', entityType: 'item-categories' },
'item-usages': { table: 'item_usages', entityType: 'item-usages' },
'acquisition-methods': { table: 'acquisition_methods', entityType: 'acquisition-methods' },
maps: { table: 'maps', entityType: 'maps' }
maps: { table: 'maps', entityType: 'maps' },
'life-tags': { table: 'life_tags', entityType: 'life-tags' }
};
const sortableContentDefinitions: Record<SortableContentType, SortableContentDefinition> = {
@@ -1068,7 +1073,8 @@ export async function getOptions(locale = defaultLocale) {
itemCategories,
itemUsages,
acquisitionMethods,
maps
maps,
lifeTags
] = await Promise.all([
optionSelect('pokemon_types', 'pokemon-types', locale),
skillOptions(locale),
@@ -1077,7 +1083,8 @@ export async function getOptions(locale = defaultLocale) {
optionSelect('item_categories', 'item-categories', locale),
optionSelect('item_usages', 'item-usages', locale),
optionSelect('acquisition_methods', 'acquisition-methods', locale),
optionSelect('maps', 'maps', locale)
optionSelect('maps', 'maps', locale),
optionSelect('life_tags', 'life-tags', locale)
]);
return {
@@ -1089,7 +1096,8 @@ export async function getOptions(locale = defaultLocale) {
itemUsages,
acquisitionMethods,
itemTags: favoriteThings,
maps
maps,
lifeTags
};
}
@@ -1231,7 +1239,10 @@ function cleanLifePostPayload(payload: Record<string, unknown>): LifePostPayload
throw validationError('Post is too long');
}
return { body };
return {
body,
tagIds: cleanIds(payload.tagIds)
};
}
function cleanLifeCommentPayload(payload: Record<string, unknown>): LifeCommentPayload {
@@ -1264,7 +1275,9 @@ function cleanLifeReactionType(value: unknown): LifeReactionType {
return value;
}
function lifePostProjection(): string {
function lifePostProjection(locale = defaultLocale): string {
const tagName = localizedName('life-tags', 'lt', locale);
return `
SELECT
lp.id,
@@ -1279,7 +1292,13 @@ function lifePostProjection(): string {
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"
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
FROM life_posts lp
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
@@ -1337,6 +1356,7 @@ function hydrateLifePost(
updatedAt: post.updatedAt,
author: post.author,
updatedBy: post.updatedBy,
tags: post.tags,
comments: commentsByPost.get(post.id) ?? [],
reactionCounts: countsByPost.get(post.id) ?? emptyLifeReactionCounts(),
myReaction: myReactionsByPost.get(post.id) ?? null
@@ -1479,10 +1499,15 @@ async function getLifeCommentById(id: number): Promise<LifeComment | null> {
return row ? { ...row, replies: [] } : null;
}
export async function listLifePosts(paramsQuery: QueryParams = {}, userId: number | null = null): Promise<LifePostsPage> {
export async function listLifePosts(
paramsQuery: QueryParams = {},
userId: number | null = null,
locale = defaultLocale
): Promise<LifePostsPage> {
const cursor = decodeLifePostCursor(paramsQuery.cursor);
const limit = cleanLifePostLimit(paramsQuery.limit);
const search = asString(paramsQuery.search)?.trim();
const tagIdValue = asString(paramsQuery.tagId)?.trim();
const params: unknown[] = [];
const conditions: string[] = [];
@@ -1491,6 +1516,17 @@ export async function listLifePosts(paramsQuery: QueryParams = {}, userId: numbe
conditions.push(`lp.body ILIKE $${params.length}`);
}
if (tagIdValue) {
const tagId = requirePositiveInteger(tagIdValue, 'Tag is invalid');
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 (cursor) {
params.push(cursor.createdAt, cursor.id);
conditions.push(`(lp.created_at, lp.id) < ($${params.length - 1}::timestamptz, $${params.length}::integer)`);
@@ -1500,7 +1536,7 @@ export async function listLifePosts(paramsQuery: QueryParams = {}, userId: numbe
params.push(limit + 1);
const rows = await query<LifePostRow>(
`
${lifePostProjection()}
${lifePostProjection(locale)}
${whereClause}
ORDER BY lp.created_at DESC, lp.id DESC
LIMIT $${params.length}
@@ -1521,10 +1557,10 @@ export async function listLifePosts(paramsQuery: QueryParams = {}, userId: numbe
};
}
async function getLifePostById(id: number, userId: number | null = null): Promise<LifePost | null> {
async function getLifePostById(id: number, userId: number | null = null, locale = defaultLocale): Promise<LifePost | null> {
const post = await queryOne<LifePostRow>(
`
${lifePostProjection()}
${lifePostProjection(locale)}
WHERE lp.id = $1
`,
[id]
@@ -1539,36 +1575,66 @@ async function getLifePostById(id: number, userId: number | null = null): Promis
return hydrateLifePost(post, commentsByPost, countsByPost, myReactionsByPost);
}
export async function createLifePost(payload: Record<string, unknown>, userId: number) {
const cleanPayload = cleanLifePostPayload(payload);
async function replaceLifePostTags(client: DbClient, postId: number, tagIds: number[]): Promise<void> {
await client.query('DELETE FROM life_post_tags WHERE post_id = $1', [postId]);
const result = await queryOne<{ id: number }>(
`
INSERT INTO life_posts (body, created_by_user_id, updated_by_user_id)
VALUES ($1, $2, $2)
RETURNING id
`,
[cleanPayload.body, userId]
);
return getLifePostById(result?.id ?? 0, userId);
for (const tagId of tagIds) {
await client.query(
`
INSERT INTO life_post_tags (post_id, tag_id)
VALUES ($1, $2)
`,
[postId, tagId]
);
}
}
export async function updateLifePost(id: number, payload: Record<string, unknown>, userId: number) {
export async function createLifePost(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
const cleanPayload = cleanLifePostPayload(payload);
const result = await queryOne<{ id: number }>(
`
UPDATE life_posts
SET body = $1, updated_by_user_id = $2, updated_at = now()
WHERE id = $3
AND created_by_user_id = $2
RETURNING id
`,
[cleanPayload.body, userId, id]
);
const id = await withTransaction(async (client) => {
const result = await client.query<{ id: number }>(
`
INSERT INTO life_posts (body, created_by_user_id, updated_by_user_id)
VALUES ($1, $2, $2)
RETURNING id
`,
[cleanPayload.body, userId]
);
return result ? getLifePostById(result.id, userId) : null;
const createdId = result.rows[0].id;
await replaceLifePostTags(client, createdId, cleanPayload.tagIds);
return createdId;
});
return getLifePostById(id, userId, locale);
}
export async function updateLifePost(id: number, payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
const cleanPayload = cleanLifePostPayload(payload);
const updatedId = await withTransaction(async (client) => {
const result = await client.query<{ id: number }>(
`
UPDATE life_posts
SET body = $1, updated_by_user_id = $2, updated_at = now()
WHERE id = $3
AND created_by_user_id = $2
RETURNING id
`,
[cleanPayload.body, userId, id]
);
const resultId = result.rows[0]?.id ?? null;
if (resultId === null) {
return null;
}
await replaceLifePostTags(client, resultId, cleanPayload.tagIds);
return resultId;
});
return updatedId ? getLifePostById(updatedId, userId, locale) : null;
}
export async function deleteLifePost(id: number, userId: number) {
@@ -1585,7 +1651,12 @@ export async function deleteLifePost(id: number, userId: number) {
return Boolean(result);
}
export async function setLifePostReaction(postId: number, payload: Record<string, unknown>, userId: number) {
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 }>(
@@ -1600,10 +1671,10 @@ export async function setLifePostReaction(postId: number, payload: Record<string
[postId, userId, reactionType]
);
return result ? getLifePostById(result.postId, userId) : null;
return result ? getLifePostById(result.postId, userId, locale) : null;
}
export async function deleteLifePostReaction(postId: number, userId: number) {
export async function deleteLifePostReaction(postId: number, userId: number, locale = defaultLocale) {
await queryOne<{ postId: number }>(
`
DELETE FROM life_post_reactions
@@ -1614,7 +1685,7 @@ export async function deleteLifePostReaction(postId: number, userId: number) {
[postId, userId]
);
return getLifePostById(postId, userId);
return getLifePostById(postId, userId, locale);
}
export async function createLifeComment(postId: number, payload: Record<string, unknown>, userId: number) {

View File

@@ -195,12 +195,14 @@ app.get('/api/daily-checklist', async (request) => listDailyChecklistItems(reque
app.get('/api/life-posts', async (request) => {
const user = await optionalUser(request);
return listLifePosts(request.query as Record<string, string | string[] | undefined>, user?.id ?? null);
return listLifePosts(request.query as Record<string, string | string[] | undefined>, user?.id ?? null, requestLocale(request));
});
app.post('/api/life-posts', async (request, reply) => {
const user = await requireVerifiedUser(request, reply);
return user ? reply.code(201).send(await createLifePost(request.body as Record<string, unknown>, user.id)) : undefined;
return user
? reply.code(201).send(await createLifePost(request.body as Record<string, unknown>, user.id, requestLocale(request)))
: undefined;
});
app.post('/api/life-posts/:postId/comments', async (request, reply) => {
@@ -234,7 +236,7 @@ app.put('/api/life-posts/:id', async (request, reply) => {
return;
}
const { id } = request.params as { id: string };
const post = await updateLifePost(Number(id), request.body as Record<string, unknown>, user.id);
const post = await updateLifePost(Number(id), request.body as Record<string, unknown>, user.id, requestLocale(request));
return post ? post : reply.code(404).send({ message: 'Not found' });
});
@@ -244,7 +246,7 @@ app.put('/api/life-posts/:id/reaction', async (request, reply) => {
return;
}
const { id } = request.params as { id: string };
const post = await setLifePostReaction(Number(id), request.body as Record<string, unknown>, user.id);
const post = await setLifePostReaction(Number(id), request.body as Record<string, unknown>, user.id, requestLocale(request));
return post ? post : reply.code(404).send({ message: 'Not found' });
});
@@ -254,7 +256,7 @@ app.delete('/api/life-posts/:id/reaction', async (request, reply) => {
return;
}
const { id } = request.params as { id: string };
const post = await deleteLifePostReaction(Number(id), user.id);
const post = await deleteLifePostReaction(Number(id), user.id, requestLocale(request));
return post ? post : reply.code(404).send({ message: 'Not found' });
});