Chunk 003: wynajem_motorowek
server/db.js (lines 1–148)
- Repository
- wynajem_motorowek
- Path
server/db.js
const fs = require("fs");
const path = require("path");
const sqlite3 = require("sqlite3");
const { open } = require("sqlite");
const { buildCancelToken, buildOrderNumber } = require("./lib/orders");
let database = null;
function resolveDbPath() {
const configuredPath = String(process.env.SQLITE_PATH || "./data/booking.sqlite").trim();
if (path.isAbsolute(configuredPath)) return configuredPath;
return path.resolve(__dirname, configuredPath);
}
function normalizeSqlPlaceholders(text) {
return String(text || "").replace(/\$(\d+)/g, "?");
}
async function init() {
if (database) return database;
const dbPath = resolveDbPath();
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
database = await open({
filename: dbPath,
driver: sqlite3.Database,
});
await database.exec("PRAGMA foreign_keys = ON;");
const migrationPath = path.resolve(__dirname, "migrations.sql");
const migrationSql = fs.readFileSync(migrationPath, "utf8");
await database.exec(migrationSql);
const resourceColumns = await database.all("PRAGMA table_info(resources)");
const hasCapacity = resourceColumns.some((col) => String(col.name || "").toLowerCase() === "capacity");
if (!hasCapacity) {
await database.exec("ALTER TABLE resources ADD COLUMN capacity INTEGER NOT NULL DEFAULT 6");
}
await database.exec("UPDATE resources SET capacity = 6 WHERE capacity IS NULL OR capacity <= 0");
const reservationColumns = await database.all("PRAGMA table_info(reservations)");
const reservationColumnNames = new Set(reservationColumns.map((col) => String(col.name || "").toLowerCase()));
if (!reservationColumnNames.has("customer_email")) {
await database.exec("ALTER TABLE reservations ADD COLUMN customer_email TEXT");
}
if (!reservationColumnNames.has("payment_method")) {
await database.exec("ALTER TABLE reservations ADD COLUMN payment_method TEXT NOT NULL DEFAULT 'ONLINE'");
}
if (!reservationColumnNames.has("order_number")) {
await database.exec("ALTER TABLE reservations ADD COLUMN order_number TEXT");
}
if (!reservationColumnNames.has("cancel_token")) {
await database.exec("ALTER TABLE reservations ADD COLUMN cancel_token TEXT");
}
await database.exec("UPDATE reservations SET payment_method = 'ONLINE' WHERE payment_method IS NULL OR payment_method = ''");
await database.exec("CREATE INDEX IF NOT EXISTS idx_reservations_order_number ON reservations(order_number)");
const missingIdentifiers = await database.all(
`SELECT id
FROM reservations
WHERE status IN ('CONFIRMED', 'CANCELLED')
AND (order_number IS NULL OR order_number = '' OR cancel_token IS NULL OR cancel_token = '')`
);
for (const row of missingIdentifiers) {
const orderNumber = buildOrderNumber(row.id);
const cancelToken = buildCancelToken(row.id);
await database.run(
`UPDATE reservations
SET order_number = COALESCE(NULLIF(order_number, ''), ?),
cancel_token = COALESCE(NULLIF(cancel_token, ''), ?)
WHERE id = ?`,
[orderNumber, cancelToken, row.id]
);
}
await database.exec(
`CREATE TABLE IF NOT EXISTS support_emails (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
)`
);
const existingResources = await database.get("SELECT COUNT(*) AS total FROM resources");
if (Number(existingResources?.total || 0) === 0) {
const krypyPath = path.resolve(__dirname, "../krypy");
if (fs.existsSync(krypyPath)) {
const folders = fs
.readdirSync(krypyPath, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort((a, b) => a.localeCompare(b, "pl"));
for (const name of folders) {
await database.run(
`INSERT OR IGNORE INTO resources (name, type, capacity, active)
VALUES (?, 'BOAT', 6, 1)`,
[name]
);
}
}
}
const supportFromEnv = String(process.env.SUPPORT_EMAILS || "").trim();
if (supportFromEnv) {
const emails = supportFromEnv
.split(",")
.map((item) => item.trim().toLowerCase())
.filter(Boolean);
for (const email of emails) {
await database.run(
`INSERT OR IGNORE INTO support_emails (email, active)
VALUES (?, 1)`,
[email]
);
}
}
return database;
}
async function query(text, params = []) {
const db = await init();
const sql = normalizeSqlPlaceholders(text).trim();
const leadingWord = sql.split(/\s+/)[0]?.toUpperCase() || "";
if (["SELECT", "PRAGMA", "WITH"].includes(leadingWord)) {
const rows = await db.all(sql, params);
return { rows, rowCount: rows.length };
}
const result = await db.run(sql, params);
return {
rows: [],
rowCount: Number(result?.changes || 0),
lastID: result?.lastID,
};
}
module.exports = {
init,
query,
resolveDbPath,
};
server/index.js (lines 1–111)
- Repository
- wynajem_motorowek
- Path
server/index.js
const express = require("express");
const cors = require("cors");
const dotenv = require("dotenv");
const path = require("path");
dotenv.config();
const db = require("./db");
const resourcesRoute = require("./routes/resources");
const availabilityRoute = require("./routes/availability");
const holdsRoute = require("./routes/holds");
const paymentsRoute = require("./routes/payments");
const ownerRoute = require("./routes/owner");
const cancellationsRoute = require("./routes/cancellations");
const app = express();
const port = Number(process.env.PORT || 3030);
const webRoot = path.resolve(__dirname, "..");
const corsOrigin = String(process.env.CORS_ORIGIN || "").trim();
if (corsOrigin) {
app.use(cors({ origin: corsOrigin }));
}
app.use(express.json({ limit: "200kb" }));
app.get("/", (_req, res) => {
res.sendFile(path.join(webRoot, "index.html"));
});
app.get("/owner_menager", (_req, res) => {
res.sendFile(path.join(webRoot, "owner_manager.html"));
});
app.get("/cancel_reservation", (_req, res) => {
res.sendFile(path.join(webRoot, "cancel_reservation.html"));
});
app.get("/rezerwacja_dziekujemy.html", (_req, res) => {
res.sendFile(path.join(webRoot, "rezerwacja_dziekujemy.html"));
});
app.get("/tlo1.jpg", (_req, res) => {
res.sendFile(path.join(webRoot, "tlo1.jpg"));
});
app.use("/assets", express.static(path.join(webRoot, "assets")));
app.use("/krypy", express.static(path.join(webRoot, "krypy")));
app.get("/api/health", (_req, res) => {
res.json({ ok: true, mode: "sqlite" });
});
app.use("/api/resources", resourcesRoute({ db }));
app.use("/api/availability", availabilityRoute({ db }));
app.use("/api/holds", holdsRoute({ db }));
app.use("/api/payments", paymentsRoute({ db }));
app.use("/api/owner", ownerRoute({ db }));
app.use("/api/cancellations", cancellationsRoute({ db }));
app.use((err, _req, res, _next) => {
const hasMessage = Boolean(err && typeof err.message === "string" && err.message.trim());
const isDbAuthError = err?.code === "SQLITE_AUTH";
const isDbConnectionError = err?.code === "SQLITE_CANTOPEN";
const isNodeConnError = ["ECONNREFUSED", "ENOTFOUND", "ETIMEDOUT", "ECONNRESET"].includes(err?.code);
const isMissingDatabase = false;
const isMissingTable = err?.code === "SQLITE_ERROR" && /no such table/i.test(String(err?.message || ""));
const status = Number(
err.status || (isDbAuthError || isDbConnectionError || isNodeConnError || isMissingDatabase || isMissingTable ? 503 : 500)
);
const reason =
err.reason ||
(isDbAuthError || isDbConnectionError || isNodeConnError || isMissingDatabase || isMissingTable
? "DB_NOT_READY"
: "SERVER_ERROR");
const message = hasMessage
? err.message
: "Database not ready. Check SQLITE_PATH and server/migrations.sql";
console.error("[booking-api:error]", {
code: err?.code,
reason,
message,
});
res.status(status).json({
ok: false,
reason,
message,
});
});
setInterval(async () => {
try {
await db.query(
`UPDATE reservations
SET status = 'EXPIRED', payment_status = COALESCE(payment_status, 'EXPIRED')
WHERE status = 'HOLD' AND expires_at < ?`,
[new Date().toISOString()]
);
} catch (_err) {}
}, 60 * 1000);
db.init()
.then(() => {
app.listen(port, () => {
console.log(`[booking-api] listening on http://localhost:${port} (sqlite)`);
});
})
.catch((err) => {
console.error("[booking-api] failed to initialize sqlite database", err);
process.exit(1);
});
server/lib/conflicts.js (lines 1–40)
- Repository
- wynajem_motorowek
- Path
server/lib/conflicts.js
const { DAY_START_MIN, DAY_END_MIN, minutesToTimeText } = require("./time");
function hasOverlap(startMs, endMs, existingBlocks) {
return existingBlocks.some((block) => startMs < block.endMs && endMs > block.startMs);
}
function findSuggestion({ startMin, endMin, blocksInDay }) {
const duration = endMin - startMin;
const normalized = blocksInDay
.map((item) => ({ startMin: item.startMin, endMin: item.endMin }))
.filter((item) => item.endMin > DAY_START_MIN && item.startMin < DAY_END_MIN)
.sort((a, b) => a.startMin - b.startMin);
let candidate = startMin;
for (const item of normalized) {
if (candidate + duration <= item.startMin) {
return {
start: minutesToTimeText(candidate),
end: minutesToTimeText(candidate + duration),
};
}
if (candidate < item.endMin) {
candidate = item.endMin;
}
}
if (candidate + duration <= DAY_END_MIN) {
return {
start: minutesToTimeText(candidate),
end: minutesToTimeText(candidate + duration),
};
}
return null;
}
module.exports = {
hasOverlap,
findSuggestion,
};
server/lib/mailer.js (lines 1–65)
- Repository
- wynajem_motorowek
- Path
server/lib/mailer.js
const nodemailer = require("nodemailer");
function buildTransportConfig() {
const host = String(process.env.SMTP_HOST || "").trim();
const port = Number(process.env.SMTP_PORT || 587);
const user = String(process.env.SMTP_USER || "").trim();
const pass = String(process.env.SMTP_PASS || "").trim();
const secure = String(process.env.SMTP_SECURE || "false").toLowerCase() === "true";
if (!host || !user || !pass) return null;
return { host, port, secure, auth: { user, pass } };
}
function getFromAddress() {
const mailFrom = String(process.env.MAIL_FROM || "").trim();
const smtpUser = String(process.env.SMTP_USER || "").trim();
if (!mailFrom) return smtpUser;
if (mailFrom.includes("@")) return mailFrom;
if (!smtpUser) return "";
return `${mailFrom} <${smtpUser}>`;
}
async function sendMail({ to, subject, text, html }) {
const config = buildTransportConfig();
if (!config) {
console.log("[mailer] SMTP not configured, skipped", { to, subject });
return { skipped: true };
}
const from = getFromAddress();
if (!from) {
console.log("[mailer] MAIL_FROM missing, skipped", { to, subject });
return { skipped: true };
}
const transporter = nodemailer.createTransport(config);
try {
await transporter.verify();
} catch (error) {
console.error("[mailer] SMTP verify failed", {
host: config.host,
port: config.port,
secure: config.secure,
error: String(error && error.message ? error.message : error),
});
throw error;
}
try {
const info = await transporter.sendMail({ from, to, subject, text, html });
console.log("[mailer] Sent", { to, subject, messageId: info && info.messageId });
return info;
} catch (error) {
console.error("[mailer] Send failed", {
to,
subject,
error: String(error && error.message ? error.message : error),
});
throw error;
}
}
module.exports = {
sendMail,
};
server/lib/notifications.js (lines 1–77)
- Repository
- wynajem_motorowek
- Path
server/lib/notifications.js
const { sendMail } = require("./mailer");
function formatDateTime(value) {
const date = new Date(String(value).replace(" ", "T"));
const dd = String(date.getDate()).padStart(2, "0");
const mm = String(date.getMonth() + 1).padStart(2, "0");
const yyyy = date.getFullYear();
const hh = String(date.getHours()).padStart(2, "0");
const min = String(date.getMinutes()).padStart(2, "0");
return `${dd}.${mm}.${yyyy} ${hh}:${min}`;
}
async function getSupportRecipients(db) {
const ownerEmail = String(process.env.OWNER_NOTIFY_EMAIL || process.env.SMTP_USER || "").trim();
const rows = (await db.query(`SELECT email FROM support_emails WHERE active = 1 ORDER BY email ASC`)).rows;
const set = new Set();
if (ownerEmail) set.add(ownerEmail.toLowerCase());
for (const row of rows) {
const email = String(row.email || "").trim().toLowerCase();
if (email) set.add(email);
}
return Array.from(set);
}
function bookingConfirmationMessage({ reservation, phoneNumber, cancelUrl }) {
const lines = [
`Potwierdzenie rezerwacji`,
`Numer rezerwacji: ${reservation.order_number || "-"}`,
`Start: ${formatDateTime(reservation.start_time)}`,
`Koniec: ${formatDateTime(reservation.end_time)}`,
`Telefon kontaktowy: ${phoneNumber}`,
`Anulowanie: ${cancelUrl}`,
];
return lines.join("\n");
}
async function sendReservationConfirmedEmails({ db, reservation, resourceName }) {
const customerEmail = String(reservation.customer_email || "").trim().toLowerCase();
const recipients = await getSupportRecipients(db);
const publicBase = String(process.env.PUBLIC_BASE_URL || `http://localhost:${process.env.PORT || 3030}`).replace(/\/$/, "");
const cancelUrl = `${publicBase}/cancel_reservation?order=${encodeURIComponent(reservation.order_number || "")}&token=${encodeURIComponent(reservation.cancel_token || "")}`;
const phoneNumber = String(process.env.CONTACT_PHONE || "+48 000 000 000");
const subject = `Potwierdzenie rezerwacji ${reservation.order_number || ""}`;
const text = bookingConfirmationMessage({ reservation, phoneNumber, cancelUrl }) + `\nŁódka: ${resourceName}`;
if (customerEmail) {
await sendMail({ to: customerEmail, subject, text });
}
if (recipients.length) {
await sendMail({ to: recipients.join(","), subject: `[OWNER COPY] ${subject}`, text });
}
}
async function sendCancellationEmails({ db, reservation, reason }) {
const customerEmail = String(reservation.customer_email || "").trim().toLowerCase();
const recipients = await getSupportRecipients(db);
const subject = `Anulowanie rezerwacji ${reservation.order_number || ""}`;
const text = [
`Rezerwacja została anulowana.`,
`Numer rezerwacji: ${reservation.order_number || "-"}`,
`Powód: ${reason || "Klient"}`,
`Status płatności: ${reservation.payment_status || "-"}`,
].join("\n");
if (customerEmail) {
await sendMail({ to: customerEmail, subject, text });
}
if (recipients.length) {
await sendMail({ to: recipients.join(","), subject: `[OWNER COPY] ${subject}`, text });
}
}
module.exports = {
sendReservationConfirmedEmails,
sendCancellationEmails,
formatDateTime,
};
server/lib/orders.js (lines 1–26)
- Repository
- wynajem_motorowek
- Path
server/lib/orders.js
const crypto = require("crypto");
function pad(value, size = 4) {
return String(value).padStart(size, "0");
}
function dateToken(date = new Date()) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
return `${y}${m}${d}`;
}
function buildOrderNumber(id) {
return `SZ-${dateToken()}-${pad(id)}`;
}
function buildCancelToken(id) {
const random = crypto.randomBytes(12).toString("hex").toUpperCase();
return `CXL-${dateToken()}-${pad(id)}-${random}`;
}
module.exports = {
buildOrderNumber,
buildCancelToken,
};
server/lib/payment_gateway.js (lines 1–19)
- Repository
- wynajem_motorowek
- Path
server/lib/payment_gateway.js
async function requestRefund({ reservation }) {
const provider = String(process.env.PAYMENT_PROVIDER || "not-configured");
console.log("[payment-gateway] refund requested", {
provider,
reservationId: reservation?.id,
orderNumber: reservation?.order_number,
paymentRef: reservation?.payment_ref,
});
return {
ok: true,
provider,
refundReference: `REFUND_REQ_${reservation?.id || "NA"}`,
};
}
module.exports = {
requestRefund,
};
server/lib/time.js (lines 1–45)
- Repository
- wynajem_motorowek
- Path
server/lib/time.js
const DAY_START_MIN = 8 * 60;
const DAY_END_MIN = 20 * 60;
function parseDateKey(dateKey) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateKey || "")) return null;
const date = new Date(`${dateKey}T00:00:00`);
if (Number.isNaN(date.getTime())) return null;
return date;
}
function parseTimeToMinutes(timeText) {
if (!/^\d{2}:\d{2}$/.test(timeText || "")) return null;
const [hh, mm] = timeText.split(":").map((part) => Number(part));
if (!Number.isInteger(hh) || !Number.isInteger(mm)) return null;
if (hh < 0 || hh > 23 || mm < 0 || mm > 59) return null;
return hh * 60 + mm;
}
function minutesToTimeText(totalMinutes) {
const hh = String(Math.floor(totalMinutes / 60)).padStart(2, "0");
const mm = String(totalMinutes % 60).padStart(2, "0");
return `${hh}:${mm}`;
}
function atDateAndMinutes(dateKey, totalMinutes) {
return new Date(`${dateKey}T00:00:00`).getTime() + totalMinutes * 60_000;
}
function toIsoAtDateAndMinutes(dateKey, totalMinutes) {
return new Date(atDateAndMinutes(dateKey, totalMinutes)).toISOString();
}
function overlap(aStartMs, aEndMs, bStartMs, bEndMs) {
return aStartMs < bEndMs && aEndMs > bStartMs;
}
module.exports = {
DAY_START_MIN,
DAY_END_MIN,
parseDateKey,
parseTimeToMinutes,
minutesToTimeText,
toIsoAtDateAndMinutes,
overlap,
};
server/lib/validation.js (lines 1–37)
- Repository
- wynajem_motorowek
- Path
server/lib/validation.js
const { DAY_START_MIN, DAY_END_MIN, parseDateKey, parseTimeToMinutes } = require("./time");
function validationError(message, reason = "VALIDATION_ERROR") {
const err = new Error(message);
err.status = 400;
err.reason = reason;
return err;
}
function validateRangeInput({ date, start, end }) {
if (!parseDateKey(date)) {
throw validationError("Invalid date format, expected YYYY-MM-DD");
}
const startMin = parseTimeToMinutes(start);
const endMin = parseTimeToMinutes(end);
if (startMin === null || endMin === null) {
throw validationError("Invalid time format, expected HH:MM");
}
if (startMin < DAY_START_MIN || endMin > DAY_END_MIN) {
throw validationError("Time must be in 08:00-20:00 range", "OUTSIDE_WORKING_HOURS");
}
if (endMin <= startMin) {
throw validationError("End time must be greater than start time", "INVALID_RANGE");
}
if (endMin - startMin > 12 * 60) {
throw validationError("Maximum reservation length is 12 hours", "TOO_LONG");
}
return { startMin, endMin };
}
module.exports = {
validateRangeInput,
validationError,
};
server/migrations.sql (lines 1–42)
- Repository
- wynajem_motorowek
- Path
server/migrations.sql
CREATE TABLE IF NOT EXISTS resources (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
type TEXT,
capacity INTEGER NOT NULL DEFAULT 6,
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
CREATE TABLE IF NOT EXISTS reservations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
resource_id INTEGER NOT NULL REFERENCES resources(id),
start_time TEXT NOT NULL,
end_time TEXT NOT NULL,
status TEXT NOT NULL,
kind TEXT NOT NULL,
expires_at TEXT,
customer_name TEXT,
customer_email TEXT,
phone TEXT,
people_count INTEGER,
want_invoice INTEGER NOT NULL DEFAULT 0,
invoice_data TEXT,
payment_status TEXT,
payment_ref TEXT,
payment_method TEXT NOT NULL DEFAULT 'ONLINE',
order_number TEXT UNIQUE,
cancel_token TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
CREATE TABLE IF NOT EXISTS support_emails (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
CREATE INDEX IF NOT EXISTS idx_resources_active ON resources(active);
CREATE INDEX IF NOT EXISTS idx_reservations_resource_time ON reservations(resource_id, start_time, end_time);
CREATE INDEX IF NOT EXISTS idx_reservations_status ON reservations(status);
CREATE INDEX IF NOT EXISTS idx_reservations_expires_at ON reservations(expires_at);