From 4afe83c4bb4c535f1c3b52df9413c03307a1ed00 Mon Sep 17 00:00:00 2001 From: AI Dev Date: Thu, 8 Oct 2026 13:52:52 +0000 Subject: [PATCH 1/2] Show a report's note: to its sender on /club, on its page once published, and on its board An owner asked whether the "Anything else: where you bought it, what it is sold as" field is kept: it is, as written for the maintainers and redacted for the public (note_public, migration 018), but nothing showed it -- not /club, not the report's page, not the board it was published on. - /club lists the member's own note beside each of their reports, as they wrote it. - The report's page shows the owner's note once the report is published; the service serves only the redacted copy, and only then. - A published report's note becomes the note of the unit it makes on its board (board_units.notes, which the board panel already shows), redacted. Units already made pick it up on the next refresh. --- data/locales/boards.en.yml | 2 + data/locales/boards.ru.yml | 2 + data/locales/boards.zh.yml | 2 + .../apps/site/src/components/club/Club.tsx | 3 ++ .../site/src/components/club/Notes.test.tsx | 42 +++++++++++++++++++ .../site/src/components/reports/Receipt.tsx | 3 ++ frontend/apps/site/src/i18n/boards.en.json | 4 +- frontend/apps/site/src/i18n/boards.ru.json | 4 +- frontend/apps/site/src/i18n/boards.zh.json | 4 +- service/cmd/openipc/club.go | 4 +- service/internal/club/newcamera_test.go | 26 ++++++++++++ service/internal/reports/club.go | 12 +++--- 12 files changed, 99 insertions(+), 9 deletions(-) create mode 100644 frontend/apps/site/src/components/club/Notes.test.tsx diff --git a/data/locales/boards.en.yml b/data/locales/boards.en.yml index 051d3233..5caedc67 100644 --- a/data/locales/boards.en.yml +++ b/data/locales/boards.en.yml @@ -89,6 +89,7 @@ en: looks_like: "Looks like {board} in the catalogue" looks_like_why: "not certain until reviewed" filed_under: "Filed under" + owner_note: 'The owner''s note:' not_found: "No report has this receipt. Check the address ipctool printed." load_failed: "The report could not be loaded. Try again in a minute." reports_title: "Reports from owners" @@ -547,6 +548,7 @@ en: rename_label: 'Your name, as it appears on the boards your reports reach' rename_save: 'Save' review_note_label: 'Reviewer' + your_note: 'Your note' 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 4237d5be..2a1bd4ca 100644 --- a/data/locales/boards.ru.yml +++ b/data/locales/boards.ru.yml @@ -89,6 +89,7 @@ ru: looks_like: "Похоже на {board} из каталога" looks_like_why: "точно станет ясно после проверки" filed_under: "Привязан к" + owner_note: 'Примечание владельца:' not_found: "Отчёта с такой квитанцией нет. Проверьте адрес, который напечатал ipctool." load_failed: "Не удалось загрузить отчёт. Попробуйте через минуту." reports_title: "Отчёты владельцев" @@ -588,6 +589,7 @@ ru: rename_label: 'Ваше имя — так оно показывается у плат, куда попали ваши материалы' rename_save: 'Сохранить' review_note_label: 'Проверяющий' + your_note: 'Ваше примечание' 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 bbe7bc72..d15bc690 100644 --- a/data/locales/boards.zh.yml +++ b/data/locales/boards.zh.yml @@ -89,6 +89,7 @@ zh: looks_like: "看起来像目录中的 {board}" looks_like_why: "审核后才能确定" filed_under: "归属" + owner_note: '所有者的备注:' not_found: "没有与此回执对应的报告。请检查 ipctool 打印的地址。" load_failed: "无法加载报告。请稍后再试。" reports_title: "用户报告" @@ -550,6 +551,7 @@ zh: rename_label: '你的名字,会显示在你的提交所属的电路板上' rename_save: '保存' review_note_label: '审核者' + your_note: '你的备注' 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 0af831a9..2902f901 100644 --- a/frontend/apps/site/src/components/club/Club.tsx +++ b/frontend/apps/site/src/components/club/Club.tsx @@ -376,6 +376,9 @@ function Ledger({ reports, locale, t }: { reports: MemberReport[]; locale: Local ))} + {r.note && ( + {t('club.your_note')}: {r.note} + )} {r.duplicate && {t('club.duplicate')}} {r.status !== 'rejected' && !r.joins && } {r.review_note && ( diff --git a/frontend/apps/site/src/components/club/Notes.test.tsx b/frontend/apps/site/src/components/club/Notes.test.tsx new file mode 100644 index 00000000..0df9cdc0 --- /dev/null +++ b/frontend/apps/site/src/components/club/Notes.test.tsx @@ -0,0 +1,42 @@ +// @vitest-environment jsdom +/** + * The note a sender types under a send -- where it was bought, what it is + * sold as -- is shown: to them on /club as they wrote it, and on the report's + * page once published (the service serves the redacted copy only then). + */ +import { afterEach, expect, test, vi } from 'vitest'; +import { cleanup, render } from '@testing-library/preact'; +import Club from './Club.tsx'; +import Receipt from '../reports/Receipt.tsx'; + +afterEach(() => { cleanup(); vi.unstubAllGlobals(); localStorage.clear(); history.replaceState(null, '', '/'); }); + +const member = { id: 'm-1', name: 'Andrei', maintainer: false, quiet: false, identities: [], stars: 0, report_stars: 0, wall_stars: 0, pending: 1 }; + +test('the sender sees their note beside the report on /club', async () => { + localStorage.setItem('openipc.club', '1'); + vi.stubGlobal('fetch', vi.fn(async (url: string) => { + if (url === '/api/v1/club/me') return new Response(JSON.stringify({ member, sign_in: { telegram: null, github: false, email: true } })); + if (url === '/api/v1/club/reports') { + return new Response(JSON.stringify({ member, reports: [{ + id: 'r-abcd2345', received_at: '2026-10-08T10:00:00Z', status: 'pending', note: 'bought on Ozon as Jooan Q9', + proposal: { maker: 'Jooan', board: 'Q9' }, files: [], stars: 0, pending: 1, + }] })); + } + if (url === '/api/v1/club/cameras') return new Response(JSON.stringify({ cameras: [], code: null, listed: true, max_cameras: 3 })); + return new Response(JSON.stringify({})); + })); + const { findByText } = render(); + expect((await findByText('bought on Ozon as Jooan Q9')).closest('span')?.textContent).toBe('Your note: bought on Ozon as Jooan Q9'); +}); + +test('a published report shows its owner\'s note on its page', async () => { + history.replaceState(null, '', '/cameras/report/?id=r-abcd2345'); + vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ + schema: 1, id: 'r-abcd2345', received_at: '2026-10-08T10:00:00Z', channel: 'web', status: 'published', + backup_consent: 'none', note: 'bought on Ozon, MAC ', files: [], + models: [{ id: 'jooan-q9', model: 'Q9', manufacturer: 'Jooan' }], same_board: 0, + })))); + const { findByText } = render(); + expect((await findByText(/bought on Ozon/)).textContent).toBe("The owner's note: bought on Ozon, MAC "); +}); diff --git a/frontend/apps/site/src/components/reports/Receipt.tsx b/frontend/apps/site/src/components/reports/Receipt.tsx index 7ffd52f3..9f1d04b2 100644 --- a/frontend/apps/site/src/components/reports/Receipt.tsx +++ b/frontend/apps/site/src/components/reports/Receipt.tsx @@ -109,6 +109,9 @@ export default function Receipt({ locale }: { locale: Locale }) { + {r.note && ( +

{t('report.owner_note')} {r.note}

+ )} {published && r.models.length > 0 && (

{t('report.filed_under')}{' '} diff --git a/frontend/apps/site/src/i18n/boards.en.json b/frontend/apps/site/src/i18n/boards.en.json index a1083450..eb5ce212 100644 --- a/frontend/apps/site/src/i18n/boards.en.json +++ b/frontend/apps/site/src/i18n/boards.en.json @@ -219,7 +219,8 @@ "why_2": "Keep your flash dumps private: only you and OpenIPC's maintainers can download them.", "why_3": "Collect stars for what you document and for keeping a camera on the Open Wall, and your name next to the boards you documented.", "why_note": "You don't need an account to browse or download firmware. The site sets no cookie for anyone who doesn't sign in.", - "why_title": "Why sign in" + "why_title": "Why sign in", + "your_note": "Your note" }, "cov_boot_log": "Boot log", "cov_document": "Docs", @@ -458,6 +459,7 @@ "lookup_button": "Show its state", "lookup_label": "Have a receipt?", "not_found": "No report has this receipt. Check the address ipctool printed.", + "owner_note": "The owner's note:", "received": "Received from {channel}", "reports_app": "app", "reports_ask": "Have this board?", diff --git a/frontend/apps/site/src/i18n/boards.ru.json b/frontend/apps/site/src/i18n/boards.ru.json index baa3806e..2174fdb0 100644 --- a/frontend/apps/site/src/i18n/boards.ru.json +++ b/frontend/apps/site/src/i18n/boards.ru.json @@ -221,7 +221,8 @@ "why_2": "Держать дампы флеш-памяти закрытыми: скачать их можете только вы и мейнтейнеры OpenIPC.", "why_3": "Получать звёзды за описанные платы и за камеру на Open Wall и видеть своё имя рядом с платами, которые вы описали.", "why_note": "Чтобы смотреть каталог и скачивать прошивки, учётная запись не нужна. Тем, кто не входит, сайт не ставит ни одной cookie.", - "why_title": "Зачем входить" + "why_title": "Зачем входить", + "your_note": "Ваше примечание" }, "cov_boot_log": "Лог загрузки", "cov_document": "Документы", @@ -480,6 +481,7 @@ "lookup_button": "Показать состояние", "lookup_label": "Есть квитанция?", "not_found": "Отчёта с такой квитанцией нет. Проверьте адрес, который напечатал ipctool.", + "owner_note": "Примечание владельца:", "received": "Получен от {channel}", "reports_app": "приложение", "reports_ask": "Есть такая плата?", diff --git a/frontend/apps/site/src/i18n/boards.zh.json b/frontend/apps/site/src/i18n/boards.zh.json index 6f93034f..ee69544f 100644 --- a/frontend/apps/site/src/i18n/boards.zh.json +++ b/frontend/apps/site/src/i18n/boards.zh.json @@ -219,7 +219,8 @@ "why_2": "让固件转储保持私密:只有你和 OpenIPC 维护者可以下载。", "why_3": "为你记录的电路板和保持在线的 Open Wall 摄像头获得星星,并在你记录的电路板旁显示你的名字。", "why_note": "浏览目录或下载固件无需账户。对不登录的访客,本站不设置任何 cookie。", - "why_title": "为什么要登录" + "why_title": "为什么要登录", + "your_note": "你的备注" }, "cov_boot_log": "启动日志", "cov_document": "文档", @@ -458,6 +459,7 @@ "lookup_button": "查看状态", "lookup_label": "有回执?", "not_found": "没有与此回执对应的报告。请检查 ipctool 打印的地址。", + "owner_note": "所有者的备注:", "received": "来自 {channel}", "reports_app": "应用", "reports_ask": "有这块板?", diff --git a/service/cmd/openipc/club.go b/service/cmd/openipc/club.go index 285a9dc8..86391937 100644 --- a/service/cmd/openipc/club.go +++ b/service/cmd/openipc/club.go @@ -76,7 +76,9 @@ func refreshReportUnits(ctx context.Context, cfg *config.Config, log *slog.Logge } // A report published on several boards is a unit on each, and each // unit's reference is its own (board_units.source_ref is unique). - c := boards.Contribution{Unit: t.Model + "-" + t.Report, Model: t.Model, By: by, + // The sender's note -- where it was bought, what it is sold as -- is + // the unit's, as published (redacted). + c := boards.Contribution{Unit: t.Model + "-" + t.Report, Model: t.Model, By: by, Note: t.Note, Evidence: []string{cfg.ClubSiteURL + boards.ReceiptMark + t.Report + "&board=" + t.Model}} for _, f := range t.Files { c.Files = append(c.Files, boards.ContributedFile{Kind: kinds[f.Kind], File: fmt.Sprintf("%d-%s", f.Position, f.Name), Source: f.Path}) diff --git a/service/internal/club/newcamera_test.go b/service/internal/club/newcamera_test.go index 8684fa1e..31988316 100644 --- a/service/internal/club/newcamera_test.go +++ b/service/internal/club/newcamera_test.go @@ -175,3 +175,29 @@ func TestANewCameraComesWithAProgrammersDump(t *testing.T) { t.Errorf("published texts: %+v (%v)", texts, err) } } + +// The note under a send -- where it was bought, what it is sold as -- is +// the sender's to see on /club as written, and once published it goes onto +// the board's unit redacted: a MAC pasted into it never reaches the page. +func TestTheSendersNoteIsShownAndPublishedRedacted(t *testing.T) { + e := newEnv(t) + ctx := context.Background() + ivan := e.signedIn(t, 777, "ivan_k", "198.51.100.1") + note := "bought on Ozon as Jooan Q9, MAC 00:12:41:ab:cd:ef" + _, out := ivan.send(t, map[string]string{"channel": "web", "model": "anjoy-ms-j10", "note": note}, map[string][]byte{"photo": photo}) + id := out["id"].(string) + if _, mine := ivan.json(t, "GET", "/api/v1/club/reports", nil); mine["reports"].([]any)[0].(map[string]any)["note"] != note { + t.Errorf("the sender's own note: %v", mine["reports"]) + } + maint := e.maintainer(t) + if code, _ := maint.json(t, "POST", "/api/v1/club/review/"+id, map[string]any{"decision": "publish"}); code != 200 { + t.Fatal("publish") + } + texts, err := e.api.Reports.Store().PublishedTexts(ctx) + if err != nil || len(texts) != 1 { + t.Fatalf("%v %v", texts, err) + } + if n := texts[0].Note; !strings.HasPrefix(n, "bought on Ozon as Jooan Q9, MAC Date: Thu, 8 Oct 2026 13:58:24 +0000 Subject: [PATCH 2/2] Address review: a published note with no public file still makes its board unit PublishedTexts joined only reports with a served text or photo file, so a note sent alone, or beside a private backup, reached no board. The files are joined optionally now, and a report with a published note is listed without them; a private backup still never goes with it. --- service/internal/club/newcamera_test.go | 25 +++++++++++++++++++++++++ service/internal/reports/club.go | 20 +++++++++++++------- 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/service/internal/club/newcamera_test.go b/service/internal/club/newcamera_test.go index 31988316..c721e4f4 100644 --- a/service/internal/club/newcamera_test.go +++ b/service/internal/club/newcamera_test.go @@ -201,3 +201,28 @@ func TestTheSendersNoteIsShownAndPublishedRedacted(t *testing.T) { t.Errorf("the board's note: %q", n) } } + +// A published report with a note and no public file -- a note alone, or one +// beside a private dump -- still makes a unit on its board, for its note; +// the dump never goes with it. +func TestANoteWithoutAPublicFileStillReachesTheBoard(t *testing.T) { + e := newEnv(t) + ctx := context.Background() + ivan := e.signedIn(t, 777, "ivan_k", "198.51.100.1") + maint := e.maintainer(t) + for _, files := range []map[string][]byte{nil, {"backup": bytes.Repeat([]byte{0x5a}, 1<<20)}} { + _, out := ivan.send(t, map[string]string{"channel": "web", "model": "anjoy-ms-j10", "note": "sold as Jooan Q9"}, files) + if code, d := maint.json(t, "POST", "/api/v1/club/review/"+out["id"].(string), map[string]any{"decision": "publish"}); code != 200 { + t.Fatalf("publish: %d %v", code, d) + } + } + texts, err := e.api.Reports.Store().PublishedTexts(ctx) + if err != nil || len(texts) != 2 { + t.Fatalf("%+v %v", texts, err) + } + for _, x := range texts { + if x.Note != "sold as Jooan Q9" || len(x.Files) != 0 || x.Model != "anjoy-ms-j10" { + t.Errorf("%+v", x) + } + } +} diff --git a/service/internal/reports/club.go b/service/internal/reports/club.go index cfd2c3a3..a1aec7a2 100644 --- a/service/internal/reports/club.go +++ b/service/internal/reports/club.go @@ -489,16 +489,19 @@ type BoardTextFile struct { } // PublishedTexts lists, for every published report linked to a board, its -// served text and photo files. A private backup is never among them. +// served text and photo files and its published note. A report with a note +// and no such file -- a note alone, or one beside a private backup -- is +// listed too, for its note. A private backup is never among them. func (s *Store) PublishedTexts(ctx context.Context) ([]BoardText, error) { rows, err := s.DB.Query(ctx, ` SELECT r.id, rm.model_id, coalesce(cm.name, ''), r.note_public, f.position, f.kind, f.name, f.public_sha256 FROM reports r JOIN report_models rm ON rm.report_id = r.id - JOIN report_files f ON f.report_id = r.id + LEFT JOIN report_files f ON f.report_id = r.id + AND f.public_sha256 IS NOT NULL AND f.kind IN ('photo', 'boot_log', 'uboot_env', 'note') LEFT JOIN report_submissions rs ON rs.report_id = r.id LEFT JOIN club_members cm ON cm.id = rs.member_id - WHERE f.public_sha256 IS NOT NULL AND f.kind IN ('photo', 'boot_log', 'uboot_env', 'note') + WHERE (f.report_id IS NOT NULL OR r.note_public <> '') AND (SELECT decision FROM report_reviews rv WHERE rv.report_id = r.id ORDER BY rv.id DESC LIMIT 1) = 'publish' ORDER BY r.received_at, r.id, rm.model_id, f.position`) if err != nil { @@ -507,16 +510,19 @@ func (s *Store) PublishedTexts(ctx context.Context) ([]BoardText, error) { defer rows.Close() var out []BoardText for rows.Next() { - var id, model, by, note, kind, name, sum string - var pos int + var id, model, by, note string + var pos *int + var kind, name, sum *string if err := rows.Scan(&id, &model, &by, ¬e, &pos, &kind, &name, &sum); err != nil { return nil, err } if n := len(out); n == 0 || out[n-1].Report != id || out[n-1].Model != model { out = append(out, BoardText{Report: id, Model: model, By: by, Note: note}) } - t := &out[len(out)-1] - t.Files = append(t.Files, BoardTextFile{Position: pos, Kind: kind, Name: name, Path: Rel(sum)}) + if pos != nil { + t := &out[len(out)-1] + t.Files = append(t.Files, BoardTextFile{Position: *pos, Kind: *kind, Name: *name, Path: Rel(*sum)}) + } } return out, rows.Err() }