Build out and push automatic report generation, remove emailing of every report indiidially

This commit is contained in:
Fred Boniface 2025-07-10 12:41:41 +01:00
parent bc642ce0a8
commit 997c758403
19 changed files with 507 additions and 338 deletions

View File

@ -2,4 +2,5 @@
node_modules
dist
dockercompose
dockerfile
dockerfile
auto-report-email

View File

@ -0,0 +1 @@
node_modules

View File

@ -0,0 +1,10 @@
FROM node:18-alpine
RUN apk add --no-cache tzdata
ENV TZ=Europe/London
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install --production
COPY . .
CMD ["node", "index.js"]

81
auto-report-mail/index.js Normal file
View File

@ -0,0 +1,81 @@
import cron from 'node-cron';
import nodemailer from 'nodemailer';
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: parseInt(process.env.SMTP_PORT || '587'),
secure: false,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
}
});
export async function sendReportEmail(start, end, subject = "TrACreport Summary") {
const url = new URL(process.env.WEBURL);
url.searchParams.set('start', start.toISOString().slice(0, 10));
url.searchParams.set('end', end.toISOString().slice(0, 10));
url.searchParams.set('password', process.env.PASSWORD);
const maxAttempts = 3;
const delay = (ms) => new Promise((res) => setTimeout(res, ms));
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
console.log(`📡 Attempt ${attempt}: fetching ${url}`);
const res = await fetch(url.toString());
if (!res.ok) throw new Error(`Fetch failed: ${res.status} ${res.statusText}`);
const html = await res.text();
await transporter.sendMail({
from: `"TrACreport Bot" <${process.env.SMTP_USER}>`,
to: process.env.MAIL_RECIPIENTS,
subject,
html,
});
console.log(`✅ Report emailed: ${subject} (${start.toISOString().slice(0, 10)})`);
return; // Success, exit function
} catch (err) {
console.error(`❌ Attempt ${attempt} failed:`, err);
if (attempt < maxAttempts) {
console.log(`⏳ Retrying in 5s...`);
await delay(5000); // Wait before retry
} else {
console.error(`🚫 All ${maxAttempts} attempts failed. Giving up.`);
}
}
}
}
// Daily Report
cron.schedule('5 1 * * *', () => {
const today = new Date();
const yesterday = new Date(today);
yesterday.setUTCDate(today.getUTCDate() - 1);
sendReportEmail(yesterday, yesterday, "TrACreport Daily Report");
});
// Weekly Report
cron.schedule('10 1 * * 1', () => {
const today = new Date();
const end = new Date(today);
end.setUTCDate(roday.getUTCDate() - 1);
const start = new Date(end);
start.setUTCDate(end.getUTCDate() - 6);
sendReportEmail(start, end, "TrACreport Weekly Report");
})
// Monthly Report
cron.schedule('15 1 1 * *', () => {
const today = new Date();
const end = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), 0)); // Last day of previous month
const start = new Date(Date.UTC(end.getUTCFullYear(), end.getUTCMonth(), 1)); // First day of previous month
sendReportEmail(start, end, "TrACreport Monthly Report");
});
console.log('📅 TrACreport schedulers are active');

35
auto-report-mail/package-lock.json generated Normal file
View File

@ -0,0 +1,35 @@
{
"name": "auto-report-mail",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "auto-report-mail",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"node-cron": "^4.2.0",
"nodemailer": "^7.0.5"
}
},
"node_modules/node-cron": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.0.tgz",
"integrity": "sha512-nOdP7uH7u55w7ybQq9fusXtsResok+ErzvOBydJUPBBaQ9W+EfBaBWFPgJ8sOB7FWQednDvVBJtgP5xA0bME7Q==",
"license": "ISC",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/nodemailer": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.5.tgz",
"integrity": "sha512-nsrh2lO3j4GkLLXoeEksAMgAOqxOv6QumNRVQTJwKH4nuiww6iC2y7GyANs9kRAxCexg3+lTWM3PZ91iLlVjfg==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
}
}
}

View File

@ -0,0 +1,16 @@
{
"name": "auto-report-mail",
"version": "1.0.0",
"main": "index.js",
"type": "module",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"node-cron": "^4.2.0",
"nodemailer": "^7.0.5"
}
}

View File

@ -6,6 +6,7 @@
"type": "module",
"scripts": {
"start": "npx tsx --watch src/index.ts",
"report": "npx tsc src/reports/reports.ts",
"test": "echo \"Error: no test specified\" && exit 1",
"build": "tsc"
},

View File

@ -1,4 +1,5 @@
import mongoose from "mongoose";
import { Report } from "./models/report.js";
let isConnected = false;
@ -23,6 +24,8 @@ export async function initDb(): Promise<void> {
await mongoose.connect(MONGO_URI);
isConnected = true;
console.log('✅ MongoDB connected');
await Report.syncIndexes();
console.log('✅ Indexes synced');
} catch (err) {
console.error('❌ MongoDB connection error:', err)
process.exit(1);

View File

@ -41,7 +41,7 @@ export async function handleFormData(data: any) {
}
}
sendMail(report);
// sendMail(report); Disable sendMail in favour of daily,weekly,monthly reports
sendToDatabase(report);
}

View File

@ -1,9 +1,10 @@
import express, { Request, Response } from "express";
import { handleFormData, fetchReports } from "./formHandler.js";
import { initDb } from "./database.js";
import { GenerateReportByDate } from "./reports/reports.js";
const app = express();
const port = process.env.port || 3000;
const port = process.env.PORT || 3000;
initDb();
@ -20,7 +21,7 @@ app.post('/submit', (req, res) => {
res.status(200).json({ status: 'ok', message: 'Form received' });
});
app.get('/fetch', async (req, res) => {
app.get('/fetchall', async (req, res) => {
try {
const data = await fetchReports();
res.status(200).json(data);
@ -29,6 +30,38 @@ app.get('/fetch', async (req, res) => {
}
});
interface ReportQuery {
start?: string;
end?: string;
password?: string;
};
const reportHandler = async (req: Request, res: Response) => {
const { start, end, password } = req.query;
if (password !== process.env.PASSWORD) {
return res.status(401).send('Unauthorized');
}
if (!start || !end) {
return res.status(400).send('Missing Parameters');
}
try {
const startDate = new Date(start as string);
const endDate = new Date(end as string);
endDate.setHours(23, 59, 59, 999); // Set to end of day
const html = await GenerateReportByDate(startDate, endDate);
res.setHeader('Content-Type', 'text/html');
res.send(html);
} catch (err) {
console.error('Report generation failed:', err);
res.status(500).send('Error generating report');
}
};
app.get('/report', reportHandler as express.RequestHandler);
app.listen(port, () => {
console.log("Server running on port:", port);
});

View File

@ -1,5 +1,7 @@
import { Schema, model, Document } from "mongoose";
import type { Report as ReportType, Fault as FaultType } from "../formHandler";
import type { BaseReport } from "../reports/reports";
interface ReportDocument extends ReportType, Document {}
@ -16,4 +18,16 @@ const ReportSchema = new Schema<ReportDocument>({
faults: { type: [FaultSchema], required: true },
});
export const Report = model<ReportDocument>('Report', ReportSchema);
ReportSchema.index({ utcTimestamp: 1 });
ReportSchema.index({ unitNumber: 1 });
export const Report = model<ReportDocument>('Report', ReportSchema);
export async function fetchReportsInRange(start: Date, end: Date): Promise<BaseReport[]> {
return Report.find({
utcTimestamp: {
$gte: start,
$lte: end,
}
}).exec();
}

View File

@ -1,132 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>TrACreport - Report Output</title>
<style>
body {
font-family: sans-serif;
}
.preface {
text-align: center;
font-size: 0.9em;
}
.train-container {
width: 75%;
margin: 2em auto;
padding: 1em;
border-radius: 8px;
}
.unit-number {
text-align: center;
margin-bottom: 1em;
font-size: 1.5em;
}
.vehicle-container {
display: flex;
justify-content: space-between;
gap: 0.5em;
}
.coach {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
}
.vehicle-number {
margin-bottom: 0.5em;
font-weight: bold;
}
.vehicle-box {
width: 100%;
height: 50px;
background-color: #e0e0e0;
border: 2px solid black;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 6px;
font-size: 1.2em;
box-sizing: border-box;
}
.cab-marker {
width: 1.5em;
text-align: center;
font-weight: bold;
background-color: #d0d0f0;
border-radius: 4px;
padding: 2px;
}
.report-count {
flex: 1;
text-align: center;
}
.comments-box {
margin-top: 1.5em;
padding: 1em;
background-color: #f9f9f9;
border: 1px solid #aaa;
border-radius: 6px;
}
.comments-box h2 {
margin-top: 0;
font-size: 1.2em;
border-bottom: 1px solid #ccc;
padding-bottom: 0.5em;
}
.comments-box p {
margin: 0.5em 0;
}
</style>
</head>
<body>
<div class="preface">
<h1>TrACreport</h1>
<h2>Report period: 09/06/2025-09/07/2025</h2>
<p>Total reports received for each vehicle in the West fleet</p>
<p>158 cabs are not recorded as cab cooling is disabled for Guards</p>
</div>
<div class="train-container">
<h1 class="unit-number">158747</h1>
<div class="vehicle-container">
<div class="coach">
<div class="vehicle-number">57747</div>
<div class="vehicle-box">
<div class="cab-marker">0</div>
<div class="report-count">2</div>
</div>
</div>
<div class="coach">
<div class="vehicle-number">51747</div>
<div class="vehicle-box">
<div class="report-count">4</div>
</div>
</div>
<div class="coach">
<div class="vehicle-number">52747</div>
<div class="vehicle-box">
<div class="report-count">5</div>
<div class="cab-marker">0</div>
</div>
</div>
</div>
<div class="comments-box">
<h2>Comments</h2>
<p>Why bother reporting?</p>
<p>Blue button in cab not working</p>
</div>
</div>
</body>
</html>

View File

@ -1,4 +1,4 @@
import { GroupedUnitReport, VehicleReport } from "./reports";
import { GroupedUnitReport, VehicleReport, Comments } from "./reports";
export function generateHTMLReport(units: GroupedUnitReport[], startDate: Date, endDate: Date, totalReports: number): string {
const unitsWithReports = units.filter(unit =>
@ -44,17 +44,28 @@ function renderCoach(vehicle: VehicleReport, index: number, total: number): stri
`;
}
function renderComments(comments: string[]): string {
const renderedComments = comments.length
? comments.map(c => `<p>${c}</p>`).join('\n')
: '<p>No comments submitted.</p>';
function renderComments(comments: Comments[]): string {
const formatDate = (date: Date): string =>
new Intl.DateTimeFormat('en-GB', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(date);
const renderedComments = comments.length
? comments.map(c =>
`<p>Reported: <strong>${c.reportedBy}</strong> on: <strong>${formatDate(c.timestamp)}</strong><br>${c.comment}</p>`
).join('\n')
: '<p>No comment</p>';
return `
<div class="comments-box">
<h2>Comments</h2>
${renderedComments}
<h2>Comments</h2>
${renderedComments}
</div>
`;
`;
}
function wrapHtml(content: string, startDate: Date, endDate: Date, totalReports: number): string {
@ -167,6 +178,7 @@ function wrapHtml(content: string, startDate: Date, endDate: Date, totalReports:
<div class="preface">
<h1>TrACreport</h1>
<h2>Report period: ${formatDate(startDate)} - ${formatDate(endDate)}</h2>
<p>All times are in Universal Time Coordinated</p>
<p class="bold">Total reports received: ${totalReports}</p>
<p>Reports are shown per saloon or cab</p>
<p>158 cabs are not recorded as cab cooling is disabled for Guards</p>

View File

@ -1,7 +1,8 @@
import { generateHTMLReport } from "./reportHtml.js";
import { writeFile } from "fs/promises";
import { fetchReportsInRange } from "../models/report.js";
interface BaseReport {
export interface BaseReport {
unitNumber: string;
reported: string;
comments?: string;
@ -17,7 +18,13 @@ interface Fault {
export interface GroupedUnitReport {
unitNumber: string;
vehicles: VehicleReport[];
comments: string[];
comments: Comments[];
}
export interface Comments {
timestamp: Date;
reportedBy: string;
comment: string;
}
export interface VehicleReport {
@ -82,7 +89,11 @@ function populateFaultReports(
const unit = unitMap.get(report.unitNumber);
if (!unit) continue;
if (report.comments) unit.comments.push(report.comments);
unit.comments.push({
comment: report.comments ?? "",
timestamp: report.utcTimestamp,
reportedBy: report.reported,
});
for (const fault of report.faults) {
const vehicle = unit.vehicles.find(v => v.vehicleNumber === fault.coach);
@ -97,7 +108,17 @@ function populateFaultReports(
return grouped;
}
export async function GenerateReportByDate(start: Date, end: Date): Promise<string> {
const unitLayout = await fetchUnitLayout();
const reports = await fetchReportsInRange(start, end);
const totalReports = reports.length;
const grouped = buildInitialReportStructure(unitLayout);
const finalReport = populateFaultReports(grouped, reports);
return generateHTMLReport(finalReport, start, end, totalReports);
}
/*
(async () => {
console.log('Fetching unit layout...');
const unitLayout = await fetchUnitLayout();

View File

@ -1,28 +1,66 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Generate Report</title>
</head>
<body>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>TrACreport</title>
<style>
body {
font-family: sans-serif;
max-width: 600px;
margin: 2rem auto;
text-align: center;
}
<h1>Generate A/C Fault Report</h1>
<p>This page is under development. For now, reports will be sent out through other means.</p>
<!--
<form action="/report/generate" method="GET">
<div>
<label for="start">Start Date</label>
<input type="date" id="start" name="start" required>
</div>
form {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
<div>
<label for="end">End Date</label>
<input type="date" id="end" name="end" required>
</div>
label {
font-weight: bold;
}
<button type="submit">Generate Report</button>
</form>
-->
</body>
input[type="date"],
input[type="password"],
button {
padding: 0.5rem;
font-size: 1rem;
}
</style>
</head>
<body>
<h1>TrACreport</h1>
<h2>Generate Fault Report</h2>
<form id="reportForm" action="/report" method="GET" target="_blank">
<div>
<label for="start">Start Date:</label>
<input type="date" id="start" name="start" required>
</div>
<div>
<label for="end">End Date:</label>
<input type="date" id="end" name="end" required>
</div>
<div>
<label for="password">Access Password:</label>
<input type="password" id="password" name="password" required>
</div>
<button type="submit">Generate Report</button>
</form>
<script>
// Default both fields to today's date
const today = new Date().toISOString().split('T')[0];
document.getElementById('start').value = today;
document.getElementById('end').value = today;
</script>
</body>
</html>

View File

@ -5,10 +5,203 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>TrACreport</title>
<link rel="stylesheet" href="/style.css">
<style>* {
box-sizing: border-box;
}
html {
width: 100%;
margin: 0;
padding: 0;
}
body {
width: 100vw;
padding: 0;
margin: 0;
text-align: center;
color: #f2f3f2;
background-color: #525252;
}
h1 {
padding-top: 20px;
padding-bottom: 20px;
margin:auto;
width: 90%;
}
#report-link {
position: fixed;
top: 10px;
left: 10px;
background: #007bff;
color: white;
padding: 0.5rem 1rem;
border-radius: 4px;
text-decoration: none;
font-weight: bold;
z-index: 9999;
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
transition: background-color 0.3s ease;
}
#report-link:hover {
background: #0056b3;
}
form {
width: 100vw;
padding: 0;
margin: 0;
margin-bottom: 25px;
}
#unitNumber {
width: 40vw;
font-size: larger;
border-radius: 4px;
text-align: center;
margin: 0;
}
h2 {
font-size: larger;
margin-top: 10px;
}
h3 {
font-size: large;
margin-bottom: 0;
}
#formExpansion {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
gap: 0.7rem 2rem;
}
#formExpansion h3 {
flex-basis: 100%;
margin-top: 1.0rem;
margin-bottom: 0rem;
font-weight: bold;
}
#formExpansion h3:first-of-type {
margin-top: 0rem;
}
.checkbox-wrapper {
display: flex;
flex-direction: column;
align-items: center;
font-size: 1rem;
cursor: pointer;
width: 5rem;
}
.checkbox-wrapper input[type="checkbox"] {
width: 1.8rem;
height: 1.8rem;
margin-top: 0rem;
cursor: pointer;
}
.checkbox-wrapper label {
text-transform: capitalize;
user-select: none;
}
#formHidden {
display: none;
width: 100vw;
margin: auto;
padding-top: 10px;
}
#radio-group {
width: 90vw;
margin:auto;
padding-top: 10px;
padding-bottom: 15px;
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.5rem;
}
#radio-group p {
font-weight: bold;
}
#radio-group label {
display: flex;
align-items: center;
font-size: 1rem;
padding: 0.25rem 0.75rem; /* top/bottom 0.25rem, left/right 0.75rem */
margin: 0;
cursor: pointer;
border: 1px solid #ccc;
border-radius: 6px;
background-color: #f9f9f9;
color:#525252;
}
#radio-group input[type="radio"] {
width: 1.5rem;
height: 1.5rem;
margin-right: 0.5rem;
}
#commentsLabel {
font-weight: bold;
}
p {
width: 90vw;
margin: auto;
}
button {
width: 60px;
height: 40px;
border-radius: 12px;
}
#formStatus {
display: none;
position: fixed;
top: 37.5vh;
left: 13vw;
width: 74vw;
height: 25vh;
text-align: center;
font-weight: bold;
border-radius: 25px;
justify-content: center;
align-items: center;
}
.status-success {
background-color: green;
color: whitesmoke;
}
.status-warn {
background-color: yellow;
color: black;
}</style>
</head>
<body>
<h1>Report an A/C Defect</h1>
<a href="/generate-report.html" id="report-link">R</a>
<form id="defectForm" action="POST">
<label for="unitNumber">Unit Number</label><br>
<input type="number" id="unitNumber" name="unitNumber" required>
@ -16,8 +209,8 @@
<!-- The contents of this DIV is inserted with JS -->
</div>
<div id="formHidden">
<h2>Have you reported this to maintenance?</h2>
<div id="radio-group">
<h2>Have you reported this to maintenance?</h2>
<label>
<input class="radio" type="radio" name="reported" value="via Defect Book" required>
via Defect Book
@ -30,6 +223,10 @@
<input class="radio" type="radio" name="reported" value="via Telephone">
via Telephone
</label>
<label>
<input class="radio" type="radio" name="reported" value="via Email">
via Email
</label>
<label>
<input class="radio" type="radio" name="reported" value="no">
No (Explain reason in comments)

View File

@ -1,6 +1,6 @@
let data = {}
const dataPromise = fetch("units.converted.json")
const dataPromise = fetch("/units.converted.json")
.then(res => {
if (!res.ok) throw new Error("Failed to load JSON");
return res.json();

View File

@ -1,165 +1,2 @@
* {
box-sizing: border-box;
}
html {
width: 100%;
margin: 0;
padding: 0;
}
body {
width: 100vw;
padding: 0;
margin: 0;
text-align: center;
color: #f2f3f2;
background-color: #525252;
}
h1 {
padding-top: 20px;
padding-bottom: 20px;
margin:auto;
width: 90%;
}
form {
width: 100vw;
padding: 0;
margin: 0;
margin-bottom: 25px;
}
#unitNumber {
width: 40vw;
font-size: larger;
border-radius: 4px;
text-align: center;
margin: 0;
}
h2 {
font-size: larger;
}
h3 {
font-size: large;
margin-bottom: 0;
}
#formExpansion {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
gap: 0.7rem 2rem;
}
#formExpansion h3 {
flex-basis: 100%;
margin-top: 1.0rem;
margin-bottom: 0rem;
font-weight: bold;
}
#formExpansion h3:first-of-type {
margin-top: 0rem;
}
.checkbox-wrapper {
display: flex;
flex-direction: column;
align-items: center;
font-size: 1rem;
cursor: pointer;
width: 5rem;
}
.checkbox-wrapper input[type="checkbox"] {
width: 1.8rem;
height: 1.8rem;
margin-top: 0rem;
cursor: pointer;
}
.checkbox-wrapper label {
text-transform: capitalize;
user-select: none;
}
#formHidden {
display: none;
width: 100vw;
margin: auto;
padding-top: 10px;
}
#radio-group {
width: 90vw;
margin:auto;
padding-top: 10px;
padding-bottom: 10px;
}
#radio-group p {
font-weight: bold;
}
#radio-group label {
display: flex;
align-items: center;
font-size: 1rem;
padding: 0.25rem 0.75rem; /* top/bottom 0.25rem, left/right 0.75rem */
margin: 0;
cursor: pointer;
}
#radio-group input[type="radio"] {
width: 1.5rem;
height: 1.5rem;
margin-right: 0.5rem;
}
#commentsLabel {
font-weight: bold;
}
p {
width: 90vw;
margin: auto;
}
button {
width: 60px;
height: 40px;
border-radius: 12px;
}
#formStatus {
display: none;
position: fixed;
top: 37.5vh;
left: 13vw;
width: 74vw;
height: 25vh;
text-align: center;
font-weight: bold;
border-radius: 25px;
justify-content: center;
align-items: center;
}
.status-success {
background-color: green;
color: whitesmoke;
}
.status-warn {
background-color: yellow;
color: black;
}

View File

@ -1,14 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2020",
"module": "ESNext",
"moduleResolution": "node",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"removeComments": true,
"resolveJsonModule": true
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true
},
"include": ["src/**/*"]
}