From 6b595e7272dea4207639ea4f73cf525329b23769 Mon Sep 17 00:00:00 2001 From: Harshit Singh Bhandari Date: Sat, 18 Jul 2026 19:59:33 +0530 Subject: [PATCH 01/12] remove comments (issue #85) Delete the comments feature end to end: API routes, UI components, lib/comments helpers, and their tests. Strip the comment branches from report/moderation code (reports schema, report creation, admin report list and page, ReportActions, RestoreButton, audit page target-type options) and drop the now-unused `comment` rate-limit bucket. Post and user reports keep working; posts.comment_count display is left alone for a later task per the plan. Co-Authored-By: Claude Fable 5 --- app/[username]/[type]/[slug]/page.tsx | 11 - app/admin/audit/page.tsx | 10 +- app/admin/reports/page.tsx | 6 - app/api/comments/[id]/restore/route.ts | 108 ---- app/api/comments/[id]/route.ts | 213 ------- app/api/comments/route.ts | 175 ------ app/api/reports/route.ts | 17 - components/admin/ReportActions.tsx | 4 +- components/admin/RestoreButton.tsx | 7 +- components/post/CommentForm.tsx | 238 -------- components/post/CommentThread.tsx | 353 ------------ components/post/CommentsSection.tsx | 135 ----- components/skeleton/CommentSkeleton.tsx | 55 -- components/skeleton/Skeleton.tsx | 2 +- lib/admin/list-reports.ts | 57 +- lib/comments/abuse.ts | 35 -- lib/comments/depth.ts | 26 - lib/comments/format-time.ts | 34 -- lib/comments/sanitize.ts | 9 - lib/comments/schema.ts | 20 - lib/comments/tree.ts | 80 --- lib/rate-limit.ts | 2 - lib/reports/schema.ts | 2 +- tests/e2e/admin.spec.ts | 2 +- tests/e2e/comments.spec.ts | 310 ---------- tests/unit/admin/list-reports.test.ts | 7 - tests/unit/api/comments-create.test.ts | 544 ------------------ tests/unit/api/comments-delete.test.ts | 315 ---------- tests/unit/api/comments-patch.test.ts | 325 ----------- tests/unit/api/comments/restore.test.ts | 154 ----- tests/unit/api/reports-post.test.ts | 40 -- tests/unit/comments/abuse.test.ts | 94 --- tests/unit/comments/depth.test.ts | 53 -- tests/unit/comments/format-time.test.ts | 31 - tests/unit/comments/sanitize.test.ts | 36 -- tests/unit/comments/schema.test.ts | 99 ---- tests/unit/comments/tree.test.ts | 114 ---- .../unit/components/comments-section.test.tsx | 271 --------- tests/unit/components/skeleton.test.tsx | 6 - tests/unit/posts/post-page.test.tsx | 4 - tests/unit/rate-limit.test.ts | 1 - 41 files changed, 11 insertions(+), 3994 deletions(-) delete mode 100644 app/api/comments/[id]/restore/route.ts delete mode 100644 app/api/comments/[id]/route.ts delete mode 100644 app/api/comments/route.ts delete mode 100644 components/post/CommentForm.tsx delete mode 100644 components/post/CommentThread.tsx delete mode 100644 components/post/CommentsSection.tsx delete mode 100644 components/skeleton/CommentSkeleton.tsx delete mode 100644 lib/comments/abuse.ts delete mode 100644 lib/comments/depth.ts delete mode 100644 lib/comments/format-time.ts delete mode 100644 lib/comments/sanitize.ts delete mode 100644 lib/comments/schema.ts delete mode 100644 lib/comments/tree.ts delete mode 100644 tests/e2e/comments.spec.ts delete mode 100644 tests/unit/api/comments-create.test.ts delete mode 100644 tests/unit/api/comments-delete.test.ts delete mode 100644 tests/unit/api/comments-patch.test.ts delete mode 100644 tests/unit/api/comments/restore.test.ts delete mode 100644 tests/unit/comments/abuse.test.ts delete mode 100644 tests/unit/comments/depth.test.ts delete mode 100644 tests/unit/comments/format-time.test.ts delete mode 100644 tests/unit/comments/sanitize.test.ts delete mode 100644 tests/unit/comments/schema.test.ts delete mode 100644 tests/unit/comments/tree.test.ts delete mode 100644 tests/unit/components/comments-section.test.tsx diff --git a/app/[username]/[type]/[slug]/page.tsx b/app/[username]/[type]/[slug]/page.tsx index aaa1b78..c11d6e3 100644 --- a/app/[username]/[type]/[slug]/page.tsx +++ b/app/[username]/[type]/[slug]/page.tsx @@ -18,13 +18,11 @@ import { MdxFailedFallback } from '@/components/error/MdxFailedFallback' import { ViewBeacon } from '@/components/posts/ViewBeacon' import { AuthorActions } from '@/components/posts/AuthorActions' import { Backlinks } from '@/components/posts/Backlinks' -import { CommentsSection } from '@/components/post/CommentsSection' import { LikeButton } from '@/components/post/LikeButton' import { BookmarkButton } from '@/components/post/BookmarkButton' import { FollowButton } from '@/components/profile/FollowButton' import { getFollowState } from '@/lib/profile/follow-state' import { ReportButton } from '@/components/report/ReportButton' -import { CommentSkeleton } from '@/components/skeleton/CommentSkeleton' import { logRouteError } from '@/lib/logging/error-log' // Home discovery rails — the read page reuses the exact same three-column // shell as `/` (issue #70). Left nav only on the left; trending tags + @@ -369,15 +367,6 @@ export default async function PostPage({ - {/* Comments are the expensive thread walk on this page — a - service-role read of every comment row + author join. Stream - them in under a `CommentSkeleton` fallback so the post body - (already in DOM) paints first and the page is scrollable - before comments resolve. */} - }> - - - {/* Mobile-only ( { for (const row of rows) { if (row.action === 'restore_post' && row.target_type === 'post') { restored.add(targetKey(row.target_type, row.target_id)) - } else if (row.action === 'restore_comment' && row.target_type === 'comment') { - restored.add(targetKey(row.target_type, row.target_id)) } } return restored @@ -26,15 +24,11 @@ function collectRestoredTargets(rows: AuditActionRow[]): Set { function restorableTarget( row: AuditActionRow, restored: Set, -): { type: 'post' | 'comment'; id: string } | null { +): { type: 'post'; id: string } | null { if (row.action === 'delete_post' && row.target_type === 'post') { if (restored.has(targetKey(row.target_type, row.target_id))) return null return { type: 'post', id: row.target_id } } - if (row.action === 'delete_comment' && row.target_type === 'comment') { - if (restored.has(targetKey(row.target_type, row.target_id))) return null - return { type: 'comment', id: row.target_id } - } return null } @@ -55,7 +49,7 @@ function formatDate(iso: string) { return DATE_FMT.format(new Date(iso)) } -const TARGET_TYPE_OPTIONS = ['post', 'comment', 'user', 'tag', 'report', 'org'] +const TARGET_TYPE_OPTIONS = ['post', 'user', 'tag', 'report', 'org'] interface PageSearchParams { actor?: string diff --git a/app/admin/reports/page.tsx b/app/admin/reports/page.tsx index a1924a8..603e74c 100644 --- a/app/admin/reports/page.tsx +++ b/app/admin/reports/page.tsx @@ -31,12 +31,6 @@ function targetLink(target: ReportTarget | null): { href: string; label: string label: target.title, } } - if (target.type === 'comment') { - return { - href: `/${target.post_author_username}/post/${target.post_slug}#comments`, - label: `Comment: "${target.excerpt}${target.excerpt.length >= 80 ? '…' : ''}"`, - } - } if (target.type === 'user') { return { href: `/${target.username}`, diff --git a/app/api/comments/[id]/restore/route.ts b/app/api/comments/[id]/restore/route.ts deleted file mode 100644 index bca73a5..0000000 --- a/app/api/comments/[id]/restore/route.ts +++ /dev/null @@ -1,108 +0,0 @@ -import type { NextRequest } from 'next/server' -import { getSession } from '@/lib/auth' -import { requireAdminApi } from '@/lib/admin' -import { createAdminSupabaseClient } from '@/lib/supabase/admin' -import { guardMutatingRequest } from '@/lib/route-guard' -import { logRouteError } from '@/lib/logging/error-log' - -export const runtime = 'nodejs' - -function json(status: number, body: Record): Response { - return new Response(JSON.stringify(body), { - status, - headers: { 'Content-Type': 'application/json' }, - }) -} - -// --------------------------------------------------------------------------- -// POST /api/comments/[id]/restore -// -// Admin-only inverse of the moderation soft-delete on /api/comments/[id]. -// Clears deleted_at + deletion_reason and writes a mod_actions row with -// action='restore_comment'. Author-deleted comments are NOT eligible. -// --------------------------------------------------------------------------- - -export async function POST( - req: NextRequest | Request, - context: { params: Promise<{ id: string }> }, -): Promise { - const session = await getSession() - const gate = await requireAdminApi(session) - if (gate) return gate - const adminUserId = session!.user.id - - const guard = await guardMutatingRequest(req, { userId: adminUserId }) - if (guard.failed) return guard.response - - const { id: commentId } = await context.params - - const admin = createAdminSupabaseClient() - - const { data: commentRow, error: commentFetchErr } = await admin - .from('comments') - .select('id, author_id, deleted_at, deletion_reason') - .eq('id', commentId) - .maybeSingle() - - if (commentFetchErr || !commentRow) { - return json(404, { error: 'not_found' }) - } - - const comment = commentRow as { - id: string - author_id: string - deleted_at: string | null - deletion_reason: 'author' | 'moderation' | null - } - - if (comment.deleted_at === null) { - return json(400, { error: 'not_deleted' }) - } - - if (comment.deletion_reason !== 'moderation') { - return json(400, { - error: 'not_restorable', - detail: comment.deletion_reason ?? 'unknown', - }) - } - - let reason: string | null = null - try { - const raw = await req.text() - if (raw) { - const parsed = JSON.parse(raw) - if (parsed && typeof parsed === 'object' && typeof parsed.reason === 'string') { - reason = parsed.reason.slice(0, 1000) - } - } - } catch { - // ignore — restore commonly has no body - } - - const { error: updateErr } = await admin - .from('comments') - .update({ deleted_at: null, deletion_reason: null }) - .eq('id', commentId) - - if (updateErr) { - return json(500, { error: 'restore_failed', detail: updateErr.message }) - } - - const { error: modErr } = await admin.from('mod_actions').insert({ - mod_user_id: adminUserId, - action: 'restore_comment', - target_type: 'comment', - target_id: String(commentId), - reason, - metadata: { author_id: comment.author_id }, - }) - if (modErr) { - logRouteError(modErr, { - route: '/api/comments/[id]/restore', - userId: adminUserId, - extra: { op: 'mod_actions_insert', commentId }, - }) - } - - return json(200, { ok: true }) -} diff --git a/app/api/comments/[id]/route.ts b/app/api/comments/[id]/route.ts deleted file mode 100644 index fa93add..0000000 --- a/app/api/comments/[id]/route.ts +++ /dev/null @@ -1,213 +0,0 @@ -import type { NextRequest } from 'next/server' -import { getSession, resolveIsAdmin } from '@/lib/auth' -import { createAdminSupabaseClient } from '@/lib/supabase/admin' -import { CommentPatchBody } from '@/lib/comments/schema' -import { sanitizeCommentBody } from '@/lib/comments/sanitize' -import { guardMutatingRequest } from '@/lib/route-guard' -import { logRouteError } from '@/lib/logging/error-log' - -export const runtime = 'nodejs' - -function json(status: number, body: Record): Response { - return new Response(JSON.stringify(body), { - status, - headers: { 'Content-Type': 'application/json' }, - }) -} - -const EDIT_WINDOW_MS = 24 * 60 * 60 * 1000 - -interface CommentRow { - id: string - author_id: string - body: string - created_at: string - deleted_at: string | null -} - -// --------------------------------------------------------------------------- -// PATCH /api/comments/[id] — author-only, 24h edit window -// --------------------------------------------------------------------------- - -export async function PATCH( - req: NextRequest | Request, - context: { params: Promise<{ id: string }> }, -): Promise { - // Step 1: auth - const session = await getSession() - if (!session?.user?.id) return json(401, { error: 'unauthorized' }) - const userId = session.user.id - - // Step 1b: origin guard (Phase 14) — no app-level RL on comment edit; - // the 24h window + author gate below already keep abuse bounded. - const guard = await guardMutatingRequest(req, { userId }) - if (guard.failed) return guard.response - - const { id } = await context.params - - const admin = createAdminSupabaseClient() - - // Step 2: load comment - const { data: commentRow, error: commentFetchErr } = await admin - .from('comments') - .select('id, author_id, body, created_at, deleted_at') - .eq('id', id) - .single() - - if (commentFetchErr || !commentRow) { - return json(404, { error: 'not_found' }) - } - const comment = commentRow as CommentRow - - // Soft-deleted comments are not editable — treat as 404 - if (comment.deleted_at !== null) { - return json(404, { error: 'not_found' }) - } - - // Step 3: author-only check — admins moderate via DELETE, not edit - if (userId !== comment.author_id) { - return json(403, { error: 'forbidden' }) - } - - // Step 4: 24h edit window (per v1 spec) - if (Date.now() - new Date(comment.created_at).getTime() > EDIT_WINDOW_MS) { - return json(403, { error: 'edit_window_expired' }) - } - - // Step 5: JSON parse - let raw: unknown - try { - raw = await req.json() - } catch { - return json(400, { error: 'invalid_json' }) - } - - // Step 6: Zod parse - const parsed = CommentPatchBody.safeParse(raw) - if (!parsed.success) { - return json(400, { - error: 'invalid_body', - issues: parsed.error.issues.map((i) => ({ - path: i.path, - message: i.message, - })), - }) - } - - // Step 7: sanitize - const sanitizedBody = sanitizeCommentBody(parsed.data.body) - if (sanitizedBody.length === 0) { - return json(400, { error: 'empty_body' }) - } - - // Step 8: update — post_id and parent_comment_id are immutable - const editedAt = new Date().toISOString() - const { error: updateErr } = await admin - .from('comments') - .update({ body: sanitizedBody, edited_at: editedAt }) - .eq('id', id) - - if (updateErr) { - return json(500, { error: 'update_failed', detail: updateErr.message }) - } - - return json(200, { id, body: sanitizedBody, edited_at: editedAt }) -} - -// --------------------------------------------------------------------------- -// DELETE /api/comments/[id] — author OR admin (soft delete, body retained) -// --------------------------------------------------------------------------- - -export async function DELETE( - req: NextRequest | Request, - context: { params: Promise<{ id: string }> }, -): Promise { - // Step 1: auth - const session = await getSession() - if (!session?.user?.id) return json(401, { error: 'unauthorized' }) - const userId = session.user.id - - // Step 1b: origin guard (Phase 14) — no app-level RL on comment delete. - const guard = await guardMutatingRequest(req, { userId }) - if (guard.failed) return guard.response - - const { id } = await context.params - - const admin = createAdminSupabaseClient() - - // Step 2: load comment - const { data: commentRow, error: commentFetchErr } = await admin - .from('comments') - .select('id, author_id, body, created_at, deleted_at') - .eq('id', id) - .single() - - if (commentFetchErr || !commentRow) { - return json(404, { error: 'not_found' }) - } - const comment = commentRow as CommentRow - - if (comment.deleted_at !== null) { - return json(404, { error: 'not_found' }) - } - - // Step 3: author OR admin gate - const isAuthor = userId === comment.author_id - const isAdminUser = await resolveIsAdmin(userId) - - if (!isAuthor && !isAdminUser) { - return json(403, { error: 'forbidden' }) - } - - // Step 4: parse optional reason from body (defensive — empty/invalid body is fine) - let reason: string | null = null - try { - const raw = await req.text() - if (raw) { - const parsed = JSON.parse(raw) - if (parsed && typeof parsed === 'object' && typeof parsed.reason === 'string') { - reason = parsed.reason.slice(0, 1000) - } - } - } catch { - // ignore — author self-delete commonly has no body - } - - // Author takes precedence over admin (matches posts DELETE pattern) - const deletion_reason: 'author' | 'moderation' = isAuthor ? 'author' : 'moderation' - - // Step 5: soft delete — body is retained for audit; render layer shows placeholder - const { error: updateErr } = await admin - .from('comments') - .update({ - deleted_at: new Date().toISOString(), - deletion_reason, - }) - .eq('id', id) - - if (updateErr) { - return json(500, { error: 'delete_failed', detail: updateErr.message }) - } - - // Step 6: if moderation delete, write a mod_actions audit row - if (deletion_reason === 'moderation') { - const { error: modErr } = await admin.from('mod_actions').insert({ - mod_user_id: userId, - action: 'delete_comment', - target_type: 'comment', - target_id: String(id), - reason, - metadata: { author_id: comment.author_id }, - }) - if (modErr) { - logRouteError(modErr, { - route: '/api/comments/[id]', - userId, - extra: { op: 'mod_actions_insert', commentId: id }, - }) - // soft failure — deletion already succeeded, do not roll back - } - } - - return json(200, { ok: true, deletion_reason }) -} diff --git a/app/api/comments/route.ts b/app/api/comments/route.ts deleted file mode 100644 index b17e2b3..0000000 --- a/app/api/comments/route.ts +++ /dev/null @@ -1,175 +0,0 @@ -import type { NextRequest } from 'next/server' -import { getSession } from '@/lib/auth' -import { createAdminSupabaseClient } from '@/lib/supabase/admin' -import { CommentCreateBody } from '@/lib/comments/schema' -import { sanitizeCommentBody } from '@/lib/comments/sanitize' -import { getNewCommentDepth } from '@/lib/comments/depth' -import { guardMutatingRequest } from '@/lib/route-guard' -import { - HONEYPOT_FIELD, - isHoneypotTripped, - isUrlHeavy, -} from '@/lib/comments/abuse' -import { logRouteError } from '@/lib/logging/error-log' - -export const runtime = 'nodejs' - -function json(status: number, body: Record): Response { - return new Response(JSON.stringify(body), { - status, - headers: { 'Content-Type': 'application/json' }, - }) -} - -const MAX_DEPTH = 5 - -export async function POST(req: NextRequest | Request): Promise { - // Step 1: auth - const session = await getSession() - if (!session?.user?.id) return json(401, { error: 'unauthorized' }) - const userId = session.user.id - - // Step 1b: origin + rate-limit guard (Phase 14) - const guard = await guardMutatingRequest(req, { bucket: 'comment', userId }) - if (guard.failed) return guard.response - - // Step 2: JSON parse - let raw: unknown - try { - raw = await req.json() - } catch { - return json(400, { error: 'invalid_json' }) - } - - // Step 2a: honeypot — bots that auto-fill all form fields (including - // visually-hidden ones) trip this. Real users never see the field, so - // the body never includes it. Generic 400 keeps the reason opaque. - if (isHoneypotTripped(raw)) { - logRouteError(new Error('honeypot tripped'), { - route: '/api/comments', - userId, - extra: { reason: 'honeypot' }, - }) - return json(400, { error: 'spam_detected' }) - } - - // Strip the honeypot field BEFORE Zod parse — CommentCreateBody is - // `.strict()` and would otherwise reject a body that includes `_h`. - if (raw && typeof raw === 'object' && HONEYPOT_FIELD in (raw as Record)) { - const cleaned = { ...(raw as Record) } - delete cleaned[HONEYPOT_FIELD] - raw = cleaned - } - - // Step 3: Zod parse - const parsed = CommentCreateBody.safeParse(raw) - if (!parsed.success) { - return json(400, { - error: 'invalid_body', - issues: parsed.error.issues.map((i) => ({ - path: i.path, - message: i.message, - })), - }) - } - - const { post_id, parent_comment_id, body } = parsed.data - - // Step 3a: URL-ratio check on the raw body (pre-sanitize) — bots dumping - // ten URLs in a comment trip this. Surfaced verbatim to real users. - if (isUrlHeavy(body)) { - return json(400, { error: 'too_many_urls' }) - } - - // Step 4: sanitize body — empty after strip means there was no real content - const sanitizedBody = sanitizeCommentBody(body) - if (sanitizedBody.length === 0) { - return json(400, { error: 'empty_body' }) - } - - const admin = createAdminSupabaseClient() - - // TODO(post-v1): the post/parent existence checks below race with concurrent - // author-deletes — a child can attach to a tombstoned parent in the window - // between read and insert. Acceptable for v1 traffic; harden with a DB-side - // trigger or partial check constraint later. - // Step 5: verify post exists and is not soft-deleted - const { data: postRow, error: postFetchErr } = await admin - .from('posts') - .select('id, deleted_at') - .eq('id', post_id) - .single() - - if (postFetchErr || !postRow) { - return json(404, { error: 'post_not_found' }) - } - const post = postRow as { id: string; deleted_at: string | null } - if (post.deleted_at !== null) { - return json(404, { error: 'post_not_found' }) - } - - // Step 6: if parent_comment_id, verify it exists, same post, not deleted - if (parent_comment_id) { - const { data: parentRow, error: parentFetchErr } = await admin - .from('comments') - .select('id, post_id, deleted_at') - .eq('id', parent_comment_id) - .single() - - if (parentFetchErr || !parentRow) { - return json(400, { error: 'parent_not_found' }) - } - const parent = parentRow as { - id: string - post_id: string - deleted_at: string | null - } - if (parent.post_id !== post_id || parent.deleted_at !== null) { - return json(400, { error: 'parent_not_found' }) - } - } - - // Step 7: compute depth via RPC-backed helper; reject if > MAX_DEPTH. - // Safe under concurrent inserts because parent_comment_id is immutable — - // the parent's depth in the chain cannot regress under us. - const depth = await getNewCommentDepth(admin, parent_comment_id ?? null) - if (depth > MAX_DEPTH) { - return json(400, { error: 'depth_exceeded', max: MAX_DEPTH }) - } - - // Step 8: insert row - const { data: inserted, error: insertErr } = await admin - .from('comments') - .insert({ - post_id, - author_id: userId, - parent_comment_id: parent_comment_id ?? null, - body: sanitizedBody, - }) - .select('id, post_id, parent_comment_id, body, author_id, created_at') - .single() - - if (insertErr || !inserted) { - return json(500, { error: 'insert_failed', detail: insertErr?.message }) - } - - // TODO(Phase 10): notification fan-out hooks here — emit to post author on root, to parent commenter on reply (skip if self). - - const row = inserted as { - id: string - post_id: string - parent_comment_id: string | null - body: string - author_id: string - created_at: string - } - - return json(201, { - id: row.id, - post_id: row.post_id, - parent_comment_id: row.parent_comment_id, - body: row.body, - author_id: row.author_id, - created_at: row.created_at, - }) -} diff --git a/app/api/reports/route.ts b/app/api/reports/route.ts index 9502211..eb5d697 100644 --- a/app/api/reports/route.ts +++ b/app/api/reports/route.ts @@ -81,23 +81,6 @@ export async function POST(req: NextRequest | Request): Promise { if (post.author_id === reporterId) { return json(400, { error: 'self_report' }) } - } else if (target_type === 'comment') { - // Existence check (soft-deleted comments still exist) - const { data: commentRow, error: commentErr } = await admin - .from('comments') - .select('id, author_id') - .eq('id', target_id) - .maybeSingle() - - if (commentErr || !commentRow) { - return json(404, { error: 'target_not_found' }) - } - - // Self-report check - const comment = commentRow as { id: string; author_id: string } - if (comment.author_id === reporterId) { - return json(400, { error: 'self_report' }) - } } // Step 5: Dedup — check for existing unresolved report from this reporter diff --git a/components/admin/ReportActions.tsx b/components/admin/ReportActions.tsx index f4a6c6d..729dc9f 100644 --- a/components/admin/ReportActions.tsx +++ b/components/admin/ReportActions.tsx @@ -43,8 +43,6 @@ export default function ReportActions({ report }: ReportActionsProps) { let deleteUrl = '' if (report.target_type === 'post') { deleteUrl = `/api/posts/${report.target_id}` - } else if (report.target_type === 'comment') { - deleteUrl = `/api/comments/${report.target_id}` } if (deleteUrl) { @@ -131,7 +129,7 @@ export default function ReportActions({ report }: ReportActionsProps) { Dismiss - {(report.target_type === 'post' || report.target_type === 'comment') && ( + {report.target_type === 'post' && (