diff --git a/internal/handler/admin.go b/internal/handler/admin.go index e58a9e4..dac408b 100644 --- a/internal/handler/admin.go +++ b/internal/handler/admin.go @@ -208,7 +208,12 @@ func (h *AdminHandler) ResolveReport(w http.ResponseWriter, r *http.Request) { response.Error(w, http.StatusBadRequest, "type must be post, comment, or user") return } - resolved, err := h.svc.ResolveReport(r.Context(), reportType, id) + var in struct { + Note string `json:"note"` + } + // Body is optional/best-effort; an empty/missing note resolves with no note. + _ = json.NewDecoder(r.Body).Decode(&in) + resolved, err := h.svc.ResolveReport(r.Context(), reportType, id, strings.TrimSpace(in.Note)) if err != nil { response.Error(w, http.StatusInternalServerError, "could not resolve report") return diff --git a/internal/model/admin.go b/internal/model/admin.go index fd27687..a656e82 100644 --- a/internal/model/admin.go +++ b/internal/model/admin.go @@ -84,6 +84,13 @@ type AdminReport struct { ReporterHandle *string `json:"reporter_handle"` ReporterName *string `json:"reporter_name"` + // ResolutionNote is the admin's note left when resolving (nil while open). + ResolutionNote *string `json:"resolution_note"` + // TargetRemoved is true when the reported content is currently removed — + // a soft-deleted post/comment, or a banned account — so the UI can offer + // "Undo" instead of "Delete"/"Ban". + TargetRemoved bool `json:"target_removed"` + // Target of the report. TargetID is the post/comment/user id; TargetText // is the post caption, comment body, or reported user's handle so the // admin sees what was flagged without a second fetch. TargetAuthorHandle diff --git a/internal/repository/admin_repo.go b/internal/repository/admin_repo.go index 272291d..97ad562 100644 --- a/internal/repository/admin_repo.go +++ b/internal/repository/admin_repo.go @@ -219,6 +219,7 @@ func (r *AdminRepo) ListReports(ctx context.Context, reportType, q string, limit SELECT r.id, r.category, r.reason, r.created_at, r.resolved_at, ru.handle, ru.name, r.post_id, p.caption, pu.handle, + r.resolution_note, COALESCE(p.deleted_at IS NOT NULL, false), COUNT(*) OVER() AS total FROM post_reports r LEFT JOIN users ru ON ru.id = r.reporter_id @@ -234,6 +235,7 @@ func (r *AdminRepo) ListReports(ctx context.Context, reportType, q string, limit SELECT r.id, r.category, r.reason, r.created_at, r.resolved_at, ru.handle, ru.name, r.comment_id, c.body, cu.handle, + r.resolution_note, COALESCE(c.deleted_at IS NOT NULL, false), COUNT(*) OVER() AS total FROM comment_reports r LEFT JOIN users ru ON ru.id = r.reporter_id @@ -248,8 +250,9 @@ func (r *AdminRepo) ListReports(ctx context.Context, reportType, q string, limit sql = ` SELECT r.id, r.category, r.reason, r.created_at, r.resolved_at, ru.handle, ru.name, - r.reported_id, tu.handle, tu.handle - , COUNT(*) OVER() AS total + r.reported_id, tu.handle, tu.handle, + r.resolution_note, COALESCE(tu.banned_at IS NOT NULL, false), + COUNT(*) OVER() AS total FROM user_reports r LEFT JOIN users ru ON ru.id = r.reporter_id LEFT JOIN users tu ON tu.id = r.reported_id @@ -274,7 +277,8 @@ func (r *AdminRepo) ListReports(ctx context.Context, reportType, q string, limit rep := model.AdminReport{Type: reportType} if err := rows.Scan(&rep.ID, &rep.Category, &rep.Reason, &rep.CreatedAt, &rep.ResolvedAt, &rep.ReporterHandle, &rep.ReporterName, - &rep.TargetID, &rep.TargetText, &rep.TargetAuthorHandle, &total); err != nil { + &rep.TargetID, &rep.TargetText, &rep.TargetAuthorHandle, + &rep.ResolutionNote, &rep.TargetRemoved, &total); err != nil { return nil, 0, fmt.Errorf("scan admin report: %w", err) } out = append(out, rep) @@ -282,10 +286,10 @@ func (r *AdminRepo) ListReports(ctx context.Context, reportType, q string, limit return out, total, rows.Err() } -// ResolveReport stamps resolved_at=now() on a still-open report of the given -// type. Idempotent and one-way (the WHERE guard). Returns true when this call -// is the one that resolved it. -func (r *AdminRepo) ResolveReport(ctx context.Context, reportType, id string) (bool, error) { +// ResolveReport stamps resolved_at=now() and stores the admin's note on a +// still-open report of the given type. Idempotent and one-way (the WHERE +// guard). Returns true when this call is the one that resolved it. +func (r *AdminRepo) ResolveReport(ctx context.Context, reportType, id, note string) (bool, error) { var table string switch reportType { case "post": @@ -299,8 +303,8 @@ func (r *AdminRepo) ResolveReport(ctx context.Context, reportType, id string) (b } // table is from a fixed whitelist above — safe to interpolate. tag, err := r.pg.Pool.Exec(ctx, - fmt.Sprintf(`UPDATE %s SET resolved_at = now() WHERE id = $1 AND resolved_at IS NULL`, table), - id, + fmt.Sprintf(`UPDATE %s SET resolved_at = now(), resolution_note = $2 WHERE id = $1 AND resolved_at IS NULL`, table), + id, note, ) if err != nil { return false, fmt.Errorf("admin resolve %s report: %w", reportType, err) diff --git a/internal/service/admin_service.go b/internal/service/admin_service.go index d73ec96..7f2886a 100644 --- a/internal/service/admin_service.go +++ b/internal/service/admin_service.go @@ -121,8 +121,8 @@ func (s *AdminService) ReplyFeedback(ctx context.Context, id, message string) (f // ResolveReport stamps a report resolved. found=true when this call resolved a // previously-open report (idempotent: a no-op on an already-resolved/unknown id // returns false). -func (s *AdminService) ResolveReport(ctx context.Context, reportType, id string) (bool, error) { - return s.repo.ResolveReport(ctx, reportType, id) +func (s *AdminService) ResolveReport(ctx context.Context, reportType, id, note string) (bool, error) { + return s.repo.ResolveReport(ctx, reportType, id, note) } // ─── Moderation actions ────────────────────────────────────────────────────── diff --git a/migrations/0041_report_resolution_note.down.sql b/migrations/0041_report_resolution_note.down.sql new file mode 100644 index 0000000..870ddff --- /dev/null +++ b/migrations/0041_report_resolution_note.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE post_reports DROP COLUMN IF EXISTS resolution_note; +ALTER TABLE comment_reports DROP COLUMN IF EXISTS resolution_note; +ALTER TABLE user_reports DROP COLUMN IF EXISTS resolution_note; diff --git a/migrations/0041_report_resolution_note.up.sql b/migrations/0041_report_resolution_note.up.sql new file mode 100644 index 0000000..0c829d6 --- /dev/null +++ b/migrations/0041_report_resolution_note.up.sql @@ -0,0 +1,5 @@ +-- A resolution note captured when an admin resolves a report (shown in the +-- admin reports table). Additive nullable column on each report table. +ALTER TABLE post_reports ADD COLUMN IF NOT EXISTS resolution_note TEXT; +ALTER TABLE comment_reports ADD COLUMN IF NOT EXISTS resolution_note TEXT; +ALTER TABLE user_reports ADD COLUMN IF NOT EXISTS resolution_note TEXT;