Skip to content

Commit 989fbac

Browse files
Nigel TatschnerNigel Tatschner
authored andcommitted
feat: PostW5 Wave B2 — share-reports moderation queue
Closes audit v2 §05 ("Reports queue"). Migration 0027 landed earlier in 52c454e; this commit ships the consumer surface end-to-end. Backend - New `share_reports` module: ShareReport/ShareReportStatus/ ShareReportReason + ShareReportStore trait + Postgres impl + MemoryShareReportStore for tests. - `POST /v1/share/report` in sharing_routes: reporter (auth'd user) must be one of {owner, recipient}; rate-limited at 5 reports per reporter per 24h; emits `share.reported` audit row. - `GET /v1/admin/sharing/reports?status=` and `POST /v1/admin/sharing/reports/:id/resolve` in admin_sharing_routes: moderator-gated (admins inherit); resolve emits `share.report_resolved` audit row; 409 on double-resolve. - main.rs wires `Arc<dyn ShareReportStore>` extension. - openapi.rs registers 3 paths + 5 schemas. - 8 new store tests (status/reason round-trips, is_resolution, create+get, list+filter+order, double-resolve rejection, not_found, case-insensitive rate-limit count). Suite 289 → 297 passing. Frontend - `apps/web/src/app/sharing/actions.ts` — `reportShareAction` server action. - `/sharing` (inbound list): per-share collapsed `<details>` with a reason picker + optional details textarea + Submit button. Form posts to the new action; recipient_handle is threaded from `session.claimedHandle` (server-side, not trusted from client). - `/admin/sharing/reports` (NEW): moderator queue page with status filter strip (open/dismissed/share_revoked/user_suspended/all), per-row form with three resolution buttons (Dismiss / Revoke / Suspend owner), optional moderator note (≤ 500 chars). Server action revalidates the path on each transition; 409 races refresh silently rather than throwing. - `/admin/sharing` overview grows a "Reports queue →" link in the existing sub-nav strip. - api.ts client functions: `reportShare`, `getAdminSharingReports`, `resolveShareReport` + 5 schema type aliases. - schema.ts regenerated. All gates green: cargo check, cargo test (297/297), cargo clippy -D warnings, cargo fmt --check, pnpm typecheck, pnpm lint (only pre-existing warnings).
1 parent e0b4f7f commit 989fbac

13 files changed

Lines changed: 2097 additions & 42 deletions

File tree

apps/web/src/app/admin/sharing/page.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,13 @@ export default async function AdminSharingOverviewPage() {
371371
>
372372
Sharing audit log →
373373
</Link>
374+
<Link
375+
href={'/admin/sharing/reports' as Route}
376+
className="ss-btn ss-btn--ghost"
377+
style={{ textDecoration: 'none' }}
378+
>
379+
Reports queue →
380+
</Link>
374381
</nav>
375382
</div>
376383
);
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
'use server';
2+
3+
/**
4+
* Server actions for the share-reports moderation queue
5+
* (audit v2 §05). One action per moderator transition; each one
6+
* wraps the `/v1/admin/sharing/reports/:id/resolve` POST, then
7+
* revalidates the queue path so the next render reflects the new
8+
* state.
9+
*
10+
* Error handling matches the rest of the admin surface: 401 sends
11+
* the user to login (with `next` set so they land back on the
12+
* queue), 403 boots them to the dashboard, 409 is the "someone
13+
* else got there first" race and falls through to a soft refresh,
14+
* anything else re-throws so the page boundary catches it.
15+
*/
16+
17+
import { revalidatePath } from 'next/cache';
18+
import { redirect } from 'next/navigation';
19+
import { ApiCallError, resolveShareReport } from '@/lib/api';
20+
import { getSession } from '@/lib/session';
21+
22+
const LOGIN_NEXT = '/auth/login?next=/admin/sharing/reports';
23+
const QUEUE_PATH = '/admin/sharing/reports';
24+
25+
/**
26+
* `outcome` is supplied by the form via a hidden input, so each
27+
* resolution button (`Dismiss` / `Revoke share` / `Suspend owner`)
28+
* posts a different value to the same action.
29+
*/
30+
export async function resolveShareReportAction(
31+
formData: FormData,
32+
): Promise<void> {
33+
const session = await getSession();
34+
if (!session) redirect(LOGIN_NEXT);
35+
36+
const id = String(formData.get('id') ?? '').trim();
37+
const outcome = String(formData.get('outcome') ?? '').trim();
38+
const noteRaw = formData.get('note');
39+
const note =
40+
typeof noteRaw === 'string' && noteRaw.trim().length > 0
41+
? noteRaw.trim()
42+
: undefined;
43+
44+
if (!id || !outcome) {
45+
revalidatePath(QUEUE_PATH);
46+
return;
47+
}
48+
49+
try {
50+
await resolveShareReport(session.token, id, { outcome, note });
51+
} catch (e) {
52+
if (e instanceof ApiCallError) {
53+
if (e.status === 401) redirect(LOGIN_NEXT);
54+
if (e.status === 403) redirect('/dashboard');
55+
if (e.status === 409) {
56+
revalidatePath(QUEUE_PATH);
57+
return;
58+
}
59+
}
60+
throw e;
61+
}
62+
revalidatePath(QUEUE_PATH);
63+
}

0 commit comments

Comments
 (0)