From 487fff259c3e47361b0ba67ef22565a8e4465ca2 Mon Sep 17 00:00:00 2001 From: AI Dev Date: Thu, 8 Oct 2026 17:48:51 +0000 Subject: [PATCH 1/2] Club: let a member edit their report, and send one already decided back for review Asked for in the OpenIPC chat: change a report while no maintainer has looked at it, and have one changed after review accepted again. - /club has Edit beside each of the member's reports: the note, the camera they proposed (maker, marking, SoC), files taken out, photos and text added. ipctool's output and a backup are what the camera said and stay as sent. POST /api/v1/club/reports/{id}/edit, the member's own only. - While it waits for review an edit is simply made. Once a maintainer has published or rejected it, saving says so first, then adds a review row 'edit' (migration 031): the report is pending again, off the board and the public pages, and back in the queue marked as edited. The next decision settles its stars as any does: a file taken out is taken back, one added is paid. - Reports stay changed only through internal/reports: Store.Edit stands the guard down for its own transaction as Takedown does, and every edit is logged in append-only report_edits. A file's position is never used twice, so the ledger's rows keep meaning what they meant. One edit and one review never interleave (the report's lock). - List read any decision but publish and reject as withdrawn; it reads 'edit' as pending now. --- CLAUDE.md | 8 +- data/locales/boards.en.yml | 9 + data/locales/boards.ru.yml | 9 + data/locales/boards.zh.yml | 9 + .../apps/site/src/components/club/Club.tsx | 23 +- .../src/components/club/EditReport.test.tsx | 60 +++ .../site/src/components/club/EditReport.tsx | 102 +++++ .../apps/site/src/components/club/Review.tsx | 2 + frontend/apps/site/src/i18n/boards.en.json | 9 + frontend/apps/site/src/i18n/boards.ru.json | 9 + frontend/apps/site/src/i18n/boards.zh.json | 9 + frontend/apps/site/src/lib/club.ts | 12 + service/cmd/openipc/main.go | 1 + service/deploytest/reports_test.go | 4 +- service/internal/club/api.go | 15 + service/internal/club/edit_test.go | 149 ++++++++ .../db/migrations/031_report_edits.sql | 26 ++ service/internal/reports/club.go | 9 +- service/internal/reports/edit.go | 349 ++++++++++++++++++ service/internal/reports/handler.go | 90 +++-- service/internal/reports/store.go | 6 +- service/internal/reports/view.go | 2 +- service/routes.json | 5 + 23 files changed, 872 insertions(+), 45 deletions(-) create mode 100644 frontend/apps/site/src/components/club/EditReport.test.tsx create mode 100644 frontend/apps/site/src/components/club/EditReport.tsx create mode 100644 service/internal/club/edit_test.go create mode 100644 service/internal/db/migrations/031_report_edits.sql create mode 100644 service/internal/reports/edit.go diff --git a/CLAUDE.md b/CLAUDE.md index 29e5ae0c..f328b0ec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -177,7 +177,13 @@ restores the image but never the schema, so keep migrations additive. lacks is sent from `/cameras/report#new` as its maker, its marking and photos (`report_proposals`, migration 028), and publishing it from the review queue adds the board (`boards.CreateModel`, alias source `club`) - -- the only way a model is made besides the importers. A report sent from + -- the only way a model is made besides the importers. A member may edit + their own report from `/club` (`reports.Store.Edit`, migration 031): the + note, the camera proposed, files out and in; ipctool's output and a backup + stay as sent. Like takedown it stands the guard down for its transaction, + and every change is logged in append-only `report_edits`. An edit to a + report already decided adds a review row `edit`: pending again, off the + board until a maintainer accepts it once more, the stars settled then. A report sent from the camera is a member's when it carries a one-time code from `/club` (`report_codes`, migration 029), in the note because every ipctool in the field can send one: `ipctool upload --note club-XXXX-XXXX`. A code may diff --git a/data/locales/boards.en.yml b/data/locales/boards.en.yml index 084b4ae5..8ac9ea28 100644 --- a/data/locales/boards.en.yml +++ b/data/locales/boards.en.yml @@ -557,6 +557,15 @@ en: rename_save: 'Save' review_note_label: 'Reviewer' your_note: 'Your note' + edit: 'Edit' + edit_save: 'Save' + edit_save_rereview: 'Save and send for review again' + edit_cancel: 'Cancel' + edit_rereview: 'A maintainer has already decided on this report. Once you save, it goes back to them, and it is off the catalogue until they accept it again.' + edit_remove: 'Take out' + edit_add: 'Add' + edited_at: 'Edited {time}' + review_edited_label: 'Edited by the sender' cameras_title: 'My cameras on the Open Wall' cameras_count: '{n} of {max} count towards stars' cameras_empty: 'No camera linked yet. Link one below: it earns stars for every month it keeps sending real pictures to the Open Wall.' diff --git a/data/locales/boards.ru.yml b/data/locales/boards.ru.yml index 3bf8863b..e9d6fc33 100644 --- a/data/locales/boards.ru.yml +++ b/data/locales/boards.ru.yml @@ -598,6 +598,15 @@ ru: rename_save: 'Сохранить' review_note_label: 'Проверяющий' your_note: 'Ваше примечание' + edit: 'Изменить' + edit_save: 'Сохранить' + edit_save_rereview: 'Сохранить и отправить на повторную проверку' + edit_cancel: 'Отмена' + edit_rereview: 'Мейнтейнер уже принял решение по этому отчёту. После сохранения он вернётся на проверку и пропадёт из каталога, пока его не примут снова.' + edit_remove: 'Убрать' + edit_add: 'Добавить' + edited_at: 'Изменён {time}' + review_edited_label: 'Изменён отправителем' cameras_title: 'Мои камеры на Open Wall' cameras_count: 'Звёзды приносят {n} из {max}' cameras_empty: 'Пока ни одной камеры. Привяжите камеру ниже: она будет приносить звёзды за каждый месяц, в который присылает на Open Wall настоящие снимки.' diff --git a/data/locales/boards.zh.yml b/data/locales/boards.zh.yml index d15bc690..79f0e304 100644 --- a/data/locales/boards.zh.yml +++ b/data/locales/boards.zh.yml @@ -552,6 +552,15 @@ zh: rename_save: '保存' review_note_label: '审核者' your_note: '你的备注' + edit: '编辑' + edit_save: '保存' + edit_save_rereview: '保存并重新提交审核' + edit_cancel: '取消' + edit_rereview: '维护者已经审核过这份报告。保存后它会重新进入审核,在再次通过之前不会出现在目录中。' + edit_remove: '移除' + edit_add: '添加' + edited_at: '编辑于 {time}' + review_edited_label: '发送者已编辑' cameras_title: '我在 Open Wall 上的摄像头' cameras_count: '{n} / {max} 台计入星星' cameras_empty: '还没有绑定摄像头。在下方绑定一台:它每持续一个月向 Open Wall 发送真实画面,就会获得星星。' diff --git a/frontend/apps/site/src/components/club/Club.tsx b/frontend/apps/site/src/components/club/Club.tsx index f7be1762..8339269c 100644 --- a/frontend/apps/site/src/components/club/Club.tsx +++ b/frontend/apps/site/src/components/club/Club.tsx @@ -19,6 +19,7 @@ import { size } from '../../lib/reports'; import Cameras from './Cameras'; import Crashes from './Crashes'; import IpctoolCode from './IpctoolCode'; +import EditReport from './EditReport'; import { STATUS_TONE, Stars } from './parts'; type Load = { state: 'loading' } | { state: 'ok'; me: Me } | { state: 'error' }; @@ -244,9 +245,8 @@ function MemberPage({ member, ways, locale, t, onChange }: { // asks /me on every page and needs only the total. const pending = (reports ?? []).reduce((n, r) => n + (r.status === 'pending' ? r.pending : 0), 0); - useEffect(() => { - fetchMine().then((r) => setReports(r.reports)).catch(() => setError(true)); - }, [member.id]); + const loadReports = () => fetchMine().then((r) => setReports(r.reports)).catch(() => setError(true)); + useEffect(() => { void loadReports(); }, [member.id]); const loadCams = () => fetchCameras().then(setCams).catch(() => setError(true)); useEffect(() => { void loadCams(); }, [member.id]); // A link to #cameras (the bot's "My cameras") lands on the section once it exists. @@ -339,14 +339,15 @@ function MemberPage({ member, ways, locale, t, onChange }: { {reports && reports.length === 0 && (

{t('club.mine_empty')} {t('club.boards_link')}

)} - {reports && reports.length > 0 && } + {reports && reports.length > 0 && { void loadReports(); onChange(); }} />}
); } -function Ledger({ reports, locale, t }: { reports: MemberReport[]; locale: Locale; t: BoardsT }) { +function Ledger({ reports, locale, t, onChange }: { reports: MemberReport[]; locale: Locale; t: BoardsT; onChange: () => void }) { + const [editing, setEditing] = useState(null); return (
@@ -384,6 +385,18 @@ function Ledger({ reports, locale, t }: { reports: MemberReport[]; locale: Local {t('club.your_note')}: {r.note} )} {r.duplicate && {t('club.duplicate')}} + {r.edited_at && ( + + {t('club.edited_at', { time: new Date(r.edited_at).toLocaleString(locale, { dateStyle: 'medium', timeStyle: 'short' }) })} + + )} + {editing === r.id + ? setEditing(null)} onSaved={() => { setEditing(null); onChange(); }} /> + : r.status !== 'withdrawn' && ( + + )} {r.status !== 'rejected' && !r.joins && } {r.review_note && ( {t('club.review_note_label')}: {r.review_note} diff --git a/frontend/apps/site/src/components/club/EditReport.test.tsx b/frontend/apps/site/src/components/club/EditReport.test.tsx new file mode 100644 index 00000000..6ec02d90 --- /dev/null +++ b/frontend/apps/site/src/components/club/EditReport.test.tsx @@ -0,0 +1,60 @@ +// @vitest-environment jsdom +/** + * A member's edit to a report, as /club sends it: only what changed, files + * to take out by position, files to add as their kind; and before a decided + * report is saved, the warning that it goes back for review. + */ +import { afterEach, expect, test, vi } from 'vitest'; +import { cleanup, fireEvent, render, waitFor } from '@testing-library/preact'; +import { useBoardsTranslations } from '../../lib/boards-i18n'; +import type { MemberReport } from '../../lib/club'; +import EditReport from './EditReport.tsx'; + +const t = useBoardsTranslations('en'); +afterEach(() => { cleanup(); vi.unstubAllGlobals(); }); + +const report = (over: Partial): MemberReport => ({ + id: 'r-abcd2345', received_at: '2026-10-08T10:00:00Z', status: 'pending', note: 'from a market', + proposal: { maker: 'Jooan', board: 'Q9' }, stars: 0, pending: 2, + files: [ + { position: 1, kind: 'photo', name: 'front.jpg', bytes: 100, url: '', points: 1 }, + { position: 2, kind: 'photo', name: 'back.jpg', bytes: 100, url: '', points: 1 }, + ], + ...over, +}); + +function capture() { + const sent: { url: string; form: FormData }[] = []; + vi.stubGlobal('fetch', vi.fn(async (url: string, init?: RequestInit) => { + sent.push({ url, form: init?.body as FormData }); + return new Response(JSON.stringify({ id: 'r-abcd2345', status: 'pending', rereview: false })); + })); + return sent; +} + +test('an edit sends what changed: the note, a photo out, a boot log in', async () => { + const sent = capture(); + const saved = vi.fn(); + const { container, getByText, getByLabelText } = render( {}} />); + expect(container.textContent).not.toContain('goes back to them'); + fireEvent.input(getByLabelText('Your note'), { target: { value: 'bought on Ozon as Q9 Pro' } }); + fireEvent.click(getByText(/back\.jpg/)); + fireEvent.change(container.querySelector('select')!, { target: { value: 'boot_log' } }); + fireEvent.change(getByLabelText('Add', { selector: 'input' }), { target: { files: [new File(['U-Boot\n'], 'boot.log')] } }); + fireEvent.click(getByText('Save')); + await waitFor(() => expect(saved).toHaveBeenCalled()); + const f = sent[0].form; + expect(sent[0].url).toBe('/api/v1/club/reports/r-abcd2345/edit'); + expect(f.get('note')).toBe('bought on Ozon as Q9 Pro'); + expect(f.getAll('remove')).toEqual(['2']); + expect((f.get('boot_log') as File).name).toBe('boot.log'); + // The camera was not touched, so it is not sent. + expect(f.get('maker')).toBeNull(); +}); + +test('a decided report says it goes back for review before it is saved', () => { + capture(); + const { getByRole, getByText } = render( {}} onCancel={() => {}} />); + expect(getByRole('note').textContent).toContain('goes back to them'); + expect(getByText('Save and send for review again')).toBeTruthy(); +}); diff --git a/frontend/apps/site/src/components/club/EditReport.tsx b/frontend/apps/site/src/components/club/EditReport.tsx new file mode 100644 index 00000000..3f3a4215 --- /dev/null +++ b/frontend/apps/site/src/components/club/EditReport.tsx @@ -0,0 +1,102 @@ +/** + * A member changing a report of theirs from /club + * (POST /api/v1/club/reports/{id}/edit): the note, the camera they proposed, + * files taken out, photos and text added. ipctool's output and a backup stay + * as sent. A report a maintainer has decided goes back to them: the form says + * so before it is saved. + */ +import { useState } from 'preact/hooks'; +import type { BoardsT } from '../../lib/boards-i18n'; +import { editReport, type MemberReport } from '../../lib/club'; + +type AddKind = 'photo' | 'boot_log' | 'uboot_env'; +const ADD_KINDS: AddKind[] = ['photo', 'boot_log', 'uboot_env']; +const field = 'rounded-md border border-hairline px-2.5 py-1.5 text-sm text-body'; + +export default function EditReport({ report, t, onSaved, onCancel }: { + report: MemberReport; t: BoardsT; onSaved: () => void; onCancel: () => void; +}) { + const [note, setNote] = useState(report.note ?? ''); + const [maker, setMaker] = useState(report.proposal?.maker ?? ''); + const [board, setBoard] = useState(report.proposal?.board ?? ''); + const [soc, setSoc] = useState(report.proposal?.soc ?? ''); + const [remove, setRemove] = useState>(new Set()); + const [kind, setKind] = useState('photo'); + const [add, setAdd] = useState([]); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const decided = report.status === 'published' || report.status === 'rejected'; + + const toggle = (pos: number) => { + const next = new Set(remove); + if (next.has(pos)) next.delete(pos); else next.add(pos); + setRemove(next); + }; + const save = (e: Event) => { + e.preventDefault(); + const form = new FormData(); + if (note.trim() !== (report.note ?? '')) form.set('note', note.trim()); + if (report.proposal && (maker.trim() !== report.proposal.maker || board.trim() !== report.proposal.board || soc.trim() !== (report.proposal.soc ?? ''))) { + form.set('maker', maker.trim()); + form.set('board', board.trim()); + form.set('soc', soc.trim()); + } + for (const pos of remove) form.append('remove', String(pos)); + for (const f of add) form.append(kind, f, f.name); + setBusy(true); + setError(null); + editReport(report.id, form) + .then(onSaved) + .catch((err: Error) => setError(t('club.send_failed', { error: err.message }))) + .finally(() => setBusy(false)); + }; + + return ( +
+ {decided &&

{t('club.edit_rereview')}

} + {report.proposal && ( +
+ + + +
+ )} +