react-doctor-cli-dev 1.0.1 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,25 +1,69 @@
1
1
  "use strict";
2
+ // ─────────────────────────────────────────────────────────────
3
+ // backend/src/db.ts
4
+ //
5
+ // SQLite database via better-sqlite3 (synchronous, fast).
6
+ //
7
+ // SCHEMA:
8
+ //
9
+ // reports — one row per analysis run
10
+ // id — auto-increment PK
11
+ // project — project name / hostname
12
+ // score — 0-100 overall performance score
13
+ // grade — A+, A, B, C, D, F
14
+ // analyzed_at — ISO timestamp from the CLI
15
+ // created_at — when the row was inserted
16
+ // static_json — full StaticReport as JSON string
17
+ // runtime_json — full RuntimeReport map as JSON string
18
+ // (screenshots stripped — stored as files)
19
+ // suggestions — full Suggestion[] as JSON string
20
+ //
21
+ // WHY SPLIT COLUMNS INSTEAD OF ONE payload COLUMN?
22
+ // The old schema stored everything in a single `payload` TEXT
23
+ // column. This meant the dashboard couldn't query individual
24
+ // fields without parsing the whole blob. Splitting into three
25
+ // typed columns lets us:
26
+ // • SELECT only the part we need (e.g. just static_json for
27
+ // the issues page)
28
+ // • Keep the list endpoint fast (no giant blobs per row)
29
+ // • Add indices on score/grade later without changing schema
30
+ //
31
+ // SCREENSHOT STORAGE:
32
+ // Screenshots are base64 PNGs — up to 200KB each. Storing them
33
+ // in SQLite bloats the DB and makes every query slower. Instead:
34
+ // • The upload route strips dataUrls from runtime_json
35
+ // • Saves each screenshot as a .png file in data/screenshots/
36
+ // • Replaces dataUrl with a relative path /screenshots/<file>
37
+ // The dashboard fetches screenshots via the static file route.
38
+ // ─────────────────────────────────────────────────────────────
2
39
  var __importDefault = (this && this.__importDefault) || function (mod) {
3
40
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
41
  };
5
42
  Object.defineProperty(exports, "__esModule", { value: true });
43
+ exports.screenshotsDir = void 0;
6
44
  const better_sqlite3_1 = __importDefault(require("better-sqlite3"));
7
45
  const path_1 = __importDefault(require("path"));
8
46
  const fs_1 = __importDefault(require("fs"));
9
47
  const dotenv_1 = __importDefault(require("dotenv"));
10
- dotenv_1.default.config();
11
- const dbPath = process.env.DB_PATH || "./data/reports.db";
48
+ dotenv_1.default.config({ path: path_1.default.join(__dirname, "..", ".env") });
49
+ const PACKAGE_ROOT = path_1.default.resolve(__dirname, "..", "..");
50
+ const DEFAULT_DB = path_1.default.join(PACKAGE_ROOT, "backend", "data", "reports.db");
51
+ const dbPath = process.env.DB_PATH || DEFAULT_DB;
12
52
  fs_1.default.mkdirSync(path_1.default.dirname(dbPath), { recursive: true });
53
+ fs_1.default.mkdirSync(path_1.default.join(path_1.default.dirname(dbPath), "screenshots"), { recursive: true });
13
54
  const db = new better_sqlite3_1.default(dbPath);
14
55
  db.exec(`
15
- CREATE TABLE IF NOT EXISTS reports (
16
- id INTEGER PRIMARY KEY AUTOINCREMENT,
17
- project TEXT NOT NULL,
18
- score INTEGER NOT NULL,
19
- grade TEXT NOT NULL,
20
- analyzed_at TEXT NOT NULL,
21
- created_at TEXT NOT NULL DEFAULT (datetime('now')),
22
- payload TEXT NOT NULL
23
- );
56
+ CREATE TABLE IF NOT EXISTS reports (
57
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
58
+ project TEXT NOT NULL,
59
+ score INTEGER NOT NULL,
60
+ grade TEXT NOT NULL,
61
+ analyzed_at TEXT NOT NULL,
62
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
63
+ static_json TEXT NOT NULL,
64
+ runtime_json TEXT NOT NULL,
65
+ suggestions TEXT NOT NULL
66
+ );
24
67
  `);
68
+ exports.screenshotsDir = path_1.default.join(path_1.default.dirname(dbPath), "screenshots");
25
69
  exports.default = db;
@@ -7,37 +7,36 @@ const express_1 = __importDefault(require("express"));
7
7
  const cors_1 = __importDefault(require("cors"));
8
8
  const helmet_1 = __importDefault(require("helmet"));
9
9
  const dotenv_1 = __importDefault(require("dotenv"));
10
+ const path_1 = __importDefault(require("path"));
10
11
  const reports_1 = __importDefault(require("./routes/reports"));
11
- dotenv_1.default.config();
12
- // Debug: Check if API_KEY is loaded
13
- console.log(`[Debug] API_KEY loaded: ${process.env.API_KEY ? '✓' : '✗'}`);
14
- console.log(`[Debug] API_KEY value: ${process.env.API_KEY || 'NOT SET'}`);
12
+ const db_1 = require("./db");
13
+ dotenv_1.default.config({ path: path_1.default.join(__dirname, "..", ".env") });
15
14
  const app = (0, express_1.default)();
16
15
  const PORT = process.env.PORT || 3000;
17
- // ─── SECURITY AND PARSING MIDDLEWARE ──────────────────────────────────────────
18
- app.use((0, helmet_1.default)()); // adds security headers
19
- app.use((0, cors_1.default)()); // allows dashboard to call the API
20
- app.use(express_1.default.json({ limit: '50mb' })); // reports can be large (screenshots)
21
- // ─── ROUTES ───────────────────────────────────────────────────────────────────
22
- // Use only ONE path (plural is REST convention)
23
- app.use('/api/reports', reports_1.default);
24
- // ─── HEALTH CHECK - NO AUTH, USED TO VERIFY SERVER IS UP ──────────────────────
25
- app.get('/health', (_req, res) => {
26
- res.json({
27
- status: 'ok',
28
- timestamp: new Date().toISOString()
29
- });
16
+ // ── Middleware ────────────────────────────────────────────────
17
+ app.use((0, helmet_1.default)());
18
+ app.use((0, cors_1.default)());
19
+ app.use(express_1.default.json({ limit: "50mb" }));
20
+ // ── Static: screenshots saved by the upload route ────────────
21
+ // Served at /screenshots/<filename>
22
+ // The dashboard uses these as <img src="/screenshots/...">
23
+ app.use("/screenshots", express_1.default.static(db_1.screenshotsDir));
24
+ // ── Routes ────────────────────────────────────────────────────
25
+ app.use("/api/reports", reports_1.default);
26
+ // ── Health check ──────────────────────────────────────────────
27
+ app.get("/health", (_req, res) => {
28
+ res.json({ status: "ok", timestamp: new Date().toISOString() });
30
29
  });
31
- // ─── 404 HANDLER ──────────────────────────────────────────────────────────────
32
- app.use((req, res) => {
33
- res.status(404).json({ message: 'Route not found' });
30
+ // ── 404 ───────────────────────────────────────────────────────
31
+ app.use((_req, res) => {
32
+ res.status(404).json({ message: "Route not found" });
34
33
  });
35
- // ─── GLOBAL ERROR HANDLER ─────────────────────────────────────────────────────
34
+ // ── Error handler ─────────────────────────────────────────────
36
35
  app.use((err, _req, res, _next) => {
37
36
  console.error(err.stack);
38
- res.status(500).json({ message: 'Internal Server Error' });
37
+ res.status(500).json({ message: "Internal Server Error" });
39
38
  });
40
- // ─── START THE SERVER ─────────────────────────────────────────────────────────
39
+ // ── Start ─────────────────────────────────────────────────────
41
40
  app.listen(PORT, () => {
42
41
  console.log(`React Doctor backend running on http://localhost:${PORT}`);
43
42
  });
@@ -1,74 +1,171 @@
1
1
  "use strict";
2
+ // ─────────────────────────────────────────────────────────────
3
+ // backend/src/routes/reports.ts
4
+ //
5
+ // All report endpoints.
6
+ //
7
+ // ENDPOINTS:
8
+ //
9
+ // GET /api/reports
10
+ // Returns a summary list (no blobs) — fast for the dashboard
11
+ // history page. Each row has id, project, score, grade, dates.
12
+ //
13
+ // GET /api/reports/:id
14
+ // Returns the full report for one run — static + runtime +
15
+ // suggestions all parsed back to objects.
16
+ //
17
+ // GET /api/reports/project/:name
18
+ // All runs for a named project, summary only.
19
+ //
20
+ // POST /api/reports/upload (requires x-api-key header)
21
+ // Accepts a FinalReport from the CLI.
22
+ // Strips screenshot dataUrls → saves as .png files.
23
+ // Stores static_json, runtime_json, suggestions in DB.
24
+ //
25
+ // SCREENSHOT HANDLING ON UPLOAD:
26
+ // The CLI sends the full FinalReport including base64 screenshots
27
+ // (up to 200KB each). We extract those before storing so the DB
28
+ // stays lean. Each screenshot is saved as:
29
+ // data/screenshots/<reportId>-<routeKey>-<label>.png
30
+ // And the dataUrl in runtime_json is replaced with:
31
+ // /screenshots/<filename>
32
+ // so the dashboard can load them as normal <img> tags.
33
+ // ─────────────────────────────────────────────────────────────
34
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
35
+ if (k2 === undefined) k2 = k;
36
+ var desc = Object.getOwnPropertyDescriptor(m, k);
37
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
38
+ desc = { enumerable: true, get: function() { return m[k]; } };
39
+ }
40
+ Object.defineProperty(o, k2, desc);
41
+ }) : (function(o, m, k, k2) {
42
+ if (k2 === undefined) k2 = k;
43
+ o[k2] = m[k];
44
+ }));
45
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
46
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
47
+ }) : function(o, v) {
48
+ o["default"] = v;
49
+ });
50
+ var __importStar = (this && this.__importStar) || (function () {
51
+ var ownKeys = function(o) {
52
+ ownKeys = Object.getOwnPropertyNames || function (o) {
53
+ var ar = [];
54
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
55
+ return ar;
56
+ };
57
+ return ownKeys(o);
58
+ };
59
+ return function (mod) {
60
+ if (mod && mod.__esModule) return mod;
61
+ var result = {};
62
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
63
+ __setModuleDefault(result, mod);
64
+ return result;
65
+ };
66
+ })();
2
67
  var __importDefault = (this && this.__importDefault) || function (mod) {
3
68
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
69
  };
5
70
  Object.defineProperty(exports, "__esModule", { value: true });
6
71
  const express_1 = require("express");
7
- const db_1 = __importDefault(require("../db"));
72
+ const path_1 = __importDefault(require("path"));
73
+ const fs_1 = __importDefault(require("fs"));
74
+ const db_1 = __importStar(require("../db"));
8
75
  const auth_1 = require("../middleware/auth");
9
76
  const router = (0, express_1.Router)();
10
- router.get("/", (req, res) => {
77
+ // ── GET /api/reports ─────────────────────────────────────────
78
+ // Summary list — no blobs, just the columns the history page needs.
79
+ router.get("/", (_req, res) => {
11
80
  try {
12
81
  const rows = db_1.default.prepare(`
13
82
  SELECT id, project, score, grade, analyzed_at, created_at
14
- FROM reports
15
- ORDER BY created_at DESC
16
- LIMIT 50
83
+ FROM reports
84
+ ORDER BY created_at DESC
85
+ LIMIT 100
17
86
  `).all();
18
87
  res.json({ count: rows.length, reports: rows });
19
88
  }
20
89
  catch (err) {
90
+ console.error("GET / error:", err.message);
21
91
  res.status(500).json({ error: "Internal server error" });
22
92
  }
23
93
  });
94
+ // ── GET /api/reports/project/:name ───────────────────────────
95
+ // All runs for one project, summary only.
24
96
  router.get("/project/:name", (req, res) => {
25
97
  try {
26
98
  const rows = db_1.default.prepare(`
27
99
  SELECT id, project, score, grade, analyzed_at, created_at
28
- FROM reports
29
- WHERE project = ?
30
- ORDER BY created_at DESC
100
+ FROM reports
101
+ WHERE project = ?
102
+ ORDER BY created_at DESC
31
103
  `).all(req.params.name);
32
- res.json({
33
- project: req.params.name,
34
- count: rows.length,
35
- reports: rows,
36
- });
104
+ res.json({ project: req.params.name, count: rows.length, reports: rows });
37
105
  }
38
106
  catch (err) {
107
+ console.error("GET /project/:name error:", err.message);
39
108
  res.status(500).json({ error: "Internal server error" });
40
109
  }
41
110
  });
42
- // ==========================================
43
- // Endpoint 1: POST /api/report/upload
44
- // يستقبل التقرير من الـ CLI ويتحقق منه ثم يحفظه
45
- // ==========================================
111
+ // ── POST /api/reports/upload ──────────────────────────────────
112
+ // Receives a FinalReport from the CLI, strips screenshots to disk,
113
+ // and stores the three JSON blobs in separate columns.
46
114
  router.post("/upload", auth_1.requireApiKey, (req, res) => {
47
115
  try {
48
- const report = req.body;
49
- // التحقق من وجود الحقول الإلزامية الثلاثة
50
- if (!report || !report.projectName || !report.analyzedAt || report.performanceScore === undefined) {
51
- res.status(400).json({ error: "Invalid report — missing required fields" });
116
+ const body = req.body;
117
+ // ── Validate required top-level fields ──────────────────
118
+ if (!body ||
119
+ !body.projectName ||
120
+ !body.analyzedAt ||
121
+ body.performanceScore === undefined ||
122
+ !body.static ||
123
+ !body.runtime ||
124
+ !body.suggestions) {
125
+ res.status(400).json({
126
+ error: "Invalid report",
127
+ missing: getMissingFields(body),
128
+ });
52
129
  return;
53
130
  }
54
- const grade = report.static?.grade ?? "N/A";
55
- // تجهيز استعلام الإدخال لـ SQLite
131
+ // ── Extract grade from static report ────────────────────
132
+ const grade = body.static?.grade ?? "N/A";
133
+ // ── Strip screenshots from runtime, save as .png files ──
134
+ // We do this BEFORE inserting so the DB never holds base64.
135
+ // The row ID isn't known yet — we'll rename after insert.
136
+ // For now we use a temp prefix and rename below.
137
+ const { cleanedRuntime, pendingScreenshots } = extractScreenshots(body.runtime);
138
+ // ── Insert the row ───────────────────────────────────────
56
139
  const stmt = db_1.default.prepare(`
57
- INSERT INTO reports (project, score, grade, analyzed_at, payload)
58
- VALUES (?, ?, ?, ?, ?)
59
- `);
60
- // تنفيذ الاستعلام وحفظ جسم التقرير كـ string
61
- const result = stmt.run(report.projectName, report.performanceScore, grade, report.analyzedAt, JSON.stringify(report));
140
+ INSERT INTO reports
141
+ (project, score, grade, analyzed_at, static_json, runtime_json, suggestions)
142
+ VALUES
143
+ (?, ?, ?, ?, ?, ?, ?)
144
+ `);
145
+ const result = stmt.run(body.projectName, body.performanceScore, grade, body.analyzedAt, JSON.stringify(body.static), JSON.stringify(cleanedRuntime), JSON.stringify(body.suggestions));
146
+ const reportId = result.lastInsertRowid;
147
+ // ── Save screenshots with final filenames ────────────────
148
+ const savedScreenshots = saveScreenshots(reportId, pendingScreenshots);
149
+ // ── Patch runtime_json with final screenshot paths ───────
150
+ // Now that we have the reportId we can write the correct paths.
151
+ if (savedScreenshots.length > 0) {
152
+ const patchedRuntime = patchScreenshotPaths(cleanedRuntime, savedScreenshots);
153
+ db_1.default.prepare("UPDATE reports SET runtime_json = ? WHERE id = ?").run(JSON.stringify(patchedRuntime), reportId);
154
+ }
62
155
  res.status(201).json({
63
156
  message: "Report saved successfully",
64
- id: result.lastInsertRowid,
157
+ id: reportId,
158
+ screenshots: savedScreenshots.length,
65
159
  });
66
160
  }
67
161
  catch (err) {
68
- console.error("Upload error:", err.message);
162
+ console.error("POST /upload error:", err.message);
69
163
  res.status(500).json({ error: "Internal server error" });
70
164
  }
71
165
  });
166
+ // ── GET /api/reports/:id ─────────────────────────────────────
167
+ // Full report for one run — parses all three JSON columns back
168
+ // to objects and returns a unified response.
72
169
  router.get("/:id", (req, res) => {
73
170
  try {
74
171
  const row = db_1.default.prepare("SELECT * FROM reports WHERE id = ?").get(req.params.id);
@@ -83,11 +180,104 @@ router.get("/:id", (req, res) => {
83
180
  grade: row.grade,
84
181
  analyzedAt: row.analyzed_at,
85
182
  createdAt: row.created_at,
86
- report: JSON.parse(row.payload),
183
+ // Parse the three JSON blobs back into objects
184
+ static: JSON.parse(row.static_json),
185
+ runtime: JSON.parse(row.runtime_json),
186
+ suggestions: JSON.parse(row.suggestions),
87
187
  });
88
188
  }
89
189
  catch (err) {
190
+ console.error("GET /:id error:", err.message);
90
191
  res.status(500).json({ error: "Internal server error" });
91
192
  }
92
193
  });
93
194
  exports.default = router;
195
+ // ─────────────────────────────────────────────────────────────
196
+ // HELPERS
197
+ // ─────────────────────────────────────────────────────────────
198
+ function getMissingFields(body) {
199
+ const required = ["projectName", "analyzedAt", "performanceScore", "static", "runtime", "suggestions"];
200
+ return required.filter(f => body?.[f] === undefined || body?.[f] === null);
201
+ }
202
+ /**
203
+ * Walk the runtime map, strip every screenshot.dataUrl,
204
+ * and collect them as Buffers ready to write to disk.
205
+ *
206
+ * Returns:
207
+ * cleanedRuntime — runtime map with dataUrls replaced by tempPath markers
208
+ * pendingScreenshots — list of screenshots to save once we have a reportId
209
+ */
210
+ function extractScreenshots(runtime) {
211
+ const pending = [];
212
+ const cleaned = {};
213
+ for (const [routeKey, routeData] of Object.entries(runtime)) {
214
+ const routeClone = { ...routeData };
215
+ if (Array.isArray(routeClone.screenshots)) {
216
+ routeClone.screenshots = routeClone.screenshots.map((shot) => {
217
+ if (!shot.dataUrl || !shot.dataUrl.startsWith("data:image/png;base64,")) {
218
+ return shot;
219
+ }
220
+ const base64 = shot.dataUrl.replace("data:image/png;base64,", "");
221
+ const buffer = Buffer.from(base64, "base64");
222
+ // Sanitise routeKey for use in a filename — replace "/" and ":" with "-"
223
+ const safeRoute = routeKey.replace(/[/:]/g, "-").replace(/^-+/, "");
224
+ const safeLabel = shot.label.replace(/[^a-z0-9]/gi, "-");
225
+ const tempPath = `__PENDING__${safeRoute}__${safeLabel}`;
226
+ pending.push({ routeKey, label: shot.label, buffer, tempPath });
227
+ return { ...shot, dataUrl: tempPath };
228
+ });
229
+ }
230
+ cleaned[routeKey] = routeClone;
231
+ }
232
+ return { cleanedRuntime: cleaned, pendingScreenshots: pending };
233
+ }
234
+ /**
235
+ * Write each screenshot buffer to data/screenshots/<reportId>-<route>-<label>.png
236
+ * Returns the list of saved files with their final URL paths.
237
+ */
238
+ function saveScreenshots(reportId, pending) {
239
+ const saved = [];
240
+ for (const shot of pending) {
241
+ const safeRoute = shot.routeKey.replace(/[/:]/g, "-").replace(/^-+/, "");
242
+ const safeLabel = shot.label.replace(/[^a-z0-9]/gi, "-");
243
+ const filename = `${reportId}-${safeRoute}-${safeLabel}.png`;
244
+ const fullPath = path_1.default.join(db_1.screenshotsDir, filename);
245
+ try {
246
+ fs_1.default.writeFileSync(fullPath, shot.buffer);
247
+ saved.push({
248
+ routeKey: shot.routeKey,
249
+ label: shot.label,
250
+ tempPath: shot.tempPath,
251
+ filePath: `/screenshots/${filename}`,
252
+ });
253
+ }
254
+ catch (err) {
255
+ console.warn(`Could not save screenshot ${filename}: ${err.message}`);
256
+ }
257
+ }
258
+ return saved;
259
+ }
260
+ /**
261
+ * Replace the __PENDING__ markers in runtime_json with
262
+ * the final /screenshots/<file> URL paths.
263
+ */
264
+ function patchScreenshotPaths(runtime, saved) {
265
+ // Build a lookup from tempPath → filePath
266
+ const lookup = {};
267
+ for (const s of saved)
268
+ lookup[s.tempPath] = s.filePath;
269
+ const patched = {};
270
+ for (const [routeKey, routeData] of Object.entries(runtime)) {
271
+ const routeClone = { ...routeData };
272
+ if (Array.isArray(routeClone.screenshots)) {
273
+ routeClone.screenshots = routeClone.screenshots.map((shot) => {
274
+ if (shot.dataUrl && lookup[shot.dataUrl]) {
275
+ return { ...shot, dataUrl: lookup[shot.dataUrl] };
276
+ }
277
+ return shot;
278
+ });
279
+ }
280
+ patched[routeKey] = routeClone;
281
+ }
282
+ return patched;
283
+ }
package/backend/src/db.ts CHANGED
@@ -1,24 +1,70 @@
1
+ // ─────────────────────────────────────────────────────────────
2
+ // backend/src/db.ts
3
+ //
4
+ // SQLite database via better-sqlite3 (synchronous, fast).
5
+ //
6
+ // SCHEMA:
7
+ //
8
+ // reports — one row per analysis run
9
+ // id — auto-increment PK
10
+ // project — project name / hostname
11
+ // score — 0-100 overall performance score
12
+ // grade — A+, A, B, C, D, F
13
+ // analyzed_at — ISO timestamp from the CLI
14
+ // created_at — when the row was inserted
15
+ // static_json — full StaticReport as JSON string
16
+ // runtime_json — full RuntimeReport map as JSON string
17
+ // (screenshots stripped — stored as files)
18
+ // suggestions — full Suggestion[] as JSON string
19
+ //
20
+ // WHY SPLIT COLUMNS INSTEAD OF ONE payload COLUMN?
21
+ // The old schema stored everything in a single `payload` TEXT
22
+ // column. This meant the dashboard couldn't query individual
23
+ // fields without parsing the whole blob. Splitting into three
24
+ // typed columns lets us:
25
+ // • SELECT only the part we need (e.g. just static_json for
26
+ // the issues page)
27
+ // • Keep the list endpoint fast (no giant blobs per row)
28
+ // • Add indices on score/grade later without changing schema
29
+ //
30
+ // SCREENSHOT STORAGE:
31
+ // Screenshots are base64 PNGs — up to 200KB each. Storing them
32
+ // in SQLite bloats the DB and makes every query slower. Instead:
33
+ // • The upload route strips dataUrls from runtime_json
34
+ // • Saves each screenshot as a .png file in data/screenshots/
35
+ // • Replaces dataUrl with a relative path /screenshots/<file>
36
+ // The dashboard fetches screenshots via the static file route.
37
+ // ─────────────────────────────────────────────────────────────
1
38
 
2
39
  import Database from "better-sqlite3";
3
- import path from "path";
4
- import fs from "fs";
5
- import dotenv from "dotenv";
40
+ import path from "path";
41
+ import fs from "fs";
42
+ import dotenv from "dotenv";
6
43
 
7
- dotenv.config();
44
+ dotenv.config({ path: path.join(__dirname, "..", ".env") });
45
+
46
+ const PACKAGE_ROOT = path.resolve(__dirname, "..", "..");
47
+ const DEFAULT_DB = path.join(PACKAGE_ROOT, "backend", "data", "reports.db");
48
+ const dbPath = process.env.DB_PATH || DEFAULT_DB;
8
49
 
9
- const dbPath = process.env.DB_PATH || "./data/reports.db";
10
50
  fs.mkdirSync(path.dirname(dbPath), { recursive: true });
51
+ fs.mkdirSync(path.join(path.dirname(dbPath), "screenshots"), { recursive: true });
52
+
11
53
  const db = new Database(dbPath);
54
+
12
55
  db.exec(`
13
- CREATE TABLE IF NOT EXISTS reports (
14
- id INTEGER PRIMARY KEY AUTOINCREMENT,
15
- project TEXT NOT NULL,
16
- score INTEGER NOT NULL,
17
- grade TEXT NOT NULL,
18
- analyzed_at TEXT NOT NULL,
19
- created_at TEXT NOT NULL DEFAULT (datetime('now')),
20
- payload TEXT NOT NULL
21
- );
56
+ CREATE TABLE IF NOT EXISTS reports (
57
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
58
+ project TEXT NOT NULL,
59
+ score INTEGER NOT NULL,
60
+ grade TEXT NOT NULL,
61
+ analyzed_at TEXT NOT NULL,
62
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
63
+ static_json TEXT NOT NULL,
64
+ runtime_json TEXT NOT NULL,
65
+ suggestions TEXT NOT NULL
66
+ );
22
67
  `);
23
68
 
24
- export default db;
69
+ export const screenshotsDir = path.join(path.dirname(dbPath), "screenshots");
70
+ export default db;
@@ -1,49 +1,46 @@
1
- import express, { Request, Response, NextFunction } from 'express';
2
- import cors from 'cors';
3
- import helmet from 'helmet';
4
- import dotenv from 'dotenv';
5
- import reportRoutes from './routes/reports';
6
- import db from './db';
1
+ import express, { Request, Response, NextFunction } from "express";
2
+ import cors from "cors";
3
+ import helmet from "helmet";
4
+ import dotenv from "dotenv";
5
+ import path from "path";
6
+ import reportRoutes from "./routes/reports";
7
+ import db, { screenshotsDir } from "./db";
7
8
 
8
- dotenv.config();
9
+ dotenv.config({ path: path.join(__dirname, "..", ".env") });
9
10
 
10
- // Debug: Check if API_KEY is loaded
11
- console.log(`[Debug] API_KEY loaded: ${process.env.API_KEY ? '✓' : '✗'}`);
12
- console.log(`[Debug] API_KEY value: ${process.env.API_KEY || 'NOT SET'}`);
13
-
14
- const app = express();
11
+ const app = express();
15
12
  const PORT = process.env.PORT || 3000;
16
13
 
17
- // ─── SECURITY AND PARSING MIDDLEWARE ──────────────────────────────────────────
18
- app.use(helmet()); // adds security headers
19
- app.use(cors()); // allows dashboard to call the API
20
- app.use(express.json({ limit: '50mb' })); // reports can be large (screenshots)
21
-
22
- // ─── ROUTES ───────────────────────────────────────────────────────────────────
23
- // Use only ONE path (plural is REST convention)
24
- app.use('/api/reports', reportRoutes);
25
-
26
- // ─── HEALTH CHECK - NO AUTH, USED TO VERIFY SERVER IS UP ──────────────────────
27
- app.get('/health', (_req: Request, res: Response) => {
28
- res.json({
29
- status: 'ok',
30
- timestamp: new Date().toISOString()
31
- });
32
- });
14
+ // ── Middleware ────────────────────────────────────────────────
15
+ app.use(helmet());
16
+ app.use(cors());
17
+ app.use(express.json({ limit: "50mb" }));
18
+
19
+ // ── Static: screenshots saved by the upload route ────────────
20
+ // Served at /screenshots/<filename>
21
+ // The dashboard uses these as <img src="/screenshots/...">
22
+ app.use("/screenshots", express.static(screenshotsDir));
33
23
 
24
+ // ── Routes ────────────────────────────────────────────────────
25
+ app.use("/api/reports", reportRoutes);
34
26
 
35
- // ─── 404 HANDLER ──────────────────────────────────────────────────────────────
36
- app.use((req: Request, res: Response) => {
37
- res.status(404).json({ message: 'Route not found' });
27
+ // ── Health check ──────────────────────────────────────────────
28
+ app.get("/health", (_req: Request, res: Response) => {
29
+ res.json({ status: "ok", timestamp: new Date().toISOString() });
38
30
  });
39
31
 
40
- // ─── GLOBAL ERROR HANDLER ─────────────────────────────────────────────────────
32
+ // ── 404 ───────────────────────────────────────────────────────
33
+ app.use((_req: Request, res: Response) => {
34
+ res.status(404).json({ message: "Route not found" });
35
+ });
36
+
37
+ // ── Error handler ─────────────────────────────────────────────
41
38
  app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
42
39
  console.error(err.stack);
43
- res.status(500).json({ message: 'Internal Server Error' });
40
+ res.status(500).json({ message: "Internal Server Error" });
44
41
  });
45
42
 
46
- // ─── START THE SERVER ─────────────────────────────────────────────────────────
43
+ // ── Start ─────────────────────────────────────────────────────
47
44
  app.listen(PORT, () => {
48
45
  console.log(`React Doctor backend running on http://localhost:${PORT}`);
49
- });
46
+ });
@@ -1,110 +1,328 @@
1
+ // ─────────────────────────────────────────────────────────────
2
+ // backend/src/routes/reports.ts
3
+ //
4
+ // All report endpoints.
5
+ //
6
+ // ENDPOINTS:
7
+ //
8
+ // GET /api/reports
9
+ // Returns a summary list (no blobs) — fast for the dashboard
10
+ // history page. Each row has id, project, score, grade, dates.
11
+ //
12
+ // GET /api/reports/:id
13
+ // Returns the full report for one run — static + runtime +
14
+ // suggestions all parsed back to objects.
15
+ //
16
+ // GET /api/reports/project/:name
17
+ // All runs for a named project, summary only.
18
+ //
19
+ // POST /api/reports/upload (requires x-api-key header)
20
+ // Accepts a FinalReport from the CLI.
21
+ // Strips screenshot dataUrls → saves as .png files.
22
+ // Stores static_json, runtime_json, suggestions in DB.
23
+ //
24
+ // SCREENSHOT HANDLING ON UPLOAD:
25
+ // The CLI sends the full FinalReport including base64 screenshots
26
+ // (up to 200KB each). We extract those before storing so the DB
27
+ // stays lean. Each screenshot is saved as:
28
+ // data/screenshots/<reportId>-<routeKey>-<label>.png
29
+ // And the dataUrl in runtime_json is replaced with:
30
+ // /screenshots/<filename>
31
+ // so the dashboard can load them as normal <img> tags.
32
+ // ─────────────────────────────────────────────────────────────
1
33
 
2
34
  import { Router, Request, Response, RequestHandler } from "express";
3
- import db from "../db";
4
- import { requireApiKey } from "../middleware/auth";
5
-
35
+ import path from "path";
36
+ import fs from "fs";
37
+ import db, { screenshotsDir } from "../db";
38
+ import { requireApiKey } from "../middleware/auth";
39
+
6
40
  const router = Router();
7
-
8
41
 
9
- router.get("/", (req: Request, res: Response) => {
42
+ // ── GET /api/reports ─────────────────────────────────────────
43
+ // Summary list — no blobs, just the columns the history page needs.
44
+
45
+ router.get("/", (_req: Request, res: Response) => {
10
46
  try {
11
47
  const rows = db.prepare(`
12
48
  SELECT id, project, score, grade, analyzed_at, created_at
13
- FROM reports
14
- ORDER BY created_at DESC
15
- LIMIT 50
49
+ FROM reports
50
+ ORDER BY created_at DESC
51
+ LIMIT 100
16
52
  `).all() as any[];
17
-
53
+
18
54
  res.json({ count: rows.length, reports: rows });
19
55
  } catch (err: any) {
56
+ console.error("GET / error:", err.message);
20
57
  res.status(500).json({ error: "Internal server error" });
21
58
  }
22
59
  });
23
-
60
+
61
+ // ── GET /api/reports/project/:name ───────────────────────────
62
+ // All runs for one project, summary only.
24
63
 
25
64
  router.get("/project/:name", (req: Request, res: Response) => {
26
65
  try {
27
66
  const rows = db.prepare(`
28
67
  SELECT id, project, score, grade, analyzed_at, created_at
29
- FROM reports
30
- WHERE project = ?
31
- ORDER BY created_at DESC
68
+ FROM reports
69
+ WHERE project = ?
70
+ ORDER BY created_at DESC
32
71
  `).all(req.params.name) as any[];
33
-
34
- res.json({
35
- project: req.params.name,
36
- count: rows.length,
37
- reports: rows,
38
- });
72
+
73
+ res.json({ project: req.params.name, count: rows.length, reports: rows });
39
74
  } catch (err: any) {
75
+ console.error("GET /project/:name error:", err.message);
40
76
  res.status(500).json({ error: "Internal server error" });
41
77
  }
42
78
  });
43
-
44
- // ==========================================
45
- // Endpoint 1: POST /api/report/upload
46
- // يستقبل التقرير من الـ CLI ويتحقق منه ثم يحفظه
47
- // ==========================================
48
- router.post("/upload", requireApiKey as RequestHandler, (req: Request, res: Response) => {
49
- try {
50
- const report = req.body;
51
-
52
- // التحقق من وجود الحقول الإلزامية الثلاثة
53
- if (!report || !report.projectName || !report.analyzedAt || report.performanceScore === undefined) {
54
- res.status(400).json({ error: "Invalid report — missing required fields" });
55
- return;
79
+
80
+ // ── POST /api/reports/upload ──────────────────────────────────
81
+ // Receives a FinalReport from the CLI, strips screenshots to disk,
82
+ // and stores the three JSON blobs in separate columns.
83
+
84
+ router.post(
85
+ "/upload",
86
+ requireApiKey as RequestHandler,
87
+ (req: Request, res: Response) => {
88
+ try {
89
+ const body = req.body;
90
+
91
+ // ── Validate required top-level fields ──────────────────
92
+ if (
93
+ !body ||
94
+ !body.projectName ||
95
+ !body.analyzedAt ||
96
+ body.performanceScore === undefined ||
97
+ !body.static ||
98
+ !body.runtime ||
99
+ !body.suggestions
100
+ ) {
101
+ res.status(400).json({
102
+ error: "Invalid report",
103
+ missing: getMissingFields(body),
104
+ });
105
+ return;
106
+ }
107
+
108
+ // ── Extract grade from static report ────────────────────
109
+ const grade: string = body.static?.grade ?? "N/A";
110
+
111
+ // ── Strip screenshots from runtime, save as .png files ──
112
+ // We do this BEFORE inserting so the DB never holds base64.
113
+ // The row ID isn't known yet — we'll rename after insert.
114
+ // For now we use a temp prefix and rename below.
115
+ const { cleanedRuntime, pendingScreenshots } = extractScreenshots(body.runtime);
116
+
117
+ // ── Insert the row ───────────────────────────────────────
118
+ const stmt = db.prepare(`
119
+ INSERT INTO reports
120
+ (project, score, grade, analyzed_at, static_json, runtime_json, suggestions)
121
+ VALUES
122
+ (?, ?, ?, ?, ?, ?, ?)
123
+ `);
124
+
125
+ const result = stmt.run(
126
+ body.projectName,
127
+ body.performanceScore,
128
+ grade,
129
+ body.analyzedAt,
130
+ JSON.stringify(body.static),
131
+ JSON.stringify(cleanedRuntime),
132
+ JSON.stringify(body.suggestions),
133
+ ) as any;
134
+
135
+ const reportId: number = result.lastInsertRowid;
136
+
137
+ // ── Save screenshots with final filenames ────────────────
138
+ const savedScreenshots = saveScreenshots(reportId, pendingScreenshots);
139
+
140
+ // ── Patch runtime_json with final screenshot paths ───────
141
+ // Now that we have the reportId we can write the correct paths.
142
+ if (savedScreenshots.length > 0) {
143
+ const patchedRuntime = patchScreenshotPaths(
144
+ cleanedRuntime,
145
+ savedScreenshots,
146
+ );
147
+ db.prepare(
148
+ "UPDATE reports SET runtime_json = ? WHERE id = ?"
149
+ ).run(JSON.stringify(patchedRuntime), reportId);
150
+ }
151
+
152
+ res.status(201).json({
153
+ message: "Report saved successfully",
154
+ id: reportId,
155
+ screenshots: savedScreenshots.length,
156
+ });
157
+ } catch (err: any) {
158
+ console.error("POST /upload error:", err.message);
159
+ res.status(500).json({ error: "Internal server error" });
56
160
  }
57
-
58
- const grade = report.static?.grade ?? "N/A";
59
-
60
- // تجهيز استعلام الإدخال لـ SQLite
61
- const stmt = db.prepare(`
62
- INSERT INTO reports (project, score, grade, analyzed_at, payload)
63
- VALUES (?, ?, ?, ?, ?)
64
- `);
65
-
66
- // تنفيذ الاستعلام وحفظ جسم التقرير كـ string
67
- const result = stmt.run(
68
- report.projectName,
69
- report.performanceScore,
70
- grade,
71
- report.analyzedAt,
72
- JSON.stringify(report)
73
- );
74
-
75
- res.status(201).json({
76
- message: "Report saved successfully",
77
- id: result.lastInsertRowid,
78
- });
79
- } catch (err: any) {
80
- console.error("Upload error:", err.message);
81
- res.status(500).json({ error: "Internal server error" });
82
- }
83
- });
84
-
161
+ },
162
+ );
163
+
164
+ // ── GET /api/reports/:id ─────────────────────────────────────
165
+ // Full report for one run — parses all three JSON columns back
166
+ // to objects and returns a unified response.
167
+
85
168
  router.get("/:id", (req: Request, res: Response) => {
86
169
  try {
87
170
  const row = db.prepare(
88
171
  "SELECT * FROM reports WHERE id = ?"
89
172
  ).get(req.params.id) as any;
90
-
173
+
91
174
  if (!row) {
92
175
  res.status(404).json({ error: "Report not found" });
93
176
  return;
94
177
  }
95
-
178
+
96
179
  res.json({
97
- id: row.id,
98
- project: row.project,
99
- score: row.score,
100
- grade: row.grade,
101
- analyzedAt: row.analyzed_at,
102
- createdAt: row.created_at,
103
- report: JSON.parse(row.payload),
180
+ id: row.id,
181
+ project: row.project,
182
+ score: row.score,
183
+ grade: row.grade,
184
+ analyzedAt: row.analyzed_at,
185
+ createdAt: row.created_at,
186
+ // Parse the three JSON blobs back into objects
187
+ static: JSON.parse(row.static_json),
188
+ runtime: JSON.parse(row.runtime_json),
189
+ suggestions: JSON.parse(row.suggestions),
104
190
  });
105
191
  } catch (err: any) {
192
+ console.error("GET /:id error:", err.message);
106
193
  res.status(500).json({ error: "Internal server error" });
107
194
  }
108
195
  });
109
-
110
- export default router;
196
+
197
+ export default router;
198
+
199
+ // ─────────────────────────────────────────────────────────────
200
+ // HELPERS
201
+ // ─────────────────────────────────────────────────────────────
202
+
203
+ function getMissingFields(body: any): string[] {
204
+ const required = ["projectName", "analyzedAt", "performanceScore", "static", "runtime", "suggestions"];
205
+ return required.filter(f => body?.[f] === undefined || body?.[f] === null);
206
+ }
207
+
208
+ interface PendingScreenshot {
209
+ routeKey: string;
210
+ label: string;
211
+ buffer: Buffer;
212
+ // placeholder path written into cleanedRuntime — replaced after insert
213
+ tempPath: string;
214
+ }
215
+
216
+ /**
217
+ * Walk the runtime map, strip every screenshot.dataUrl,
218
+ * and collect them as Buffers ready to write to disk.
219
+ *
220
+ * Returns:
221
+ * cleanedRuntime — runtime map with dataUrls replaced by tempPath markers
222
+ * pendingScreenshots — list of screenshots to save once we have a reportId
223
+ */
224
+ function extractScreenshots(
225
+ runtime: Record<string, any>,
226
+ ): { cleanedRuntime: Record<string, any>; pendingScreenshots: PendingScreenshot[] } {
227
+ const pending: PendingScreenshot[] = [];
228
+ const cleaned: Record<string, any> = {};
229
+
230
+ for (const [routeKey, routeData] of Object.entries(runtime)) {
231
+ const routeClone = { ...routeData };
232
+
233
+ if (Array.isArray(routeClone.screenshots)) {
234
+ routeClone.screenshots = routeClone.screenshots.map((shot: any) => {
235
+ if (!shot.dataUrl || !shot.dataUrl.startsWith("data:image/png;base64,")) {
236
+ return shot;
237
+ }
238
+
239
+ const base64 = shot.dataUrl.replace("data:image/png;base64,", "");
240
+ const buffer = Buffer.from(base64, "base64");
241
+
242
+ // Sanitise routeKey for use in a filename — replace "/" and ":" with "-"
243
+ const safeRoute = routeKey.replace(/[/:]/g, "-").replace(/^-+/, "");
244
+ const safeLabel = shot.label.replace(/[^a-z0-9]/gi, "-");
245
+ const tempPath = `__PENDING__${safeRoute}__${safeLabel}`;
246
+
247
+ pending.push({ routeKey, label: shot.label, buffer, tempPath });
248
+
249
+ return { ...shot, dataUrl: tempPath };
250
+ });
251
+ }
252
+
253
+ cleaned[routeKey] = routeClone;
254
+ }
255
+
256
+ return { cleanedRuntime: cleaned, pendingScreenshots: pending };
257
+ }
258
+
259
+ interface SavedScreenshot {
260
+ routeKey: string;
261
+ label: string;
262
+ tempPath: string;
263
+ filePath: string; // relative URL path served by express static
264
+ }
265
+
266
+ /**
267
+ * Write each screenshot buffer to data/screenshots/<reportId>-<route>-<label>.png
268
+ * Returns the list of saved files with their final URL paths.
269
+ */
270
+ function saveScreenshots(
271
+ reportId: number,
272
+ pending: PendingScreenshot[],
273
+ ): SavedScreenshot[] {
274
+ const saved: SavedScreenshot[] = [];
275
+
276
+ for (const shot of pending) {
277
+ const safeRoute = shot.routeKey.replace(/[/:]/g, "-").replace(/^-+/, "");
278
+ const safeLabel = shot.label.replace(/[^a-z0-9]/gi, "-");
279
+ const filename = `${reportId}-${safeRoute}-${safeLabel}.png`;
280
+ const fullPath = path.join(screenshotsDir, filename);
281
+
282
+ try {
283
+ fs.writeFileSync(fullPath, shot.buffer);
284
+ saved.push({
285
+ routeKey: shot.routeKey,
286
+ label: shot.label,
287
+ tempPath: shot.tempPath,
288
+ filePath: `/screenshots/${filename}`,
289
+ });
290
+ } catch (err: any) {
291
+ console.warn(`Could not save screenshot ${filename}: ${err.message}`);
292
+ }
293
+ }
294
+
295
+ return saved;
296
+ }
297
+
298
+ /**
299
+ * Replace the __PENDING__ markers in runtime_json with
300
+ * the final /screenshots/<file> URL paths.
301
+ */
302
+ function patchScreenshotPaths(
303
+ runtime: Record<string, any>,
304
+ saved: SavedScreenshot[],
305
+ ): Record<string, any> {
306
+ // Build a lookup from tempPath → filePath
307
+ const lookup: Record<string, string> = {};
308
+ for (const s of saved) lookup[s.tempPath] = s.filePath;
309
+
310
+ const patched: Record<string, any> = {};
311
+
312
+ for (const [routeKey, routeData] of Object.entries(runtime)) {
313
+ const routeClone = { ...routeData };
314
+
315
+ if (Array.isArray(routeClone.screenshots)) {
316
+ routeClone.screenshots = routeClone.screenshots.map((shot: any) => {
317
+ if (shot.dataUrl && lookup[shot.dataUrl]) {
318
+ return { ...shot, dataUrl: lookup[shot.dataUrl] };
319
+ }
320
+ return shot;
321
+ });
322
+ }
323
+
324
+ patched[routeKey] = routeClone;
325
+ }
326
+
327
+ return patched;
328
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-doctor-cli-dev",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "description": "React performance analyzer with static analysis, runtime profiling, rule engine, and dashboard upload",
5
5
  "main": "index.js",
6
6
  "type": "commonjs",
Binary file