From 0b3e6d4b2f8c588279de3057480c62ac5fd54d31 Mon Sep 17 00:00:00 2001 From: IK Date: Thu, 3 Sep 2026 08:14:56 +0700 Subject: [PATCH] Add audited user report deletion --- .../alembic/versions/0006_report_deletion.py | 16 ++++++++++ apps/api/app/main.py | 30 ++++++++++++++++--- apps/api/app/models.py | 1 + apps/api/app/storage.py | 4 +++ apps/api/tests/test_api.py | 27 ++++++++++++++++- apps/web/src/pages/admin/moderation.astro | 6 ++-- apps/web/src/styles/global.css | 2 +- docs/ROADMAP.md | 2 +- 8 files changed, 78 insertions(+), 10 deletions(-) create mode 100644 apps/api/alembic/versions/0006_report_deletion.py diff --git a/apps/api/alembic/versions/0006_report_deletion.py b/apps/api/alembic/versions/0006_report_deletion.py new file mode 100644 index 0000000..0c70186 --- /dev/null +++ b/apps/api/alembic/versions/0006_report_deletion.py @@ -0,0 +1,16 @@ +"""Soft deletion and anonymization marker for user reports.""" +from alembic import op +import sqlalchemy as sa + +revision = "0006" +down_revision = "0005" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("catch_report", sa.Column("deleted_at", sa.DateTime(timezone=True))) + + +def downgrade() -> None: + op.drop_column("catch_report", "deleted_at") diff --git a/apps/api/app/main.py b/apps/api/app/main.py index b62ba73..cf35704 100644 --- a/apps/api/app/main.py +++ b/apps/api/app/main.py @@ -17,14 +17,14 @@ from .config import settings from .importer import ImportSourceError, import_records, normalize from .models import Bait, BaitKind, CatchReport, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, Waterbody from .schemas import ActivityOut, AdminCatchReportOut, BaitOut, CatchOut, CatchReportCreate, CatchReportCreated, FishOut, ImportRunOut, ModerationUpdate, OfficialRecordOut, SpotOut, WaterbodyOut -from .storage import ScreenshotError, signed_screenshot_url, upload_screenshot +from .storage import ScreenshotError, delete_screenshot, signed_screenshot_url, upload_screenshot app = FastAPI(title="RF4 Spotter API", version="0.1.0") app.add_middleware( CORSMiddleware, allow_origins=["http://localhost:4321", "http://127.0.0.1:4321"], - allow_methods=["GET", "PATCH"], + allow_methods=["GET", "PATCH", "DELETE"], allow_headers=["Authorization", "Content-Type"], ) Db = Annotated[Session, Depends(get_session)] @@ -190,14 +190,14 @@ def add_screenshot(report_id: UUID, db: Db, screenshot: UploadFile = File()) -> @app.get("/api/v1/admin/catch-reports", response_model=list[AdminCatchReportOut]) def admin_reports(db: Db, _: Annotated[str, Depends(_admin)], status: ModerationStatus = ModerationStatus.pending, limit: int = Query(50, ge=1, le=100)) -> list[AdminCatchReportOut]: - reports = list(db.scalars(select(CatchReport).options(joinedload(CatchReport.fish), joinedload(CatchReport.waterbody), joinedload(CatchReport.spot), joinedload(CatchReport.bait)).where(CatchReport.source_type == SourceType.user, CatchReport.moderation_status == status).order_by(CatchReport.reported_at).limit(limit))) + reports = list(db.scalars(select(CatchReport).options(joinedload(CatchReport.fish), joinedload(CatchReport.waterbody), joinedload(CatchReport.spot), joinedload(CatchReport.bait)).where(CatchReport.source_type == SourceType.user, CatchReport.moderation_status == status, CatchReport.deleted_at.is_(None)).order_by(CatchReport.reported_at).limit(limit))) return [AdminCatchReportOut(id=r.id, fish=r.fish.name_ru, waterbody=r.waterbody.name_ru, coordinates=f"{r.spot.x}:{r.spot.y}" if r.spot else "—", weight_g=r.weight_g, bait=r.bait.name if r.bait else None, player_name=r.player_name, reported_at=r.reported_at, moderation_status=r.moderation_status.value, comment=(r.raw_payload or {}).get("comment"), screenshot_url=signed_screenshot_url(r.screenshot_key) if r.screenshot_key else None) for r in reports] @app.patch("/api/v1/admin/catch-reports/{report_id}", response_model=CatchReportCreated) def moderate_report(report_id: UUID, payload: ModerationUpdate, db: Db, moderator: Annotated[str, Depends(_admin)]) -> CatchReportCreated: report = db.get(CatchReport, report_id) - if report is None or report.source_type != SourceType.user: + if report is None or report.source_type != SourceType.user or report.deleted_at is not None: raise HTTPException(status_code=404, detail="catch report not found") previous = report.moderation_status report.moderation_status = ModerationStatus(payload.status) @@ -206,6 +206,28 @@ def moderate_report(report_id: UUID, payload: ModerationUpdate, db: Db, moderato return CatchReportCreated(id=report.id, moderation_status=report.moderation_status.value) +@app.delete("/api/v1/admin/catch-reports/{report_id}", status_code=204, response_class=Response) +def delete_report(report_id: UUID, db: Db, moderator: Annotated[str, Depends(_admin)]) -> Response: + report = db.get(CatchReport, report_id) + if report is None or report.source_type != SourceType.user or report.deleted_at is not None: + raise HTTPException(status_code=404, detail="catch report not found") + previous = report.moderation_status + if report.screenshot_key: + try: + delete_screenshot(report.screenshot_key) + except Exception as exc: + raise HTTPException(status_code=502, detail="screenshot deletion failed") from exc + report.moderation_status = ModerationStatus.rejected + report.deleted_at = datetime.now(timezone.utc) + report.player_name = None + report.source_url = None + report.screenshot_key = None + report.raw_payload = None + db.add(ModerationEvent(catch_report=report, created_at=report.deleted_at, previous_status=previous, new_status=ModerationStatus.rejected, moderator=moderator, reason="user report deleted and anonymized")) + db.commit() + return Response(status_code=204) + + def _check_rate_limit(client: str) -> None: now = datetime.now(timezone.utc) recent = _submissions[client] diff --git a/apps/api/app/models.py b/apps/api/app/models.py index 9cb0456..6d109a8 100644 --- a/apps/api/app/models.py +++ b/apps/api/app/models.py @@ -92,6 +92,7 @@ class CatchReport(Base): source_confidence: Mapped[int] moderation_status: Mapped[ModerationStatus] = mapped_column(Enum(ModerationStatus)) screenshot_key: Mapped[str | None] = mapped_column(Text) + deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) raw_payload: Mapped[dict | None] = mapped_column(JSON) fish: Mapped[Fish] = relationship() spot: Mapped[Spot | None] = relationship() diff --git a/apps/api/app/storage.py b/apps/api/app/storage.py index e553169..a99ce59 100644 --- a/apps/api/app/storage.py +++ b/apps/api/app/storage.py @@ -64,3 +64,7 @@ def upload_screenshot(raw: bytes) -> str: def signed_screenshot_url(key: str) -> str: return public_client().generate_presigned_url("get_object", Params={"Bucket": settings.s3_bucket, "Key": key}, ExpiresIn=900) + + +def delete_screenshot(key: str) -> None: + client().delete_object(Bucket=settings.s3_bucket, Key=key) diff --git a/apps/api/tests/test_api.py b/apps/api/tests/test_api.py index 5ce0fb9..ea5781b 100644 --- a/apps/api/tests/test_api.py +++ b/apps/api/tests/test_api.py @@ -1,6 +1,7 @@ from __future__ import annotations from datetime import datetime, timedelta, timezone +from uuid import UUID from fastapi.testclient import TestClient from sqlalchemy import create_engine @@ -9,7 +10,7 @@ from sqlalchemy.pool import StaticPool from app.database import Base, get_session from app.main import app -from app.models import Bait, BaitKind, CatchReport, Fish, ImportStatus, ModerationStatus, OfficialRecordImport, SourceType, Spot, Waterbody +from app.models import Bait, BaitKind, CatchReport, Fish, ImportStatus, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, Waterbody engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool) @@ -122,3 +123,27 @@ def test_pending_report_accepts_one_validated_screenshot(monkeypatch) -> None: assert response.status_code == 204 duplicate = client.post(f"/api/v1/catch-reports/{created['id']}/screenshot", files={"screenshot": ("catch.jpg", b"image-bytes", "image/jpeg")}) assert duplicate.status_code == 409 + + +def test_admin_delete_anonymizes_report_removes_screenshot_and_keeps_audit(monkeypatch) -> None: + created = client.post("/api/v1/catch-reports", json={"fish_slug": "pike", "waterbody_slug": "test-lake", "x": 93, "y": 94, "weight_g": 4300, "player_name": "Private Player", "source_url": "https://example.test/private", "comment": "private comment"}).json() + with Session(engine) as db: + report = db.get(CatchReport, UUID(created["id"])) + report.screenshot_key = "reports/private.jpg" + db.commit() + deleted_keys: list[str] = [] + monkeypatch.setattr("app.main.delete_screenshot", deleted_keys.append) + headers = {"Authorization": "Bearer change-me-in-production"} + response = client.delete(f"/api/v1/admin/catch-reports/{created['id']}", headers=headers) + assert response.status_code == 204 + assert deleted_keys == ["reports/private.jpg"] + with Session(engine) as db: + report = db.get(CatchReport, UUID(created["id"])) + assert report.deleted_at is not None + assert report.moderation_status == ModerationStatus.rejected + assert report.player_name is None and report.source_url is None + assert report.screenshot_key is None and report.raw_payload is None + event = db.query(ModerationEvent).filter_by(catch_report_id=report.id).order_by(ModerationEvent.created_at.desc()).first() + assert event is not None + assert event.reason == "user report deleted and anonymized" + assert client.delete(f"/api/v1/admin/catch-reports/{created['id']}", headers=headers).status_code == 404 diff --git a/apps/web/src/pages/admin/moderation.astro b/apps/web/src/pages/admin/moderation.astro index 8915ed9..4e83c41 100644 --- a/apps/web/src/pages/admin/moderation.astro +++ b/apps/web/src/pages/admin/moderation.astro @@ -24,13 +24,13 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000"; if (!response.ok) throw new Error(response.status === 401 ? "Неверный административный токен." : "Не удалось загрузить очередь."); const reports: Record[] = await response.json(); if (!reports.length) { list.innerHTML = '

Очередь пуста

Новых уловов для проверки нет.

'; return; } - list.innerHTML = reports.map(report => `
На проверке

${esc(report.fish)}

${esc(report.waterbody)} · ${esc(report.coordinates)}

Вес
${esc(report.weight_g)} г
Приманка
${esc(report.bait)}
Игрок
${esc(report.player_name)}
Отправлено
${esc(new Date(String(report.reported_at)).toLocaleString("ru-RU"))}
${report.comment ? `
${esc(report.comment)}
` : ""}
${report.screenshot_url ? `Скриншот улова ${esc(report.fish)}` : '
Скриншот не приложен
'}
`).join(""); + list.innerHTML = reports.map(report => `
На проверке

${esc(report.fish)}

${esc(report.waterbody)} · ${esc(report.coordinates)}

Вес
${esc(report.weight_g)} г
Приманка
${esc(report.bait)}
Игрок
${esc(report.player_name)}
Отправлено
${esc(new Date(String(report.reported_at)).toLocaleString("ru-RU"))}
${report.comment ? `
${esc(report.comment)}
` : ""}
${report.screenshot_url ? `Скриншот улова ${esc(report.fish)}` : '
Скриншот не приложен
'}
`).join(""); } login?.addEventListener("submit", async event => { event.preventDefault(); token = String(new FormData(login).get("token") || ""); try { await loadQueue(); login.hidden = true; } catch (cause) { fail(cause instanceof Error ? cause.message : "Ошибка загрузки."); } }); list?.addEventListener("click", async event => { - const button = (event.target as HTMLElement).closest("button[data-decision]"); const card = button?.closest("[data-report-id]"); if (!button || !card || !root) return; + const button = (event.target as HTMLElement).closest("button[data-decision],button[data-delete]"); const card = button?.closest("[data-report-id]"); if (!button || !card || !root) return; button.disabled = true; const reason = card.querySelector("textarea")?.value || null; - try { const response = await fetch(`${root.dataset.apiUrl}/api/v1/admin/catch-reports/${card.dataset.reportId}`, {method:"PATCH",headers:{Authorization:`Bearer ${token}`,"Content-Type":"application/json"},body:JSON.stringify({status:button.dataset.decision,reason})}); if (!response.ok) throw new Error("Не удалось сохранить решение."); card.remove(); if (list && !list.children.length) list.innerHTML = '

Очередь пуста

Все записи обработаны.

'; } catch (cause) { button.disabled = false; fail(cause instanceof Error ? cause.message : "Ошибка сохранения."); } + try { const deleting = button.hasAttribute("data-delete"); const response = await fetch(`${root.dataset.apiUrl}/api/v1/admin/catch-reports/${card.dataset.reportId}`, {method:deleting ? "DELETE" : "PATCH",headers:{Authorization:`Bearer ${token}`,"Content-Type":"application/json"},body:deleting ? undefined : JSON.stringify({status:button.dataset.decision,reason})}); if (!response.ok) throw new Error("Не удалось сохранить решение."); card.remove(); if (list && !list.children.length) list.innerHTML = '

Очередь пуста

Все записи обработаны.

'; } catch (cause) { button.disabled = false; fail(cause instanceof Error ? cause.message : "Ошибка сохранения."); } }); diff --git a/apps/web/src/styles/global.css b/apps/web/src/styles/global.css index 485af3a..7f03441 100644 --- a/apps/web/src/styles/global.css +++ b/apps/web/src/styles/global.css @@ -7,7 +7,7 @@ .detail-card{position:sticky;top:116px;margin:0;background:var(--deep);color:#f5f8f3;border:0;border-radius:18px;padding:27px;overflow:hidden}.detail-card:before{content:"";position:absolute;width:420px;height:420px;border:1px solid #ffffff0f;border-radius:50%;left:48%;top:-210px;box-shadow:0 0 0 48px #ffffff08,0 0 0 96px #ffffff05}.detail-card>*{position:relative;z-index:1}.detail-head{display:flex;justify-content:space-between}.detail-head .overline,.best-lure .overline{color:#a7b6b3}.detail-head h2{font-size:30px}.detail-head h2 em{color:var(--lime);font-size:22px}.detail-head>a{width:40px;height:40px;display:grid;place-items:center;border:1px solid #ffffff34;border-radius:50%;text-decoration:none}.detail-score{display:grid;grid-template-columns:126px 1fr;gap:20px;align-items:center;padding:34px 0 29px}.score-ring{width:122px;height:122px;display:grid;place-items:center;border-radius:50%;background:conic-gradient(var(--lime) var(--score),#ffffff16 0);position:relative}.score-ring:after{content:"";position:absolute;inset:8px;background:var(--deep);border-radius:50%}.score-ring>div{z-index:1;text-align:center;display:flex;flex-direction:column}.score-ring strong{font:400 39px/1 Georgia,serif}.score-ring span{color:#a9b8b5;font-size:10px;text-transform:uppercase}.detail-score>div>span{color:#a9b8b5;font-size:11px;text-transform:uppercase;letter-spacing:.1em}.detail-score>div>strong{display:block;margin:5px 0 8px;font:400 24px Georgia,serif;color:var(--lime)}.detail-score p{color:#b7c3c0;font-size:13px;line-height:1.5;margin:0}.metric-grid{display:grid;grid-template-columns:1fr 1fr;border:1px solid #ffffff1c;border-radius:12px;overflow:hidden}.metric-grid>div{min-height:91px;padding:15px;display:grid;grid-template-columns:23px 1fr;gap:3px 8px;border-bottom:1px solid #ffffff1c}.metric-grid>div:nth-child(odd){border-right:1px solid #ffffff1c}.metric-grid>div:nth-last-child(-n+2){border-bottom:0}.metric-grid>div>span{grid-row:1/3;color:var(--lime)}.metric-grid small{color:#9cadaa;text-transform:uppercase;font-size:9px}.metric-grid strong{font:400 17px Georgia,serif}.best-lure{margin-top:28px}.best-lure>div{display:grid;grid-template-columns:18px 1fr auto;gap:11px;align-items:center;padding:15px 0;border-bottom:1px solid #ffffff17}.best-lure>div span{color:var(--lime);font-size:12px}.confidence-note{display:flex;gap:10px;padding:16px;background:#ffffff0a;border-radius:10px;margin-top:22px;font-size:12px;line-height:1.45;color:#aebcba}.confidence-note>span:first-child{color:var(--lime)}.confidence-note strong{color:#fff} .state{min-height:240px;display:grid;place-items:center;align-content:center;text-align:center;border:1px dashed #b9c7bf;border-radius:16px;color:#71817f}.state h2{font:400 28px Georgia,serif;color:var(--deep);margin:0}.how-it-works{border-top:1px solid #cfd8d1;padding:85px 0 105px;display:grid;grid-template-columns:.8fr 1.2fr;gap:70px}.how-it-works h2{font-size:47px;line-height:1.05}.principles{display:grid;grid-template-columns:repeat(3,1fr);gap:18px}.principles article{padding-top:24px;border-top:2px solid #294b4e}.principles article>span{font:italic 16px Georgia,serif;color:#82928f}.principles h3{margin:28px 0 8px;font:400 22px Georgia,serif}.principles p{font-size:14px;line-height:1.55;color:#657572} .records-hero,.form-hero{width:min(1360px,calc(100% - 64px));margin:auto;padding:76px 0 48px;display:flex;align-items:end;justify-content:space-between;gap:70px}.records-hero h1,.form-hero h1{font-size:clamp(58px,7vw,108px);margin-bottom:0}.records-hero>div:last-child,.form-hero>p{max-width:500px;color:#627370;line-height:1.65}.source-status{display:grid;grid-template-columns:auto 1fr;gap:4px 9px}.source-status small{grid-column:2}.status-dot{width:9px;height:9px;border-radius:50%;background:#82928f}.status-dot.success{background:#83b83a}.record-filters,.report-form{width:min(1360px,calc(100% - 64px));margin:0 auto 55px;padding:25px;background:#fff;border:1px solid #d4ddd6;border-radius:18px;box-shadow:0 30px 80px #16383c12}.record-filters{display:flex;gap:14px;background:var(--deep)}.record-filters label{flex:1}.record-filters input,.report-form input,.report-form textarea,.report-form select{display:block;width:100%;margin-top:7px;padding:13px 14px;border:1px solid #d6dfd7;border-radius:9px;background:#f3f6f1;color:var(--deep)}.record-table{width:min(1360px,calc(100% - 64px));margin:auto;background:#fff;border-radius:16px;overflow:hidden}.record-row{display:grid;grid-template-columns:1.2fr .65fr 1.1fr 1.5fr 1fr .75fr;gap:14px;padding:20px;border-top:1px solid #e3e9e3}.record-head{background:#e4ebe3;border:0}.record-head span{font-size:10px;text-transform:uppercase;letter-spacing:.08em;color:#647572}.record-row>span,.record-row>time{font-size:13px;color:#60716e}.official-note{width:min(1360px,calc(100% - 64px));margin:20px auto 100px;color:#647572;font-size:12px}.form-hero{align-items:center}.form-hero>div{}.form-hero>p{font-size:17px}.notice{width:min(1360px,calc(100% - 64px));margin:0 auto 18px;padding:16px;border-radius:10px}.notice.success{background:#dff0c3;color:#426315}.notice.error{background:#f5d8d2;color:#8a3025}.report-form{max-width:900px;padding:34px}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:20px 16px}.report-form label{display:block;color:#60716e}.report-form .wide{display:block;margin-top:20px}.report-form input:focus,.report-form textarea:focus,.report-form select:focus{outline:none;border-color:#839d4b;box-shadow:0 0 0 3px #c9f45b45}.privacy{font-size:12px;color:#647572}.honeypot{position:absolute;left:-9999px}.back{display:inline-block;margin:40px max(32px,calc((100% - 1360px)/2)) 10px;color:#60716e}.spot-hero,.periods,.detail-grid{width:min(1180px,calc(100% - 64px));margin-inline:auto}.spot-hero{padding:50px;background:var(--deep);color:#fff;border-radius:18px;display:flex;justify-content:space-between}.spot-hero h1{font-size:70px}.spot-hero p{color:#a9b8b5}.pin{width:160px;height:160px;border:1px solid #ffffff34;border-radius:50%;display:grid;place-items:center;color:var(--lime);font:400 35px Georgia}.periods{display:grid;grid-template-columns:repeat(3,1fr);margin-top:20px;background:#fff;border-radius:14px;overflow:hidden}.periods>div{text-align:center;padding:25px;border-right:1px solid #e2e8e2}.periods strong{display:block;font:400 32px Georgia;color:#537014}.periods span{font-size:12px;color:#647572}.detail-grid{display:grid;grid-template-columns:2fr 1fr;gap:24px;padding-bottom:80px}.catch-list article{display:flex;justify-content:space-between;padding:16px 0;border-bottom:1px solid #d5ded7}.catch-list article>div{display:flex;flex-direction:column}.catch-list article>div:last-child{text-align:right}.catch-list span{font-size:12px;color:#647572}.detail-grid aside{margin-top:50px;padding:25px;background:#fff;border-radius:14px}.detail-grid aside li{padding:10px 0;border-bottom:1px solid #e2e8e2}.note{font-size:12px;color:#647572} -.moderation-app{width:min(1120px,calc(100% - 64px));margin:0 auto 100px}.admin-login{display:flex;gap:14px;align-items:end;padding:25px;background:var(--deep);border-radius:16px}.admin-login label{flex:1;color:#a8b7b4;font-size:12px;text-transform:uppercase;letter-spacing:.1em;font-weight:700}.admin-login input{display:block;width:100%;margin-top:7px;padding:13px 14px;border:1px solid #ffffff29;border-radius:9px;background:#ffffff0c;color:#fff}.admin-login button,.moderation-actions button{height:48px;padding:0 24px;border:0;border-radius:9px;background:var(--lime);color:var(--deep);font-weight:750}.moderation-app>.privacy{margin:10px 4px 25px}.moderation-app>.notice{width:100%}.moderation-list{display:grid;gap:18px}.moderation-card{display:grid;grid-template-columns:1fr 300px;gap:25px;padding:25px;background:#fff;border:1px solid #d4ddd6;border-radius:16px}.moderation-summary h2{font:400 32px Georgia,serif;margin:12px 0 4px}.moderation-summary>p{color:#647572;margin:0 0 20px}.moderation-summary dl{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin:0}.moderation-summary dl div{border-top:1px solid #e1e8e2;padding-top:10px}.moderation-summary dt{font-size:10px;text-transform:uppercase;color:#7a8a87}.moderation-summary dd{margin:3px 0 0}.moderation-summary blockquote{margin:18px 0 0;padding:13px;border-left:3px solid var(--lime);background:#f2f6ef;color:#566865}.moderation-proof img,.no-proof{width:100%;height:210px;object-fit:cover;border-radius:11px}.no-proof{display:grid;place-items:center;background:#edf2ec;color:#71817f}.moderation-actions{grid-column:1/-1;display:flex;align-items:end;gap:14px;border-top:1px solid #e1e8e2;padding-top:18px}.moderation-actions label{flex:1;color:#60716e;font-size:12px;text-transform:uppercase;letter-spacing:.08em}.moderation-actions textarea{display:block;width:100%;margin-top:6px;padding:10px;border:1px solid #d4ddd6;border-radius:8px;resize:vertical}.moderation-actions>div{display:flex;gap:8px}.moderation-actions .reject{background:#f0d4ce;color:#842f25}.moderation-actions button:disabled{opacity:.55} +.moderation-app{width:min(1120px,calc(100% - 64px));margin:0 auto 100px}.admin-login{display:flex;gap:14px;align-items:end;padding:25px;background:var(--deep);border-radius:16px}.admin-login label{flex:1;color:#a8b7b4;font-size:12px;text-transform:uppercase;letter-spacing:.1em;font-weight:700}.admin-login input{display:block;width:100%;margin-top:7px;padding:13px 14px;border:1px solid #ffffff29;border-radius:9px;background:#ffffff0c;color:#fff}.admin-login button,.moderation-actions button{height:48px;padding:0 24px;border:0;border-radius:9px;background:var(--lime);color:var(--deep);font-weight:750}.moderation-app>.privacy{margin:10px 4px 25px}.moderation-app>.notice{width:100%}.moderation-list{display:grid;gap:18px}.moderation-card{display:grid;grid-template-columns:1fr 300px;gap:25px;padding:25px;background:#fff;border:1px solid #d4ddd6;border-radius:16px}.moderation-summary h2{font:400 32px Georgia,serif;margin:12px 0 4px}.moderation-summary>p{color:#647572;margin:0 0 20px}.moderation-summary dl{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin:0}.moderation-summary dl div{border-top:1px solid #e1e8e2;padding-top:10px}.moderation-summary dt{font-size:10px;text-transform:uppercase;color:#7a8a87}.moderation-summary dd{margin:3px 0 0}.moderation-summary blockquote{margin:18px 0 0;padding:13px;border-left:3px solid var(--lime);background:#f2f6ef;color:#566865}.moderation-proof img,.no-proof{width:100%;height:210px;object-fit:cover;border-radius:11px}.no-proof{display:grid;place-items:center;background:#edf2ec;color:#71817f}.moderation-actions{grid-column:1/-1;display:flex;align-items:end;gap:14px;border-top:1px solid #e1e8e2;padding-top:18px}.moderation-actions label{flex:1;color:#60716e;font-size:12px;text-transform:uppercase;letter-spacing:.08em}.moderation-actions textarea{display:block;width:100%;margin-top:6px;padding:10px;border:1px solid #d4ddd6;border-radius:8px;resize:vertical}.moderation-actions>div{display:flex;gap:8px}.moderation-actions .reject{background:#f0d4ce;color:#842f25}.moderation-actions .delete{background:transparent;color:#842f25;border:1px solid #d8aaa1}.moderation-actions button:disabled{opacity:.55} footer{min-height:118px;background:var(--deep);color:#dbe4df;padding:28px max(32px,calc((100vw - 1360px)/2));display:grid;grid-template-columns:1fr 1fr auto;align-items:center;gap:28px}footer .brand-mark{border-color:#ffffff32}footer p{font-size:12px;color:#92a4a0}footer>span{font:italic 16px Georgia;color:var(--lime)} @media(max-width:1040px){.content-grid{width:min(100% - 36px,900px)}.topbar{width:calc(100% - 36px);grid-template-columns:1fr auto}.live-badge{display:none}.intro{grid-template-columns:1fr;gap:34px;padding-top:54px}.lake-card{height:280px}.dashboard{grid-template-columns:1fr}.detail-card{position:relative;top:auto}.how-it-works{grid-template-columns:1fr}.records-hero,.form-hero{display:block}.records-hero>div:last-child,.form-hero>p{margin-top:25px}} @media(max-width:720px){.content-grid,.records-hero,.form-hero,.record-filters,.record-table,.official-note,.report-form,.spot-hero,.periods,.detail-grid{width:calc(100% - 28px)}.topbar{width:100%;padding:13px 14px 0;display:flex;flex-wrap:wrap;height:auto}.topbar .brand{flex:1}.topbar nav{order:2;width:100%;height:46px;overflow-x:auto}.topbar nav a{flex:0 0 auto;font-size:13px}.intro h1,.records-hero h1,.form-hero h1{font-size:55px}.intro{padding-top:48px}.lake-card{height:225px}.filters-wrap{position:relative}.filters{grid-template-columns:1fr 1fr}.dashboard{padding:45px 0 74px}.spot-card{grid-template-columns:34px 1fr;padding:18px 18px 18px 14px;gap:10px}.spot-stats{grid-column:2;border:0;border-top:1px solid #e2e8e2;padding:13px 0 0;grid-template-columns:repeat(4,1fr)}.card-arrow{display:none}.detail-score{grid-template-columns:105px 1fr}.score-ring{width:100px;height:100px}.principles{grid-template-columns:1fr}.record-filters{display:grid}.record-row{grid-template-columns:1fr 1fr}.record-head{display:none}.record-row>*:nth-child(even){text-align:right}.form-grid,.detail-grid{grid-template-columns:1fr}.spot-hero{padding:28px}.spot-hero h1{font-size:48px}.pin{display:none}footer{grid-template-columns:1fr auto;padding:30px 20px}footer p{grid-column:1/-1;order:3}} diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 1f5ae0e..7037602 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -31,7 +31,7 @@ - [x] Добавить административный веб-интерфейс очереди модерации поверх существующего API (`/admin/moderation`, токен только в памяти страницы). - [x] Показать скриншот, данные улова и причину решения; реализовать действия «одобрить» и «отклонить» (проверено в браузере на desktop и 390 px). -- [ ] Добавить удаление пользовательского сообщения администратором с аудитом действия. +- [x] Добавить удаление пользовательского сообщения администратором с аудитом действия (обезличивание записи, удаление объекта MinIO, миграция `0006`). - [ ] Заменить in-memory rate limit на общее хранилище, пригодное для нескольких API-процессов и перезапусков. - [ ] Валидировать одновременно содержимое, MIME, расширение и лимит изображения; добавить тесты каждого отказа. - [ ] Добавить сквозной тест: отправка → pending → модерация → появление одобренного улова в публичной статистике.