server/routes/holds.js Repository wynajem_motorowek Original path server/routes/holds.jsRole SOURCE Size 9440 bytes Lines 284 SHA-256 e7d9dba649c2bf3c577bd0c52cee80ff83eb2fb69a6d507bab5bdd531761cd44Displayed range 1–284 Previous file/page · Project index · Next file/page
const express = require("express");
const { findSuggestion, hasOverlap } = require("../lib/conflicts");
const { DAY_START_MIN, DAY_END_MIN, toIsoAtDateAndMinutes } = require("../lib/time");
const { validateRangeInput, validationError } = require("../lib/validation");
const holdThrottleByIp = new Map();
function ipKey(req) {
return req.headers["x-forwarded-for"] || req.socket.remoteAddress || "unknown";
}
function toSqlTimestamp(date, minutes) {
const hh = String(Math.floor(minutes / 60)).padStart(2, "0");
const mm = String(minutes % 60).padStart(2, "0");
return `${date} ${hh}:${mm}:00`;
}
function toClientIso(value) {
if (!value) return null;
if (value instanceof Date) return value.toISOString();
return String(value).replace(" ", "T");
}
function nowIso() {
return new Date().toISOString();
}
module.exports = function holdsRoute({ db }) {
const router = express.Router();
router.post("/", async (req, res, next) => {
try {
await db.query(
`UPDATE reservations
SET status = 'EXPIRED', payment_status = COALESCE(payment_status, 'EXPIRED')
WHERE status = 'HOLD' AND expires_at < ?`,
[nowIso()]
);
const key = String(ipKey(req));
const now = Date.now();
const lastTime = holdThrottleByIp.get(key) || 0;
if (now - lastTime < 1500) {
return res.status(429).json({ ok: false, reason: "TOO_MANY_REQUESTS" });
}
holdThrottleByIp.set(key, now);
const resourceName = String(req.body?.resourceName || "").trim();
const date = String(req.body?.date || "");
const start = String(req.body?.start || "");
const end = String(req.body?.end || "");
const wantInvoice = Boolean(req.body?.wantInvoice);
const paymentMethodRaw = String(req.body?.paymentMethod || "ONLINE").toUpperCase();
const paymentMethod = paymentMethodRaw === "CASH" ? "CASH" : "ONLINE";
const peopleCount = Number(req.body?.peopleCount || 1);
if (!resourceName) throw validationError("resourceName is required");
const { startMin, endMin } = validateRangeInput({ date, start, end });
const startIso = toIsoAtDateAndMinutes(date, startMin);
const endIso = toIsoAtDateAndMinutes(date, endMin);
const requestedStartMs = new Date(startIso).getTime();
const requestedEndMs = new Date(endIso).getTime();
const startTs = toSqlTimestamp(date, startMin);
const endTs = toSqlTimestamp(date, endMin);
const resourceResult = await db.query(
`SELECT id, capacity
FROM resources
WHERE active = 1 AND name = ?
LIMIT 1`,
[resourceName]
);
const resource = resourceResult.rows[0];
if (!resource) {
return res.status(404).json({ ok: false, reason: "RESOURCE_NOT_FOUND" });
}
const capacity = Number(resource.capacity || 6);
if (!Number.isInteger(peopleCount) || peopleCount <= 0) {
return res.status(400).json({ ok: false, reason: "INVALID_PEOPLE_COUNT" });
}
if (peopleCount > capacity) {
return res.status(400).json({ ok: false, reason: "CAPACITY_EXCEEDED", maxCapacity: capacity });
}
const dayStartTs = `${date} 08:00:00`;
const dayEndTs = `${date} 20:00:00`;
const existingResult = await db.query(
`SELECT (CAST(strftime('%H', start_time) AS INTEGER) * 60 + CAST(strftime('%M', start_time) AS INTEGER)) AS start_min,
(CAST(strftime('%H', end_time) AS INTEGER) * 60 + CAST(strftime('%M', end_time) AS INTEGER)) AS end_min,
kind
FROM reservations
WHERE resource_id = ?
AND status IN ('HOLD','CONFIRMED')
AND start_time < ?
AND end_time > ?
ORDER BY start_time ASC`,
[resource.id, dayEndTs, dayStartTs]
);
const blocks = [];
for (const item of existingResult.rows) {
blocks.push({
startMs: requestedStartMs + (item.start_min - startMin) * 60_000,
endMs: requestedStartMs + (item.end_min - startMin) * 60_000,
startMin: item.start_min,
endMin: item.end_min,
});
if (String(item.kind || "") === "CUSTOMER") {
const serviceEndMin = Math.min(DAY_END_MIN, item.end_min + 15);
if (serviceEndMin > item.end_min) {
blocks.push({
startMs: requestedStartMs + (item.end_min - startMin) * 60_000,
endMs: requestedStartMs + (serviceEndMin - startMin) * 60_000,
startMin: item.end_min,
endMin: serviceEndMin,
});
}
}
}
const busy = hasOverlap(requestedStartMs, requestedEndMs, blocks);
if (busy) {
const suggestion = findSuggestion({ startMin, endMin, blocksInDay: blocks });
if (suggestion) {
const [suggestedStartH, suggestedStartM] = suggestion.start.split(":").map(Number);
const [suggestedEndH, suggestedEndM] = suggestion.end.split(":").map(Number);
const suggestedStartMin = suggestedStartH * 60 + suggestedStartM;
const suggestedEndMin = suggestedEndH * 60 + suggestedEndM;
if (suggestedStartMin < DAY_START_MIN || suggestedEndMin > DAY_END_MIN) {
return res.json({ ok: false, reason: "BUSY" });
}
}
return res.json({
ok: false,
reason: "BUSY",
...(suggestion ? { suggestion } : {}),
});
}
const holdResult = await db.query(
`INSERT INTO reservations (
resource_id,
start_time,
end_time,
status,
kind,
expires_at,
people_count,
want_invoice,
payment_method,
payment_status
) VALUES (
?,
?,
?,
'HOLD',
'CUSTOMER',
strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '+15 minutes'),
?,
?,
?,
'NOT_STARTED'
)`,
[resource.id, startTs, endTs, peopleCount, wantInvoice ? 1 : 0, paymentMethod]
);
const holdId = Number(holdResult.lastID || 0);
const holdData = await db.query(`SELECT id, expires_at FROM reservations WHERE id = ? LIMIT 1`, [holdId]);
const hold = holdData.rows[0];
return res.json({
ok: true,
holdId: hold.id,
expiresAt: toClientIso(hold.expires_at),
});
} catch (err) {
next(err);
}
});
router.post("/:holdId/details", async (req, res, next) => {
try {
await db.query(
`UPDATE reservations
SET status = 'EXPIRED', payment_status = COALESCE(payment_status, 'EXPIRED')
WHERE status = 'HOLD' AND expires_at < ?`,
[nowIso()]
);
const holdId = Number(req.params.holdId || 0);
if (!Number.isInteger(holdId) || holdId <= 0) {
return res.status(400).json({ ok: false, reason: "INVALID_HOLD_ID" });
}
const currentResult = await db.query(
`SELECT rv.id, rv.status, rv.expires_at, r.capacity
FROM reservations rv
JOIN resources r ON r.id = rv.resource_id
WHERE rv.id = ?
LIMIT 1`,
[holdId]
);
const current = currentResult.rows[0];
if (!current) {
return res.status(404).json({ ok: false, reason: "HOLD_NOT_FOUND" });
}
if (current.status !== "HOLD") {
return res.status(409).json({ ok: false, reason: "HOLD_NOT_ACTIVE" });
}
if (current.expires_at && new Date(current.expires_at).getTime() <= Date.now()) {
await db.query(
`UPDATE reservations
SET status = 'EXPIRED', payment_status = 'EXPIRED'
WHERE id = ?`,
[holdId]
);
return res.status(409).json({ ok: false, reason: "EXPIRED" });
}
const details = {
name: String(req.body?.name || req.body?.customerName || "").trim(),
email: String(req.body?.email || req.body?.customerEmail || "").trim().toLowerCase(),
phone: String(req.body?.phone || "").trim(),
peopleCount: Number(req.body?.peopleCount || 1),
wantInvoice: Boolean(req.body?.wantInvoice),
invoice: req.body?.invoice || req.body?.invoiceData || null,
};
if (!details.name) throw validationError("Name is required");
if (!details.email) throw validationError("Email is required");
if (!details.email.includes("@")) throw validationError("Email format is invalid");
if (!details.phone) throw validationError("Phone is required");
if (details.wantInvoice) {
const nip = String(details.invoice?.nip || "").replace(/\D/g, "");
if (nip.length !== 10) {
return res.status(400).json({ ok: false, reason: "INVALID_NIP_FORMAT", message: "NIP must have exactly 10 digits" });
}
details.invoice = {
...(details.invoice || {}),
nip,
};
}
if (!Number.isInteger(details.peopleCount) || details.peopleCount <= 0) {
return res.status(400).json({ ok: false, reason: "INVALID_PEOPLE_COUNT" });
}
const capacity = Number(current.capacity || 6);
if (details.peopleCount > capacity) {
return res.status(400).json({ ok: false, reason: "CAPACITY_EXCEEDED", maxCapacity: capacity });
}
await db.query(
`UPDATE reservations
SET customer_name = ?,
customer_email = ?,
phone = ?,
people_count = ?,
want_invoice = ?,
invoice_data = ?,
payment_status = 'PENDING'
WHERE id = ?`,
[
details.name,
details.email,
details.phone,
details.peopleCount,
details.wantInvoice ? 1 : 0,
JSON.stringify(details.invoice),
holdId,
]
);
return res.json({ ok: true, next: `/pay/test?holdId=${encodeURIComponent(holdId)}` });
} catch (err) {
next(err);
}
});
return router;
};