From 5887d1255a4bda46f2321d94b7eacf3122f02a62 Mon Sep 17 00:00:00 2001 From: chiko Date: Fri, 9 Oct 2026 00:34:48 +0200 Subject: [PATCH] Initial TypeScript event calendar service --- .env.example | 20 ++ .gitignore | 10 + README.md | 37 +++ package-lock.json | 606 +++++++++++++++++++++++++++++++++++++++++++ package.json | 25 ++ src/index.ts | 110 ++++++++ src/notifications.ts | 88 +++++++ src/poller.ts | 180 +++++++++++++ src/storage.ts | 262 +++++++++++++++++++ src/types.ts | 34 +++ tsconfig.json | 17 ++ 11 files changed, 1389 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/index.ts create mode 100644 src/notifications.ts create mode 100644 src/poller.ts create mode 100644 src/storage.ts create mode 100644 src/types.ts create mode 100644 tsconfig.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..917ee89 --- /dev/null +++ b/.env.example @@ -0,0 +1,20 @@ +PORT=3000 +DB_PATH=./data/events.sqlite + +# Apprise API service +apprise_host=apprise +apprise_port=80 +apprise_https=false +notification_mock=false + +# Private ntfy server +ntfy_host=ntfy.example.com +ntfy_topic=77th-events +ntfy_username= +ntfy_password= + +# Discord incoming webhook; use WEBHOOK_ID/WEBHOOK_TOKEN or the full HTTPS URL +# dc_webhook=WEBHOOK_ID/WEBHOOK_TOKEN +discord_botname=77th Event Calendar +discord_avatar_url= +discord_href= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..480d6b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +node_modules/ +dist/ +data/*.sqlite +data/*.sqlite-shm +data/*.sqlite-wal +.env +.env.* +!.env.example +coverage/ +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..ead594c --- /dev/null +++ b/README.md @@ -0,0 +1,37 @@ +# 77th EV Calendar Notify Service + +A TypeScript + Node.js service that polls the 77th JSOC events API, exposes its events through a REST API, stores them in SQLite, publishes an ICS feed, and sends change notifications through an Apprise server. + +## Requirements + +- Node.js 22.9 or newer +- npm + +## Run + +```sh +npm install +Copy-Item .env.example .env +npm run dev +``` + +The service requests `https://77th-jsoc.com/service.php?action=get_events` for each calendar month touched by today through the next 60 days. It sets `year`, `month`, and `timezone=-120` on every request and polls every 15 minutes. The API's `uid` is the event identity in SQLite, the REST API, and ICS `UID`; the event title remains the display summary. + +Each successful poll stores source events in `data/events.sqlite` (or the path configured by `DB_PATH`). When opening the older title-keyed database schema, the service preserves its old table under an `events_legacy_title_*` name and starts a silent UID-based baseline. If the events table is empty, the poll is treated as an initial snapshot and sends no notifications. Later additions, changes, and removals are queued durably in SQLite and sent through Apprise. Failed sends remain queued for retry. Events removed from the source remain in SQLite as cancelled ICS entries. + +## Apprise and notification configuration + +Copy `.env.example` to `.env` and set the Apprise host, ntfy host/topic, and Discord webhook. `dc_webhook` accepts `WEBHOOK_ID/WEBHOOK_TOKEN` or the full Discord webhook URL. ntfy credentials are optional, but set both username and password if authentication is required. Leave `notification_mock=false` to send; set it to `true` to log and consume notifications without sending. + +The service posts a JSON payload to Apprise `/notify` with both the ntfy and Discord URLs. Apprise supports multiple destination URLs in one request. [Apprise API](https://github.com/caronc/apprise-api/blob/master/README.md) + +A `.env` file is optional at startup; without one, the server still runs, but notification sends will remain queued until the notification settings are configured. `PORT` defaults to `3000`; `DB_PATH` defaults to `./data/events.sqlite`. + +For production, run `npm run build` followed by `npm start`, and keep the process running with your preferred process manager or service host. + +## Endpoints + +- `GET /health` — service and polling status +- `GET /api/events` — latest active events and polling status +- `POST /api/refresh` — trigger an immediate poll +- `GET /calendar.ics` — active events and cancellation entries for calendar subscription diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..e03de51 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,606 @@ +{ + "name": "77th-ev-cal-ntfy-srv", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "77th-ev-cal-ntfy-srv", + "version": "0.1.0", + "dependencies": { + "better-sqlite3": "^13.0.3" + }, + "devDependencies": { + "@types/better-sqlite3": "^9.6.0", + "@types/node": "^22.0.0", + "tsx": "^4.19.0", + "typescript": "^5.7.0" + }, + "engines": { + "node": ">=22.9" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/better-sqlite3": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-9.6.0.tgz", + "integrity": "sha512-ZEEwBSgMu7GYJOynoagg5X9JbxfL6dTJDsgViJIqh67jV44kyOr9RXfmFjLK5rzC4MWssP06t9hu/JwGDnUbCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "22.20.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", + "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/better-sqlite3": { + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz", + "integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==", + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.0.0" + }, + "engines": { + "node": ">=22.9" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/node-addon-api": { + "version": "8.9.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz", + "integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/tsx": { + "version": "4.23.15", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..1295c5f --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "name": "77th-ev-cal-ntfy-srv", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "REST API and ICS calendar feed backed by the 77th JSOC events API", + "scripts": { + "dev": "node --env-file-if-exists=.env --import tsx --watch src/index.ts", + "build": "tsc -p tsconfig.json", + "start": "node --env-file-if-exists=.env dist/index.js", + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "engines": { + "node": "\u003e=22.9" + }, + "devDependencies": { + "@types/better-sqlite3": "^9.6.0", + "@types/node": "^22.0.0", + "tsx": "^4.19.0", + "typescript": "^5.7.0" + }, + "dependencies": { + "better-sqlite3": "^13.0.3" + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..8e6a9f7 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,110 @@ +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import { getEvents, getPollStatus, refresh, startPolling } from './poller.js'; +import type { StoredCalendarEvent } from './types.js'; +import { getCalendarEntries } from './storage.js'; + +const port = Number(process.env.PORT ?? 3000); + +function sendJson(response: ServerResponse, status: number, body: unknown): void { + response.writeHead(status, { 'content-type': 'application/json; charset=utf-8' }); + response.end(JSON.stringify(body)); +} + +function escapeIcs(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/\r?\n/g, '\\n').replace(/,/g, '\\,').replace(/;/g, '\\;'); +} + +function icsDate(value: string): string { + return new Date(value).toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z'); +} + +function icsDay(value: string): string { + return value.slice(0, 10).replace(/-/g, ''); +} + +function toIcsEvent(event: StoredCalendarEvent): string { + return [ + 'BEGIN:VEVENT', + `UID:${escapeIcs(event.id)}@77th-jsoc.com`, + `DTSTAMP:${icsDate(new Date().toISOString())}`, + `SEQUENCE:${event.sequence}`, + `STATUS:${event.status === 'cancelled' ? 'CANCELLED' : 'CONFIRMED'}`, + ...(event.allDay + ? [`DTSTART;VALUE=DATE:${icsDay(event.start)}`, `DTEND;VALUE=DATE:${icsDay(event.end)}`] + : [`DTSTART:${icsDate(event.start)}`, `DTEND:${icsDate(event.end)}`]), + `SUMMARY:${escapeIcs(event.title)}`, + ...(event.description ? [`DESCRIPTION:${escapeIcs(event.description)}`] : []), + ...(event.location ? [`LOCATION:${escapeIcs(event.location)}`] : []), + ...(event.url ? [`URL:${escapeIcs(event.url)}`] : []), + 'END:VEVENT', + ].join('\r\n'); +} + +function calendarBody(): string { + return [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//77th EV Calendar Notify Service//EN', + 'CALSCALE:GREGORIAN', + 'METHOD:PUBLISH', + 'X-WR-CALNAME:77th EV Calendar', + ...getCalendarEntries().map(toIcsEvent), + 'END:VCALENDAR', + '', + ].join('\r\n'); +} + +async function handle(request: IncomingMessage, response: ServerResponse): Promise { + const url = new URL(request.url ?? '/', `http://${request.headers.host ?? 'localhost'}`); + if (request.method === 'GET' && url.pathname === '/health') { + sendJson(response, 200, { status: 'ok', ...getPollStatus() }); + return; + } + if (request.method === 'GET' && url.pathname === '/api/events') { + sendJson(response, 200, { events: getEvents(), ...getPollStatus() }); + return; + } + if (request.method === 'POST' && url.pathname === '/api/refresh') { + await refresh(); + const status = getPollStatus(); + sendJson(response, status.lastError ? 502 : 200, status); + return; + } + if (request.method === 'GET' && url.pathname === '/calendar.ics') { + response.writeHead(200, { + 'content-type': 'text/calendar; charset=utf-8', + 'content-disposition': 'inline; filename="calendar.ics"', + 'cache-control': 'public, max-age=300', + }); + response.end(calendarBody()); + return; + } + sendJson(response, 404, { error: 'Not found' }); +} + +const server = createServer((request, response) => { + void handle(request, response).catch((error: unknown) => { + sendJson(response, 500, { error: error instanceof Error ? error.message : 'Internal server error' }); + }); +}); + +const pollTimer = startPolling(); +server.listen(port, () => { + console.log(`HTTP server listening on port ${port}`); + console.log(`Calendar feed: http://localhost:${port}/calendar.ics`); +}); + +function shutdown(): void { + clearInterval(pollTimer); + server.close(() => process.exit(0)); +} +process.on('SIGINT', shutdown); +process.on('SIGTERM', shutdown); + + + + + + + + diff --git a/src/notifications.ts b/src/notifications.ts new file mode 100644 index 0000000..2259aa0 --- /dev/null +++ b/src/notifications.ts @@ -0,0 +1,88 @@ +import type { PendingNotification } from './types.js'; + +interface NotificationPayload { + urls: string[]; + title: string; + body: string; + format: 'markdown'; +} + +function queryString(values: Record): string { + const query = new URLSearchParams(); + for (const [key, value] of Object.entries(values)) { + if (value) query.set(key, value); + } + const result = query.toString(); + return result ? `?${result}` : ''; +} + +function ntfyUrl(link?: string): string { + const host = process.env.ntfy_host; + const topic = process.env.ntfy_topic; + if (!host || !topic) throw new Error('ntfy_host and ntfy_topic must be configured'); + const username = process.env.ntfy_username; + const password = process.env.ntfy_password; + if (Boolean(username) !== Boolean(password)) throw new Error('Configure both ntfy_username and ntfy_password, or neither'); + const credentials = username && password + ? `${encodeURIComponent(username)}:${encodeURIComponent(password)}@` + : ''; + return `ntfys://${credentials}${host}/${encodeURIComponent(topic)}${queryString({ click: link, format: 'markdown' })}`; +} + +function getDiscordWebhookParts(value: string): { id: string; token: string } { + let parts: string[]; + if (value.startsWith('https://') || value.startsWith('http://') || value.startsWith('discord://')) { + const parsed = new URL(value); + parts = parsed.pathname.split('/').filter(Boolean); + if (parsed.protocol === 'discord:') parts = [parsed.hostname, ...parts]; + if (parts[0] === 'api' && parts[1] === 'webhooks') parts = parts.slice(2); + } else { + parts = value.split('/').filter(Boolean); + } + if (parts.length !== 2 || !parts[0] || !parts[1]) { + throw new Error('dc_webhook must be a Discord webhook URL or WEBHOOK_ID/WEBHOOK_TOKEN'); + } + return { id: decodeURIComponent(parts[0]), token: decodeURIComponent(parts[1]) }; +} + +function discordUrl(link?: string): string { + const webhook = process.env.dc_webhook; + if (!webhook) throw new Error('dc_webhook must be configured'); + const { id, token } = getDiscordWebhookParts(webhook); + const botname = process.env.discord_botname; + const username = botname ? `${encodeURIComponent(botname)}@` : ''; + return `discord://${username}${encodeURIComponent(id)}/${encodeURIComponent(token)}/${queryString({ + href: link ?? process.env.discord_href, + avatar_url: process.env.discord_avatar_url, + format: 'markdown', + })}`; +} + +function appriseEndpoint(): string { + const protocol = process.env.apprise_https === 'true' ? 'https' : 'http'; + const host = process.env.apprise_host || 'apprise'; + const port = process.env.apprise_port || (protocol === 'https' ? '443' : '80'); + return `${protocol}://${host}:${port}/notify`; +} + +export async function sendNotification(notification: PendingNotification): Promise { + if (process.env.notification_mock === 'true') { + console.info('[notification mock]', notification.title, notification.body); + return; + } + + const payload: NotificationPayload = { + urls: [ntfyUrl(notification.link), discordUrl(notification.link)], + title: notification.title, + body: notification.body, + format: 'markdown', + }; + const response = await fetch(appriseEndpoint(), { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json' }, + body: JSON.stringify(payload), + signal: AbortSignal.timeout(20_000), + }); + if (!response.ok) throw new Error(`Apprise returned HTTP ${response.status}`); +} + diff --git a/src/poller.ts b/src/poller.ts new file mode 100644 index 0000000..14b5dee --- /dev/null +++ b/src/poller.ts @@ -0,0 +1,180 @@ +import type { CalendarEvent, PollStatus, StoredCalendarEvent } from './types.js'; +import { getActiveEvents, getPendingNotifications, markNotificationFailed, markNotificationSent, saveMonthlySnapshot } from './storage.js'; +import { sendNotification } from './notifications.js'; + +const endpoint = 'https://77th-jsoc.com/service.php?action=get_events'; +const timezoneOffsetMinutes = -120; +const pollIntervalMs = 15 * 60 * 1000; +const horizonDays = 60; +let events: CalendarEvent[] = getActiveEvents(); +let lastCheckedAt: string | null = null; +let lastUpdatedAt: string | null = null; +let lastError: string | null = null; +let inFlight: Promise | undefined; + +interface SourceEvent { + uid: string | number; + title: string; + description?: string; + date_at: string; + time_start: string; + time_end: string; + timezone?: string | number; + location?: string; + link?: string; + posted_by?: string; + event_type?: string | number; +} + +interface MonthlySnapshot { + monthKey: string; + events: StoredCalendarEvent[]; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function eventDateTime(date: string, time: string, offsetMinutes: number): string { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date); + const timeMatch = /^(\d{1,2}):(\d{2})$/.exec(time); + if (!match || !timeMatch) throw new Error(`Invalid event date or time: ${date} ${time}`); + const [, year, month, day] = match; + const [, hour, minute] = timeMatch; + const localAsUtc = Date.UTC(Number(year), Number(month) - 1, Number(day), Number(hour), Number(minute)); + return new Date(localAsUtc + offsetMinutes * 60_000).toISOString(); +} + +function nextDate(date: string): string { + const [year, month, day] = date.split('-').map(Number); + return new Date(Date.UTC(year!, month! - 1, day! + 1)).toISOString().slice(0, 10); +} + +function mapEvent(value: unknown, index: number, sourceMonth: string): StoredCalendarEvent { + if (!isRecord(value)) throw new Error(`Event at index ${index} must be an object`); + const source = value as unknown as Partial; + if ((typeof source.uid !== 'string' && typeof source.uid !== 'number') || String(source.uid).trim() === '') { + throw new Error(`Event at index ${index} is missing its uid`); + } + if (typeof source.title !== 'string' || !source.title.trim() || typeof source.date_at !== 'string') { + throw new Error(`Event at index ${index} is missing its title or date_at`); + } + if (typeof source.time_start !== 'string' || typeof source.time_end !== 'string') { + throw new Error(`Event at index ${index} is missing its start or end time`); + } + + const allDay = source.time_start.toLowerCase() === 'all' || source.time_end.toLowerCase() === 'all'; + let start: string; + let end: string; + if (allDay) { + start = `${source.date_at}T00:00:00.000Z`; + end = `${nextDate(source.date_at)}T00:00:00.000Z`; + } else { + const offset = Number(source.timezone ?? timezoneOffsetMinutes); + if (!Number.isFinite(offset)) throw new Error(`Event at index ${index} has an invalid timezone offset`); + start = eventDateTime(source.date_at, source.time_start, offset); + let endMs = Date.parse(eventDateTime(source.date_at, source.time_end, offset)); + const startMs = Date.parse(start); + if (endMs < startMs) endMs += 24 * 60 * 60 * 1000; + if (endMs === startMs) endMs += 60 * 60 * 1000; + end = new Date(endMs).toISOString(); + } + + const eventTypes: Record = { '1': 'Public Event', '2': 'Private Mission', '3': 'Private Meeting' }; + const eventType = eventTypes[String(source.event_type)] ?? (source.event_type === undefined ? 'Unknown' : `Type ${source.event_type}`); + return { + id: String(source.uid), + title: source.title, + eventType, + start, + end, + sourceMonth, + status: 'confirmed', + sequence: 0, + ...(allDay ? { allDay: true } : {}), + ...(source.description ? { description: source.description } : {}), + ...(source.location ? { location: source.location } : {}), + ...(source.posted_by ? { postedBy: source.posted_by } : {}), + ...(source.link ? { url: source.link } : {}), + }; +} + +function monthsInRange(from: Date, through: Date): Array<{ year: number; month: number; monthKey: string }> { + const months: Array<{ year: number; month: number; monthKey: string }> = []; + const cursor = new Date(Date.UTC(from.getUTCFullYear(), from.getUTCMonth(), 1)); + const last = new Date(Date.UTC(through.getUTCFullYear(), through.getUTCMonth(), 1)); + while (cursor <= last) { + const year = cursor.getUTCFullYear(); + const month = cursor.getUTCMonth() + 1; + months.push({ year, month, monthKey: `${year}-${String(month).padStart(2, '0')}` }); + cursor.setUTCMonth(cursor.getUTCMonth() + 1); + } + return months; +} + +async function fetchMonth(year: number, month: number, monthKey: string): Promise { + const url = new URL(endpoint); + url.searchParams.set('year', String(year)); + url.searchParams.set('month', String(month)); + url.searchParams.set('timezone', String(timezoneOffsetMinutes)); + const response = await fetch(url, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(20_000) }); + if (!response.ok) throw new Error(`Events endpoint returned HTTP ${response.status} for ${monthKey}`); + const payload: unknown = await response.json(); + if (!isRecord(payload) || !Array.isArray(payload.events)) { + throw new Error(`Events endpoint returned an invalid response for ${monthKey}`); + } + return { monthKey, events: payload.events.map((event, index) => mapEvent(event, index, monthKey)) }; +} + +async function deliverPendingNotifications(): Promise { + for (const notification of getPendingNotifications()) { + try { + await sendNotification(notification); + markNotificationSent(notification.id); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + markNotificationFailed(notification.id, message); + console.error(`Apprise notification ${notification.id} failed: ${message}`); + } + } +} + +export function getEvents(): CalendarEvent[] { + return events; +} + +export function getPollStatus(): PollStatus { + return { configured: true, lastCheckedAt, lastUpdatedAt, lastError, eventCount: events.length }; +} + +export function refresh(): Promise { + if (inFlight) return inFlight; + inFlight = (async () => { + lastCheckedAt = new Date().toISOString(); + try { + const now = new Date(); + const through = new Date(now.getTime() + horizonDays * 24 * 60 * 60 * 1000); + const months = monthsInRange(now, through); + const snapshots = await Promise.all(months.map(({ year, month, monthKey }) => fetchMonth(year, month, monthKey))); + const monthKeys = snapshots.map((snapshot) => snapshot.monthKey); + const incoming = snapshots.flatMap((snapshot) => snapshot.events); + const uniqueByUid = new Map(incoming.map((event) => [event.id, event])); + const { initialPoll } = saveMonthlySnapshot(monthKeys, [...uniqueByUid.values()]); + if (initialPoll) console.info('Initial event snapshot saved; notifications are suppressed for this poll.'); + events = getActiveEvents(); + lastUpdatedAt = new Date().toISOString(); + lastError = null; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + } + await deliverPendingNotifications(); + })().finally(() => { + inFlight = undefined; + }); + return inFlight; +} + +export function startPolling(): NodeJS.Timeout { + void refresh(); + return setInterval(() => void refresh(), pollIntervalMs); +} diff --git a/src/storage.ts b/src/storage.ts new file mode 100644 index 0000000..c8f18df --- /dev/null +++ b/src/storage.ts @@ -0,0 +1,262 @@ +import Database from 'better-sqlite3'; +import { mkdirSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import type { CalendarEvent, PendingNotification, StoredCalendarEvent } from './types.js'; + +const databasePath = resolve(process.env.DB_PATH ?? './data/events.sqlite'); +mkdirSync(dirname(databasePath), { recursive: true }); +const database = new Database(databasePath); +database.pragma('journal_mode = WAL'); + +const existingEventsTable = database.prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'events'", +).get(); +if (existingEventsTable) { + const columns = database.pragma('table_info(events)') as Array<{ name: string }>; + if (!columns.some((column) => column.name === 'uid')) { + database.exec(`ALTER TABLE events RENAME TO events_legacy_title_${Date.now()}`); + } +} + +database.exec(` + CREATE TABLE IF NOT EXISTS events ( + uid TEXT PRIMARY KEY COLLATE BINARY, + title TEXT NOT NULL, + event_type TEXT NOT NULL DEFAULT 'Unknown', + start TEXT NOT NULL, + end TEXT NOT NULL, + description TEXT, + location TEXT, + posted_by TEXT, + url TEXT, + all_day INTEGER NOT NULL DEFAULT 0, + source_month TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('confirmed', 'cancelled')), + sequence INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL, + cancelled_at TEXT + ); + CREATE TABLE IF NOT EXISTS notification_outbox ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uid TEXT NOT NULL, + sequence INTEGER NOT NULL, + change_type TEXT NOT NULL CHECK (change_type IN ('added', 'changed', 'removed')), + title TEXT NOT NULL, + body TEXT NOT NULL, + link TEXT, + created_at TEXT NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + last_error TEXT, + sent_at TEXT, + UNIQUE (uid, sequence, change_type) + ); +`); + +const eventColumns = database.pragma('table_info(events)') as Array<{ name: string }>; +if (!eventColumns.some((column) => column.name === 'event_type')) database.exec("ALTER TABLE events ADD COLUMN event_type TEXT NOT NULL DEFAULT 'Unknown'"); +if (!eventColumns.some((column) => column.name === 'posted_by')) database.exec('ALTER TABLE events ADD COLUMN posted_by TEXT'); + +const outboxColumns = database.pragma('table_info(notification_outbox)') as Array<{ name: string }>; +if (!outboxColumns.some((column) => column.name === 'link')) database.exec('ALTER TABLE notification_outbox ADD COLUMN link TEXT'); + +const activeRows = database.prepare(` + SELECT uid AS id, uid, title, event_type AS eventType, start, end, description, location, posted_by AS postedBy, url, + all_day AS allDay, source_month AS sourceMonth, status, sequence + FROM events WHERE status = 'confirmed' ORDER BY start, uid +`); +const calendarRows = database.prepare(` + SELECT uid AS id, uid, title, event_type AS eventType, start, end, description, location, posted_by AS postedBy, url, + all_day AS allDay, source_month AS sourceMonth, status, sequence + FROM events ORDER BY start, uid +`); +const eventByUid = database.prepare(` + SELECT uid AS id, uid, title, event_type AS eventType, start, end, description, location, posted_by AS postedBy, url, + all_day AS allDay, source_month AS sourceMonth, status, sequence + FROM events WHERE uid = ? +`); +const upsertEvent = database.prepare(` + INSERT INTO events (uid, title, event_type, start, end, description, location, posted_by, url, all_day, source_month, status, sequence, updated_at, cancelled_at) + VALUES (@uid, @title, @eventType, @start, @end, @description, @location, @postedBy, @url, @allDay, @sourceMonth, 'confirmed', @sequence, @updatedAt, NULL) + ON CONFLICT(uid) DO UPDATE SET + title = excluded.title, + event_type = excluded.event_type, + start = excluded.start, + end = excluded.end, + description = excluded.description, + location = excluded.location, + posted_by = excluded.posted_by, + url = excluded.url, + all_day = excluded.all_day, + source_month = excluded.source_month, + status = 'confirmed', + sequence = excluded.sequence, + updated_at = excluded.updated_at, + cancelled_at = NULL +`); +const cancelEvent = database.prepare(` + UPDATE events SET status = 'cancelled', sequence = sequence + 1, updated_at = ?, cancelled_at = ? + WHERE uid = ? AND status = 'confirmed' +`); +const pendingRows = database.prepare(` + SELECT id, title, body, link, 'markdown' AS format + FROM notification_outbox WHERE sent_at IS NULL ORDER BY id +`); +const markSent = database.prepare('UPDATE notification_outbox SET sent_at = ?, last_error = NULL WHERE id = ?'); +const markFailed = database.prepare('UPDATE notification_outbox SET attempts = attempts + 1, last_error = ? WHERE id = ?'); + +function mapRow(row: Record): StoredCalendarEvent { + return { + id: String(row.uid), + title: String(row.title), + eventType: String(row.eventType ?? 'Unknown'), + start: String(row.start), + end: String(row.end), + allDay: Number(row.allDay) === 1, + sourceMonth: String(row.sourceMonth), + status: row.status === 'cancelled' ? 'cancelled' : 'confirmed', + sequence: Number(row.sequence), + ...(typeof row.description === 'string' ? { description: row.description } : {}), + ...(typeof row.location === 'string' ? { location: row.location } : {}), + ...(typeof row.postedBy === 'string' ? { postedBy: row.postedBy } : {}), + ...(typeof row.url === 'string' ? { url: row.url } : {}), + }; +} + +function eventDetails(event: StoredCalendarEvent): string[] { + // Source event times are wall-clock values in fixed GMT+2 throughout the year. + const start = event.allDay ? undefined : new Date(Date.parse(event.start) + 120 * 60_000); + const date = start ? start.toISOString().slice(0, 10) : event.start.slice(0, 10); + let time = 'All day'; + if (start) { + const hour = start.getUTCHours(); + const minute = start.getUTCMinutes(); + const delta = hour * 60 + minute - 21 * 60; + const offset = delta === 0 ? ' (OP Time)' : ` (OP Time ${delta > 0 ? '+' : '-'}${Math.floor(Math.abs(delta) / 60)}:${String(Math.abs(delta) % 60).padStart(2, '0')})`; + time = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}${offset}`; + } + return [ + `Title: ${event.title}`, + `Date: ${date}`, + `Time: ${time}`, + `Type: ${event.eventType}`, + ...(event.location ? [`Location: ${event.location}`] : []), + ...(event.postedBy ? [`By: ${event.postedBy}`] : []), + ...(event.url ? [`Link: ${event.url}`] : []), + ...(event.description ? [`Description: ${event.description}`] : []), + ]; +} + +function queueNotification( + kind: 'added' | 'changed' | 'removed', + event: StoredCalendarEvent, + previous?: StoredCalendarEvent, +): void { + const label = kind === 'added' ? '' : kind === 'changed' ? '' : ''; + const metaTitle = `${label} - ${event.title} (${event.eventType})`; + const body = [ + metaTitle, + '', + ...(previous && kind === 'changed' ? ['Previous details:', ...eventDetails(previous), '', 'Current details:', ...eventDetails(event)] : eventDetails(event)), + ].join('\n'); + database.prepare(` + INSERT OR IGNORE INTO notification_outbox (uid, sequence, change_type, title, body, link, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run(event.id, event.sequence, kind, metaTitle, body, event.url ?? null, new Date().toISOString()); +} + +export function getActiveEvents(): CalendarEvent[] { + return (activeRows.all() as Record[]).map((row) => { + const event = mapRow(row); + return { + id: event.id, + title: event.title, + start: event.start, + end: event.end, + ...(event.allDay ? { allDay: true } : {}), + ...(event.description ? { description: event.description } : {}), + ...(event.location ? { location: event.location } : {}), + ...(event.url ? { url: event.url } : {}), + }; + }); +} + +export function getCalendarEntries(): StoredCalendarEvent[] { + return (calendarRows.all() as Record[]).map(mapRow); +} + +export function saveMonthlySnapshot(monthKeys: string[], incoming: StoredCalendarEvent[]): { initialPoll: boolean } { + const now = new Date().toISOString(); + const uniqueIncoming = new Map(incoming.map((event) => [event.id, event])); + const presentUids = new Set(uniqueIncoming.keys()); + const transaction = database.transaction(() => { + const count = database.prepare('SELECT COUNT(*) AS count FROM events').get() as { count: number }; + const initialPoll = count.count === 0; + + for (const event of uniqueIncoming.values()) { + const row = eventByUid.get(event.id) as Record | undefined; + const previous = row ? mapRow(row) : undefined; + const changed = previous !== undefined && previous.status === 'confirmed' && ( + previous.title !== event.title || previous.eventType !== event.eventType || previous.start !== event.start || previous.end !== event.end || + (previous.description ?? '') !== (event.description ?? '') || + (previous.location ?? '') !== (event.location ?? '') || (previous.postedBy ?? '') !== (event.postedBy ?? '') || (previous.url ?? '') !== (event.url ?? '') || + Boolean(previous.allDay) !== Boolean(event.allDay) + ); + const reactivated = previous?.status === 'cancelled'; + const kind = initialPoll ? undefined : previous === undefined || reactivated ? 'added' : changed ? 'changed' : undefined; + const sequence = (previous?.sequence ?? 0) + (kind ? 1 : 0); + const saved: StoredCalendarEvent = { ...event, sequence, status: 'confirmed' }; + + upsertEvent.run({ + uid: event.id, + title: event.title, + eventType: event.eventType, + start: event.start, + end: event.end, + description: event.description ?? null, + location: event.location ?? null, + postedBy: event.postedBy ?? null, + url: event.url ?? null, + allDay: event.allDay ? 1 : 0, + sourceMonth: event.sourceMonth, + sequence, + updatedAt: now, + }); + if (!initialPoll && kind) queueNotification(kind, saved, changed ? previous : undefined); + } + + if (!initialPoll && monthKeys.length > 0) { + const scopedRows = database.prepare( + `SELECT uid AS id, uid, title, event_type AS eventType, start, end, description, location, posted_by AS postedBy, url, all_day AS allDay, source_month AS sourceMonth, status, sequence + FROM events WHERE status = 'confirmed' AND source_month IN (${monthKeys.map(() => '?').join(', ')})`, + ).all(...monthKeys) as Record[]; + for (const row of scopedRows) { + const previous = mapRow(row); + if (presentUids.has(previous.id)) continue; + const removed: StoredCalendarEvent = { ...previous, status: 'cancelled', sequence: previous.sequence + 1 }; + cancelEvent.run(now, now, previous.id); + queueNotification('removed', removed); + } + } + + return { initialPoll }; + }); + return transaction(); +} + +export function getPendingNotifications(): PendingNotification[] { + return (pendingRows.all() as Record[]).map((row) => ({ + id: Number(row.id), + title: String(row.title), + body: String(row.body), + format: 'markdown', + ...(typeof row.link === 'string' ? { link: row.link } : {}), + })); +} + +export function markNotificationSent(id: number): void { + markSent.run(new Date().toISOString(), id); +} + +export function markNotificationFailed(id: number, error: string): void { + markFailed.run(error.slice(0, 1000), id); +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..d3e0ad4 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,34 @@ +export interface CalendarEvent { + id: string; + title: string; + start: string; + end: string; + description?: string; + location?: string; + url?: string; + allDay?: boolean; +} + +export interface StoredCalendarEvent extends CalendarEvent { + eventType: string; + postedBy?: string; + sourceMonth: string; + status: 'confirmed' | 'cancelled'; + sequence: number; +} + +export interface PendingNotification { + id: number; + title: string; + body: string; + format: 'markdown'; + link?: string; +} + +export interface PollStatus { + configured: boolean; + lastCheckedAt: string | null; + lastUpdatedAt: string | null; + lastError: string | null; + eventCount: number; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..f7a9c22 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "rootDir": "src", + "outDir": "dist", + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "sourceMap": true + }, + "include": ["src/**/*.ts"] +}