Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
server/feedback.json
server/feedback.json.migrated-*
server/feedback.json.corrupt-*
server/feedback.db
server/feedback.db-journal
server/feedback.db-wal
server/feedback.db-shm
server/screenshots/
server/receiver.config.json
node_modules/
Expand Down
15 changes: 11 additions & 4 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,16 +169,23 @@ When `endpoint` is set, the widget POSTs the payload to that URL. A local receiv
node server/receive.js
```

- Accepts payload at `POST /feedback`, appending to `server/feedback.json` by default
- Accepts payload at `POST /feedback`, storing it in `server/feedback.db` (sqlite) by default
- Imports download-mode JSON bundles at `POST /import`, storing them in the same inbox format
- Renders an inbox of received feedback at `GET /`
- The inbox has text search plus status / kind / reviewer / source / Slack filters
- Each feedback has a triage status (`new` / `accepted` / `fixed` / `ignored`) editable from the card; statuses persist to `server/feedback.json`
- The inbox has text search plus status / kind / project / demo / reviewer / source / Slack filters
- Each feedback has a triage status (`new` / `accepted` / `fixed` / `ignored`) editable from the card; statuses persist to sqlite
- `POST /feedback/:id/status` updates the status via the API (body: `{"status": "accepted"}`)
- `DELETE /feedback/:id` removes a feedback and its screenshot (also from the card's delete button)
- With GitHub configured, each inbox card can create a GitHub Issue (see below)
- Imports `.patchloop-feedback.json` files from the inbox UI
- Returns the raw JSON at `GET /feedback.json`
- Returns the raw JSON at `GET /feedback.json` (filterable via `?projectId=` / `?demoId=` / `?status=`)
- Serves saved screenshots from `GET /screenshots/:file`

### Storage

Feedback is stored in the built-in `node:sqlite` (`server/feedback.db`). The backend sits behind a small async interface in `server/store.js` (`init` / `insert` / `get` / `list` / `update` / `delete` / `count`), so it can be swapped for another backend (e.g. MySQL) later without changing the receiver.

On startup, an existing legacy `server/feedback.json` is migrated into sqlite once and archived to `feedback.json.migrated-<timestamp>` (or `feedback.json.corrupt-<timestamp>` if unreadable, starting empty). The db path is set via `FEEDBACK_DB_PATH` and the migration source via `FEEDBACK_STORE_PATH`.
- Configurable via `PORT` / `HOST` env (default `127.0.0.1:4000`)
- Configurable storage path via `FEEDBACK_STORE_PATH`
- Configurable screenshot directory via `SCREENSHOT_DIR`
Expand Down
15 changes: 11 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,16 +169,23 @@ submit のたびに `document` で `patchloop:feedback` が発火し、`event.de
node server/receive.js
```

- `POST /feedback` で payload を受け取り、デフォルトでは `server/feedback.json` に追記します
- `POST /feedback` で payload を受け取り、デフォルトでは `server/feedback.db`(sqlite)に保存します
- `POST /import` で download mode の JSON bundle を読み込み、通常の inbox と同じ形式で保存します
- `GET /` で受信した feedback の一覧(inbox)を表示します
- inbox にはテキスト検索と status / kind / reviewer / source / Slack の絞り込みがあります
- 各 feedback には triage status(`new` / `accepted` / `fixed` / `ignored`)があり、card 上の select から変更できます。status は `server/feedback.json` に永続化されます
- inbox にはテキスト検索と status / kind / project / demo / reviewer / source / Slack の絞り込みがあります
- 各 feedback には triage status(`new` / `accepted` / `fixed` / `ignored`)があり、card 上の select から変更できます。status は sqlite に永続化されます
- `POST /feedback/:id/status` で API からも status を更新できます(body は `{"status": "accepted"}` 形式)
- `DELETE /feedback/:id` で feedback と紐づく screenshot を削除できます(inbox の card の「削除」ボタンからも)
- GitHub 連携を設定すると、inbox の各 card から GitHub Issue を作成できます(後述)
- inbox UI から `.patchloop-feedback.json` を選択して import できます
- `GET /feedback.json` で raw JSON を返します
- `GET /feedback.json` で raw JSON を返します(`?projectId=` / `?demoId=` / `?status=` で絞り込み可)
- `GET /screenshots/:file` で保存済み screenshot を返します

### ストレージ

feedback は組み込みの `node:sqlite`(`server/feedback.db`)に保存します。バックエンドは `server/store.js` の小さな非同期インターフェース(`init` / `insert` / `get` / `list` / `update` / `delete` / `count`)の背後に隔離してあり、将来 MySQL 等の別バックエンドに差し替えても receiver 本体は変更不要です。

起動時に旧形式の `server/feedback.json` が残っていれば一度だけ sqlite に取り込み、元ファイルは `feedback.json.migrated-<timestamp>` に退避します(壊れていれば `feedback.json.corrupt-<timestamp>` に退避して空で起動)。db ファイルのパスは `FEEDBACK_DB_PATH`、移行元の JSON は `FEEDBACK_STORE_PATH` で指定できます。
- `PORT` / `HOST` env で変更可能(デフォルトは `127.0.0.1:4000`)
- `FEEDBACK_STORE_PATH` env で保存先を変更できます
- `SCREENSHOT_DIR` env で screenshot 保存先を変更できます
Expand Down
149 changes: 67 additions & 82 deletions server/receive.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,16 @@ const path = require("path");
const crypto = require("crypto");

const { truncateText, present, escapeHtml, slackEscape, formatSlackCode, formatSlackLink, formatViewport, formatTarget } = require("../shared/format.js");
const { createStore } = require("./store.js");

const CONFIG_PATH = process.env.PATCHLOOP_RECEIVER_CONFIG || path.join(__dirname, "receiver.config.json");
const config = loadConfig(CONFIG_PATH);
const configDir = path.dirname(CONFIG_PATH);

const PORT = numberSetting(process.env.PORT, numberSetting(config.port, 4000));
const HOST = process.env.HOST || config.host || "127.0.0.1";
const STORE_PATH = process.env.FEEDBACK_STORE_PATH || pathFromConfig(config.feedbackStorePath, path.join(__dirname, "feedback.json"));
const LEGACY_STORE_PATH = process.env.FEEDBACK_STORE_PATH || pathFromConfig(config.feedbackStorePath, path.join(__dirname, "feedback.json"));
const DB_PATH = process.env.FEEDBACK_DB_PATH || pathFromConfig(config.feedbackDbPath, path.join(__dirname, "feedback.db"));
const MAX_BODY_BYTES = numberSetting(process.env.MAX_BODY_BYTES, numberSetting(config.maxBodyBytes, 3_000_000));
const SCREENSHOT_DIR = process.env.SCREENSHOT_DIR || pathFromConfig(config.screenshotDir, path.join(__dirname, "screenshots"));
const SCREENSHOT_MAX_BYTES = numberSetting(process.env.SCREENSHOT_MAX_BYTES, numberSetting(config.screenshotMaxBytes, 1_500_000));
Expand All @@ -40,7 +42,7 @@ const FEEDBACK_STATUSES = ["new", "accepted", "fixed", "ignored"];
// so every stored item carries a version going forward.
const DEFAULT_SCHEMA_VERSION = 1;

let feedback = loadFeedback();
let store;

const server = http.createServer((req, res) => {
setCors(res);
Expand Down Expand Up @@ -117,15 +119,27 @@ const server = http.createServer((req, res) => {
res.end("Not Found");
});

server.listen(PORT, HOST, () => {
console.log(`[PatchLoop receiver] listening on http://${HOST}:${PORT}`);
console.log(`[PatchLoop receiver] config file: ${config.__loaded ? CONFIG_PATH : "not loaded"}`);
console.log(`[PatchLoop receiver] feedback file: ${STORE_PATH}`);
console.log(`[PatchLoop receiver] screenshot dir: ${SCREENSHOT_DIR}`);
console.log(`[PatchLoop receiver] Slack webhook: ${SLACK_WEBHOOK_URL ? "enabled" : "disabled"}`);
console.log(`[PatchLoop receiver] Slack image mode: ${SLACK_IMAGE_MODE}`);
console.log(`[PatchLoop receiver] Slack file upload: ${SLACK_BOT_TOKEN && SLACK_UPLOAD_CHANNEL_ID ? "enabled" : "disabled"}`);
console.log(`[PatchLoop receiver] GitHub issues: ${GITHUB_CONFIGURED ? `enabled (${GITHUB_REPO})` : "disabled"}`);
// Storage is initialized (and the legacy JSON store migrated) before the
// server accepts requests, so no handler can run against an unready store.
async function start() {
store = createStore({ dbPath: DB_PATH, legacyJsonPath: LEGACY_STORE_PATH });
await store.init();

server.listen(PORT, HOST, () => {
console.log(`[PatchLoop receiver] listening on http://${HOST}:${PORT}`);
console.log(`[PatchLoop receiver] config file: ${config.__loaded ? CONFIG_PATH : "not loaded"}`);
console.log(`[PatchLoop receiver] feedback db: ${DB_PATH}`);
console.log(`[PatchLoop receiver] screenshot dir: ${SCREENSHOT_DIR}`);
console.log(`[PatchLoop receiver] Slack webhook: ${SLACK_WEBHOOK_URL ? "enabled" : "disabled"}`);
console.log(`[PatchLoop receiver] Slack image mode: ${SLACK_IMAGE_MODE}`);
console.log(`[PatchLoop receiver] Slack file upload: ${SLACK_BOT_TOKEN && SLACK_UPLOAD_CHANNEL_ID ? "enabled" : "disabled"}`);
console.log(`[PatchLoop receiver] GitHub issues: ${GITHUB_CONFIGURED ? `enabled (${GITHUB_REPO})` : "disabled"}`);
});
}

start().catch((error) => {
console.error(`[PatchLoop receiver] failed to start: ${error.message}`);
process.exit(1);
});

function setCors(res) {
Expand Down Expand Up @@ -203,16 +217,15 @@ function handlePostFeedback(req, res) {
...(stored.integrations || {}),
slack: await deliverToSlack(stored)
};
feedback.unshift(stored);
persist();
await store.insert(stored);
const slackLog = stored.integrations.slack.status === "disabled"
? ""
: ` slack=${stored.integrations.slack.status}`;
console.log(`[PatchLoop receiver] received feedback id=${payload?.id || "?"} comment="${truncateText(payload?.comment || "", 60)}"${slackLog}`);
respondJson(res, 201, {
ok: true,
id: payload?.id,
count: feedback.length,
count: await store.count(),
slack: stored.integrations.slack
});
});
Expand Down Expand Up @@ -243,31 +256,34 @@ function handlePostImport(req, res) {
}
};

feedback.unshift(stored);
persist();
await store.insert(stored);
console.log(`[PatchLoop receiver] imported feedback id=${stored.id || "?"} comment="${truncateText(stored.comment || "", 60)}"`);
respondJson(res, 201, {
ok: true,
id: stored.id,
count: feedback.length,
count: await store.count(),
source: "import"
});
});
}

async function handleDeleteFeedback(req, res, id) {
const index = feedback.findIndex((entry) => entry.id === id);
if (index === -1) {
let removed;
try {
removed = await store.delete(id);
} catch (error) {
respondJson(res, 500, { ok: false, error: error.message });
return;
}
if (!removed) {
respondJson(res, 404, { ok: false, error: `Unknown feedback id: ${id}` });
return;
}

const [removed] = feedback.splice(index, 1);
persist();
// Await the file removal so a 200 means the screenshot is gone too.
await deleteScreenshotFile(removed.screenshot);
console.log(`[PatchLoop receiver] deleted feedback id=${id}`);
respondJson(res, 200, { ok: true, id, count: feedback.length });
respondJson(res, 200, { ok: true, id, count: await store.count() });
}

// Removes the stored screenshot for a deleted feedback. Confined to
Expand All @@ -294,15 +310,12 @@ function handlePostStatus(req, res, id) {
return;
}

const item = feedback.find((entry) => entry.id === id);
if (!item) {
const updated = await store.update(id, { status, statusUpdatedAt: new Date().toISOString() });
if (!updated) {
respondJson(res, 404, { ok: false, error: `Unknown feedback id: ${id}` });
return;
}

item.status = status;
item.statusUpdatedAt = new Date().toISOString();
persist();
respondJson(res, 200, { ok: true, id, status });
});
}
Expand All @@ -314,7 +327,7 @@ function handlePostGitHubIssue(req, res, id) {
return;
}

const item = feedback.find((entry) => entry.id === id);
const item = await store.get(id);
if (!item) {
respondJson(res, 404, { ok: false, error: `Unknown feedback id: ${id}` });
return;
Expand All @@ -327,8 +340,7 @@ function handlePostGitHubIssue(req, res, id) {
}

const github = await createGitHubIssue(item);
item.integrations = { ...(item.integrations || {}), github };
persist();
await store.update(id, { integrations: { ...(item.integrations || {}), github } });
console.log(`[PatchLoop receiver] github issue ${github.status} id=${id}${github.url ? ` url=${github.url}` : ""}`);

if (github.status === "created") {
Expand Down Expand Up @@ -557,14 +569,21 @@ function requireNonEmptyString(value, label) {
}
}

function handleGetInbox(req, res) {
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(renderInbox(feedback));
async function handleGetInbox(req, res) {
try {
const items = await store.list({});
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(renderInbox(items));
} catch (error) {
res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
res.end("Internal Server Error");
console.warn(`[PatchLoop receiver] inbox render failed: ${error.message}`);
}
}

// GET /feedback.json supports ?projectId=&demoId=&status= to narrow the
// export, so a shared receiver can hand each project just its own feedback.
function handleGetFeedbackJson(req, res) {
async function handleGetFeedbackJson(req, res) {
let params;
try {
params = new URL(req.url, "http://localhost").searchParams;
Expand All @@ -573,21 +592,26 @@ function handleGetFeedbackJson(req, res) {
return;
}

const projectId = params.get("projectId");
const demoId = params.get("demoId");
const status = params.get("status");
// Empty query values mean "no filter" (matching the inbox's "all" option),
// not "items whose field equals the empty string".
const projectId = params.get("projectId") || null;
const demoId = params.get("demoId") || null;
const status = params.get("status") || null;

if (status != null && !FEEDBACK_STATUSES.includes(status)) {
respondJson(res, 400, { ok: false, error: `status must be one of: ${FEEDBACK_STATUSES.join(", ")}` });
return;
}

const filtered = feedback.filter((item) =>
(projectId == null || (item.projectId || "") === projectId)
&& (demoId == null || (item.demoId || "") === demoId)
&& (status == null || feedbackStatusOf(item) === status));

respondJson(res, 200, filtered);
try {
const filter = {};
if (projectId != null) filter.projectId = projectId;
if (demoId != null) filter.demoId = demoId;
if (status != null) filter.status = status;
respondJson(res, 200, await store.list(filter));
} catch (error) {
respondJson(res, 500, { ok: false, error: error.message });
}
}

function handleGetWidgetScript(req, res) {
Expand Down Expand Up @@ -671,45 +695,6 @@ function handleGetScreenshot(req, res) {
});
}

// An unreadable store must never be silently replaced: persist() rewrites the
// whole file, so starting from [] would destroy all previous feedback on the
// next write. Move the broken file aside and start fresh instead.
function loadFeedback() {
let raw;
try {
raw = fs.readFileSync(STORE_PATH, "utf8");
} catch (error) {
if (error.code !== "ENOENT") {
console.warn(`[PatchLoop receiver] feedback store unreadable: ${error.message}`);
}
return [];
}

try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) throw new Error("store content is not an array");
return parsed;
} catch (error) {
const backupPath = `${STORE_PATH}.corrupt-${Date.now()}`;
try {
fs.renameSync(STORE_PATH, backupPath);
console.warn(`[PatchLoop receiver] feedback store corrupt (${error.message}); backed up to ${backupPath}`);
} catch (backupError) {
console.warn(`[PatchLoop receiver] feedback store corrupt and backup failed: ${backupError.message}`);
}
return [];
}
}

// Write-then-rename keeps the store readable even if the process dies
// mid-write; a torn direct write would corrupt the only copy.
function persist() {
fs.mkdirSync(path.dirname(STORE_PATH), { recursive: true });
const tempPath = `${STORE_PATH}.tmp`;
fs.writeFileSync(tempPath, JSON.stringify(feedback, null, 2));
fs.renameSync(tempPath, STORE_PATH);
}

function saveScreenshot(screenshot, id) {
if (!screenshot) return null;

Expand Down
Loading