const sqlite3 = require("sqlite3").verbose();
const path = require("path");
const dbPath = path.join(process.cwd(), "data", "iai.db");
const db = new sqlite3.Database(dbPath);
db.serialize(() => {
db.run(`CREATE TABLE IF NOT EXISTS help_requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
category TEXT NOT NULL,
urgency TEXT NOT NULL,
mode TEXT NOT NULL,
location TEXT,
detail TEXT NOT NULL,
requester_name TEXT NOT NULL,
requester_contact TEXT,
created_at TEXT NOT NULL,
ip_address TEXT,
status TEXT NOT NULL DEFAULT 'open'
)`);
db.run(`CREATE TABLE IF NOT EXISTS help_claims (
id INTEGER PRIMARY KEY AUTOINCREMENT,
request_id INTEGER NOT NULL,
helper_name TEXT NOT NULL,
helper_contact TEXT,
helper_role TEXT,
message TEXT,
created_at TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
FOREIGN KEY (request_id) REFERENCES help_requests(id) ON DELETE CASCADE
)`);
db.run(`CREATE TABLE IF NOT EXISTS help_receipt_map (
request_id INTEGER PRIMARY KEY,
receipt_id INTEGER NOT NULL
)`);
// Ensure Trust Receipt tables exist (Module #2). Safe if already created.
db.run(`CREATE TABLE IF NOT EXISTS trust_receipts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
actor_name TEXT NOT NULL,
actor_contact TEXT,
role TEXT,
scope TEXT,
notes TEXT,
created_at TEXT NOT NULL,
ip_address TEXT,
status TEXT NOT NULL DEFAULT 'submitted'
)`);
db.run(`CREATE TABLE IF NOT EXISTS trust_checklist_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
receipt_id INTEGER NOT NULL,
item_text TEXT NOT NULL,
is_done INTEGER NOT NULL DEFAULT 0
)`);
db.run(`CREATE TABLE IF NOT EXISTS trust_evidence (
id INTEGER PRIMARY KEY AUTOINCREMENT,
receipt_id INTEGER NOT NULL,
kind TEXT NOT NULL,
url TEXT,
filename TEXT,
stored_path TEXT,
hash_sha256 TEXT,
created_at TEXT NOT NULL
)`);
});
module.exports = db;
const path = require("path");
const fs = require("fs");
const crypto = require("crypto");
const multer = require("multer");
const express = require("express");
const db = require("./db");
function getClientIp(req) {
return req.headers["x-forwarded-for"]?.toString()?.split(",")[0]?.trim() || req.socket.remoteAddress;
}
function sha256File(filePath) {
const buf = fs.readFileSync(filePath);
return crypto.createHash("sha256").update(buf).digest("hex");
}
function nowISO() {
return new Date().toISOString();
}
module.exports = function HelpExchangeModule({ app, mount, moduleDir }) {
// uploads for evidence files
const uploadsDir = path.join(moduleDir, "uploads");
if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true });
const upload = multer({ dest: uploadsDir });
app.use(mount, express.static(path.join(moduleDir, "public")));
app.use(express.json({ limit: "2mb" }));
// ---------- Requests ----------
// POST /api/requests
app.post(`${mount}/api/requests`, (req, res) => {
const {
title, category, urgency, mode, location, detail,
requester_name, requester_contact
} = req.body || {};
if (!title || !detail || !requester_name) {
return res.status(400).json({ error: "missing_required_fields" });
}
const cat = String(category || "general").trim();
const urg = ["low", "normal", "high"].includes(String(urgency)) ? String(urgency) : "normal";
const m = ["online", "onsite", "hybrid"].includes(String(mode)) ? String(mode) : "online";
const created_at = nowISO();
const ip = getClientIp(req);
db.run(
`INSERT INTO help_requests
(title, category, urgency, mode, location, detail, requester_name, requester_contact, created_at, ip_address, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'open')`,
[
String(title).trim(),
cat,
urg,
m,
String(location || "").trim(),
String(detail).trim(),
String(requester_name).trim(),
String(requester_contact || "").trim(),
created_at,
ip
],
function () {
res.json({ request_id: this.lastID });
}
);
});
// GET /api/requests?status=open
app.get(`${mount}/api/requests`, (req, res) => {
const status = String(req.query.status || "open").trim();
const allowed = ["open", "claimed", "done", "closed", "all"];
const s = allowed.includes(status) ? status : "open";
const sql = (s === "all")
? `SELECT * FROM help_requests ORDER BY id DESC LIMIT 200`
: `SELECT * FROM help_requests WHERE status = ? ORDER BY id DESC LIMIT 200`;
const params = (s === "all") ? [] : [s];
db.all(sql, params, (err, rows) => {
if (err) return res.status(500).json({ error: "db_error" });
res.json({ items: rows || [] });
});
});
// GET /api/requests/:id (includes claim + receipt mapping if any)
app.get(`${mount}/api/requests/:id`, (req, res) => {
const id = Number(req.params.id);
if (!Number.isFinite(id) || id <= 0) return res.status(400).json({ error: "invalid_id" });
db.get(`SELECT * FROM help_requests WHERE id = ?`, [id], (err, request) => {
if (err) return res.status(500).json({ error: "db_error" });
if (!request) return res.json({ valid: false });
db.get(
`SELECT * FROM help_claims WHERE request_id = ? AND status = 'active' ORDER BY id DESC LIMIT 1`,
[id],
(err2, claim) => {
if (err2) return res.status(500).json({ error: "db_error" });
db.get(
`SELECT receipt_id FROM help_receipt_map WHERE request_id = ?`,
[id],
(err3, mapRow) => {
if (err3) return res.status(500).json({ error: "db_error" });
res.json({
valid: true,
request,
claim: claim || null,
receipt_id: mapRow?.receipt_id || null
});
}
);
}
);
});
});
// ---------- Claim ----------
// POST /api/requests/:id/claim
app.post(`${mount}/api/requests/:id/claim`, (req, res) => {
const id = Number(req.params.id);
const { helper_name, helper_contact, helper_role, message } = req.body || {};
if (!Number.isFinite(id) || id <= 0) return res.status(400).json({ error: "invalid_id" });
if (!helper_name) return res.status(400).json({ error: "missing_helper_name" });
db.get(`SELECT status FROM help_requests WHERE id = ?`, [id], (err, row) => {
if (err) return res.status(500).json({ error: "db_error" });
if (!row) return res.status(404).json({ error: "not_found" });
if (row.status !== "open") return res.status(400).json({ error: "request_not_open" });
const created_at = nowISO();
db.run(
`INSERT INTO help_claims (request_id, helper_name, helper_contact, helper_role, message, created_at, status)
VALUES (?, ?, ?, ?, ?, ?, 'active')`,
[
id,
String(helper_name).trim(),
String(helper_contact || "").trim(),
String(helper_role || "").trim(),
String(message || "").trim(),
created_at
],
function () {
db.run(`UPDATE help_requests SET status = 'claimed' WHERE id = ?`, [id], () => {
res.json({ claim_id: this.lastID });
});
}
);
});
});
// ---------- Complete -> Auto Trust Receipt ----------
// POST /api/requests/:id/complete
// body: { completion_notes?, evidence_links?: string[] }
app.post(`${mount}/api/requests/:id/complete`, (req, res) => {
const id = Number(req.params.id);
const { completion_notes, evidence_links } = req.body || {};
if (!Number.isFinite(id) || id <= 0) return res.status(400).json({ error: "invalid_id" });
db.get(`SELECT * FROM help_requests WHERE id = ?`, [id], (err, request) => {
if (err) return res.status(500).json({ error: "db_error" });
if (!request) return res.status(404).json({ error: "not_found" });
db.get(
`SELECT * FROM help_claims WHERE request_id = ? AND status = 'active' ORDER BY id DESC LIMIT 1`,
[id],
(err2, claim) => {
if (err2) return res.status(500).json({ error: "db_error" });
if (!claim) return res.status(400).json({ error: "no_active_claim" });
// Create Trust Receipt
const receiptTitle = `HELP: ${request.title}`;
const actorName = claim.helper_name;
const actorContact = claim.helper_contact || "";
const role = claim.helper_role || "Helper";
const scope = "LMN Community";
const notes = [
`Category: ${request.category}`,
`Mode: ${request.mode}`,
`Urgency: ${request.urgency}`,
`Requester: ${request.requester_name} (${request.requester_contact || ""})`,
`Helper message: ${claim.message || ""}`,
`Completion notes: ${String(completion_notes || "").trim()}`
].filter(Boolean).join("\n");
const created_at = nowISO();
const ip = request.ip_address || "";
db.run(
`INSERT INTO trust_receipts (title, actor_name, actor_contact, role, scope, notes, created_at, ip_address, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'submitted')`,
[receiptTitle, actorName, actorContact, role, scope, notes, created_at, ip],
function () {
const receiptId = this.lastID;
// checklist defaults
const checklist = [
"Đã liên hệ người yêu cầu",
"Đã thực hiện hỗ trợ",
"Đã cung cấp minh chứng"
];
const stmt = db.prepare(
`INSERT INTO trust_checklist_items (receipt_id, item_text, is_done) VALUES (?, ?, 0)`
);
checklist.forEach(t => stmt.run([receiptId, t]));
stmt.finalize(() => {
// evidence links (optional)
const links = Array.isArray(evidence_links) ? evidence_links : [];
const evStmt = db.prepare(
`INSERT INTO trust_evidence (receipt_id, kind, url, filename, stored_path, hash_sha256, created_at)
VALUES (?, 'link', ?, '', '', '', ?)`
);
for (const u of links) {
const url = String(u || "").trim();
if (url && (url.startsWith("http://") || url.startsWith("https://"))) {
evStmt.run([receiptId, url, nowISO()]);
}
}
evStmt.finalize(() => {
// mark request & claim done + map receipt
db.run(`UPDATE help_requests SET status = 'done' WHERE id = ?`, [id], () => {
db.run(`UPDATE help_claims SET status = 'done' WHERE request_id = ? AND id = ?`, [id, claim.id], () => {
db.run(
`INSERT OR REPLACE INTO help_receipt_map (request_id, receipt_id) VALUES (?, ?)`,
[id, receiptId],
() => res.json({ ok: true, receipt_id: receiptId })
);
});
});
});
});
}
);
}
);
});
});
// Upload file as evidence INTO trust_evidence of mapped receipt
// POST /api/requests/:id/evidence/file (multipart form-data: file)
app.post(`${mount}/api/requests/:id/evidence/file`, upload.single("file"), (req, res) => {
const id = Number(req.params.id);
if (!Number.isFinite(id) || id <= 0) return res.status(400).json({ error: "invalid_id" });
if (!req.file) return res.status(400).json({ error: "file_required" });
db.get(`SELECT receipt_id FROM help_receipt_map WHERE request_id = ?`, [id], (err, mapRow) => {
if (err) return res.status(500).json({ error: "db_error" });
if (!mapRow?.receipt_id) return res.status(400).json({ error: "receipt_not_created_yet" });
const hash = sha256File(req.file.path);
const created_at = nowISO();
db.run(
`INSERT INTO trust_evidence (receipt_id, kind, url, filename, stored_path, hash_sha256, created_at)
VALUES (?, 'file', '', ?, ?, ?, ?)`,
[mapRow.receipt_id, req.file.originalname, req.file.path, hash, created_at],
function () {
res.json({ evidence_id: this.lastID, receipt_id: mapRow.receipt_id, hash_sha256: hash });
}
);
});
});
};