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:
@@ -119,6 +119,21 @@ 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_posts (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
body text NOT NULL CHECK (length(body) BETWEEN 1 AND 2000),
|
||||
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()
|
||||
);
|
||||
|
||||
ALTER TABLE life_posts DROP COLUMN IF EXISTS link_url;
|
||||
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 skills (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
name text NOT NULL UNIQUE,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user