feat(life): implement soft delete for life posts
Add deleted_at and deleted_by_user_id to life_posts schema Update queries to filter out and prevent interactions with deleted posts
This commit is contained in:
@@ -378,10 +378,11 @@ Life Post 可配置:
|
|||||||
- 所有人都可以浏览 Life 信息流。
|
- 所有人都可以浏览 Life 信息流。
|
||||||
- 信息流按创建时间倒序展示。
|
- 信息流按创建时间倒序展示。
|
||||||
- 已注册并完成邮箱验证的用户可以发布 Life Post。
|
- 已注册并完成邮箱验证的用户可以发布 Life Post。
|
||||||
- 作者本人可以编辑、删除自己的 Life Post。
|
- 作者本人可以编辑、删除自己的 Life Post;删除 Life Post 使用软删除。
|
||||||
- 已注册并完成邮箱验证的用户发布或编辑 Life Post 时可以选择一个或多个 Life 标签。
|
- 已注册并完成邮箱验证的用户发布或编辑 Life Post 时可以选择一个或多个 Life 标签。
|
||||||
- 已注册并完成邮箱验证的用户可以评论 Life Post,并回复顶层评论。
|
- 已注册并完成邮箱验证的用户可以评论 Life Post,并回复顶层评论。
|
||||||
- 评论作者可以删除自己的评论;删除评论后正文不再展示,已有回复保留在原位置。
|
- 评论作者可以删除自己的评论;删除评论后正文不再展示,已有回复保留在原位置。
|
||||||
|
- 已软删除的 Life Post 不出现在信息流、搜索或标签筛选结果中,也不能继续编辑、评论或设置 Reaction。
|
||||||
- 每条 Life Post 默认只展示评论入口与评论数量;评论列表、回复和评论输入默认折叠,用户点击后展开。
|
- 每条 Life Post 默认只展示评论入口与评论数量;评论列表、回复和评论输入默认折叠,用户点击后展开。
|
||||||
- 已注册并完成邮箱验证的用户可以对每条 Life Post 选择一个 Reaction;普通点击默认设置 `like`,再次点击 `like` 会取消,当前为其他 Reaction 时普通点击会替换为 `like`。
|
- 已注册并完成邮箱验证的用户可以对每条 Life Post 选择一个 Reaction;普通点击默认设置 `like`,再次点击 `like` 会取消,当前为其他 Reaction 时普通点击会替换为 `like`。
|
||||||
- Life Reaction 的其他类型通过右键 / context menu 打开 Popup 选择;再次选择当前 Reaction 会取消,选择其他 Reaction 会替换原 Reaction。
|
- Life Reaction 的其他类型通过右键 / context menu 打开 Popup 选择;再次选择当前 Reaction 会取消,选择其他 Reaction 会替换原 Reaction。
|
||||||
@@ -399,6 +400,7 @@ API 暴露边界:
|
|||||||
- Life Reaction 对外只返回按类型汇总的数量和当前用户自己的 Reaction,不返回其他用户的 Reaction 明细。
|
- Life Reaction 对外只返回按类型汇总的数量和当前用户自己的 Reaction,不返回其他用户的 Reaction 明细。
|
||||||
- Life Post 列表 API 返回分页结果:`items`、`nextCursor`、`hasMore`;`cursor` 是不透明分页令牌。
|
- Life Post 列表 API 返回分页结果:`items`、`nextCursor`、`hasMore`;`cursor` 是不透明分页令牌。
|
||||||
- API 不返回邮箱、token/hash、内部调试字段或不必要的审计 payload。
|
- API 不返回邮箱、token/hash、内部调试字段或不必要的审计 payload。
|
||||||
|
- API 不返回 Life Post 的 `deleted_at`、`deleted_by_user_id` 等内部软删除字段。
|
||||||
- 非作者不能编辑或删除其他用户的 Life Post。
|
- 非作者不能编辑或删除其他用户的 Life Post。
|
||||||
- 非作者不能删除其他用户的 Life Comment。
|
- 非作者不能删除其他用户的 Life Comment。
|
||||||
|
|
||||||
|
|||||||
@@ -136,16 +136,24 @@ CREATE TABLE IF NOT EXISTS life_posts (
|
|||||||
body text NOT NULL CHECK (length(body) BETWEEN 1 AND 2000),
|
body text NOT NULL CHECK (length(body) BETWEEN 1 AND 2000),
|
||||||
created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
||||||
updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
deleted_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
deleted_at timestamptz,
|
||||||
created_at timestamptz NOT NULL DEFAULT now(),
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
updated_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_url;
|
||||||
ALTER TABLE life_posts DROP COLUMN IF EXISTS link_title;
|
ALTER TABLE life_posts DROP COLUMN IF EXISTS link_title;
|
||||||
|
ALTER TABLE life_posts ADD COLUMN IF NOT EXISTS deleted_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||||
|
ALTER TABLE life_posts ADD COLUMN IF NOT EXISTS deleted_at timestamptz;
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS life_posts_created_at_idx
|
CREATE INDEX IF NOT EXISTS life_posts_created_at_idx
|
||||||
ON life_posts(created_at DESC, id DESC);
|
ON life_posts(created_at DESC, id DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS life_posts_active_created_at_idx
|
||||||
|
ON life_posts(created_at DESC, id DESC)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS life_post_tags (
|
CREATE TABLE IF NOT EXISTS life_post_tags (
|
||||||
post_id integer NOT NULL REFERENCES life_posts(id) ON DELETE CASCADE,
|
post_id integer NOT NULL REFERENCES life_posts(id) ON DELETE CASCADE,
|
||||||
tag_id integer NOT NULL REFERENCES life_tags(id) ON DELETE CASCADE,
|
tag_id integer NOT NULL REFERENCES life_tags(id) ON DELETE CASCADE,
|
||||||
|
|||||||
@@ -1509,7 +1509,7 @@ export async function listLifePosts(
|
|||||||
const search = asString(paramsQuery.search)?.trim();
|
const search = asString(paramsQuery.search)?.trim();
|
||||||
const tagIdValue = asString(paramsQuery.tagId)?.trim();
|
const tagIdValue = asString(paramsQuery.tagId)?.trim();
|
||||||
const params: unknown[] = [];
|
const params: unknown[] = [];
|
||||||
const conditions: string[] = [];
|
const conditions: string[] = ['lp.deleted_at IS NULL'];
|
||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
params.push(`%${search}%`);
|
params.push(`%${search}%`);
|
||||||
@@ -1562,6 +1562,7 @@ async function getLifePostById(id: number, userId: number | null = null, locale
|
|||||||
`
|
`
|
||||||
${lifePostProjection(locale)}
|
${lifePostProjection(locale)}
|
||||||
WHERE lp.id = $1
|
WHERE lp.id = $1
|
||||||
|
AND lp.deleted_at IS NULL
|
||||||
`,
|
`,
|
||||||
[id]
|
[id]
|
||||||
);
|
);
|
||||||
@@ -1620,6 +1621,7 @@ export async function updateLifePost(id: number, payload: Record<string, unknown
|
|||||||
SET body = $1, updated_by_user_id = $2, updated_at = now()
|
SET body = $1, updated_by_user_id = $2, updated_at = now()
|
||||||
WHERE id = $3
|
WHERE id = $3
|
||||||
AND created_by_user_id = $2
|
AND created_by_user_id = $2
|
||||||
|
AND deleted_at IS NULL
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`,
|
`,
|
||||||
[cleanPayload.body, userId, id]
|
[cleanPayload.body, userId, id]
|
||||||
@@ -1640,9 +1642,14 @@ export async function updateLifePost(id: number, payload: Record<string, unknown
|
|||||||
export async function deleteLifePost(id: number, userId: number) {
|
export async function deleteLifePost(id: number, userId: number) {
|
||||||
const result = await queryOne<{ id: number }>(
|
const result = await queryOne<{ id: number }>(
|
||||||
`
|
`
|
||||||
DELETE FROM life_posts
|
UPDATE life_posts
|
||||||
|
SET deleted_at = now(),
|
||||||
|
deleted_by_user_id = $2,
|
||||||
|
updated_by_user_id = $2,
|
||||||
|
updated_at = now()
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
AND created_by_user_id = $2
|
AND created_by_user_id = $2
|
||||||
|
AND deleted_at IS NULL
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`,
|
`,
|
||||||
[id, userId]
|
[id, userId]
|
||||||
@@ -1663,7 +1670,12 @@ export async function setLifePostReaction(
|
|||||||
`
|
`
|
||||||
INSERT INTO life_post_reactions (post_id, user_id, reaction_type)
|
INSERT INTO life_post_reactions (post_id, user_id, reaction_type)
|
||||||
SELECT $1, $2, $3
|
SELECT $1, $2, $3
|
||||||
WHERE EXISTS (SELECT 1 FROM life_posts WHERE id = $1)
|
WHERE EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM life_posts
|
||||||
|
WHERE id = $1
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
)
|
||||||
ON CONFLICT (post_id, user_id)
|
ON CONFLICT (post_id, user_id)
|
||||||
DO UPDATE SET reaction_type = EXCLUDED.reaction_type, updated_at = now()
|
DO UPDATE SET reaction_type = EXCLUDED.reaction_type, updated_at = now()
|
||||||
RETURNING post_id AS "postId"
|
RETURNING post_id AS "postId"
|
||||||
@@ -1680,6 +1692,12 @@ export async function deleteLifePostReaction(postId: number, userId: number, loc
|
|||||||
DELETE FROM life_post_reactions
|
DELETE FROM life_post_reactions
|
||||||
WHERE post_id = $1
|
WHERE post_id = $1
|
||||||
AND user_id = $2
|
AND user_id = $2
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM life_posts
|
||||||
|
WHERE id = $1
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
)
|
||||||
RETURNING post_id AS "postId"
|
RETURNING post_id AS "postId"
|
||||||
`,
|
`,
|
||||||
[postId, userId]
|
[postId, userId]
|
||||||
@@ -1695,7 +1713,12 @@ export async function createLifeComment(postId: number, payload: Record<string,
|
|||||||
`
|
`
|
||||||
INSERT INTO life_post_comments (post_id, body, created_by_user_id)
|
INSERT INTO life_post_comments (post_id, body, created_by_user_id)
|
||||||
SELECT $1, $2, $3
|
SELECT $1, $2, $3
|
||||||
WHERE EXISTS (SELECT 1 FROM life_posts WHERE id = $1)
|
WHERE EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM life_posts
|
||||||
|
WHERE id = $1
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`,
|
`,
|
||||||
[postId, cleanPayload.body, userId]
|
[postId, cleanPayload.body, userId]
|
||||||
@@ -1717,10 +1740,12 @@ export async function createLifeCommentReply(
|
|||||||
INSERT INTO life_post_comments (post_id, parent_comment_id, body, created_by_user_id)
|
INSERT INTO life_post_comments (post_id, parent_comment_id, body, created_by_user_id)
|
||||||
SELECT lc.post_id, lc.id, $3, $4
|
SELECT lc.post_id, lc.id, $3, $4
|
||||||
FROM life_post_comments lc
|
FROM life_post_comments lc
|
||||||
|
JOIN life_posts lp ON lp.id = lc.post_id
|
||||||
WHERE lc.post_id = $1
|
WHERE lc.post_id = $1
|
||||||
AND lc.id = $2
|
AND lc.id = $2
|
||||||
AND lc.parent_comment_id IS NULL
|
AND lc.parent_comment_id IS NULL
|
||||||
AND lc.deleted_at IS NULL
|
AND lc.deleted_at IS NULL
|
||||||
|
AND lp.deleted_at IS NULL
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`,
|
`,
|
||||||
[postId, commentId, cleanPayload.body, userId]
|
[postId, commentId, cleanPayload.body, userId]
|
||||||
|
|||||||
Reference in New Issue
Block a user