feat(life): add community feed for user posts

Add life_posts schema and CRUD API endpoints
Implement LifeView with inline composer and feed display
This commit is contained in:
2026-05-01 21:03:09 +08:00
parent 49aae3bd7c
commit cd1891cc82
11 changed files with 680 additions and 2 deletions

View File

@@ -104,6 +104,10 @@ type DailyChecklistPayload = {
translations: TranslationInput;
};
type LifePostPayload = {
body: string;
};
type HabitatPayload = {
name: string;
translations: TranslationInput;
@@ -1168,6 +1172,99 @@ export async function deleteDailyChecklistItem(id: number, userId: number) {
});
}
function cleanLifePostPayload(payload: Record<string, unknown>): LifePostPayload {
const body = cleanName(payload.body, 'Please enter a post');
if (body.length > 2000) {
throw validationError('Post is too long');
}
return { body };
}
function lifePostProjection(): string {
return `
SELECT
lp.id,
lp.body,
lp.created_at AS "createdAt",
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"
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
`;
}
export function listLifePosts() {
return query(`
${lifePostProjection()}
ORDER BY lp.created_at DESC, lp.id DESC
`);
}
async function getLifePostById(id: number) {
return queryOne(
`
${lifePostProjection()}
WHERE lp.id = $1
`,
[id]
);
}
export async function createLifePost(payload: Record<string, unknown>, userId: number) {
const cleanPayload = cleanLifePostPayload(payload);
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);
}
export async function updateLifePost(id: number, payload: Record<string, unknown>, userId: number) {
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]
);
return result ? getLifePostById(result.id) : null;
}
export async function deleteLifePost(id: number, userId: number) {
const result = await queryOne<{ id: number }>(
`
DELETE FROM life_posts
WHERE id = $1
AND created_by_user_id = $2
RETURNING id
`,
[id, userId]
);
return Boolean(result);
}
export function isConfigType(type: string): type is ConfigType {
return Object.hasOwn(configDefinitions, type);
}

View File

@@ -10,6 +10,7 @@ import {
createHabitat,
createItem,
createLanguage,
createLifePost,
createPokemon,
createRecipe,
deleteConfig,
@@ -17,6 +18,7 @@ import {
deleteHabitat,
deleteItem,
deleteLanguage,
deleteLifePost,
deletePokemon,
deleteRecipe,
getHabitat,
@@ -30,6 +32,7 @@ import {
listHabitats,
listItems,
listLanguages,
listLifePosts,
listPokemon,
listRecipes,
reorderConfig,
@@ -44,6 +47,7 @@ import {
updateHabitat,
updateItem,
updateLanguage,
updateLifePost,
updatePokemon,
updateRecipe
} from './queries.ts';
@@ -171,6 +175,33 @@ 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.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;
});
app.put('/api/life-posts/:id', async (request, reply) => {
const user = await requireVerifiedUser(request, reply);
if (!user) {
return;
}
const { id } = request.params as { id: string };
const post = await updateLifePost(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', async (request, reply) => {
const user = await requireVerifiedUser(request, reply);
if (!user) {
return;
}
const { id } = request.params as { id: string };
const deleted = await deleteLifePost(Number(id), user.id);
return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' });
});
app.get('/api/pokemon', async (request) =>
listPokemon(request.query as Record<string, string | string[] | undefined>, requestLocale(request))
);