feat(life): add reactions to life posts

Support 'like', 'helpful', 'fun', and 'thanks' reactions.
Add API endpoints and database schema for post reactions.
Update UI with reaction picker and summary counts.
This commit is contained in:
2026-05-01 21:49:56 +08:00
parent a683982b80
commit 71b7e838ed
9 changed files with 605 additions and 32 deletions

View File

@@ -152,6 +152,18 @@ CREATE INDEX IF NOT EXISTS life_post_comments_post_idx
CREATE INDEX IF NOT EXISTS life_post_comments_parent_idx
ON life_post_comments(parent_comment_id, created_at, id);
CREATE TABLE IF NOT EXISTS life_post_reactions (
post_id integer NOT NULL REFERENCES life_posts(id) ON DELETE CASCADE,
user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
reaction_type text NOT NULL CHECK (reaction_type IN ('like', 'helpful', 'fun', 'thanks')),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (post_id, user_id)
);
CREATE INDEX IF NOT EXISTS life_post_reactions_post_idx
ON life_post_reactions(post_id, reaction_type);
CREATE TABLE IF NOT EXISTS skills (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name text NOT NULL UNIQUE,

View File

@@ -112,6 +112,9 @@ type LifeCommentPayload = {
body: string;
};
type LifeReactionType = 'like' | 'helpful' | 'fun' | 'thanks';
type LifeReactionCounts = Record<LifeReactionType, number>;
type LifeCommentRow = {
id: number;
postId: number;
@@ -138,6 +141,8 @@ type LifePostRow = {
type LifePost = LifePostRow & {
comments: LifeComment[];
reactionCounts: LifeReactionCounts;
myReaction: LifeReactionType | null;
};
type HabitatPayload = {
@@ -210,6 +215,7 @@ const timeOfDays = ['早晨', '中午', '傍晚', '晚上'];
const weathers = ['晴天', '阴天', '雨天'];
const defaultLocale = 'en';
const localePattern = /^[a-z]{2}(-[A-Z]{2})?$/;
const lifeReactionTypes = ['like', 'helpful', 'fun', 'thanks'] as const;
const pokemonStatLabels: Array<{ key: keyof PokemonStats; label: string }> = [
{ key: 'hp', label: 'HP' },
{ key: 'attack', label: 'Attack' },
@@ -1222,6 +1228,27 @@ function cleanLifeCommentPayload(payload: Record<string, unknown>): LifeCommentP
return { body };
}
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('Reaction is invalid');
}
return value;
}
function lifePostProjection(): string {
return `
SELECT
@@ -1309,6 +1336,65 @@ async function lifeCommentsForPosts(postIds: number[]): Promise<Map<number, Life
return commentsByPost;
}
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 };
}
async function getLifeCommentById(id: number): Promise<LifeComment | null> {
const row = await queryOne<LifeCommentRow>(
`
@@ -1320,21 +1406,25 @@ async function getLifeCommentById(id: number): Promise<LifeComment | null> {
return row ? { ...row, replies: [] } : null;
}
export async function listLifePosts(): Promise<LifePost[]> {
export async function listLifePosts(userId: number | null = null): Promise<LifePost[]> {
const posts = await query<LifePostRow>(`
${lifePostProjection()}
ORDER BY lp.created_at DESC, lp.id DESC
`);
const commentsByPost = await lifeCommentsForPosts(posts.map((post) => post.id));
const postIds = posts.map((post) => post.id);
const commentsByPost = await lifeCommentsForPosts(postIds);
const { countsByPost, myReactionsByPost } = await lifeReactionsForPosts(postIds, userId);
return posts.map((post) => ({
...post,
comments: commentsByPost.get(post.id) ?? []
comments: commentsByPost.get(post.id) ?? [],
reactionCounts: countsByPost.get(post.id) ?? emptyLifeReactionCounts(),
myReaction: myReactionsByPost.get(post.id) ?? null
}));
}
async function getLifePostById(id: number): Promise<LifePost | null> {
async function getLifePostById(id: number, userId: number | null = null): Promise<LifePost | null> {
const post = await queryOne<LifePostRow>(
`
${lifePostProjection()}
@@ -1348,9 +1438,12 @@ async function getLifePostById(id: number): Promise<LifePost | null> {
}
const commentsByPost = await lifeCommentsForPosts([post.id]);
const { countsByPost, myReactionsByPost } = await lifeReactionsForPosts([post.id], userId);
return {
...post,
comments: commentsByPost.get(post.id) ?? []
comments: commentsByPost.get(post.id) ?? [],
reactionCounts: countsByPost.get(post.id) ?? emptyLifeReactionCounts(),
myReaction: myReactionsByPost.get(post.id) ?? null
};
}
@@ -1366,7 +1459,7 @@ export async function createLifePost(payload: Record<string, unknown>, userId: n
[cleanPayload.body, userId]
);
return getLifePostById(result?.id ?? 0);
return getLifePostById(result?.id ?? 0, userId);
}
export async function updateLifePost(id: number, payload: Record<string, unknown>, userId: number) {
@@ -1383,7 +1476,7 @@ export async function updateLifePost(id: number, payload: Record<string, unknown
[cleanPayload.body, userId, id]
);
return result ? getLifePostById(result.id) : null;
return result ? getLifePostById(result.id, userId) : null;
}
export async function deleteLifePost(id: number, userId: number) {
@@ -1400,6 +1493,38 @@ export async function deleteLifePost(id: number, userId: number) {
return Boolean(result);
}
export async function setLifePostReaction(postId: number, payload: Record<string, unknown>, userId: number) {
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)
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]
);
return result ? getLifePostById(result.postId, userId) : null;
}
export async function deleteLifePostReaction(postId: number, userId: number) {
await queryOne<{ postId: number }>(
`
DELETE FROM life_post_reactions
WHERE post_id = $1
AND user_id = $2
RETURNING post_id AS "postId"
`,
[postId, userId]
);
return getLifePostById(postId, userId);
}
export async function createLifeComment(postId: number, payload: Record<string, unknown>, userId: number) {
const cleanPayload = cleanLifeCommentPayload(payload);

View File

@@ -22,6 +22,7 @@ import {
deleteLanguage,
deleteLifeComment,
deleteLifePost,
deleteLifePostReaction,
deletePokemon,
deleteRecipe,
getHabitat,
@@ -45,6 +46,7 @@ import {
reorderLanguages,
reorderPokemon,
reorderRecipes,
setLifePostReaction,
updateConfig,
updateDailyChecklistItem,
updateHabitat,
@@ -144,6 +146,19 @@ async function requireVerifiedUser(request: FastifyRequest, reply: FastifyReply)
return user;
}
async function optionalUser(request: FastifyRequest): Promise<AuthUser | null> {
const token = getBearerToken(request.headers.authorization);
if (!token) {
return null;
}
try {
return await getUserBySessionToken(token);
} catch {
return null;
}
}
app.post('/api/auth/register', async (request, reply) =>
reply.code(201).send(await registerUser(request.body as Record<string, unknown>, requestLocale(request)))
);
@@ -178,7 +193,10 @@ app.get('/api/options', async (request) => getOptions(requestLocale(request)));
app.get('/api/daily-checklist', async (request) => listDailyChecklistItems(requestLocale(request)));
app.get('/api/life-posts', async () => listLifePosts());
app.get('/api/life-posts', async (request) => {
const user = await optionalUser(request);
return listLifePosts(user?.id ?? null);
});
app.post('/api/life-posts', async (request, reply) => {
const user = await requireVerifiedUser(request, reply);
@@ -220,6 +238,26 @@ app.put('/api/life-posts/:id', async (request, reply) => {
return post ? post : reply.code(404).send({ message: 'Not found' });
});
app.put('/api/life-posts/:id/reaction', async (request, reply) => {
const user = await requireVerifiedUser(request, reply);
if (!user) {
return;
}
const { id } = request.params as { id: string };
const post = await setLifePostReaction(Number(id), request.body as Record<string, unknown>, user.id);
return post ? post : reply.code(404).send({ message: 'Not found' });
});
app.delete('/api/life-posts/:id/reaction', async (request, reply) => {
const user = await requireVerifiedUser(request, reply);
if (!user) {
return;
}
const { id } = request.params as { id: string };
const post = await deleteLifePostReaction(Number(id), user.id);
return post ? post : reply.code(404).send({ message: 'Not found' });
});
app.delete('/api/life-posts/:id', async (request, reply) => {
const user = await requireVerifiedUser(request, reply);
if (!user) {