Initial commit

**Motivations:**
- Initialisation du versionning git pour le projet

**Root causes:**
- N/A (Nouveau projet)

**Correctifs:**
- N/A

**Evolutions:**
- Structure initiale du projet
- Ajout du .gitignore

**Pages affectées:**
- Tous les fichiers
This commit is contained in:
2026-03-03 22:24:17 +01:00
commit e031c9a1d2
155 changed files with 22334 additions and 0 deletions

228
server/routes/sales.js Normal file
View File

@@ -0,0 +1,228 @@
import express from "express";
import {
getAccountByPublicKey,
getZooByAccountId,
getSalesForZoo,
getActiveSaleListings,
createSaleListing,
placeBid,
acceptSale,
rejectSale,
markSaleDelivered,
expireSaleListings,
processValidatedSales,
} from "../db.js";
import { verifySignature, buildSignMessage, hashBody } from "../auth.js";
const router = express.Router();
const TIMESTAMP_TOLERANCE_MS = 5 * 60 * 1000;
function requireSignature() {
return (req, res, next) => {
const publicKey = req.headers["x-public-key"];
const signature = req.headers["x-signature"];
const timestamp = req.headers["x-timestamp"];
if (!publicKey || !signature || !timestamp) {
res.status(401).json({ error: "Missing X-Public-Key, X-Signature, or X-Timestamp" });
return;
}
const now = Date.now();
const ts = new Date(timestamp).getTime();
if (Number.isNaN(ts) || Math.abs(now - ts) > TIMESTAMP_TOLERANCE_MS) {
res.status(401).json({ error: "Invalid or expired timestamp" });
return;
}
const body = req.bodyRaw !== undefined && req.bodyRaw !== null ? req.bodyRaw : "";
const bodyHash = hashBody(body);
const path = req.originalUrl || req.baseUrl + req.path || req.path;
const message = buildSignMessage(req.method, path, timestamp, bodyHash);
if (!verifySignature(publicKey, signature, message)) {
res.status(401).json({ error: "Invalid signature" });
return;
}
getAccountByPublicKey(publicKey).then((account) => {
if (!account) {
res.status(401).json({ error: "Unknown account" });
return;
}
req.account = account;
next();
}).catch(next);
};
}
function optionalSignature() {
return (req, res, next) => {
const publicKey = req.headers["x-public-key"];
const signature = req.headers["x-signature"];
const timestamp = req.headers["x-timestamp"];
if (!publicKey || !signature || !timestamp) {
next();
return;
}
const now = Date.now();
const ts = new Date(timestamp).getTime();
if (Number.isNaN(ts) || Math.abs(now - ts) > TIMESTAMP_TOLERANCE_MS) {
next();
return;
}
const body = req.bodyRaw !== undefined && req.bodyRaw !== null ? req.bodyRaw : "";
const bodyHash = hashBody(body);
const path = req.originalUrl || req.baseUrl + req.path || req.path;
const message = buildSignMessage(req.method, path, timestamp, bodyHash);
if (!verifySignature(publicKey, signature, message)) {
next();
return;
}
getAccountByPublicKey(publicKey).then((account) => {
req.account = account ?? undefined;
next();
}).catch(next);
};
}
/** GET /api/sales — optional auth: with auth returns asSeller, asBuyerUndelivered, active; without auth returns active only. */
router.get("/", optionalSignature(), async (req, res, next) => {
try {
await expireSaleListings();
if (req.account) {
await processValidatedSales();
const zoo = await getZooByAccountId(req.account.id);
if (!zoo) {
res.json({ asSeller: [], asBuyerUndelivered: [], active: await getActiveSaleListings() });
return;
}
const data = await getSalesForZoo(zoo.id);
res.json(data);
return;
}
const active = await getActiveSaleListings();
res.json({ active });
} catch (e) {
next(e);
}
});
/** POST /api/sales — create listing (auth). Body: { animalId, isBaby, price, endAt, reproductionScoreAtSale? }. */
router.post("/", requireSignature(), async (req, res, next) => {
try {
const zoo = await getZooByAccountId(req.account.id);
if (!zoo) {
res.status(404).json({ error: "No zoo for this account" });
return;
}
const { animalId, isBaby, price, endAt, reproductionScoreAtSale } = req.body ?? {};
if (typeof animalId !== "string" || !animalId.trim()) {
res.status(400).json({ error: "animalId required" });
return;
}
if (typeof isBaby !== "boolean") {
res.status(400).json({ error: "isBaby boolean required" });
return;
}
const initialPrice = Number(price);
if (!Number.isFinite(initialPrice) || initialPrice < 0) {
res.status(400).json({ error: "price must be a non-negative number" });
return;
}
const endAtDate = endAt ? new Date(endAt) : null;
if (!endAtDate || Number.isNaN(endAtDate.getTime())) {
res.status(400).json({ error: "endAt required (ISO date string)" });
return;
}
const { id } = await createSaleListing({
sellerZooId: zoo.id,
animalId: animalId.trim(),
isBaby,
initialPrice,
endAt: endAtDate.toISOString(),
reproductionScoreAtSale: reproductionScoreAtSale != null ? Number(reproductionScoreAtSale) : undefined,
});
res.status(201).json({ id });
} catch (e) {
next(e);
}
});
/** POST /api/sales/:id/bid — place bid (auth). Body: { amount }. */
router.post("/:id/bid", requireSignature(), async (req, res, next) => {
try {
const zoo = await getZooByAccountId(req.account.id);
if (!zoo) {
res.status(404).json({ error: "No zoo for this account" });
return;
}
const amount = Number(req.body?.amount);
if (!Number.isFinite(amount) || amount < 0) {
res.status(400).json({ error: "amount must be a non-negative number" });
return;
}
const result = await placeBid(req.params.id, zoo.id, amount);
if (!result.ok) {
res.status(400).json({ error: result.reason ?? "Bid failed" });
return;
}
res.json({ ok: true });
} catch (e) {
next(e);
}
});
/** POST /api/sales/:id/accept — seller accepts best bid (auth). */
router.post("/:id/accept", requireSignature(), async (req, res, next) => {
try {
const zoo = await getZooByAccountId(req.account.id);
if (!zoo) {
res.status(404).json({ error: "No zoo for this account" });
return;
}
const result = await acceptSale(req.params.id, zoo.id);
if (!result.ok) {
res.status(400).json({ error: result.reason ?? "Accept failed" });
return;
}
res.json({ ok: true });
} catch (e) {
next(e);
}
});
/** POST /api/sales/:id/reject — seller rejects current best bid (auth). */
router.post("/:id/reject", requireSignature(), async (req, res, next) => {
try {
const zoo = await getZooByAccountId(req.account.id);
if (!zoo) {
res.status(404).json({ error: "No zoo for this account" });
return;
}
const result = await rejectSale(req.params.id, zoo.id);
if (!result.ok) {
res.status(400).json({ error: result.reason ?? "Reject failed" });
return;
}
res.json({ ok: true });
} catch (e) {
next(e);
}
});
/** POST /api/sales/:id/deliver — buyer marks as delivered (auth). */
router.post("/:id/deliver", requireSignature(), async (req, res, next) => {
try {
const zoo = await getZooByAccountId(req.account.id);
if (!zoo) {
res.status(404).json({ error: "No zoo for this account" });
return;
}
const result = await markSaleDelivered(req.params.id, zoo.id);
if (!result.ok) {
res.status(400).json({ error: result.reason ?? "Deliver failed" });
return;
}
res.json({ ok: true });
} catch (e) {
next(e);
}
});
export default router;