diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..437b554
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,22 @@
+# ── Server ──────────────────────────────────────────────────────────────────
+PORT=3000
+
+# ── Email Notifications (SMTP) ───────────────────────────────────────────────
+# Leave blank to disable email alerts
+SMTP_HOST=smtp.example.com
+SMTP_PORT=587
+SMTP_SECURE=false
+SMTP_USER=you@example.com
+SMTP_PASS=yourpassword
+SMTP_FROM=alerts@example.com
+# Comma-separated list of recipient addresses
+NOTIFY_EMAIL=team@example.com
+
+# ── Slack Notifications ──────────────────────────────────────────────────────
+# Leave blank to disable Slack alerts
+# Create an Incoming Webhook at https://api.slack.com/messaging/webhooks
+SLACK_WEBHOOK_URL=
+
+# ── Generic Webhook ──────────────────────────────────────────────────────────
+# Leave blank to disable. POST payload: { event, timestamp, venues[] }
+WEBHOOK_URL=
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..9331324
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+node_modules/
+.env
+*.log
+db/
diff --git a/README.md b/README.md
index fe4178b..60211c0 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,94 @@
-# Web-tracker
\ No newline at end of file
+# Venue Carousel Tracker
+
+Monitors homepage carousel tiles across all O2 Academy venues and alerts the team when tiles are outdated or expiring soon.
+
+---
+
+## Requirements
+
+- [Node.js](https://nodejs.org) v18 or higher
+- npm (included with Node.js)
+
+---
+
+## Option 1 — Run Locally
+
+**1. Clone the repo**
+```bash
+git clone https://github.com/Lmhgn/Web-tracker.git
+cd Web-tracker
+```
+
+**2. Install dependencies**
+```bash
+npm install
+```
+
+**3. Start the server**
+```bash
+npm start
+```
+
+**4. Open your browser**
+```
+http://localhost:3000
+```
+
+On first run the server automatically creates the database and a default admin account:
+- **Username:** `admin`
+- **Password:** `admin`
+
+> Change your password after first login via the account menu.
+
+---
+
+## Option 2 — Deploy to Railway (free, no terminal needed)
+
+[Railway](https://railway.app) connects directly to your GitHub repo and hosts the server for you.
+
+**1.** Go to [railway.app](https://railway.app) and sign in with GitHub
+
+**2.** Click **New Project → Deploy from GitHub repo**
+
+**3.** Select **Lmhgn/Web-tracker** from the list
+
+**4.** Railway detects it as a Node.js app and deploys automatically
+
+**5.** Click the generated URL (e.g. `https://web-tracker-xxxx.up.railway.app`) to open the app
+
+Default login is the same: `admin` / `admin`
+
+> Railway gives 500 free hours/month — enough to run this continuously.
+
+---
+
+## Environment Variables (optional)
+
+Create a `.env` file in the project root to configure email alerts:
+
+```
+SMTP_HOST=smtp.example.com
+SMTP_PORT=587
+SMTP_USER=you@example.com
+SMTP_PASS=yourpassword
+ALERT_TO=team@example.com
+SESSION_SECRET=change-this-to-a-random-string
+```
+
+On Railway, set these under **Project → Variables** instead of a `.env` file.
+
+---
+
+## Status Indicators
+
+| Colour | Meaning |
+|---|---|
+| Red — Outdated | Event date has passed — tile needs replacing immediately |
+| Orange — Expiring Soon | Event is within the warning threshold (default 7 days) |
+| Green — OK | Event date is comfortably in the future |
+
+---
+
+## Venues Tracked
+
+All 20 O2 Academy venues plus Edinburgh Corn Exchange.
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..128cb49
--- /dev/null
+++ b/index.html
@@ -0,0 +1,1813 @@
+
+
+
+
+
+ Venue Carousel Tracker
+
+
+
+
+
+
+
+
+
Venue Carousel Tracker
+
Sign in to continue
+
+
+
+
+ Username
+
+
+
+ Password
+
+
+
+ Sign In
+
+
+
+
+
+
+
+
+
+
+
+
+
+
— Outdated
+
— Expiring Soon
+
— Up to Date
+
— Total Events
+
— Venues
+
+
+
+
+
+
+
+
+
+
+
+ All
+ Outdated
+ Expiring Soon
+ Up to Date
+ Errors
+
+
+
+
+
+
+ Cards
+ Events
+ Catalogue
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Settings
+
+ Configure the warning threshold and auto-refresh schedule. Settings are saved in your browser.
+
+
+
+ Auto-refresh interval
+
+ Off
+ Every 15 minutes
+ Every 30 minutes
+ Every hour
+ Every 2 hours
+
+
+
+
+
Warning threshold — flag tiles expiring within
+
+
days (default: 7). Email/Slack notifications are configured via server .env
+
+
+
+ Cancel
+ Save
+
+
+
+
+
+
+
+
User Management
+
Manage who can access this tool.
+
+
+ Username Role Last Login Actions
+ Loading…
+
+
+
+
Add User
+
+
+
+
+ User
+ Admin
+
+
+
+
Add User
+
+
+
+ Close
+
+
+
+
+
+
+
+
Change Password
+
+
+ Current Password
+
+
+
+ New Password
+
+
+
+ Confirm New Password
+
+
+
+ Cancel
+ Save
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/nixpacks.toml b/nixpacks.toml
new file mode 100644
index 0000000..76c16b8
--- /dev/null
+++ b/nixpacks.toml
@@ -0,0 +1,8 @@
+[phases.setup]
+nixPkgs = ["nodejs_20", "python3", "gcc", "gnumake"]
+
+[phases.install]
+cmds = ["npm ci"]
+
+[start]
+cmd = "npm start"
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..db89593
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,1778 @@
+{
+ "name": "venue-carousel-tracker",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "venue-carousel-tracker",
+ "version": "1.0.0",
+ "dependencies": {
+ "axios": "^1.6.0",
+ "bcryptjs": "^3.0.3",
+ "better-sqlite3": "^12.6.2",
+ "cheerio": "^1.0.0",
+ "dotenv": "^16.3.1",
+ "express": "^4.18.2",
+ "express-session": "^1.19.0",
+ "node-cron": "^3.0.3",
+ "nodemailer": "^6.9.7"
+ }
+ },
+ "node_modules/accepts": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
+ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "~2.1.34",
+ "negotiator": "0.6.3"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/array-flatten": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+ "license": "MIT"
+ },
+ "node_modules/asynckit": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+ "license": "MIT"
+ },
+ "node_modules/axios": {
+ "version": "1.13.6",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
+ "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
+ "license": "MIT",
+ "dependencies": {
+ "follow-redirects": "^1.15.11",
+ "form-data": "^4.0.5",
+ "proxy-from-env": "^1.1.0"
+ }
+ },
+ "node_modules/base64-js": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/bcryptjs": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
+ "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
+ "license": "BSD-3-Clause",
+ "bin": {
+ "bcrypt": "bin/bcrypt"
+ }
+ },
+ "node_modules/better-sqlite3": {
+ "version": "12.6.2",
+ "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.2.tgz",
+ "integrity": "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "bindings": "^1.5.0",
+ "prebuild-install": "^7.1.1"
+ },
+ "engines": {
+ "node": "20.x || 22.x || 23.x || 24.x || 25.x"
+ }
+ },
+ "node_modules/bindings": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
+ "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
+ "license": "MIT",
+ "dependencies": {
+ "file-uri-to-path": "1.0.0"
+ }
+ },
+ "node_modules/bl": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
+ "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer": "^5.5.0",
+ "inherits": "^2.0.4",
+ "readable-stream": "^3.4.0"
+ }
+ },
+ "node_modules/body-parser": {
+ "version": "1.20.4",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
+ "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "content-type": "~1.0.5",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "~1.2.0",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "on-finished": "~2.4.1",
+ "qs": "~6.14.0",
+ "raw-body": "~2.5.3",
+ "type-is": "~1.6.18",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/body-parser/node_modules/iconv-lite": {
+ "version": "0.4.24",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/boolbase": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
+ "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
+ "license": "ISC"
+ },
+ "node_modules/buffer": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
+ "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "^1.3.1",
+ "ieee754": "^1.1.13"
+ }
+ },
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/cheerio": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz",
+ "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==",
+ "license": "MIT",
+ "dependencies": {
+ "cheerio-select": "^2.1.0",
+ "dom-serializer": "^2.0.0",
+ "domhandler": "^5.0.3",
+ "domutils": "^3.2.2",
+ "encoding-sniffer": "^0.2.1",
+ "htmlparser2": "^10.1.0",
+ "parse5": "^7.3.0",
+ "parse5-htmlparser2-tree-adapter": "^7.1.0",
+ "parse5-parser-stream": "^7.1.2",
+ "undici": "^7.19.0",
+ "whatwg-mimetype": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=20.18.1"
+ },
+ "funding": {
+ "url": "https://github.com/cheeriojs/cheerio?sponsor=1"
+ }
+ },
+ "node_modules/cheerio-select": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz",
+ "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "boolbase": "^1.0.0",
+ "css-select": "^5.1.0",
+ "css-what": "^6.1.0",
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3",
+ "domutils": "^3.0.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
+ "node_modules/chownr": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
+ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
+ "license": "ISC"
+ },
+ "node_modules/combined-stream": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "license": "MIT",
+ "dependencies": {
+ "delayed-stream": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/content-disposition": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
+ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "5.2.1"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
+ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
+ "license": "MIT"
+ },
+ "node_modules/css-select": {
+ "version": "5.2.2",
+ "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
+ "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "boolbase": "^1.0.0",
+ "css-what": "^6.1.0",
+ "domhandler": "^5.0.2",
+ "domutils": "^3.0.1",
+ "nth-check": "^2.0.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
+ "node_modules/css-what": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
+ "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">= 6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
+ "node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/decompress-response": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
+ "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
+ "license": "MIT",
+ "dependencies": {
+ "mimic-response": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/deep-extend": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
+ "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/delayed-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/destroy": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
+ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/dom-serializer": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
+ "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.2",
+ "entities": "^4.2.0"
+ },
+ "funding": {
+ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+ }
+ },
+ "node_modules/domelementtype": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
+ "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/domhandler": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
+ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "domelementtype": "^2.3.0"
+ },
+ "engines": {
+ "node": ">= 4"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domhandler?sponsor=1"
+ }
+ },
+ "node_modules/domutils": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
+ "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "dom-serializer": "^2.0.0",
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domutils?sponsor=1"
+ }
+ },
+ "node_modules/dotenv": {
+ "version": "16.6.1",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
+ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "license": "MIT"
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/encoding-sniffer": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz",
+ "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==",
+ "license": "MIT",
+ "dependencies": {
+ "iconv-lite": "^0.6.3",
+ "whatwg-encoding": "^3.1.1"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/encoding-sniffer?sponsor=1"
+ }
+ },
+ "node_modules/end-of-stream": {
+ "version": "1.4.5",
+ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
+ "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
+ "license": "MIT",
+ "dependencies": {
+ "once": "^1.4.0"
+ }
+ },
+ "node_modules/entities": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-set-tostringtag": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/expand-template": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
+ "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
+ "license": "(MIT OR WTFPL)",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/express": {
+ "version": "4.22.1",
+ "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
+ "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "~1.3.8",
+ "array-flatten": "1.1.1",
+ "body-parser": "~1.20.3",
+ "content-disposition": "~0.5.4",
+ "content-type": "~1.0.4",
+ "cookie": "~0.7.1",
+ "cookie-signature": "~1.0.6",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "finalhandler": "~1.3.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.0",
+ "merge-descriptors": "1.0.3",
+ "methods": "~1.1.2",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "path-to-regexp": "~0.1.12",
+ "proxy-addr": "~2.0.7",
+ "qs": "~6.14.0",
+ "range-parser": "~1.2.1",
+ "safe-buffer": "5.2.1",
+ "send": "~0.19.0",
+ "serve-static": "~1.16.2",
+ "setprototypeof": "1.2.0",
+ "statuses": "~2.0.1",
+ "type-is": "~1.6.18",
+ "utils-merge": "1.0.1",
+ "vary": "~1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/express-session": {
+ "version": "1.19.0",
+ "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz",
+ "integrity": "sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==",
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "~0.7.2",
+ "cookie-signature": "~1.0.7",
+ "debug": "~2.6.9",
+ "depd": "~2.0.0",
+ "on-headers": "~1.1.0",
+ "parseurl": "~1.3.3",
+ "safe-buffer": "~5.2.1",
+ "uid-safe": "~2.1.5"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/file-uri-to-path": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
+ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
+ "license": "MIT"
+ },
+ "node_modules/finalhandler": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
+ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "statuses": "~2.0.2",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/follow-redirects": {
+ "version": "1.15.11",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
+ "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/RubenVerborgh"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0"
+ },
+ "peerDependenciesMeta": {
+ "debug": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/form-data": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
+ "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
+ "license": "MIT",
+ "dependencies": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "es-set-tostringtag": "^2.1.0",
+ "hasown": "^2.0.2",
+ "mime-types": "^2.1.12"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fs-constants": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
+ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
+ "license": "MIT"
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/github-from-package": {
+ "version": "0.0.0",
+ "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
+ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
+ "license": "MIT"
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-tostringtag": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+ "license": "MIT",
+ "dependencies": {
+ "has-symbols": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+ "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/htmlparser2": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
+ "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
+ "funding": [
+ "https://github.com/fb55/htmlparser2?sponsor=1",
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3",
+ "domutils": "^3.2.2",
+ "entities": "^7.0.1"
+ }
+ },
+ "node_modules/htmlparser2/node_modules/entities": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/ieee754": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/ini": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
+ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
+ "license": "ISC"
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/methods": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+ "license": "MIT",
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mimic-response": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
+ "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/minimist": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
+ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/mkdirp-classic": {
+ "version": "0.5.3",
+ "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
+ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
+ "license": "MIT"
+ },
+ "node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "license": "MIT"
+ },
+ "node_modules/napi-build-utils": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
+ "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
+ "license": "MIT"
+ },
+ "node_modules/negotiator": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/node-abi": {
+ "version": "3.87.0",
+ "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz",
+ "integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==",
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.3.5"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/node-cron": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz",
+ "integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==",
+ "license": "ISC",
+ "dependencies": {
+ "uuid": "8.3.2"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/nodemailer": {
+ "version": "6.10.1",
+ "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
+ "integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==",
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/nth-check": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
+ "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "boolbase": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/nth-check?sponsor=1"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/on-headers": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
+ "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "license": "ISC",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/parse5": {
+ "version": "7.3.0",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
+ "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^6.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/parse5-htmlparser2-tree-adapter": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz",
+ "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==",
+ "license": "MIT",
+ "dependencies": {
+ "domhandler": "^5.0.3",
+ "parse5": "^7.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/parse5-parser-stream": {
+ "version": "7.1.2",
+ "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz",
+ "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==",
+ "license": "MIT",
+ "dependencies": {
+ "parse5": "^7.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/parse5/node_modules/entities": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
+ "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "0.1.12",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
+ "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
+ "license": "MIT"
+ },
+ "node_modules/prebuild-install": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
+ "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
+ "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
+ "license": "MIT",
+ "dependencies": {
+ "detect-libc": "^2.0.0",
+ "expand-template": "^2.0.3",
+ "github-from-package": "0.0.0",
+ "minimist": "^1.2.3",
+ "mkdirp-classic": "^0.5.3",
+ "napi-build-utils": "^2.0.0",
+ "node-abi": "^3.3.0",
+ "pump": "^3.0.0",
+ "rc": "^1.2.7",
+ "simple-get": "^4.0.0",
+ "tar-fs": "^2.0.0",
+ "tunnel-agent": "^0.6.0"
+ },
+ "bin": {
+ "prebuild-install": "bin.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/proxy-from-env": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
+ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
+ "license": "MIT"
+ },
+ "node_modules/pump": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
+ "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
+ "license": "MIT",
+ "dependencies": {
+ "end-of-stream": "^1.1.0",
+ "once": "^1.3.1"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.14.2",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
+ "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/random-bytes": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
+ "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "2.5.3",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
+ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/raw-body/node_modules/iconv-lite": {
+ "version": "0.4.24",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rc": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
+ "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
+ "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
+ "dependencies": {
+ "deep-extend": "^0.6.0",
+ "ini": "~1.3.0",
+ "minimist": "^1.2.0",
+ "strip-json-comments": "~2.0.1"
+ },
+ "bin": {
+ "rc": "cli.js"
+ }
+ },
+ "node_modules/readable-stream": {
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+ "license": "MIT",
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "7.7.4",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
+ "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/send": {
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
+ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "1.2.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.1",
+ "mime": "1.6.0",
+ "ms": "2.1.3",
+ "on-finished": "~2.4.1",
+ "range-parser": "~1.2.1",
+ "statuses": "~2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/send/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/serve-static": {
+ "version": "1.16.3",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
+ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "parseurl": "~1.3.3",
+ "send": "~0.19.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "license": "ISC"
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+ "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3",
+ "side-channel-list": "^1.0.0",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
+ "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/simple-concat": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
+ "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/simple-get": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
+ "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "decompress-response": "^6.0.0",
+ "once": "^1.3.1",
+ "simple-concat": "^1.0.0"
+ }
+ },
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/string_decoder": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.2.0"
+ }
+ },
+ "node_modules/strip-json-comments": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
+ "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/tar-fs": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
+ "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "chownr": "^1.1.1",
+ "mkdirp-classic": "^0.5.2",
+ "pump": "^3.0.0",
+ "tar-stream": "^2.1.4"
+ }
+ },
+ "node_modules/tar-stream": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
+ "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "bl": "^4.0.3",
+ "end-of-stream": "^1.4.1",
+ "fs-constants": "^1.0.0",
+ "inherits": "^2.0.3",
+ "readable-stream": "^3.1.1"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/tunnel-agent": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
+ "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/type-is": {
+ "version": "1.6.18",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+ "license": "MIT",
+ "dependencies": {
+ "media-typer": "0.3.0",
+ "mime-types": "~2.1.24"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/uid-safe": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
+ "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==",
+ "license": "MIT",
+ "dependencies": {
+ "random-bytes": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/undici": {
+ "version": "7.22.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz",
+ "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.18.1"
+ }
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "license": "MIT"
+ },
+ "node_modules/utils-merge": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
+ "node_modules/uuid": {
+ "version": "8.3.2",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
+ "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
+ "license": "MIT",
+ "bin": {
+ "uuid": "dist/bin/uuid"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/whatwg-encoding": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
+ "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
+ "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
+ "license": "MIT",
+ "dependencies": {
+ "iconv-lite": "0.6.3"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
+ "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "license": "ISC"
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..d546b93
--- /dev/null
+++ b/package.json
@@ -0,0 +1,24 @@
+{
+ "name": "venue-carousel-tracker",
+ "version": "1.0.0",
+ "description": "Tracks homepage carousel tiles across O2 Academy venues and alerts when they are outdated",
+ "main": "server.js",
+ "scripts": {
+ "start": "node server.js",
+ "dev": "npx nodemon server.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "dependencies": {
+ "axios": "^1.6.0",
+ "bcryptjs": "^3.0.3",
+ "better-sqlite3": "^12.6.2",
+ "cheerio": "^1.0.0",
+ "dotenv": "^16.3.1",
+ "express": "^4.18.2",
+ "express-session": "^1.19.0",
+ "node-cron": "^3.0.3",
+ "nodemailer": "^6.9.7"
+ }
+}
diff --git a/public/app.js b/public/app.js
new file mode 100644
index 0000000..cc61440
--- /dev/null
+++ b/public/app.js
@@ -0,0 +1,230 @@
+/* ── Venue Carousel Tracker — Frontend ── */
+
+let allVenueData = {};
+let currentFilter = 'all';
+let notifyPermission = Notification.permission;
+let alertedOutdated = new Set(); // track which have already triggered a notification this session
+
+// ── Utilities ──────────────────────────────────────────────────────────────
+
+function daysRelative(isoDate) {
+ if (!isoDate) return null;
+ const today = new Date();
+ today.setHours(0, 0, 0, 0);
+ const d = new Date(isoDate);
+ d.setHours(0, 0, 0, 0);
+ return Math.floor((d - today) / 86400000);
+}
+
+function formatRelative(isoDate) {
+ const days = daysRelative(isoDate);
+ if (days === null) return '';
+ if (days === 0) return 'Today';
+ if (days === 1) return 'Tomorrow';
+ if (days === -1) return '1 day ago';
+ if (days < 0) return `${Math.abs(days)}d ago`;
+ if (days <= 7) return `${days}d away`;
+ return '';
+}
+
+function formatLastChecked(iso) {
+ if (!iso) return '—';
+ const d = new Date(iso);
+ return d.toLocaleString('en-GB', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
+}
+
+function statusLabel(status) {
+ return { outdated: 'Outdated', expiring_soon: 'Expiring Soon', ok: 'Up to Date', error: 'Error', unknown: 'Unknown' }[status] || status;
+}
+
+// ── Render ─────────────────────────────────────────────────────────────────
+
+function renderSummary(venues) {
+ const arr = Object.values(venues);
+ const outdated = arr.filter(v => v.overallStatus === 'outdated').length;
+ const expiring = arr.filter(v => v.overallStatus === 'expiring_soon').length;
+ const ok = arr.filter(v => v.overallStatus === 'ok').length;
+ document.getElementById('count-outdated').textContent = outdated;
+ document.getElementById('count-expiring').textContent = expiring;
+ document.getElementById('count-ok').textContent = ok;
+ document.getElementById('count-total').textContent = arr.length;
+}
+
+function buildTileHTML(tile) {
+ const rel = formatRelative(tile.date);
+ return `
+
+
+
+
${esc(tile.artist)}
+
${esc(tile.dateStr)}
+
+ ${rel ? `
${rel} ` : ''}
+
`;
+}
+
+function buildCardHTML(venue) {
+ const tilesHTML = venue.tiles.length
+ ? venue.tiles.map(buildTileHTML).join('')
+ : 'No carousel tiles found.
';
+
+ const errorHTML = venue.error
+ ? `
+
+ ${esc(venue.error)}
+
` : '';
+
+ return `
+
+
+
+ ${errorHTML || tilesHTML}
+
+
+ `;
+}
+
+function renderVenues(venues) {
+ const grid = document.getElementById('venue-grid');
+ const arr = Object.values(venues);
+
+ const filtered = currentFilter === 'all'
+ ? arr
+ : arr.filter(v => v.overallStatus === currentFilter);
+
+ if (filtered.length === 0) {
+ grid.innerHTML = `No venues match this filter.
`;
+ return;
+ }
+
+ // Sort: outdated first, then expiring, then ok, then error
+ const order = { outdated: 0, expiring_soon: 1, ok: 2, unknown: 3, error: 4 };
+ filtered.sort((a, b) => (order[a.overallStatus] ?? 9) - (order[b.overallStatus] ?? 9));
+
+ grid.innerHTML = filtered.map(buildCardHTML).join('');
+}
+
+function esc(str) {
+ return String(str).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"');
+}
+
+// ── Browser Notifications ──────────────────────────────────────────────────
+
+async function requestNotificationPermission() {
+ if (!('Notification' in window)) return;
+ if (Notification.permission === 'granted') {
+ notifyPermission = 'granted';
+ updateNotifyBtn();
+ return;
+ }
+ const perm = await Notification.requestPermission();
+ notifyPermission = perm;
+ updateNotifyBtn();
+}
+
+function updateNotifyBtn() {
+ const btn = document.getElementById('notify-btn');
+ if (notifyPermission === 'granted') {
+ btn.textContent = 'Alerts On';
+ btn.style.background = 'rgba(22,163,74,.3)';
+ }
+}
+
+function triggerBrowserNotifications(venues) {
+ if (notifyPermission !== 'granted') return;
+ const outdated = Object.values(venues).filter(v => v.overallStatus === 'outdated' && !alertedOutdated.has(v.name));
+ outdated.forEach(v => {
+ alertedOutdated.add(v.name);
+ const outdatedTiles = v.tiles.filter(t => t.status === 'outdated');
+ new Notification(`Outdated tiles: ${v.name}`, {
+ body: outdatedTiles.map(t => `${t.artist} — ${t.dateStr}`).join('\n'),
+ icon: '/favicon.ico',
+ tag: v.name,
+ });
+ });
+}
+
+// ── SSE Connection ─────────────────────────────────────────────────────────
+
+function connectSSE() {
+ const evtSource = new EventSource('/api/events');
+
+ evtSource.onmessage = (e) => {
+ const msg = JSON.parse(e.data);
+ if (msg.type === 'scraping_started') {
+ document.getElementById('scraping-banner').classList.remove('hidden');
+ document.getElementById('refresh-btn').disabled = true;
+ } else if (msg.type === 'update') {
+ document.getElementById('scraping-banner').classList.add('hidden');
+ document.getElementById('refresh-btn').disabled = false;
+ document.getElementById('last-checked').textContent = formatLastChecked(msg.lastChecked);
+ allVenueData = msg.data || {};
+ renderSummary(allVenueData);
+ renderVenues(allVenueData);
+ triggerBrowserNotifications(allVenueData);
+ }
+ };
+
+ evtSource.onerror = () => {
+ // Reconnect after 5 seconds
+ evtSource.close();
+ setTimeout(connectSSE, 5000);
+ };
+}
+
+// ── Filter Buttons ─────────────────────────────────────────────────────────
+
+document.querySelectorAll('.filter-btn').forEach(btn => {
+ btn.addEventListener('click', () => {
+ document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
+ btn.classList.add('active');
+ currentFilter = btn.dataset.filter;
+ renderVenues(allVenueData);
+ });
+});
+
+// ── Refresh Button ─────────────────────────────────────────────────────────
+
+document.getElementById('refresh-btn').addEventListener('click', async () => {
+ document.getElementById('refresh-btn').disabled = true;
+ document.getElementById('scraping-banner').classList.remove('hidden');
+ try {
+ await fetch('/api/refresh', { method: 'POST' });
+ } catch (err) {
+ document.getElementById('scraping-banner').classList.add('hidden');
+ document.getElementById('refresh-btn').disabled = false;
+ }
+});
+
+// ── Notification Button ────────────────────────────────────────────────────
+
+document.getElementById('notify-btn').addEventListener('click', requestNotificationPermission);
+
+// ── Settings Panel ─────────────────────────────────────────────────────────
+
+document.getElementById('open-settings').addEventListener('click', () => {
+ document.getElementById('settings-panel').classList.remove('hidden');
+});
+
+document.getElementById('close-settings').addEventListener('click', () => {
+ document.getElementById('settings-panel').classList.add('hidden');
+});
+
+document.getElementById('settings-panel').addEventListener('click', (e) => {
+ if (e.target === document.getElementById('settings-panel')) {
+ document.getElementById('settings-panel').classList.add('hidden');
+ }
+});
+
+// ── Init ───────────────────────────────────────────────────────────────────
+
+updateNotifyBtn();
+connectSSE();
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..18df21f
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,121 @@
+
+
+
+
+
+ Venue Carousel Tracker
+
+
+
+
+
+
+
+
+
+
+ —
+ Outdated
+
+
+ —
+ Expiring Soon
+
+
+ —
+ Up to Date
+
+
+ —
+ Venues
+
+
+
+
+
+
+ Scanning all venues, this may take a minute…
+
+
+
+
+ All
+ Outdated
+ Expiring Soon
+ Up to Date
+ Errors
+
+
+
+
+
+
+
Loading venue data…
+
+
+
+
+
+
+
Notification Settings
+
+ Configure how the server sends alerts when carousel tiles become outdated.
+ Update your .env file and restart the server to apply.
+
+
+
+
Email (SMTP)
+
+SMTP_HOST=smtp.example.com
+SMTP_PORT=587
+SMTP_SECURE=false
+SMTP_USER=you@example.com
+SMTP_PASS=yourpassword
+SMTP_FROM=alerts@example.com
+NOTIFY_EMAIL=team@example.com
+
+
+
+
Slack
+
+SLACK_WEBHOOK_URL=https://hooks.slack.com/services/…
+
+ Generic Webhook
+
+WEBHOOK_URL=https://your-server.com/webhook
+
+
+
+
Close
+
+
+
+
+
+
+
+
+
diff --git a/public/style.css b/public/style.css
new file mode 100644
index 0000000..a36e533
--- /dev/null
+++ b/public/style.css
@@ -0,0 +1,452 @@
+/* ── Reset & Variables ── */
+*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+
+:root {
+ --red: #dc2626;
+ --red-light: #fef2f2;
+ --red-border: #fca5a5;
+ --orange: #d97706;
+ --orange-light:#fffbeb;
+ --orange-border:#fcd34d;
+ --green: #16a34a;
+ --green-light:#f0fdf4;
+ --green-border:#86efac;
+ --gray: #6b7280;
+ --gray-light: #f9fafb;
+ --gray-border:#e5e7eb;
+ --bg: #f1f5f9;
+ --surface: #ffffff;
+ --text: #111827;
+ --text-muted: #6b7280;
+ --border: #e2e8f0;
+ --radius: 12px;
+ --shadow: 0 1px 3px rgba(0,0,0,.08), 0 1px 2px rgba(0,0,0,.06);
+ --shadow-md: 0 4px 6px rgba(0,0,0,.07), 0 2px 4px rgba(0,0,0,.06);
+}
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+ background: var(--bg);
+ color: var(--text);
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+}
+
+/* ── Header ── */
+.site-header {
+ background: #1e293b;
+ color: #fff;
+ padding: 1.25rem 2rem;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+ flex-wrap: wrap;
+}
+
+.site-header h1 {
+ font-size: 1.35rem;
+ font-weight: 700;
+ letter-spacing: -.02em;
+}
+
+.header-subtitle {
+ font-size: 0.8rem;
+ color: #94a3b8;
+ margin-top: 2px;
+}
+
+.header-right {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ flex-wrap: wrap;
+}
+
+.last-checked-wrap {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ font-size: 0.75rem;
+}
+
+.last-checked-wrap .label { color: #94a3b8; }
+.last-checked-time { color: #e2e8f0; font-weight: 500; }
+
+/* ── Buttons ── */
+.btn-primary, .btn-secondary {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4rem;
+ padding: 0.45rem 1rem;
+ border-radius: 8px;
+ font-size: 0.85rem;
+ font-weight: 600;
+ cursor: pointer;
+ border: none;
+ transition: background .15s, opacity .15s;
+}
+
+.btn-primary {
+ background: #3b82f6;
+ color: #fff;
+}
+.btn-primary:hover { background: #2563eb; }
+.btn-primary:disabled { opacity: .55; cursor: not-allowed; }
+
+.btn-secondary {
+ background: rgba(255,255,255,.12);
+ color: #e2e8f0;
+ border: 1px solid rgba(255,255,255,.18);
+}
+.btn-secondary:hover { background: rgba(255,255,255,.2); }
+
+/* ── Summary Bar ── */
+.summary-bar {
+ display: flex;
+ gap: 1rem;
+ padding: 1rem 2rem;
+ background: var(--surface);
+ border-bottom: 1px solid var(--border);
+ flex-wrap: wrap;
+}
+
+.summary-card {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 0.6rem 1.5rem;
+ border-radius: 10px;
+ border: 1.5px solid var(--gray-border);
+ background: var(--gray-light);
+ min-width: 100px;
+}
+
+.summary-count {
+ font-size: 1.8rem;
+ font-weight: 800;
+ line-height: 1;
+}
+
+.summary-label {
+ font-size: 0.72rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: .05em;
+ color: var(--text-muted);
+ margin-top: 4px;
+}
+
+.summary-outdated { border-color: var(--red-border); background: var(--red-light); }
+.summary-outdated .summary-count { color: var(--red); }
+
+.summary-expiring { border-color: var(--orange-border); background: var(--orange-light); }
+.summary-expiring .summary-count { color: var(--orange); }
+
+.summary-ok { border-color: var(--green-border); background: var(--green-light); }
+.summary-ok .summary-count { color: var(--green); }
+
+.summary-total .summary-count { color: #334155; }
+
+/* ── Scraping Banner ── */
+.scraping-banner {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 0.75rem 2rem;
+ background: #eff6ff;
+ border-bottom: 1px solid #bfdbfe;
+ color: #1e40af;
+ font-size: 0.875rem;
+ font-weight: 500;
+}
+
+/* ── Filter Bar ── */
+.filter-bar {
+ display: flex;
+ gap: 0.5rem;
+ padding: 1rem 2rem 0;
+ flex-wrap: wrap;
+}
+
+.filter-btn {
+ padding: 0.35rem 0.9rem;
+ border-radius: 20px;
+ border: 1.5px solid var(--border);
+ background: var(--surface);
+ font-size: 0.8rem;
+ font-weight: 600;
+ color: var(--text-muted);
+ cursor: pointer;
+ transition: all .15s;
+}
+
+.filter-btn:hover { border-color: #94a3b8; color: var(--text); }
+.filter-btn.active { background: #1e293b; color: #fff; border-color: #1e293b; }
+
+/* ── Venue Grid ── */
+.venue-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
+ gap: 1.25rem;
+ padding: 1.25rem 2rem 2rem;
+ flex: 1;
+}
+
+/* ── Venue Card ── */
+.venue-card {
+ background: var(--surface);
+ border-radius: var(--radius);
+ box-shadow: var(--shadow);
+ border-left: 4px solid var(--gray-border);
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ transition: box-shadow .2s;
+}
+
+.venue-card:hover { box-shadow: var(--shadow-md); }
+
+.venue-card.status-outdated { border-left-color: var(--red); }
+.venue-card.status-expiring_soon { border-left-color: var(--orange); }
+.venue-card.status-ok { border-left-color: var(--green); }
+.venue-card.status-error { border-left-color: var(--gray); }
+
+.venue-card-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ padding: 1rem 1.1rem 0.75rem;
+ gap: 0.5rem;
+}
+
+.venue-name {
+ font-size: 0.95rem;
+ font-weight: 700;
+ color: var(--text);
+ text-decoration: none;
+ line-height: 1.3;
+}
+
+.venue-name:hover { text-decoration: underline; color: #2563eb; }
+
+.venue-badge {
+ flex-shrink: 0;
+ font-size: 0.68rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: .06em;
+ padding: 0.2rem 0.55rem;
+ border-radius: 20px;
+}
+
+.badge-outdated { background: var(--red-light); color: var(--red); border: 1px solid var(--red-border); }
+.badge-expiring_soon{ background: var(--orange-light); color: var(--orange); border: 1px solid var(--orange-border); }
+.badge-ok { background: var(--green-light); color: var(--green); border: 1px solid var(--green-border); }
+.badge-error { background: var(--gray-light); color: var(--gray); border: 1px solid var(--gray-border); }
+.badge-unknown { background: var(--gray-light); color: var(--gray); border: 1px solid var(--gray-border); }
+
+/* ── Tile List ── */
+.tile-list {
+ padding: 0 1.1rem 1rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.4rem;
+ flex: 1;
+}
+
+.tile-item {
+ display: flex;
+ align-items: center;
+ gap: 0.6rem;
+ padding: 0.5rem 0.65rem;
+ border-radius: 8px;
+ background: #f8fafc;
+ border: 1px solid var(--border);
+}
+
+.tile-item.tile-outdated { background: var(--red-light); border-color: var(--red-border); }
+.tile-item.tile-expiring_soon{ background: var(--orange-light); border-color: var(--orange-border); }
+.tile-item.tile-ok { background: var(--green-light); border-color: var(--green-border); }
+
+.tile-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+
+.tile-outdated .tile-dot { background: var(--red); }
+.tile-expiring_soon .tile-dot { background: var(--orange); }
+.tile-ok .tile-dot { background: var(--green); }
+.tile-unknown .tile-dot { background: var(--gray); }
+
+.tile-info {
+ flex: 1;
+ min-width: 0;
+}
+
+.tile-artist {
+ font-size: 0.8rem;
+ font-weight: 600;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ color: var(--text);
+}
+
+.tile-date {
+ font-size: 0.72rem;
+ color: var(--text-muted);
+ margin-top: 1px;
+}
+
+.tile-outdated .tile-date { color: var(--red); font-weight: 600; }
+.tile-expiring_soon .tile-date{ color: var(--orange); font-weight: 600; }
+
+.tile-days-ago {
+ font-size: 0.68rem;
+ font-weight: 700;
+ flex-shrink: 0;
+ padding: 0.15rem 0.45rem;
+ border-radius: 4px;
+}
+
+.tile-outdated .tile-days-ago { background: var(--red); color: #fff; }
+.tile-expiring_soon .tile-days-ago { background: var(--orange); color: #fff; }
+.tile-ok .tile-days-ago { color: var(--green); }
+
+/* ── Error State ── */
+.venue-error {
+ font-size: 0.78rem;
+ color: var(--gray);
+ padding: 0.75rem 1.1rem 1rem;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+/* ── Card Footer ── */
+.venue-card-footer {
+ padding: 0.5rem 1.1rem 0.75rem;
+ border-top: 1px solid var(--border);
+ font-size: 0.72rem;
+ color: var(--text-muted);
+}
+
+/* ── Loading Placeholder ── */
+.loading-placeholder {
+ grid-column: 1 / -1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 1rem;
+ padding: 3rem;
+ color: var(--text-muted);
+}
+
+/* ── Spinner ── */
+.spinner {
+ width: 20px;
+ height: 20px;
+ border: 3px solid #e2e8f0;
+ border-top-color: #3b82f6;
+ border-radius: 50%;
+ animation: spin .7s linear infinite;
+ flex-shrink: 0;
+}
+
+.spinner.large { width: 36px; height: 36px; border-width: 4px; }
+
+@keyframes spin { to { transform: rotate(360deg); } }
+
+/* ── Settings Panel ── */
+.settings-panel {
+ position: fixed;
+ inset: 0;
+ background: rgba(0,0,0,.5);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 999;
+ padding: 1rem;
+}
+
+.settings-card {
+ background: var(--surface);
+ border-radius: var(--radius);
+ padding: 2rem;
+ max-width: 700px;
+ width: 100%;
+ box-shadow: 0 20px 60px rgba(0,0,0,.3);
+}
+
+.settings-card h2 { font-size: 1.2rem; margin-bottom: 0.5rem; }
+
+.settings-description {
+ font-size: 0.85rem;
+ color: var(--text-muted);
+ margin-bottom: 1.5rem;
+ line-height: 1.5;
+}
+
+.settings-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 1.5rem;
+ margin-bottom: 1.5rem;
+}
+
+.settings-section h3 {
+ font-size: 0.9rem;
+ margin-bottom: 0.5rem;
+ color: #334155;
+}
+
+.env-block {
+ display: block;
+ background: #1e293b;
+ color: #7dd3fc;
+ padding: 0.75rem 1rem;
+ border-radius: 8px;
+ font-size: 0.78rem;
+ line-height: 1.7;
+ white-space: pre;
+}
+
+/* ── Footer ── */
+.site-footer {
+ padding: 0.75rem 2rem;
+ border-top: 1px solid var(--border);
+ background: var(--surface);
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ font-size: 0.78rem;
+ color: var(--text-muted);
+}
+
+.settings-link {
+ background: none;
+ border: none;
+ color: #3b82f6;
+ font-size: 0.78rem;
+ cursor: pointer;
+ text-decoration: underline;
+}
+
+.settings-link:hover { color: #2563eb; }
+
+/* ── Utility ── */
+.hidden { display: none !important; }
+
+/* ── Responsive ── */
+@media (max-width: 600px) {
+ .site-header { padding: 1rem; }
+ .summary-bar { padding: 0.75rem 1rem; }
+ .filter-bar { padding: 0.75rem 1rem 0; }
+ .venue-grid { padding: 1rem; grid-template-columns: 1fr; }
+ .settings-grid { grid-template-columns: 1fr; }
+ .header-right { width: 100%; justify-content: flex-end; }
+}
diff --git a/railway.json b/railway.json
new file mode 100644
index 0000000..46bcfcf
--- /dev/null
+++ b/railway.json
@@ -0,0 +1,11 @@
+{
+ "$schema": "https://railway.app/railway.schema.json",
+ "build": {
+ "builder": "NIXPACKS"
+ },
+ "deploy": {
+ "startCommand": "npm start",
+ "restartPolicyType": "ON_FAILURE",
+ "restartPolicyMaxRetries": 10
+ }
+}
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..9d7662c
--- /dev/null
+++ b/server.js
@@ -0,0 +1,606 @@
+'use strict';
+
+require('dotenv').config();
+const express = require('express');
+const session = require('express-session');
+const bcrypt = require('bcryptjs');
+const Database = require('better-sqlite3');
+const axios = require('axios');
+const cheerio = require('cheerio');
+const cron = require('node-cron');
+const nodemailer = require('nodemailer');
+const path = require('path');
+const fs = require('fs');
+
+// ── Database ─────────────────────────────────────────────────────────────────
+
+const DB_DIR = path.join(__dirname, 'db');
+if (!fs.existsSync(DB_DIR)) fs.mkdirSync(DB_DIR, { recursive: true });
+
+const db = new Database(path.join(DB_DIR, 'tracker.db'));
+db.pragma('journal_mode = WAL');
+
+db.exec(`
+ CREATE TABLE IF NOT EXISTS users (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ username TEXT UNIQUE NOT NULL,
+ password_hash TEXT NOT NULL,
+ role TEXT NOT NULL DEFAULT 'user',
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ last_login TEXT
+ );
+
+ CREATE TABLE IF NOT EXISTS appearances (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ artist TEXT NOT NULL,
+ venue_name TEXT NOT NULL,
+ venue_url TEXT NOT NULL,
+ event_date TEXT,
+ date_str TEXT NOT NULL DEFAULT '',
+ first_seen_at TEXT NOT NULL,
+ last_seen_at TEXT NOT NULL,
+ scan_count INTEGER NOT NULL DEFAULT 1
+ );
+
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_appearances_unique
+ ON appearances(artist, venue_name, COALESCE(event_date, ''));
+`);
+
+// Seed default admin when the table is empty
+if (db.prepare('SELECT COUNT(*) AS n FROM users').get().n === 0) {
+ db.prepare("INSERT INTO users (username, password_hash, role) VALUES ('admin', ?, 'admin')")
+ .run(bcrypt.hashSync('admin', 10));
+ console.log('\x1b[33m[SETUP] Default admin created — username: admin / password: admin\x1b[0m');
+ console.log('\x1b[33m[SETUP] Please change this password after your first login.\x1b[0m');
+}
+
+// ── SQLite session store ──────────────────────────────────────────────────────
+// Persists sessions in SQLite so they survive process restarts on Railway.
+
+db.exec(`
+ CREATE TABLE IF NOT EXISTS sessions (
+ sid TEXT PRIMARY KEY,
+ data TEXT NOT NULL,
+ expires_at INTEGER NOT NULL
+ );
+`);
+
+// Purge expired sessions on startup
+db.prepare('DELETE FROM sessions WHERE expires_at < ?').run(Date.now());
+// Purge expired sessions every hour
+setInterval(() => {
+ db.prepare('DELETE FROM sessions WHERE expires_at < ?').run(Date.now());
+}, 3600 * 1000);
+
+const { EventEmitter } = require('events');
+
+class SQLiteStore extends EventEmitter {
+ constructor() { super(); this.ttl = 7 * 24 * 3600 * 1000; }
+ get(sid, cb) {
+ try {
+ const row = db.prepare('SELECT data, expires_at FROM sessions WHERE sid = ?').get(sid);
+ if (!row || row.expires_at < Date.now()) return cb(null, null);
+ cb(null, JSON.parse(row.data));
+ } catch (e) { cb(e); }
+ }
+ set(sid, session, cb) {
+ try {
+ const maxAge = (session.cookie && session.cookie.maxAge) ? session.cookie.maxAge : this.ttl;
+ const expires_at = Date.now() + maxAge;
+ db.prepare('INSERT OR REPLACE INTO sessions (sid, data, expires_at) VALUES (?, ?, ?)')
+ .run(sid, JSON.stringify(session), expires_at);
+ cb(null);
+ } catch (e) { cb(e); }
+ }
+ destroy(sid, cb) {
+ try { db.prepare('DELETE FROM sessions WHERE sid = ?').run(sid); cb(null); }
+ catch (e) { cb(e); }
+ }
+ touch(sid, session, cb) { this.set(sid, session, cb); }
+}
+
+// ── Express + session ────────────────────────────────────────────────────────
+
+const app = express();
+app.set('trust proxy', 1); // Railway terminates TLS at its edge proxy
+app.use(express.json({ limit: '2mb' }));
+app.use(session({
+ store: new SQLiteStore(),
+ secret: process.env.SESSION_SECRET || 'venue-tracker-change-me-in-production',
+ resave: false,
+ saveUninitialized: false,
+ cookie: { maxAge: 7 * 24 * 3600 * 1000, httpOnly: true, sameSite: 'lax' },
+}));
+
+// Serve the single-page app from the project root
+app.get('/', (req, res) => res.sendFile(path.join(__dirname, 'index.html')));
+
+// ── Auth middleware ──────────────────────────────────────────────────────────
+
+function requireAuth(req, res, next) {
+ if (req.session?.userId) return next();
+ res.status(401).json({ error: 'Not authenticated' });
+}
+
+function requireAdmin(req, res, next) {
+ if (req.session?.role === 'admin') return next();
+ res.status(403).json({ error: 'Admin only' });
+}
+
+// ── Auth routes ──────────────────────────────────────────────────────────────
+
+app.post('/api/auth/login', (req, res) => {
+ const { username, password } = req.body || {};
+ if (!username || !password)
+ return res.status(400).json({ error: 'Username and password required' });
+
+ const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username.trim());
+ if (!user || !bcrypt.compareSync(password, user.password_hash))
+ return res.status(401).json({ error: 'Invalid credentials' });
+
+ db.prepare("UPDATE users SET last_login = datetime('now') WHERE id = ?").run(user.id);
+ req.session.userId = user.id;
+ req.session.username = user.username;
+ req.session.role = user.role;
+ res.json({ username: user.username, role: user.role });
+});
+
+app.post('/api/auth/logout', (req, res) => {
+ req.session.destroy(() => res.json({ ok: true }));
+});
+
+app.get('/api/auth/me', requireAuth, (req, res) => {
+ res.json({ username: req.session.username, role: req.session.role });
+});
+
+app.post('/api/auth/change-password', requireAuth, (req, res) => {
+ const { currentPassword, newPassword } = req.body || {};
+ if (!currentPassword || !newPassword)
+ return res.status(400).json({ error: 'Both passwords required' });
+ if (newPassword.length < 6)
+ return res.status(400).json({ error: 'New password must be at least 6 characters' });
+
+ const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.session.userId);
+ if (!bcrypt.compareSync(currentPassword, user.password_hash))
+ return res.status(401).json({ error: 'Current password is incorrect' });
+
+ db.prepare('UPDATE users SET password_hash = ? WHERE id = ?')
+ .run(bcrypt.hashSync(newPassword, 10), user.id);
+ res.json({ ok: true });
+});
+
+// ── User management (admin only) ─────────────────────────────────────────────
+
+app.get('/api/users', requireAuth, requireAdmin, (req, res) => {
+ res.json(db.prepare(
+ 'SELECT id, username, role, created_at, last_login FROM users ORDER BY id'
+ ).all());
+});
+
+app.post('/api/users', requireAuth, requireAdmin, (req, res) => {
+ const { username, password, role = 'user' } = req.body || {};
+ if (!username || !password)
+ return res.status(400).json({ error: 'Username and password required' });
+ if (password.length < 6)
+ return res.status(400).json({ error: 'Password must be at least 6 characters' });
+ if (!['admin', 'user'].includes(role))
+ return res.status(400).json({ error: 'Role must be admin or user' });
+ try {
+ const r = db.prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)')
+ .run(username.trim(), bcrypt.hashSync(password, 10), role);
+ res.json({ id: r.lastInsertRowid, username: username.trim(), role });
+ } catch (e) {
+ if (e.message.includes('UNIQUE')) return res.status(409).json({ error: 'Username already exists' });
+ throw e;
+ }
+});
+
+app.patch('/api/users/:id/password', requireAuth, requireAdmin, (req, res) => {
+ const { newPassword } = req.body || {};
+ if (!newPassword || newPassword.length < 6)
+ return res.status(400).json({ error: 'Password must be at least 6 characters' });
+ db.prepare('UPDATE users SET password_hash = ? WHERE id = ?')
+ .run(bcrypt.hashSync(newPassword, 10), parseInt(req.params.id));
+ res.json({ ok: true });
+});
+
+app.delete('/api/users/:id', requireAuth, requireAdmin, (req, res) => {
+ const id = parseInt(req.params.id);
+ if (id === req.session.userId)
+ return res.status(400).json({ error: 'Cannot delete your own account' });
+ db.prepare('DELETE FROM users WHERE id = ?').run(id);
+ res.json({ ok: true });
+});
+
+// ── Catalogue routes ─────────────────────────────────────────────────────────
+
+// Summary — all artists with aggregate stats, optionally filtered
+app.get('/api/catalogue', requireAuth, (req, res) => {
+ const q = (req.query.q || '').trim().toLowerCase();
+ res.json(db.prepare(`
+ SELECT
+ artist,
+ COUNT(DISTINCT venue_name) AS venue_count,
+ COUNT(*) AS appearance_count,
+ MIN(first_seen_at) AS first_seen_at,
+ MAX(last_seen_at) AS last_seen_at,
+ SUM(scan_count) AS total_scans
+ FROM appearances
+ WHERE (@q = '' OR LOWER(artist) LIKE @pattern)
+ GROUP BY artist
+ ORDER BY MAX(last_seen_at) DESC
+ `).all({ q, pattern: q ? `%${q}%` : '%' }));
+});
+
+// Full appearance history for one artist
+app.get('/api/catalogue/artist/:artist', requireAuth, (req, res) => {
+ res.json(db.prepare(`
+ SELECT * FROM appearances
+ WHERE artist = ?
+ ORDER BY COALESCE(event_date, last_seen_at) ASC, venue_name ASC
+ `).all(decodeURIComponent(req.params.artist)));
+});
+
+// Upsert tile appearances after each scrape
+function logAppearances(venues) {
+ const now = new Date().toISOString();
+ const upsert = db.prepare(`
+ INSERT INTO appearances
+ (artist, venue_name, venue_url, event_date, date_str, first_seen_at, last_seen_at, scan_count)
+ VALUES (@artist, @venue_name, @venue_url, @event_date, @date_str, @now, @now, 1)
+ ON CONFLICT(artist, venue_name, COALESCE(event_date, ''))
+ DO UPDATE SET last_seen_at = @now, scan_count = scan_count + 1, date_str = @date_str
+ `);
+ db.transaction(() => {
+ for (const v of venues) {
+ for (const t of (v.tiles || [])) {
+ if (!t.artist) continue;
+ upsert.run({ artist: t.artist, venue_name: v.name, venue_url: v.url,
+ event_date: t.date || null, date_str: t.dateStr || '', now });
+ }
+ }
+ })();
+}
+
+// ── Venue list ────────────────────────────────────────────────────────────────
+
+const VENUES = [
+ { name: 'O2 Academy Birmingham', url: 'https://www.academymusicgroup.com/o2academybirmingham', type: 'amg' },
+ { name: 'O2 Academy Bournemouth', url: 'https://www.academymusicgroup.com/o2academybournemouth', type: 'amg' },
+ { name: 'O2 Academy Bristol', url: 'https://www.academymusicgroup.com/o2academybristol', type: 'amg' },
+ { name: 'O2 Academy Brixton', url: 'https://www.academymusicgroup.com/o2academybrixton', type: 'amg' },
+ { name: 'O2 Academy Glasgow', url: 'https://www.academymusicgroup.com/o2academyglasgow', type: 'amg' },
+ { name: 'O2 Academy Islington', url: 'https://www.academymusicgroup.com/o2academyislington', type: 'amg' },
+ { name: 'O2 Academy Leeds', url: 'https://www.academymusicgroup.com/o2academyleeds', type: 'amg' },
+ { name: 'O2 Academy Leicester', url: 'https://www.academymusicgroup.com/o2academyleicester', type: 'amg' },
+ { name: 'O2 Academy Liverpool', url: 'https://www.academymusicgroup.com/o2academyliverpool', type: 'amg' },
+ { name: 'O2 Academy Oxford', url: 'https://www.academymusicgroup.com/o2academyoxford', type: 'amg' },
+ { name: 'O2 Academy Sheffield', url: 'https://www.academymusicgroup.com/o2academysheffield', type: 'amg' },
+ { name: 'O2 Apollo Manchester', url: 'https://www.academymusicgroup.com/o2apollomanchester', type: 'amg' },
+ { name: 'O2 City Hall Newcastle', url: 'https://www.academymusicgroup.com/o2cityhallnewcastle', type: 'amg' },
+ { name: 'O2 Forum Kentish Town', url: 'https://www.academymusicgroup.com/o2forumkentishtown', type: 'amg' },
+ { name: 'O2 Guildhall Southampton', url: 'https://www.academymusicgroup.com/o2guildhallsouthampton', type: 'amg' },
+ { name: 'O2 Institute Birmingham', url: 'https://www.academymusicgroup.com/o2institutebirmingham', type: 'amg' },
+ { name: 'O2 Ritz Manchester', url: 'https://www.academymusicgroup.com/o2ritzmanchester', type: 'amg' },
+ { name: "O2 Shepherd's Bush Empire", url: 'https://www.academymusicgroup.com/o2shepherdsbushempire', type: 'amg' },
+ { name: 'O2 Victoria Warehouse Manchester', url: 'https://www.academymusicgroup.com/o2victoriawarehousemanchester', type: 'amg' },
+ { name: 'Edinburgh Corn Exchange', url: 'https://www.edinburghcornexchange.co.uk', type: 'ece' },
+];
+
+// ── Date helpers ─────────────────────────────────────────────────────────────
+
+const MONTH_MAP = {
+ january:0,february:1,march:2,april:3,may:4,june:5,
+ july:6,august:7,september:8,october:9,november:10,december:11,
+ jan:0,feb:1,mar:2,apr:3,may:4,jun:5,
+ jul:6,aug:7,sep:8,oct:9,nov:10,dec:11,
+};
+
+function parseEventDate(s) {
+ if (!s) return null;
+ let m = s.match(/(\d{1,2})\s+(January|February|March|April|May|June|July|August|September|October|November|December)\s+(\d{4})/i);
+ if (m) return new Date(+m[3], MONTH_MAP[m[2].toLowerCase()], +m[1]);
+ m = s.match(/(\d{1,2})\s+(JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC)\s+(\d{4})/i);
+ if (m) return new Date(+m[3], MONTH_MAP[m[2].toLowerCase()], +m[1]);
+ return null;
+}
+
+function getTileStatus(date, warnDays = parseInt(process.env.WARN_DAYS || '7')) {
+ if (!date) return 'unknown';
+ const today = new Date(); today.setHours(0, 0, 0, 0);
+ const diff = Math.floor((date - today) / 86400000);
+ if (diff < 0) return 'outdated';
+ if (diff <= warnDays) return 'expiring_soon';
+ return 'ok';
+}
+
+// ── Scrapers (server-side — no CORS proxies needed) ───────────────────────────
+
+const EXCLUDED_RE = /what[\u2018\u2019\u0027]s\s+new|coming\s+up/i;
+
+const HTTP_HEADERS = {
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36',
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
+ 'Accept-Language': 'en-GB,en;q=0.9',
+};
+
+function buildExcludedSet($) {
+ const excluded = new Set();
+ $('h1,h2,h3,h4').each((_, h) => {
+ if (!EXCLUDED_RE.test($(h).text())) return;
+ let el = $(h).parent();
+ for (let d = 0; d < 6; d++) {
+ const items = el.find('[data-testid="module-content-list-item"]');
+ if (items.length) { items.each((_, i) => excluded.add(i)); break; }
+ el = el.parent();
+ if (!el.length) break;
+ }
+ });
+ return excluded;
+}
+
+function sortByDate(tiles) {
+ return tiles.sort((a, b) => {
+ if (!a.date && !b.date) return 0;
+ if (!a.date) return 1;
+ if (!b.date) return -1;
+ return new Date(a.date) - new Date(b.date);
+ });
+}
+
+async function scrapeAMG(venue) {
+ const { data } = await axios.get(venue.url, { headers: HTTP_HEADERS, timeout: 20000 });
+ const $ = cheerio.load(data);
+ const excluded = buildExcludedSet($);
+ const tiles = [];
+ const seen = new Set();
+
+ function addTile(artist, dateStr) {
+ if (!artist) return;
+ const key = artist + '|' + dateStr;
+ if (seen.has(key)) return;
+ seen.add(key);
+ const date = parseEventDate(dateStr);
+ tiles.push({ artist, dateStr, date: date?.toISOString() || null, status: getTileStatus(date) });
+ }
+
+ $('[data-testid="carousel-slide"]').each((_, slide) => {
+ addTile(
+ $('h2,h3', slide).first().text().trim(),
+ $('p', slide).first().text().trim(),
+ );
+ });
+
+ $('[data-testid="module-content-list-item"]').each((_, item) => {
+ if (excluded.has(item)) return;
+ const artist = $('[data-testid="module-content-list-item-title"]', item).text().trim();
+ const dateStr = $('[data-testid="module-content-list-item-subtitle"]', item).text().trim();
+ if (!parseEventDate(dateStr)) return;
+ addTile(artist, dateStr);
+ });
+
+ return sortByDate(tiles);
+}
+
+async function scrapeECE(venue) {
+ const { data } = await axios.get(venue.url, { headers: HTTP_HEADERS, timeout: 20000 });
+ const $ = cheerio.load(data);
+ const excluded = buildExcludedSet($);
+ const tiles = [];
+ const seen = new Set();
+
+ $('[data-testid="module-content-list-medium"]').first()
+ .find('[data-testid="module-content-list-item"]').each((_, item) => {
+ if (excluded.has(item)) return;
+ const artist = $('[data-testid="module-content-list-item-title"]', item).text().trim();
+ const dateStr = $('[data-testid="module-content-list-item-subtitle"]', item).text().trim();
+ if (!artist) return;
+ const key = artist + '|' + dateStr;
+ if (seen.has(key)) return; seen.add(key);
+ const date = parseEventDate(dateStr);
+ tiles.push({ artist, dateStr, date: date?.toISOString() || null, status: getTileStatus(date) });
+ });
+
+ return sortByDate(tiles);
+}
+
+async function scrapeVenue(venue) {
+ try {
+ const tiles = venue.type === 'amg' ? await scrapeAMG(venue) : await scrapeECE(venue);
+ const outdN = tiles.filter(t => t.status === 'outdated').length;
+ const expN = tiles.filter(t => t.status === 'expiring_soon').length;
+ const overall = outdN > 0 ? 'outdated' : expN > 0 ? 'expiring_soon' : tiles.length ? 'ok' : 'unknown';
+ return { name: venue.name, url: venue.url, tiles, outdN, expN, overall,
+ error: null, checkedAt: new Date().toISOString(), removed: [], hasChanges: false };
+ } catch (err) {
+ console.error(`[scrape] ${venue.name}: ${err.message}`);
+ return { name: venue.name, url: venue.url, tiles: [], outdN: 0, expN: 0, overall: 'error',
+ error: err.message, checkedAt: new Date().toISOString(), removed: [], hasChanges: false };
+ }
+}
+
+// ── Scrape orchestration ─────────────────────────────────────────────────────
+
+let venueData = {};
+let lastChecked = null;
+let isScraping = false;
+const previouslyOutdated = new Set();
+const sseClients = [];
+
+function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
+
+async function scrapeAll() {
+ if (isScraping) return;
+ isScraping = true;
+ console.log(`[${new Date().toISOString()}] Starting full scrape…`);
+ broadcastSSE({ type: 'scraping_started', total: VENUES.length });
+
+ let done = 0;
+ const newData = {};
+
+ for (let i = 0; i < VENUES.length; i += 4) {
+ const batch = VENUES.slice(i, i + 4);
+ const results = await Promise.all(batch.map(scrapeVenue));
+ results.forEach(r => {
+ newData[r.name] = r;
+ done++;
+ broadcastSSE({ type: 'venue_done', venue: r, done, total: VENUES.length });
+ });
+ if (i + 4 < VENUES.length) await sleep(300);
+ }
+
+ try { logAppearances(Object.values(newData)); } catch (e) { console.error('[catalogue]', e.message); }
+
+ const newlyOutdated = [];
+ Object.values(newData).forEach(v => {
+ if (v.overall === 'outdated' && !previouslyOutdated.has(v.name)) newlyOutdated.push(v);
+ v.overall === 'outdated' ? previouslyOutdated.add(v.name) : previouslyOutdated.delete(v.name);
+ });
+
+ venueData = newData;
+ lastChecked = new Date().toISOString();
+ isScraping = false;
+
+ broadcastSSE({ type: 'scrape_complete', data: venueData, lastChecked });
+ console.log(`[${new Date().toISOString()}] Scrape complete.`);
+
+ if (newlyOutdated.length) {
+ await sendEmailNotification(newlyOutdated);
+ await sendSlackNotification(newlyOutdated);
+ await sendWebhookNotification(newlyOutdated);
+ }
+}
+
+async function scrapeOne(venueName) {
+ const venue = VENUES.find(v => v.name === venueName);
+ if (!venue) return;
+ broadcastSSE({ type: 'venue_loading', name: venueName });
+ const result = await scrapeVenue(venue);
+ venueData[venueName] = result;
+ try { logAppearances([result]); } catch (e) { console.error('[catalogue]', e.message); }
+ broadcastSSE({ type: 'venue_done', venue: result, done: null, total: null });
+}
+
+// ── SSE ───────────────────────────────────────────────────────────────────────
+
+function broadcastSSE(payload) {
+ const msg = `data: ${JSON.stringify(payload)}\n\n`;
+ for (const client of sseClients) {
+ try { client.res.write(msg); } catch {}
+ }
+}
+
+// ── Notifications ────────────────────────────────────────────────────────────
+
+async function sendEmailNotification(venues) {
+ if (!process.env.SMTP_HOST || !process.env.NOTIFY_EMAIL) return;
+ try {
+ const transporter = nodemailer.createTransport({
+ host: process.env.SMTP_HOST,
+ port: parseInt(process.env.SMTP_PORT || '587'),
+ secure: process.env.SMTP_SECURE === 'true',
+ auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
+ });
+ const rows = venues.map(v => {
+ const tiles = v.tiles.filter(t => t.status === 'outdated');
+ return `
+ ${v.name} ` +
+ tiles.map(t => `${t.artist}
+ ${t.dateStr} `).join('');
+ }).join('');
+ await transporter.sendMail({
+ from: process.env.SMTP_FROM || process.env.SMTP_USER,
+ to: process.env.NOTIFY_EMAIL,
+ subject: `[Venue Tracker] ${venues.length} venue(s) have outdated homepage tiles`,
+ html: `Outdated Homepage Carousel Tiles
+ Sent at ${new Date().toUTCString()}
`,
+ });
+ } catch (err) { console.error('[email]', err.message); }
+}
+
+async function sendSlackNotification(venues) {
+ if (!process.env.SLACK_WEBHOOK_URL) return;
+ try {
+ const blocks = venues.flatMap(v => {
+ const tiles = v.tiles.filter(t => t.status === 'outdated');
+ return [
+ { type: 'section', text: { type: 'mrkdwn', text: `*<${v.url}|${v.name}>* — ${tiles.length} outdated tile(s)` } },
+ { type: 'section', text: { type: 'mrkdwn', text: tiles.map(t => `• ${t.artist} — ~${t.dateStr}~`).join('\n') } },
+ ];
+ });
+ await axios.post(process.env.SLACK_WEBHOOK_URL, {
+ text: `:rotating_light: ${venues.length} venue(s) have outdated homepage tiles`,
+ blocks: [{ type: 'header', text: { type: 'plain_text', text: 'Outdated Homepage Carousel Tiles' } }, ...blocks],
+ });
+ } catch (err) { console.error('[slack]', err.message); }
+}
+
+async function sendWebhookNotification(venues) {
+ if (!process.env.WEBHOOK_URL) return;
+ try {
+ await axios.post(process.env.WEBHOOK_URL, {
+ event: 'outdated_tiles_detected',
+ timestamp: new Date().toISOString(),
+ venues: venues.map(v => ({ name: v.name, url: v.url,
+ outdatedTiles: v.tiles.filter(t => t.status === 'outdated') })),
+ });
+ } catch (err) { console.error('[webhook]', err.message); }
+}
+
+// ── API routes ────────────────────────────────────────────────────────────────
+
+app.get('/api/status', requireAuth, (req, res) => {
+ res.json({ venues: venueData, lastChecked, isScraping });
+});
+
+app.post('/api/refresh', requireAuth, (req, res) => {
+ res.json({ ok: true });
+ scrapeAll();
+});
+
+app.post('/api/refresh/:venue', requireAuth, (req, res) => {
+ res.json({ ok: true });
+ scrapeOne(decodeURIComponent(req.params.venue));
+});
+
+app.get('/api/events', requireAuth, (req, res) => {
+ res.setHeader('Content-Type', 'text/event-stream');
+ res.setHeader('Cache-Control', 'no-cache');
+ res.setHeader('Connection', 'keep-alive');
+ res.setHeader('X-Accel-Buffering', 'no');
+ res.flushHeaders();
+
+ const client = { id: Date.now(), res };
+ sseClients.push(client);
+
+ // Send current state immediately so the page is never blank
+ res.write(`data: ${JSON.stringify({
+ type: 'scrape_complete', data: venueData, lastChecked,
+ })}\n\n`);
+ if (isScraping) {
+ res.write(`data: ${JSON.stringify({ type: 'scraping_started', total: VENUES.length })}\n\n`);
+ }
+
+ const ping = setInterval(() => {
+ try { res.write(': ping\n\n'); } catch { clearInterval(ping); }
+ }, 15000);
+
+ req.on('close', () => {
+ clearInterval(ping);
+ const idx = sseClients.indexOf(client);
+ if (idx !== -1) sseClients.splice(idx, 1);
+ });
+});
+
+// ── Start ─────────────────────────────────────────────────────────────────────
+
+const PORT = process.env.PORT || 3000;
+app.listen(PORT, () => {
+ console.log(`\x1b[32m✓ Venue Carousel Tracker → http://localhost:${PORT}\x1b[0m`);
+ scrapeAll();
+});
+
+cron.schedule('0 * * * *', () => {
+ console.log('[cron] Hourly scrape triggered.');
+ scrapeAll();
+});