From 466be3505ed9b5cf63a41bf197c9f30e967466a9 Mon Sep 17 00:00:00 2001
From: Charlie <84845481+llamavert@users.noreply.github.com>
Date: Tue, 19 Aug 2025 20:08:12 +1000
Subject: [PATCH 1/3] V2 Improvements (#45)
---
.github/ISSUE_TEMPLATE/bug_report.md | 12 +-
.github/ISSUE_TEMPLATE/config.yml | 2 +-
.github/ISSUE_TEMPLATE/feature_request.md | 2 +-
.github/ISSUE_TEMPLATE/task_request.md | 9 +-
.github/PULL_REQUEST_TEMPLATE.md | 17 +-
.github/dependabot.yml | 18 +-
CODE_OF_CONDUCT.md | 22 +-
CONTRIBUTING.md | 5 +-
README.md | 3 +-
SECURITY.md | 8 +-
openapi.json | 856 ++++-
schema.sql | 50 +-
scripts/generate-openapi.mjs | 79 +-
src/index.ts | 2134 ++++++++---
src/network/connection.ts | 103 +-
src/services/airport.ts | 102 +-
src/services/auth.ts | 148 +-
src/services/bars/handlers.ts | 129 +-
src/services/cache.ts | 270 +-
src/services/contact.ts | 76 +
src/services/contributions.ts | 201 +-
src/services/database-context.ts | 287 +-
src/services/database-session.ts | 587 ++-
src/services/divisions.ts | 101 +-
src/services/faqs.ts | 72 +
src/services/github.ts | 384 +-
src/services/id.ts | 5 +-
src/services/notam.ts | 4 +-
src/services/points.ts | 151 +-
src/services/polygons.ts | 100 +-
src/services/posthog.ts | 237 +-
src/services/releases.ts | 71 +
src/services/roles.ts | 67 +-
src/services/service-pool.ts | 244 +-
src/services/support.ts | 2 +-
src/services/users.ts | 46 +-
src/services/vatsim.ts | 4 +-
src/services/xml-sanitizer.ts | 47 +
src/types.ts | 16 +-
worker-configuration.d.ts | 4129 ++++++++++++++++-----
40 files changed, 7828 insertions(+), 2972 deletions(-)
create mode 100644 src/services/contact.ts
create mode 100644 src/services/faqs.ts
create mode 100644 src/services/releases.ts
create mode 100644 src/services/xml-sanitizer.ts
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
index 771a644..0609f75 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.md
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -1,7 +1,7 @@
---
name: 🐛 Bug report
about: Report a problem or unexpected behavior
-title: "[BUG] "
+title: '[BUG] '
labels: ['bug']
assignees: []
---
@@ -11,6 +11,7 @@ A clear and concise description of what the bug is.
**How to reproduce**
Steps to reproduce the behavior (e.g.,):
+
1. Go to '...'
2. Click on '...'
3. Observe that '...' occurs
@@ -21,10 +22,11 @@ A clear and concise description of what you expected to happen.
**Screenshots or logs (if applicable)**
If applicable, add screenshots or copy/paste logs to help explain your problem.
-**Environment (if relevant)**
-- OS / Platform: [e.g. Windows 10, Ubuntu 20.04, macOS 11]
-- Version (if applicable): [e.g. 1.2.3]
+**Environment (if relevant)**
+
+- OS / Platform: [e.g. Windows 10, Ubuntu 20.04, macOS 11]
+- Version (if applicable): [e.g. 1.2.3]
- Additional info: [e.g. browser, plugin version, external dependency versions]
**Additional context**
-Add any other context about the problem here.
\ No newline at end of file
+Add any other context about the problem here.
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
index ec4bb38..3ba13e0 100644
--- a/.github/ISSUE_TEMPLATE/config.yml
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -1 +1 @@
-blank_issues_enabled: false
\ No newline at end of file
+blank_issues_enabled: false
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
index 3a7a797..c254c5e 100644
--- a/.github/ISSUE_TEMPLATE/feature_request.md
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -1,7 +1,7 @@
---
name: ✨ Feature request
about: Suggest a new feature or enhancement
-title: "[FEATURE] "
+title: '[FEATURE] '
labels: ['enhancement']
assignees: []
---
diff --git a/.github/ISSUE_TEMPLATE/task_request.md b/.github/ISSUE_TEMPLATE/task_request.md
index da046b2..3ec0b7a 100644
--- a/.github/ISSUE_TEMPLATE/task_request.md
+++ b/.github/ISSUE_TEMPLATE/task_request.md
@@ -1,7 +1,7 @@
---
name: ✅ Task / chore
about: A general task or chore (e.g., “update docs,” “refactor code”)
-title: "[TASK] "
+title: '[TASK] '
labels: ['task']
assignees: []
---
@@ -12,9 +12,10 @@ What needs to be done? Briefly describe the scope of the work.
**Why is this task needed?**
Explain why it’s important (e.g., “fixes a bug,” “improves performance,” “updates documentation”).
-**Acceptance criteria**
-- [ ] Criterion 1
-- [ ] Criterion 2
+**Acceptance criteria**
+
+- [ ] Criterion 1
+- [ ] Criterion 2
- [ ] Criterion 3 (if any)
**Additional notes**
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index 5f17f27..faa04a1 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -1,18 +1,21 @@
## Summary
-
+
## Changes Made
+
--
--
--
+
+-
+-
+-
## Additional Information
-
+
## Author Information
+
**Discord Username:**
**VATSIM CID:**
@@ -20,5 +23,5 @@
### Checklist:
-* [ ] Have you followed the guidelines in our Contributing document?
-* [ ] Have you checked to ensure there aren't other open Pull Requests for the same update/change?
+- [ ] Have you followed the guidelines in our Contributing document?
+- [ ] Have you checked to ensure there aren't other open Pull Requests for the same update/change?
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index bc61c55..5ce8324 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -1,17 +1,17 @@
version: 2
updates:
- - package-ecosystem: "npm"
- directory: "/"
+ - package-ecosystem: 'npm'
+ directory: '/'
schedule:
- interval: "weekly"
+ interval: 'weekly'
open-pull-requests-limit: 5
- - package-ecosystem: "cargo"
- directory: "/"
+ - package-ecosystem: 'cargo'
+ directory: '/'
schedule:
- interval: "weekly"
+ interval: 'weekly'
open-pull-requests-limit: 5
- - package-ecosystem: "nuget"
- directory: "/"
+ - package-ecosystem: 'nuget'
+ directory: '/'
schedule:
- interval: "weekly"
+ interval: 'weekly'
open-pull-requests-limit: 5
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
index 6079532..b21f9fd 100644
--- a/CODE_OF_CONDUCT.md
+++ b/CODE_OF_CONDUCT.md
@@ -17,23 +17,23 @@ diverse, inclusive, and healthy community.
Examples of behavior that contributes to a positive environment for our
community include:
-* Demonstrating empathy and kindness toward other people
-* Being respectful of differing opinions, viewpoints, and experiences
-* Giving and gracefully accepting constructive feedback
-* Accepting responsibility and apologizing to those affected by our mistakes,
+- Demonstrating empathy and kindness toward other people
+- Being respectful of differing opinions, viewpoints, and experiences
+- Giving and gracefully accepting constructive feedback
+- Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
-* Focusing on what is best not just for us as individuals, but for the overall
+- Focusing on what is best not just for us as individuals, but for the overall
community
Examples of unacceptable behavior include:
-* The use of sexualized language or imagery, and sexual attention or advances of
+- The use of sexualized language or imagery, and sexual attention or advances of
any kind
-* Trolling, insulting or derogatory comments, and personal or political attacks
-* Public or private harassment
-* Publishing others' private information, such as a physical or email address,
+- Trolling, insulting or derogatory comments, and personal or political attacks
+- Public or private harassment
+- Publishing others' private information, such as a physical or email address,
without their explicit permission
-* Other conduct which could reasonably be considered inappropriate in a
+- Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
@@ -129,4 +129,4 @@ For answers to common questions about this code of conduct, see the FAQ at
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
[Mozilla CoC]: https://github.com/mozilla/diversity
[FAQ]: https://www.contributor-covenant.org/faq
-[translations]: https://www.contributor-covenant.org/translations
\ No newline at end of file
+[translations]: https://www.contributor-covenant.org/translations
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 4f1db25..f53f724 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -20,6 +20,7 @@ Thank you for your interest in contributing to BARS Core! This guide will help y
cd Core
```
+
2. **Install Dependencies**
@@ -37,11 +38,9 @@ Thank you for your interest in contributing to BARS Core! This guide will help y
**Set up Cloudflare configuration:**
The `wrangler.toml` file is already configured and safe to use as-is. For local testing, you'll need to:
-
1. Create your own D1 SQL database in the [Cloudflare Dashboard](https://dash.cloudflare.com) (Storage & Databases > D1 SQL)
-
2. Edit `wrangler.toml` and update the database configuration (see comments in the file):
- `account_id`: Your Cloudflare account ID (found in dash.cloudflare.com/your-id/home)
- `VATSIM_CLIENT_ID`: Your VATSIM Connect application client ID
@@ -49,7 +48,6 @@ Thank you for your interest in contributing to BARS Core! This guide will help y
- `database_id`: Your D1 database ID (found in your database page)
-
3. Update `package.json` scripts to use your database name:
- Replace `bars-db` with your database name in the `update-db-local` and `update-db` scripts
- Example: `"update-db": "wrangler d1 execute bars-dev-example --remote --file schema.sql",`
@@ -72,7 +70,6 @@ Thank you for your interest in contributing to BARS Core! This guide will help y
Edit `.dev.vars` and add your API credentials:
-
- `VATSIM_CLIENT_SECRET`: Your VATSIM Connect application secret
- `AIRPORTDB_API_KEY`: Your [AirportDB](https://airportdb.io/) API key (optional for basic testing)
diff --git a/README.md b/README.md
index 5d9e161..cf627bc 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,6 @@

[](https://stopbars.com/discord)
-
Core is the foundational backend infrastructure that powers BARS. This service provides comprehensive APIs, backend systems, and real-time capabilities for all BARS services & applications across the product suite.
@@ -31,4 +30,4 @@ If you find a bug or have a feature suggestion, please submit an issue [on our G
## Disclaimer
-BARS is an **independent third-party** software project. **We are not affiliated** with, endorsed by, or connected to VATSIM, vatSys, Microsoft Flight Simulator, or any other simulation, controller client supported by our software.
\ No newline at end of file
+BARS is an **independent third-party** software project. **We are not affiliated** with, endorsed by, or connected to VATSIM, vatSys, Microsoft Flight Simulator, or any other simulation, controller client supported by our software.
diff --git a/SECURITY.md b/SECURITY.md
index db6c0ed..3d89592 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -4,10 +4,10 @@
We actively maintain and apply security patches to the latest stable version of this project.
-| Version | Supported |
-|---------|--------------------|
-| Latest | ✅ Yes |
-| Older | ❌ No |
+| Version | Supported |
+| ------- | --------- |
+| Latest | ✅ Yes |
+| Older | ❌ No |
## Reporting a Vulnerability
diff --git a/openapi.json b/openapi.json
index efbb193..1cba543 100644
--- a/openapi.json
+++ b/openapi.json
@@ -50,7 +50,7 @@
"description": "Creation and management of lighting/navigation point data."
},
{
- "name": "Support",
+ "name": "Generation",
"description": "Utilities for generating light support / BARS XML artifacts."
},
{
@@ -69,6 +69,10 @@
"name": "CDN",
"description": "File storage, upload, listing, and deletion via CDN-backed storage."
},
+ {
+ "name": "FAQ",
+ "description": "Frequently Asked Questions (FAQ) management and retrieval."
+ },
{
"name": "EuroScope",
"description": "EuroScope sector file upload, listing, and permission checks by ICAO."
@@ -87,6 +91,163 @@
}
],
"paths": {
+ "/contact": {
+ "post": {
+ "summary": "Submit a contact form",
+ "tags": ["Contact"],
+ "description": "Public endpoint to submit a contact/support message. Limited to 1 submission per 24 hours per IP.",
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["email", "topic", "message"],
+ "properties": {
+ "email": {
+ "type": "string",
+ "format": "email"
+ },
+ "topic": {
+ "type": "string"
+ },
+ "message": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "Message stored"
+ },
+ "400": {
+ "description": "Validation error"
+ },
+ "429": {
+ "description": "Rate limited (already submitted within 24h)"
+ }
+ }
+ },
+ "get": {
+ "summary": "List submitted contact messages",
+ "x-hidden": true,
+ "tags": ["Contact", "Staff"],
+ "description": "Returns all contact messages (newest first). Requires Product Manager or higher.",
+ "security": [
+ {
+ "VatsimToken": []
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Messages returned"
+ },
+ "401": {
+ "description": "Unauthorized"
+ },
+ "403": {
+ "description": "Forbidden"
+ }
+ }
+ }
+ },
+ "/contact/{id}/status": {
+ "patch": {
+ "summary": "Update contact message status",
+ "x-hidden": true,
+ "tags": ["Contact", "Staff"],
+ "description": "Set status to pending, handling, or handled. Requires Product Manager or higher.",
+ "security": [
+ {
+ "VatsimToken": []
+ }
+ ],
+ "parameters": [
+ {
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["status"],
+ "properties": {
+ "status": {
+ "type": "string",
+ "enum": ["pending", "handling", "handled"]
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Updated message returned"
+ },
+ "400": {
+ "description": "Invalid status"
+ },
+ "401": {
+ "description": "Unauthorized"
+ },
+ "403": {
+ "description": "Forbidden"
+ },
+ "404": {
+ "description": "Message not found"
+ }
+ }
+ }
+ },
+ "/contact/{id}": {
+ "delete": {
+ "summary": "Delete a contact message",
+ "x-hidden": true,
+ "tags": ["Contact", "Staff"],
+ "description": "Permanently deletes a contact message. Requires Product Manager or higher.",
+ "security": [
+ {
+ "VatsimToken": []
+ }
+ ],
+ "parameters": [
+ {
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "204": {
+ "description": "Deleted"
+ },
+ "401": {
+ "description": "Unauthorized"
+ },
+ "403": {
+ "description": "Forbidden"
+ },
+ "404": {
+ "description": "Not found"
+ }
+ }
+ }
+ },
"/connect": {
"get": {
"summary": "Establish a WebSocket for an airport",
@@ -201,6 +362,40 @@
}
}
},
+ "/auth/display-mode": {
+ "put": {
+ "summary": "Update preferred display name mode",
+ "tags": ["Auth"],
+ "security": [
+ {
+ "VatsimToken": []
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["mode"],
+ "properties": {
+ "mode": {
+ "type": "integer",
+ "enum": [0, 1, 2],
+ "description": "0=First,1=First LastInitial,2=CID"
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Updated"
+ }
+ }
+ }
+ },
"/auth/regenerate-api-key": {
"post": {
"summary": "Regenerate API key",
@@ -306,6 +501,44 @@
}
}
},
+ "/airports/nearest": {
+ "get": {
+ "summary": "Find nearest airport",
+ "tags": ["Airports"],
+ "description": "Returns the nearest airport to a given latitude/longitude. Results are cached in 5NM buckets for high performance.",
+ "parameters": [
+ {
+ "in": "query",
+ "name": "lat",
+ "required": true,
+ "description": "Latitude in decimal degrees (-90 to 90)",
+ "schema": {
+ "type": "number"
+ }
+ },
+ {
+ "in": "query",
+ "name": "lon",
+ "required": true,
+ "description": "Longitude in decimal degrees (-180 to 180)",
+ "schema": {
+ "type": "number"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Nearest airport returned"
+ },
+ "400": {
+ "description": "Invalid coordinates"
+ },
+ "404": {
+ "description": "No airport found"
+ }
+ }
+ }
+ },
"/divisions": {
"get": {
"summary": "List all divisions",
@@ -362,25 +595,10 @@
}
}
},
- "/divisions/user": {
- "get": {
- "summary": "Get divisions for current user",
- "tags": ["Divisions"],
- "security": [
- {
- "VatsimToken": []
- }
- ],
- "responses": {
- "200": {
- "description": "User divisions returned"
- }
- }
- }
- },
"/divisions/{id}": {
- "get": {
- "summary": "Get division details",
+ "put": {
+ "x-hidden": true,
+ "summary": "Update division name",
"tags": ["Divisions"],
"security": [
{
@@ -397,19 +615,37 @@
}
}
],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["name"],
+ "properties": {
+ "name": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
"responses": {
"200": {
- "description": "Division returned"
+ "description": "Division updated"
+ },
+ "403": {
+ "description": "Forbidden"
},
"404": {
"description": "Division not found"
}
}
- }
- },
- "/divisions/{id}/members": {
- "get": {
- "summary": "List division members",
+ },
+ "delete": {
+ "x-hidden": true,
+ "summary": "Delete a division",
"tags": ["Divisions"],
"security": [
{
@@ -427,17 +663,19 @@
}
],
"responses": {
- "200": {
- "description": "Members listed"
+ "204": {
+ "description": "Division deleted"
+ },
+ "403": {
+ "description": "Forbidden"
},
"404": {
"description": "Division not found"
}
}
},
- "post": {
- "x-hidden": true,
- "summary": "Add member to division",
+ "get": {
+ "summary": "Get division details",
"tags": ["Divisions"],
"security": [
{
@@ -454,23 +692,96 @@
}
}
],
- "requestBody": {
- "required": true,
- "content": {
- "application/json": {
- "schema": {
- "type": "object",
- "required": ["vatsimId", "role"],
- "properties": {
- "vatsimId": {
- "type": "string"
- },
- "role": {
- "type": "string"
- }
- }
- }
- }
+ "responses": {
+ "200": {
+ "description": "Division returned"
+ },
+ "404": {
+ "description": "Division not found"
+ }
+ }
+ }
+ },
+ "/divisions/user": {
+ "get": {
+ "summary": "Get divisions for current user",
+ "tags": ["Divisions"],
+ "security": [
+ {
+ "VatsimToken": []
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "User divisions returned"
+ }
+ }
+ }
+ },
+ "/divisions/{id}/members": {
+ "get": {
+ "summary": "List division members",
+ "tags": ["Divisions"],
+ "security": [
+ {
+ "VatsimToken": []
+ }
+ ],
+ "parameters": [
+ {
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Members listed"
+ },
+ "404": {
+ "description": "Division not found"
+ }
+ }
+ },
+ "post": {
+ "x-hidden": true,
+ "summary": "Add member to division",
+ "tags": ["Divisions"],
+ "security": [
+ {
+ "VatsimToken": []
+ }
+ ],
+ "parameters": [
+ {
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["vatsimId", "role"],
+ "properties": {
+ "vatsimId": {
+ "type": "string"
+ },
+ "role": {
+ "type": "string"
+ }
+ }
+ }
+ }
}
},
"responses": {
@@ -879,7 +1190,7 @@
"/supports/generate": {
"post": {
"summary": "Generate Light Supports and BARS XML",
- "tags": ["Support"],
+ "tags": ["Generation"],
"description": "Upload raw XML and generate both light supports XML and processed BARS XML.",
"requestBody": {
"required": true,
@@ -1070,6 +1381,94 @@
}
}
},
+ "/staff/manage": {
+ "get": {
+ "x-hidden": true,
+ "summary": "List staff members",
+ "tags": ["Staff"],
+ "security": [
+ {
+ "VatsimToken": []
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Staff listed"
+ },
+ "403": {
+ "description": "Forbidden"
+ }
+ }
+ },
+ "post": {
+ "x-hidden": true,
+ "summary": "Add or update a staff member",
+ "tags": ["Staff"],
+ "security": [
+ {
+ "VatsimToken": []
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["vatsimId", "role"],
+ "properties": {
+ "vatsimId": {
+ "type": "string"
+ },
+ "role": {
+ "type": "string",
+ "enum": ["LEAD_DEVELOPER", "PRODUCT_MANAGER"]
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Staff added/updated"
+ },
+ "403": {
+ "description": "Forbidden"
+ }
+ }
+ }
+ },
+ "/staff/manage/{vatsimId}": {
+ "delete": {
+ "x-hidden": true,
+ "summary": "Remove staff member",
+ "tags": ["Staff"],
+ "security": [
+ {
+ "VatsimToken": []
+ }
+ ],
+ "parameters": [
+ {
+ "in": "path",
+ "name": "vatsimId",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Staff removed"
+ },
+ "403": {
+ "description": "Forbidden"
+ }
+ }
+ }
+ },
"/contributions": {
"get": {
"summary": "List contributions",
@@ -1119,9 +1518,6 @@
"type": "object",
"required": ["airportIcao", "packageName", "submittedXml"],
"properties": {
- "userDisplayName": {
- "type": "string"
- },
"airportIcao": {
"type": "string"
},
@@ -1294,18 +1690,34 @@
}
}
},
- "/contributions/user/display-name": {
+ "/maps/{icao}/packages/{package}/latest": {
"get": {
- "summary": "Get display name for authenticated user",
- "tags": ["Contributions"],
- "security": [
+ "summary": "Get latest approved BARS map XML (raw content) for an airport & package",
+ "tags": ["Generation"],
+ "parameters": [
{
- "VatsimToken": []
+ "in": "path",
+ "name": "icao",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "in": "path",
+ "name": "package",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
}
],
"responses": {
"200": {
- "description": "Display name returned"
+ "description": "BARS XML document returned inline (application/xml)"
+ },
+ "404": {
+ "description": "Not found"
}
}
}
@@ -1548,6 +1960,165 @@
}
}
},
+ "/releases": {
+ "get": {
+ "summary": "List all product releases (optionally filtered)",
+ "tags": ["Installer"],
+ "parameters": [
+ {
+ "in": "query",
+ "name": "product",
+ "schema": {
+ "type": "string",
+ "enum": ["Pilot-Client", "vatSys-Plugin", "EuroScope-Plugin", "Installer", "SimConnect.NET"]
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Releases listed"
+ }
+ }
+ }
+ },
+ "/releases/latest": {
+ "get": {
+ "summary": "Get latest release for a product",
+ "tags": ["Installer"],
+ "parameters": [
+ {
+ "in": "query",
+ "name": "product",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "enum": ["Pilot-Client", "vatSys-Plugin", "EuroScope-Plugin", "Installer", "SimConnect.NET"]
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Latest release returned"
+ },
+ "404": {
+ "description": "Not found"
+ }
+ }
+ }
+ },
+ "/releases/upload": {
+ "post": {
+ "x-hidden": true,
+ "summary": "Create a new product release (lead developer only)",
+ "tags": ["Installer"],
+ "security": [
+ {
+ "VatsimToken": []
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "multipart/form-data": {
+ "schema": {
+ "type": "object",
+ "required": ["file", "product", "version"],
+ "properties": {
+ "file": {
+ "type": "string",
+ "format": "binary"
+ },
+ "product": {
+ "type": "string",
+ "enum": ["Pilot-Client", "vatSys-Plugin", "EuroScope-Plugin", "Installer", "SimConnect.NET"]
+ },
+ "version": {
+ "type": "string"
+ },
+ "changelog": {
+ "type": "string"
+ },
+ "image": {
+ "type": "string",
+ "format": "binary",
+ "description": "Optional promotional image (PNG/JPEG, max 5MB)"
+ }
+ }
+ }
+ }
+ }
+ },
+ "x-notes": [
+ "Product \"Installer\" requires an .exe file upload.",
+ "Product \"SimConnect.NET\" does not require a file upload (metadata + changelog only; version links to NuGet)."
+ ],
+ "responses": {
+ "201": {
+ "description": "Release created"
+ },
+ "403": {
+ "description": "Forbidden"
+ }
+ }
+ }
+ },
+ "/releases/{id}/changelog": {
+ "put": {
+ "x-hidden": true,
+ "summary": "Update changelog content for a release",
+ "description": "Update only the changelog text of an existing release record.",
+ "tags": ["Installer"],
+ "security": [
+ {
+ "VatsimToken": []
+ }
+ ],
+ "parameters": [
+ {
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["changelog"],
+ "properties": {
+ "changelog": {
+ "type": "string",
+ "maxLength": 20000
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Changelog updated"
+ },
+ "400": {
+ "description": "Validation error"
+ },
+ "401": {
+ "description": "Unauthorized"
+ },
+ "403": {
+ "description": "Forbidden"
+ },
+ "404": {
+ "description": "Release not found"
+ }
+ }
+ }
+ },
"/purge-cache": {
"post": {
"x-hidden": true,
@@ -1598,6 +2169,177 @@
}
}
},
+ "/faqs": {
+ "get": {
+ "summary": "List public FAQs",
+ "tags": ["FAQ"],
+ "responses": {
+ "200": {
+ "description": "FAQs returned"
+ }
+ }
+ }
+ },
+ "/staff/faqs": {
+ "post": {
+ "x-hidden": true,
+ "summary": "Create FAQ",
+ "tags": ["Staff", "FAQ"],
+ "security": [
+ {
+ "VatsimToken": []
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["question", "answer", "order_position"],
+ "properties": {
+ "question": {
+ "type": "string"
+ },
+ "answer": {
+ "type": "string"
+ },
+ "order_position": {
+ "type": "integer"
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "Created"
+ }
+ }
+ }
+ },
+ "/staff/faqs/{id}": {
+ "put": {
+ "x-hidden": true,
+ "summary": "Update FAQ",
+ "tags": ["Staff", "FAQ"],
+ "security": [
+ {
+ "VatsimToken": []
+ }
+ ],
+ "parameters": [
+ {
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "question": {
+ "type": "string"
+ },
+ "answer": {
+ "type": "string"
+ },
+ "order_position": {
+ "type": "integer"
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Updated"
+ },
+ "404": {
+ "description": "Not found"
+ }
+ }
+ },
+ "delete": {
+ "x-hidden": true,
+ "summary": "Delete FAQ",
+ "tags": ["Staff", "FAQ"],
+ "security": [
+ {
+ "VatsimToken": []
+ }
+ ],
+ "parameters": [
+ {
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Deletion result"
+ }
+ }
+ }
+ },
+ "/staff/faqs/reorder": {
+ "post": {
+ "x-hidden": true,
+ "summary": "Bulk reorder FAQs",
+ "tags": ["Staff", "FAQ"],
+ "security": [
+ {
+ "VatsimToken": []
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["updates"],
+ "properties": {
+ "updates": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": ["id", "order_position"],
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "order_position": {
+ "type": "integer"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Reordered"
+ }
+ }
+ }
+ },
"/health": {
"get": {
"summary": "System/service health check",
diff --git a/schema.sql b/schema.sql
index 5c36abb..9531d4d 100644
--- a/schema.sql
+++ b/schema.sql
@@ -5,6 +5,9 @@ CREATE TABLE IF NOT EXISTS users (
api_key TEXT NOT NULL,
last_api_key_regen DATETIME DEFAULT CURRENT_TIMESTAMP,
email TEXT NOT NULL,
+ full_name TEXT, -- Stored full name from VATSIM (first + last)
+ display_mode INTEGER NOT NULL DEFAULT 0,
+ display_name TEXT, -- Cached computed display name
created_at TEXT NOT NULL,
last_login TEXT NOT NULL
);
@@ -101,7 +104,6 @@ CREATE TABLE IF NOT EXISTS active_objects (
CREATE TABLE IF NOT EXISTS contributions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
- user_display_name TEXT,
airport_icao TEXT NOT NULL,
package_name TEXT NOT NULL,
submitted_xml TEXT NOT NULL,
@@ -155,4 +157,48 @@ CREATE INDEX IF NOT EXISTS idx_division_airports_composite ON division_airports(
CREATE INDEX IF NOT EXISTS idx_division_airports_icao ON division_airports(icao);
-- Points table composite index
-CREATE INDEX IF NOT EXISTS idx_points_airport_type ON points(airport_id, type);
\ No newline at end of file
+CREATE INDEX IF NOT EXISTS idx_points_airport_type ON points(airport_id, type);
+
+-- FAQs table for public frequently asked questions
+CREATE TABLE IF NOT EXISTS faqs (
+ id TEXT PRIMARY KEY,
+ question TEXT NOT NULL,
+ answer TEXT NOT NULL,
+ order_position INTEGER NOT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE INDEX IF NOT EXISTS idx_faqs_order ON faqs(order_position ASC);
+
+-- Installer releases table for distributable products
+CREATE TABLE IF NOT EXISTS installer_releases (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ product TEXT NOT NULL, -- Pilot-Client | vatSys-Plugin | EuroScope-Plugin | Installer | SimConnect.NET (external NuGet, no binary stored)
+ version TEXT NOT NULL,
+ file_key TEXT NOT NULL,
+ file_size INTEGER NOT NULL,
+ file_hash TEXT NOT NULL, -- sha256 hex
+ changelog TEXT,
+ image_url TEXT, -- Optional promotional image
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE(product, version)
+);
+CREATE INDEX IF NOT EXISTS idx_installer_releases_product ON installer_releases(product);
+CREATE INDEX IF NOT EXISTS idx_installer_releases_created_at ON installer_releases(created_at DESC);
+
+-- Contact messages table for public contact form submissions
+CREATE TABLE IF NOT EXISTS contact_messages (
+ id TEXT PRIMARY KEY, -- uuid
+ email TEXT NOT NULL,
+ topic TEXT NOT NULL,
+ message TEXT NOT NULL,
+ ip_address TEXT NOT NULL,
+ status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','handling','handled')),
+ handled_by TEXT,
+ handled_at DATETIME,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+);
+CREATE INDEX IF NOT EXISTS idx_contact_messages_created_at ON contact_messages(created_at DESC);
+CREATE INDEX IF NOT EXISTS idx_contact_messages_ip_created ON contact_messages(ip_address, created_at DESC);
+CREATE INDEX IF NOT EXISTS idx_contact_messages_status ON contact_messages(status);
\ No newline at end of file
diff --git a/scripts/generate-openapi.mjs b/scripts/generate-openapi.mjs
index 76e2cfd..a068bf8 100644
--- a/scripts/generate-openapi.mjs
+++ b/scripts/generate-openapi.mjs
@@ -9,45 +9,46 @@ const __dirname = path.dirname(__filename);
const root = path.resolve(__dirname, '..');
const options = {
- definition: {
- openapi: '3.0.4',
- info: {
- title: 'BARS Core API',
- version: '2.0.0',
- description: 'API documentation for BARS Core',
- contact: {
- name: 'BARS Support',
- email: 'support@stopbars.com',
- url: 'https://stopbars.com/support'
- }
- },
- externalDocs: {
- description: 'Find more info here',
- url: 'https://docs.stopbars.com'
- },
- servers: [
- { url: 'https://v2.stopbars.com', description: 'Production' },
- { url: 'http://localhost:8787', description: 'Local development (wrangler dev)' }
- ],
- tags: [
- { name: 'RealTime', description: 'WebSocket connection and real-time state interaction endpoints.' },
- { name: 'State', description: 'Endpoints for retrieving current system or airport lighting/network state.' },
- { name: 'Auth', description: 'Authentication, account management, and API key lifecycle.' },
- { name: 'Airports', description: 'Lookup and metadata endpoints for airports.' },
- { name: 'Divisions', description: 'Division management, membership, and associated airport access.' },
- { name: 'Points', description: 'Creation and management of lighting/navigation point data.' },
- { name: 'Support', description: 'Utilities for generating light support / BARS XML artifacts.' },
- { name: 'NOTAM', description: 'Global NOTAM retrieval and (staff) updates.' },
- { name: 'Contributions', description: 'Community lighting package submission, review, and leaderboard.' },
- { name: 'Staff', description: 'Restricted staff-only operational and moderation endpoints (hidden from public docs).' },
- { name: 'CDN', description: 'File storage, upload, listing, and deletion via CDN-backed storage.' },
- { name: 'EuroScope', description: 'EuroScope sector file upload, listing, and permission checks by ICAO.' },
- { name: 'Cache', description: 'Administrative cache management operations.' },
- { name: 'GitHub', description: 'Repository contributor information.' },
- { name: 'System', description: 'System health and OpenAPI specification discovery.' }
- ]
- },
- apis: [path.join(root, 'src', '**', '*.ts')]
+ definition: {
+ openapi: '3.0.4',
+ info: {
+ title: 'BARS Core API',
+ version: '2.0.0',
+ description: 'API documentation for BARS Core',
+ contact: {
+ name: 'BARS Support',
+ email: 'support@stopbars.com',
+ url: 'https://stopbars.com/support',
+ },
+ },
+ externalDocs: {
+ description: 'Find more info here',
+ url: 'https://docs.stopbars.com',
+ },
+ servers: [
+ { url: 'https://v2.stopbars.com', description: 'Production' },
+ { url: 'http://localhost:8787', description: 'Local development (wrangler dev)' },
+ ],
+ tags: [
+ { name: 'RealTime', description: 'WebSocket connection and real-time state interaction endpoints.' },
+ { name: 'State', description: 'Endpoints for retrieving current system or airport lighting/network state.' },
+ { name: 'Auth', description: 'Authentication, account management, and API key lifecycle.' },
+ { name: 'Airports', description: 'Lookup and metadata endpoints for airports.' },
+ { name: 'Divisions', description: 'Division management, membership, and associated airport access.' },
+ { name: 'Points', description: 'Creation and management of lighting/navigation point data.' },
+ { name: 'Generation', description: 'Utilities for generating light support / BARS XML artifacts.' },
+ { name: 'NOTAM', description: 'Global NOTAM retrieval and (staff) updates.' },
+ { name: 'Contributions', description: 'Community lighting package submission, review, and leaderboard.' },
+ { name: 'Staff', description: 'Restricted staff-only operational and moderation endpoints (hidden from public docs).' },
+ { name: 'CDN', description: 'File storage, upload, listing, and deletion via CDN-backed storage.' },
+ { name: 'FAQ', description: 'Frequently Asked Questions (FAQ) management and retrieval.' },
+ { name: 'EuroScope', description: 'EuroScope sector file upload, listing, and permission checks by ICAO.' },
+ { name: 'Cache', description: 'Administrative cache management operations.' },
+ { name: 'GitHub', description: 'Repository contributor information.' },
+ { name: 'System', description: 'System health and OpenAPI specification discovery.' },
+ ],
+ },
+ apis: [path.join(root, 'src', '**', '*.ts')],
};
const openapiSpec = swaggerJsdoc(options);
diff --git a/src/index.ts b/src/index.ts
index cdf6e4a..187b721 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -5,14 +5,13 @@ import { PointChangeset, PointData } from './types';
import { VatsimService } from './services/vatsim';
import { AuthService } from './services/auth';
import { StaffRole } from './services/roles';
+import { InstallerProduct } from './services/releases';
import { Connection } from './network/connection';
import { UserService } from './services/users';
import { DatabaseContextFactory } from './services/database-context';
import { withCache, CacheKeys } from './services/cache';
import { ServicePool } from './services/service-pool';
-import { PostHogService } from './services/posthog';
-
-// Shared point regex
+import { sanitizeContributionXml } from './services/xml-sanitizer';
const POINT_ID_REGEX = /^[A-Z0-9-_]+$/;
interface CreateDivisionPayload {
@@ -34,7 +33,6 @@ interface ApproveAirportPayload {
}
interface ContributionSubmissionPayload {
- userDisplayName?: string;
airportIcao: string;
packageName: string;
submittedXml: string;
@@ -72,24 +70,25 @@ const app = new Hono<{
auth?: any;
vatsim?: any;
userService?: any;
+ clientIp?: string;
};
}>();
-// Analytics middleware (PostHog) – skip clearly useless noise (OPTIONS, favicon, configured ignores)
app.use('*', async (c, next) => {
const start = Date.now();
await next();
try {
const url = new URL(c.req.url);
const path = url.pathname;
- // Basic noise filters
- if (c.req.method === 'OPTIONS') return; // CORS preflight
+ if (c.req.method === 'OPTIONS') return;
if (path === '/favicon.ico') return;
if (path.includes('/health')) return;
- // Env-driven ignore list: comma separated exact paths or prefix* globs
const ignoreRaw = (c.env as any).ANALYTICS_IGNORE as string | undefined;
if (ignoreRaw) {
- const ignores = ignoreRaw.split(',').map(s => s.trim()).filter(Boolean);
+ const ignores = ignoreRaw
+ .split(',')
+ .map((s) => s.trim())
+ .filter(Boolean);
for (const pattern of ignores) {
if (pattern.endsWith('*')) {
const prefix = pattern.slice(0, -1);
@@ -100,26 +99,288 @@ app.use('*', async (c, next) => {
}
}
const posthog = ServicePool.getPostHog(c.env);
- posthog.track('API Request', {
- path,
- method: c.req.method,
- status: c.res?.status ?? 0,
- duration_ms: Date.now() - start,
- }, 'anonymous');
+ posthog.track(
+ 'API Request',
+ {
+ path,
+ method: c.req.method,
+ status: c.res?.status ?? 0,
+ duration_ms: Date.now() - start,
+ },
+ 'anonymous',
+ );
} catch (err) {
// eslint-disable-next-line no-console
console.warn('[Analytics] failed', err instanceof Error ? err.message : err);
}
});
-// Add CORS middleware
-app.use('*', cors({
- origin: '*',
- allowHeaders: ['Content-Type', 'Authorization', 'X-Vatsim-Token', 'Upgrade', 'X-Client-Type'],
- allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
-}));
+app.use(
+ '*',
+ cors({
+ origin: '*',
+ allowHeaders: ['Content-Type', 'Authorization', 'X-Vatsim-Token', 'Upgrade', 'X-Client-Type'],
+ allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
+ }),
+);
+
+// Extract client IP (best-effort) and attach to context
+app.use('*', async (c, next) => {
+ const cf = c.req.header('CF-Connecting-IP');
+ const real = c.req.header('X-Real-IP');
+ const fwdFor = c.req.header('X-Forwarded-For');
+ const forwarded = c.req.header('Forwarded');
+ let ip: string | undefined = cf || real;
+ if (!ip && fwdFor) {
+ ip = fwdFor.split(',')[0].trim();
+ }
+ if (!ip && forwarded) {
+ // Forwarded: for=1.2.3.4; proto=http; by=...
+ const match = forwarded.match(/for=([^;]+)/i);
+ if (match) ip = match[1].replace(/"/g, '');
+ }
+ c.set('clientIp', ip || '0.0.0.0');
+ await next();
+});
+
+/**
+ * @openapi
+ * /contact:
+ * post:
+ * summary: Submit a contact form
+ * tags:
+ * - Contact
+ * description: Public endpoint to submit a contact/support message. Limited to 1 submission per 24 hours per IP.
+ * requestBody:
+ * required: true
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * required: [email, topic, message]
+ * properties:
+ * email:
+ * type: string
+ * format: email
+ * topic:
+ * type: string
+ * message:
+ * type: string
+ * responses:
+ * 201:
+ * description: Message stored
+ * 400:
+ * description: Validation error
+ * 429:
+ * description: Rate limited (already submitted within 24h)
+ */
+app.post('/contact', async (c) => {
+ const dbContext = DatabaseContextFactory.createRequestContext(c.env.DB, c.req.raw);
+ try {
+ let body: any;
+ try {
+ body = await c.req.json();
+ } catch {
+ return dbContext.jsonResponse({ error: 'Invalid JSON body' }, { status: 400 });
+ }
+ const email = typeof body.email === 'string' ? body.email.trim() : '';
+ const topic = typeof body.topic === 'string' ? body.topic.trim() : '';
+ const message = typeof body.message === 'string' ? body.message.trim() : '';
+ const ip = c.get('clientIp') || '0.0.0.0';
+
+ const emailRegex = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
+ if (!email || !emailRegex.test(email)) {
+ return dbContext.jsonResponse({ error: 'Invalid email' }, { status: 400 });
+ }
+ if (!topic || topic.length < 3 || topic.length > 120) {
+ return dbContext.jsonResponse({ error: 'Invalid topic', message: 'topic must be 3-120 chars' }, { status: 400 });
+ }
+ if (!message || message.length < 5 || message.length > 4000) {
+ return dbContext.jsonResponse({ error: 'Invalid message', message: 'message must be 5-4000 chars' }, { status: 400 });
+ }
+
+ const contact = ServicePool.getContact(c.env);
+ const already = await contact.hasRecentSubmissionFromIp(ip, 24);
+ if (already) {
+ return dbContext.jsonResponse(
+ { error: 'Rate limited', message: 'Only one submission per 24 hours from this IP' },
+ { status: 429 },
+ );
+ }
+ const stored = await contact.createMessage(email, topic, message, ip);
+ return dbContext.jsonResponse({ success: true, id: stored.id, created_at: stored.created_at }, { status: 201 });
+ } finally {
+ dbContext.close();
+ }
+});
+
+/**
+ * @openapi
+ * /contact:
+ * get:
+ * summary: List submitted contact messages
+ * x-hidden: true
+ * tags:
+ * - Contact
+ * - Staff
+ * description: Returns all contact messages (newest first). Requires Product Manager or higher.
+ * security:
+ * - VatsimToken: []
+ * responses:
+ * 200:
+ * description: Messages returned
+ * 401:
+ * description: Unauthorized
+ * 403:
+ * description: Forbidden
+ */
+app.get('/contact', async (c) => {
+ const vatsimToken = c.req.header('X-Vatsim-Token');
+ if (!vatsimToken) return c.text('Unauthorized', 401);
+
+ const dbContext = DatabaseContextFactory.createRequestContext(c.env.DB, c.req.raw);
+ try {
+ const vatsim = ServicePool.getVatsim(c.env);
+ const auth = ServicePool.getAuth(c.env);
+ const roles = ServicePool.getRoles(c.env);
+ const vatsimUser = await vatsim.getUser(vatsimToken);
+ const user = await auth.getUserByVatsimId(vatsimUser.id);
+ if (!user) return dbContext.textResponse('Unauthorized', { status: 401 });
+
+ const allowed = await roles.hasPermission(user.id, StaffRole.PRODUCT_MANAGER);
+ if (!allowed) return dbContext.textResponse('Forbidden', { status: 403 });
+
+ const contact = ServicePool.getContact(c.env);
+ const messages = await contact.listMessages();
+ return dbContext.jsonResponse({ messages });
+ } finally {
+ dbContext.close();
+ }
+});
+
+/**
+ * @openapi
+ * /contact/{id}/status:
+ * patch:
+ * summary: Update contact message status
+ * x-hidden: true
+ * tags:
+ * - Contact
+ * - Staff
+ * description: Set status to pending, handling, or handled. Requires Product Manager or higher.
+ * security:
+ * - VatsimToken: []
+ * parameters:
+ * - in: path
+ * name: id
+ * required: true
+ * schema:
+ * type: string
+ * requestBody:
+ * required: true
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * required: [status]
+ * properties:
+ * status:
+ * type: string
+ * enum: [pending, handling, handled]
+ * responses:
+ * 200:
+ * description: Updated message returned
+ * 400:
+ * description: Invalid status
+ * 401:
+ * description: Unauthorized
+ * 403:
+ * description: Forbidden
+ * 404:
+ * description: Message not found
+ */
+app.patch('/contact/:id/status', async (c) => {
+ const vatsimToken = c.req.header('X-Vatsim-Token');
+ if (!vatsimToken) return c.text('Unauthorized', 401);
+ const id = c.req.param('id');
+ const dbContext = DatabaseContextFactory.createRequestContext(c.env.DB, c.req.raw);
+ try {
+ let body: any;
+ try { body = await c.req.json(); } catch { return dbContext.jsonResponse({ error: 'Invalid JSON body' }, { status: 400 }); }
+ const status = body?.status;
+ if (!['pending', 'handling', 'handled'].includes(status)) {
+ return dbContext.jsonResponse({ error: 'Invalid status' }, { status: 400 });
+ }
+ const vatsim = ServicePool.getVatsim(c.env);
+ const auth = ServicePool.getAuth(c.env);
+ const roles = ServicePool.getRoles(c.env);
+ const vatsimUser = await vatsim.getUser(vatsimToken);
+ const user = await auth.getUserByVatsimId(vatsimUser.id);
+ if (!user) return dbContext.textResponse('Unauthorized', { status: 401 });
+ const allowed = await roles.hasPermission(user.id, StaffRole.PRODUCT_MANAGER);
+ if (!allowed) return dbContext.textResponse('Forbidden', { status: 403 });
+ const contact = ServicePool.getContact(c.env);
+ const existing = await contact.getMessage(id);
+ if (!existing) return dbContext.textResponse('Not found', { status: 404 });
+ const updated = await contact.updateStatus(id, status, user.vatsim_id);
+ return dbContext.jsonResponse({ message: updated });
+ } finally {
+ dbContext.close();
+ }
+});
+
+/**
+ * @openapi
+ * /contact/{id}:
+ * delete:
+ * summary: Delete a contact message
+ * x-hidden: true
+ * tags:
+ * - Contact
+ * - Staff
+ * description: Permanently deletes a contact message. Requires Product Manager or higher.
+ * security:
+ * - VatsimToken: []
+ * parameters:
+ * - in: path
+ * name: id
+ * required: true
+ * schema:
+ * type: string
+ * responses:
+ * 204:
+ * description: Deleted
+ * 401:
+ * description: Unauthorized
+ * 403:
+ * description: Forbidden
+ * 404:
+ * description: Not found
+ */
+app.delete('/contact/:id', async (c) => {
+ const vatsimToken = c.req.header('X-Vatsim-Token');
+ if (!vatsimToken) return c.text('Unauthorized', 401);
+ const id = c.req.param('id');
+ const dbContext = DatabaseContextFactory.createRequestContext(c.env.DB, c.req.raw);
+ try {
+ const vatsim = ServicePool.getVatsim(c.env);
+ const auth = ServicePool.getAuth(c.env);
+ const roles = ServicePool.getRoles(c.env);
+ const vatsimUser = await vatsim.getUser(vatsimToken);
+ const user = await auth.getUserByVatsimId(vatsimUser.id);
+ if (!user) return dbContext.textResponse('Unauthorized', { status: 401 });
+ const allowed = await roles.hasPermission(user.id, StaffRole.PRODUCT_MANAGER);
+ if (!allowed) return dbContext.textResponse('Forbidden', { status: 403 });
+ const contact = ServicePool.getContact(c.env);
+ const existing = await contact.getMessage(id);
+ if (!existing) return dbContext.textResponse('Not found', { status: 404 });
+ await contact.deleteMessage(id);
+ return dbContext.textResponse('', { status: 204 });
+ } finally {
+ dbContext.close();
+ }
+});
-// Connect endpoint
/**
* @openapi
* /connect:
@@ -159,9 +420,12 @@ app.use('*', cors({
app.get('/connect', async (c) => {
const upgradeHeader = c.req.header('Upgrade');
if (upgradeHeader !== 'websocket') {
- return c.json({
- message: 'This endpoint is for WebSocket connections only. Use a WebSocket client to test.',
- }, 400);
+ return c.json(
+ {
+ message: 'This endpoint is for WebSocket connections only. Use a WebSocket client to test.',
+ },
+ 400,
+ );
}
const airportId = c.req.query('airport');
@@ -214,9 +478,12 @@ app.get('/connect', async (c) => {
app.get('/state', async (c) => {
const airport = c.req.query('airport');
if (!airport) {
- return c.json({
- error: 'Airport parameter required',
- }, 400);
+ return c.json(
+ {
+ error: 'Airport parameter required',
+ },
+ 400,
+ );
}
// Create database context for this request with bookmark handling
@@ -228,7 +495,7 @@ app.get('/state', async (c) => {
await dbContext.db.executeWrite("DELETE FROM active_objects WHERE last_updated <= datetime('now', '-2 day')");
const activeObjectsResult = await dbContext.db.executeRead(
- "SELECT id, name FROM active_objects WHERE last_updated > datetime('now', '-2 day')"
+ "SELECT id, name FROM active_objects WHERE last_updated > datetime('now', '-2 day')",
);
const allStates = await Promise.all(
@@ -275,9 +542,12 @@ app.get('/state', async (c) => {
const obj = c.env.BARS.get(id);
if (airport.length !== 4) {
- return dbContext.jsonResponse({
- error: 'Invalid airport ICAO',
- }, { status: 400 });
+ return dbContext.jsonResponse(
+ {
+ error: 'Invalid airport ICAO',
+ },
+ { status: 400 },
+ );
}
const stateRequest = new Request(`https://internal/state?airport=${airport}`, {
@@ -360,21 +630,100 @@ app.get('/auth/account', async (c) => {
const auth = ServicePool.getAuth(c.env);
const vatsimUser = await vatsim.getUser(vatsimToken);
- const user = await auth.getUserByVatsimId(vatsimUser.id);
+ let user = await auth.getUserByVatsimId(vatsimUser.id);
if (!user) {
return dbContext.textResponse('User not found', { status: 404 });
}
+ // Backfill full_name if missing locally but available from VATSIM
+ if ((!user.full_name || user.full_name.trim() === '') && (vatsimUser.first_name || vatsimUser.last_name)) {
+ const newFullName = [vatsimUser.first_name, vatsimUser.last_name].filter(Boolean).join(' ').trim();
+ if (newFullName) {
+ try {
+ await auth.updateFullName(user.id, newFullName);
+ } catch {
+ /* ignore */
+ }
+ const refreshed = await auth.getUserByVatsimId(vatsimUser.id);
+ if (refreshed) user = refreshed;
+ }
+ }
return dbContext.jsonResponse({
- ...user,
+ id: user.id,
+ vatsim_id: user.vatsim_id,
email: vatsimUser.email,
+ api_key: user.api_key,
+ full_name: user.full_name || null,
+ display_mode: user.display_mode ?? 0,
+ display_name: user.display_name || auth.computeDisplayName(user, vatsimUser),
+ created_at: user.created_at,
+ last_login: user.last_login,
});
} finally {
dbContext.close();
}
});
-// Regenerate API key
+/**
+ * @openapi
+ * /auth/display-mode:
+ * put:
+ * summary: Update preferred display name mode
+ * tags:
+ * - Auth
+ * security:
+ * - VatsimToken: []
+ * requestBody:
+ * required: true
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * required: [mode]
+ * properties:
+ * mode:
+ * type: integer
+ * enum: [0,1,2]
+ * description: 0=First,1=First LastInitial,2=CID
+ * responses:
+ * 200:
+ * description: Updated
+ */
+app.put('/auth/display-mode', async (c) => {
+ const vatsimToken = c.req.header('X-Vatsim-Token');
+ if (!vatsimToken) return c.text('Unauthorized', 401);
+
+ let body: any;
+ try {
+ body = await c.req.json();
+ } catch {
+ return c.json({ error: 'Invalid JSON body' }, 400);
+ }
+
+ const rawMode = body?.mode;
+ const mode = Number(rawMode);
+ if (!Number.isInteger(mode) || ![0, 1, 2].includes(mode)) {
+ return c.json({ error: 'Invalid mode', message: 'mode must be integer 0,1,2' }, 400);
+ }
+
+ const dbContext = DatabaseContextFactory.createRequestContext(c.env.DB, c.req.raw);
+ try {
+ const vatsim = ServicePool.getVatsim(c.env);
+ const auth = ServicePool.getAuth(c.env);
+ const vatsimUser = await vatsim.getUser(vatsimToken);
+ const user = await auth.getUserByVatsimId(vatsimUser.id);
+ if (!user) return dbContext.textResponse('User not found', { status: 404 });
+ await auth.updateDisplayMode(user.id, mode);
+ return dbContext.jsonResponse({ mode });
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : 'Unknown error';
+ const status = msg.includes('Invalid display mode') ? 400 : 500;
+ return dbContext.jsonResponse({ error: 'Failed to update display mode', message: msg }, { status });
+ } finally {
+ dbContext.close();
+ }
+});
+
/**
* @openapi
* /auth/regenerate-api-key:
@@ -418,7 +767,7 @@ app.post('/auth/regenerate-api-key', async (c) => {
// Check when the user last regenerated their API key using session-aware query
const lastRegenerationResult = await dbContext.db.executeRead<{ last_api_key_regen: string }>(
'SELECT last_api_key_regen FROM users WHERE id = ?',
- [user.id]
+ [user.id],
);
const lastRegeneration = lastRegenerationResult.results[0];
@@ -435,11 +784,14 @@ app.post('/auth/regenerate-api-key', async (c) => {
const remainingHours = Math.floor(remainingMs / (60 * 60 * 1000));
const remainingMinutes = Math.floor((remainingMs % (60 * 60 * 1000)) / (60 * 1000));
- return dbContext.jsonResponse({
- error: 'Rate limited',
- message: `You can only regenerate your API key once every 24 hours. Please try again in ${remainingHours} hour${remainingHours !== 1 ? 's' : ''}${remainingMinutes > 0 ? ` and ${remainingMinutes} minute${remainingMinutes !== 1 ? 's' : ''}` : ''}.`,
- retryAfter: Math.ceil(remainingMs / 1000),
- }, { status: 429 });
+ return dbContext.jsonResponse(
+ {
+ error: 'Rate limited',
+ message: `You can only regenerate your API key once every 24 hours. Please try again in ${remainingHours} hour${remainingHours !== 1 ? 's' : ''}${remainingMinutes > 0 ? ` and ${remainingMinutes} minute${remainingMinutes !== 1 ? 's' : ''}` : ''}.`,
+ retryAfter: Math.ceil(remainingMs / 1000),
+ },
+ { status: 429 },
+ );
}
}
@@ -447,26 +799,25 @@ app.post('/auth/regenerate-api-key', async (c) => {
const newApiKey = await auth.regenerateApiKey(user.id);
// Update the last regeneration timestamp using session-aware write
- await dbContext.db.executeWrite(
- "UPDATE users SET last_api_key_regen = datetime('now') WHERE id = ?",
- [user.id]
- );
+ await dbContext.db.executeWrite("UPDATE users SET last_api_key_regen = datetime('now') WHERE id = ?", [user.id]);
return dbContext.jsonResponse({
success: true,
apiKey: newApiKey,
});
} catch (error) {
- return dbContext.jsonResponse({
- error: 'Failed to regenerate API key',
- message: error instanceof Error ? error.message : 'Unknown error',
- }, { status: 500 });
+ return dbContext.jsonResponse(
+ {
+ error: 'Failed to regenerate API key',
+ message: error instanceof Error ? error.message : 'Unknown error',
+ },
+ { status: 500 },
+ );
} finally {
dbContext.close();
}
});
-// Delete account
/**
* @openapi
* /auth/delete:
@@ -505,7 +856,6 @@ app.delete('/auth/delete', async (c) => {
}
});
-// Check if staff
/**
* @openapi
* /auth/is-staff:
@@ -522,33 +872,30 @@ app.delete('/auth/delete', async (c) => {
* 401:
* description: Unauthorized
*/
-app.get('/auth/is-staff',
- withCache(CacheKeys.withUser('is-staff'), 3600, 'auth'),
- async (c) => {
- const authHeader = c.req.header('Authorization');
- if (!authHeader) {
- return c.text('Unauthorized', 401);
- }
+app.get('/auth/is-staff', withCache(CacheKeys.withUser('is-staff'), 3600, 'auth'), async (c) => {
+ const authHeader = c.req.header('Authorization');
+ if (!authHeader) {
+ return c.text('Unauthorized', 401);
+ }
- const token = authHeader.replace('Bearer ', '');
+ const token = authHeader.replace('Bearer ', '');
- const vatsim = ServicePool.getVatsim(c.env);
- const auth = ServicePool.getAuth(c.env);
- const roles = ServicePool.getRoles(c.env);
+ const vatsim = ServicePool.getVatsim(c.env);
+ const auth = ServicePool.getAuth(c.env);
+ const roles = ServicePool.getRoles(c.env);
- const vatsimUser = await vatsim.getUser(token);
- const user = await auth.getUserByVatsimId(vatsimUser.id);
+ const vatsimUser = await vatsim.getUser(token);
+ const user = await auth.getUserByVatsimId(vatsimUser.id);
- if (!user) {
- return c.text('Unauthorized', 401);
- }
+ if (!user) {
+ return c.text('Unauthorized', 401);
+ }
- const isStaff = await roles.isStaff(user.id);
- const role = await roles.getUserRole(user.id);
- return c.json({ isStaff, role });
- });
+ const isStaff = await roles.isStaff(user.id);
+ const role = await roles.getUserRole(user.id);
+ return c.json({ isStaff, role });
+});
-// Airports endpoint
/**
* @openapi
* /airports:
@@ -577,7 +924,8 @@ app.get('/auth/is-staff',
* 404:
* description: Airport not found
*/
-app.get('/airports',
+app.get(
+ '/airports',
withCache(CacheKeys.fromUrl, 31536000, 'airports'), // Cache for 1 year because airports data doesn't change ever :P
async (c) => {
const airports = ServicePool.getAirport(c.env);
@@ -611,10 +959,76 @@ app.get('/airports',
} catch (error) {
return c.json({ error: 'Failed to fetch airport data' }, 500);
}
- }
+ },
+);
+
+/**
+ * @openapi
+ * /airports/nearest:
+ * get:
+ * summary: Find nearest airport
+ * tags:
+ * - Airports
+ * description: Returns the nearest airport to a given latitude/longitude. Results are cached in 5NM buckets for high performance.
+ * parameters:
+ * - in: query
+ * name: lat
+ * required: true
+ * description: Latitude in decimal degrees (-90 to 90)
+ * schema: { type: number }
+ * - in: query
+ * name: lon
+ * required: true
+ * description: Longitude in decimal degrees (-180 to 180)
+ * schema: { type: number }
+ * responses:
+ * 200:
+ * description: Nearest airport returned
+ * 400:
+ * description: Invalid coordinates
+ * 404:
+ * description: No airport found
+ */
+app.get(
+ '/airports/nearest',
+ withCache(
+ (req) => {
+ // Bucket cache key by ~5NM (~9.26km). 1 degree lat ~111km => bucket size deg ≈ 9.26/111 ≈ 0.083
+ const url = new URL(req.url);
+ const lat = parseFloat(url.searchParams.get('lat') || '0');
+ const lon = parseFloat(url.searchParams.get('lon') || '0');
+ const bucketDeg = 0.083; // ~5NM
+ const bucketLat = Math.round(lat / bucketDeg);
+ const bucketLon = Math.round(lon / bucketDeg);
+ return `/airports/nearest/${bucketLat}_${bucketLon}`;
+ },
+ 600,
+ 'airports',
+ ),
+ async (c) => {
+ const latStr = c.req.query('lat');
+ const lonStr = c.req.query('lon');
+
+ if (!latStr || !lonStr) {
+ return c.text('Missing lat/lon', 400);
+ }
+ const lat = parseFloat(latStr);
+ const lon = parseFloat(lonStr);
+ if (Number.isNaN(lat) || Number.isNaN(lon) || lat < -90 || lat > 90 || lon < -180 || lon > 180) {
+ return c.text('Invalid lat/lon', 400);
+ }
+
+ try {
+ const airports = ServicePool.getAirport(c.env);
+ const nearest = await airports.getNearestAirport(lat, lon);
+ if (!nearest) return c.text('No airport found', 404);
+ return c.json(nearest);
+ } catch (err) {
+ return c.json({ error: 'Failed to find nearest airport' }, 500);
+ }
+ },
);
-// Divisions routes
const divisionsApp = new Hono<{
Bindings: Env;
Variables: {
@@ -625,7 +1039,6 @@ const divisionsApp = new Hono<{
};
}>();
-// Middleware to get authenticated user for divisions
divisionsApp.use('*', async (c, next) => {
const vatsimToken = c.req.header('X-Vatsim-Token');
if (!vatsimToken) {
@@ -649,7 +1062,6 @@ divisionsApp.use('*', async (c, next) => {
await next();
});
-// GET /divisions - List all divisions
/**
* @openapi
* /divisions:
@@ -671,7 +1083,6 @@ divisionsApp.get('/', async (c) => {
return c.json(allDivisions);
});
-// POST /divisions - Create new division (requires lead_developer role)
/**
* @openapi
* /divisions:
@@ -710,15 +1121,105 @@ divisionsApp.post('/', async (c) => {
return c.text('Forbidden', 403);
}
- const { name, headVatsimId } = await c.req.json() as CreateDivisionPayload;
+ const { name, headVatsimId } = (await c.req.json()) as CreateDivisionPayload;
const division = await divisions.createDivision(name, headVatsimId);
return c.json(division);
});
-// GET /divisions/user - Get user's divisions
/**
* @openapi
- * /divisions/user:
+ * /divisions/{id}:
+ * put:
+ * x-hidden: true
+ * summary: Update division name
+ * tags:
+ * - Divisions
+ * security:
+ * - VatsimToken: []
+ * parameters:
+ * - in: path
+ * name: id
+ * required: true
+ * schema: { type: integer }
+ * requestBody:
+ * required: true
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * required: [name]
+ * properties:
+ * name:
+ * type: string
+ * responses:
+ * 200:
+ * description: Division updated
+ * 403:
+ * description: Forbidden
+ * 404:
+ * description: Division not found
+ */
+divisionsApp.put('/:id', async (c) => {
+ const user = c.get('user');
+ const roles = ServicePool.getRoles(c.env);
+ const divisions = ServicePool.getDivisions(c.env);
+ const id = parseInt(c.req.param('id'));
+
+ const isLeadDev = await roles.hasPermission(user.id, StaffRole.LEAD_DEVELOPER);
+ if (!isLeadDev) return c.text('Forbidden', 403);
+
+ const existing = await divisions.getDivision(id);
+ if (!existing) return c.text('Division not found', 404);
+
+ const body = (await c.req.json()) as { name: string };
+ if (!body.name || !body.name.trim()) return c.text('Invalid name', 400);
+
+ const updated = await divisions.updateDivisionName(id, body.name.trim());
+ return c.json(updated);
+});
+
+/**
+ * @openapi
+ * /divisions/{id}:
+ * delete:
+ * x-hidden: true
+ * summary: Delete a division
+ * tags:
+ * - Divisions
+ * security:
+ * - VatsimToken: []
+ * parameters:
+ * - in: path
+ * name: id
+ * required: true
+ * schema: { type: integer }
+ * responses:
+ * 204:
+ * description: Division deleted
+ * 403:
+ * description: Forbidden
+ * 404:
+ * description: Division not found
+ */
+divisionsApp.delete('/:id', async (c) => {
+ const user = c.get('user');
+ const roles = ServicePool.getRoles(c.env);
+ const divisions = ServicePool.getDivisions(c.env);
+ const id = parseInt(c.req.param('id'));
+
+ const isLeadDev = await roles.hasPermission(user.id, StaffRole.LEAD_DEVELOPER);
+ if (!isLeadDev) return c.text('Forbidden', 403);
+
+ const existing = await divisions.getDivision(id);
+ if (!existing) return c.text('Division not found', 404);
+
+ await divisions.deleteDivision(id);
+ return c.body(null, 204);
+});
+
+/**
+ * @openapi
+ * /divisions/user:
* get:
* summary: Get divisions for current user
* tags:
@@ -729,17 +1230,14 @@ divisionsApp.post('/', async (c) => {
* 200:
* description: User divisions returned
*/
-divisionsApp.get('/user',
- withCache(CacheKeys.withUser('divisions'), 3600, 'divisions'),
- async (c) => {
- const vatsimUser = c.get('vatsimUser');
- const divisions = ServicePool.getDivisions(c.env);
+divisionsApp.get('/user', withCache(CacheKeys.withUser('divisions'), 3600, 'divisions'), async (c) => {
+ const vatsimUser = c.get('vatsimUser');
+ const divisions = ServicePool.getDivisions(c.env);
- const userDivisions = await divisions.getUserDivisions(vatsimUser.id);
- return c.json(userDivisions);
- });
+ const userDivisions = await divisions.getUserDivisions(vatsimUser.id);
+ return c.json(userDivisions);
+});
-// GET /divisions/:id - Get division details
/**
* @openapi
* /divisions/{id}:
@@ -760,21 +1258,18 @@ divisionsApp.get('/user',
* 404:
* description: Division not found
*/
-divisionsApp.get('/:id',
- withCache(CacheKeys.fromParams('id'), 2592000, 'divisions'),
- async (c) => {
- const divisionId = parseInt(c.req.param('id'));
- const divisions = ServicePool.getDivisions(c.env);
+divisionsApp.get('/:id', withCache(CacheKeys.fromParams('id'), 2592000, 'divisions'), async (c) => {
+ const divisionId = parseInt(c.req.param('id'));
+ const divisions = ServicePool.getDivisions(c.env);
- const division = await divisions.getDivision(divisionId);
- if (!division) {
- return c.text('Division not found', 404);
- }
+ const division = await divisions.getDivision(divisionId);
+ if (!division) {
+ return c.text('Division not found', 404);
+ }
- return c.json(division);
- });
+ return c.json(division);
+});
-// GET /divisions/:id/members - List division members
/**
* @openapi
* /divisions/{id}/members:
@@ -809,7 +1304,6 @@ divisionsApp.get('/:id/members', async (c) => {
return c.json(members);
});
-// POST /divisions/:id/members - Add member (requires nav_head role)
/**
* @openapi
* /divisions/{id}/members:
@@ -859,12 +1353,11 @@ divisionsApp.post('/:id/members', async (c) => {
return c.text('Forbidden', 403);
}
- const { vatsimId, role } = await c.req.json() as AddMemberPayload;
+ const { vatsimId, role } = (await c.req.json()) as AddMemberPayload;
const member = await divisions.addMember(divisionId, vatsimId, role);
return c.json(member);
});
-// DELETE /divisions/:id/members/:vatsimId - Remove member (requires nav_head role)
/**
* @openapi
* /divisions/{id}/members/{vatsimId}:
@@ -916,7 +1409,6 @@ divisionsApp.delete('/:id/members/:vatsimId', async (c) => {
return c.body(null, 204);
});
-// GET /divisions/:id/airports - List division airports
/**
* @openapi
* /divisions/{id}/airports:
@@ -937,23 +1429,20 @@ divisionsApp.delete('/:id/members/:vatsimId', async (c) => {
* 404:
* description: Division not found
*/
-divisionsApp.get('/:id/airports',
- withCache(CacheKeys.fromParams('id'), 600, 'divisions'),
- async (c) => {
- const divisionId = parseInt(c.req.param('id'));
- const divisions = ServicePool.getDivisions(c.env);
+divisionsApp.get('/:id/airports', withCache(CacheKeys.fromParams('id'), 600, 'divisions'), async (c) => {
+ const divisionId = parseInt(c.req.param('id'));
+ const divisions = ServicePool.getDivisions(c.env);
- // Verify division exists
- const division = await divisions.getDivision(divisionId);
- if (!division) {
- return c.text('Division not found', 404);
- }
+ // Verify division exists
+ const division = await divisions.getDivision(divisionId);
+ if (!division) {
+ return c.text('Division not found', 404);
+ }
- const airports = await divisions.getDivisionAirports(divisionId);
- return c.json(airports);
- });
+ const airports = await divisions.getDivisionAirports(divisionId);
+ return c.json(airports);
+});
-// POST /divisions/:id/airports - Request airport addition (requires division membership)
/**
* @openapi
* /divisions/{id}/airports:
@@ -996,7 +1485,7 @@ divisionsApp.post('/:id/airports', async (c) => {
return c.text('Division not found', 404);
}
- const { icao } = await c.req.json() as RequestAirportPayload;
+ const { icao } = (await c.req.json()) as RequestAirportPayload;
const airport = await divisions.requestAirport(divisionId, icao, vatsimUser.id);
return c.json(airport);
});
@@ -1056,7 +1545,7 @@ divisionsApp.post('/:id/airports/:airportId/approve', async (c) => {
return c.text('Forbidden', 403);
}
- const { approved } = await c.req.json() as ApproveAirportPayload;
+ const { approved } = (await c.req.json()) as ApproveAirportPayload;
const airport = await divisions.approveAirport(airportId, vatsimUser.id, approved);
return c.json(airport);
});
@@ -1082,7 +1571,8 @@ app.route('/divisions', divisionsApp);
* 400:
* description: Invalid ICAO
*/
-app.get('/airports/:icao/points',
+app.get(
+ '/airports/:icao/points',
withCache(CacheKeys.fromUrl, 600, 'airports'), // 1296000 - For after beta
async (c) => {
const airportId = c.req.param('icao');
@@ -1096,7 +1586,8 @@ app.get('/airports/:icao/points',
const airportPoints = await points.getAirportPoints(airportId);
return c.json(airportPoints);
- });
+ },
+);
/**
* @openapi
@@ -1148,7 +1639,7 @@ app.post('/airports/:icao/points', async (c) => {
const points = ServicePool.getPoints(c.env);
- const pointData = await c.req.json() as PointData;
+ const pointData = (await c.req.json()) as PointData;
const newPoint = await points.createPoint(airportId, user.vatsim_id, pointData);
return c.json(newPoint, 201);
});
@@ -1202,7 +1693,7 @@ app.post('/airports/:icao/points/batch', async (c) => {
const points = ServicePool.getPoints(c.env);
- const changeset = await c.req.json() as PointChangeset;
+ const changeset = (await c.req.json()) as PointChangeset;
const newPoints = await points.applyChangeset(airportId, user.vatsim_id, changeset);
return c.json(newPoints, 201);
});
@@ -1265,7 +1756,7 @@ app.put('/airports/:icao/points/:id', async (c) => {
const points = ServicePool.getPoints(c.env);
- const updates = await c.req.json() as Partial;
+ const updates = (await c.req.json()) as Partial;
const updatedPoint = await points.updatePoint(pointId, vatsimUser.id, updates);
return c.json(updatedPoint);
});
@@ -1331,7 +1822,6 @@ app.delete('/airports/:icao/points/:id', async (c) => {
}
});
-// Get single point by ID
/**
* @openapi
* /points/{id}:
@@ -1350,25 +1840,23 @@ app.delete('/airports/:icao/points/:id', async (c) => {
* 404:
* description: Not found
*/
-app.get('/points/:id',
- withCache(CacheKeys.fromUrl, 3600, 'points'),
- async (c) => {
- const pointId = c.req.param('id');
+app.get('/points/:id', withCache(CacheKeys.fromUrl, 3600, 'points'), async (c) => {
+ const pointId = c.req.param('id');
- // Validate point ID format (alphanumeric, dash, underscore)
- if (!pointId.match(POINT_ID_REGEX)) {
- return c.text('Invalid point ID format', 400);
- }
+ // Validate point ID format (alphanumeric, dash, underscore)
+ if (!pointId.match(POINT_ID_REGEX)) {
+ return c.text('Invalid point ID format', 400);
+ }
- const points = ServicePool.getPoints(c.env);
- const point = await points.getPoint(pointId);
+ const points = ServicePool.getPoints(c.env);
+ const point = await points.getPoint(pointId);
- if (!point) {
- return c.text('Point not found', 404);
- }
+ if (!point) {
+ return c.text('Point not found', 404);
+ }
- return c.json(point);
- });
+ return c.json(point);
+});
// Get multiple points by IDs (batch endpoint)
/**
@@ -1390,72 +1878,81 @@ app.get('/points/:id',
* 400:
* description: Validation error
*/
-app.get('/points',
- withCache(CacheKeys.fromUrl, 3600, 'points'),
- async (c) => {
- const ids = c.req.query('ids');
+app.get('/points', withCache(CacheKeys.fromUrl, 3600, 'points'), async (c) => {
+ const ids = c.req.query('ids');
- if (!ids) {
- return c.json({
+ if (!ids) {
+ return c.json(
+ {
error: 'Missing ids query parameter',
- message: 'Provide comma-separated point IDs: /points?ids=id1,id2,id3'
- }, 400);
- }
+ message: 'Provide comma-separated point IDs: /points?ids=id1,id2,id3',
+ },
+ 400,
+ );
+ }
- // Parse and validate point IDs
- const pointIds = ids.split(',')
- .map(id => id.trim())
- .filter(id => id.length > 0);
+ // Parse and validate point IDs
+ const pointIds = ids
+ .split(',')
+ .map((id) => id.trim())
+ .filter((id) => id.length > 0);
- if (pointIds.length === 0) {
- return c.json({
- error: 'No valid point IDs provided'
- }, 400);
- }
+ if (pointIds.length === 0) {
+ return c.json(
+ {
+ error: 'No valid point IDs provided',
+ },
+ 400,
+ );
+ }
- if (pointIds.length > 100) {
- return c.json({
+ if (pointIds.length > 100) {
+ return c.json(
+ {
error: 'Too many point IDs requested',
- message: 'Maximum 100 points can be requested at once'
- }, 400);
- }
-
+ message: 'Maximum 100 points can be requested at once',
+ },
+ 400,
+ );
+ }
- const invalidIds = pointIds.filter(id => !id.match(POINT_ID_REGEX));
- if (invalidIds.length > 0) {
- return c.json({
+ const invalidIds = pointIds.filter((id) => !id.match(POINT_ID_REGEX));
+ if (invalidIds.length > 0) {
+ return c.json(
+ {
error: 'Invalid point ID format',
- invalidIds
- }, 400);
- }
+ invalidIds,
+ },
+ 400,
+ );
+ }
- const points = ServicePool.getPoints(c.env);
+ const points = ServicePool.getPoints(c.env);
- // Fetch all points in parallel
- const pointPromises = pointIds.map(id => points.getPoint(id));
- const pointResults = await Promise.all(pointPromises);
+ // Fetch all points in parallel
+ const pointPromises = pointIds.map((id) => points.getPoint(id));
+ const pointResults = await Promise.all(pointPromises);
- // Filter out null results and create response
- const foundPoints = pointResults.filter(point => point !== null);
- const foundIds = foundPoints.map(point => point!.id);
- const notFoundIds = pointIds.filter(id => !foundIds.includes(id));
+ // Filter out null results and create response
+ const foundPoints = pointResults.filter((point) => point !== null);
+ const foundIds = foundPoints.map((point) => point!.id);
+ const notFoundIds = pointIds.filter((id) => !foundIds.includes(id));
- return c.json({
- points: foundPoints,
- requested: pointIds.length,
- found: foundPoints.length,
- notFound: notFoundIds.length > 0 ? notFoundIds : undefined
- });
+ return c.json({
+ points: foundPoints,
+ requested: pointIds.length,
+ found: foundPoints.length,
+ notFound: notFoundIds.length > 0 ? notFoundIds : undefined,
});
+});
-// Light Support endpoints
/**
* @openapi
* /supports/generate:
* post:
* summary: Generate Light Supports and BARS XML
* tags:
- * - Support
+ * - Generation
* description: Upload raw XML and generate both light supports XML and processed BARS XML.
* requestBody:
* required: true
@@ -1483,41 +1980,42 @@ app.post('/supports/generate', async (c) => {
const icao = formData.get('icao')?.toString();
if (!xmlFile || !(xmlFile instanceof File)) {
- return c.json({
- error: 'XML file is required',
- }, 400);
+ return c.json({ error: 'XML file is required' }, 400);
}
-
if (!icao) {
- return c.json({
- error: 'ICAO code is required',
- }, 400);
+ return c.json({ error: 'ICAO code is required' }, 400);
+ }
+
+ const MAX_XML_BYTES = 200_000;
+ if (xmlFile.size > MAX_XML_BYTES) {
+ return c.json({ error: `XML file too large (>${MAX_XML_BYTES} bytes)` }, 400);
+ }
+
+ const rawXml = await xmlFile.text();
+
+ let sanitized: string;
+ try {
+ sanitized = sanitizeContributionXml(rawXml, { maxBytes: MAX_XML_BYTES });
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : 'Invalid XML';
+ return c.json({ error: msg }, 400);
}
- const xmlContent = await xmlFile.text();
const supportService = ServicePool.getSupport(c.env);
const polygonService = ServicePool.getPolygons(c.env);
- // Generate both XML files in parallel
const [supportsXml, barsXml] = await Promise.all([
- supportService.generateLightSupportsXML(xmlContent, icao),
- polygonService.processBarsXML(xmlContent, icao),
+ supportService.generateLightSupportsXML(sanitized, icao),
+ polygonService.processBarsXML(sanitized, icao),
]);
- // Return both XMLs as a JSON response
- return c.json({
- supportsXml,
- barsXml,
- });
+ return c.json({ supportsXml, barsXml });
} catch (error) {
console.error('Error generating XMLs:', error);
- return c.json({
- error: error instanceof Error ? error.message : 'Unknown error generating XMLs',
- }, 500);
+ return c.json({ error: error instanceof Error ? error.message : 'Unknown error generating XMLs' }, 500);
}
});
-// NOTAM endpoints
/**
* @openapi
* /notam:
@@ -1529,7 +2027,8 @@ app.post('/supports/generate', async (c) => {
* 200:
* description: Current NOTAM returned
*/
-app.get('/notam',
+app.get(
+ '/notam',
withCache(() => 'global-notam', 900, 'notam'),
async (c) => {
const notamService = ServicePool.getNotam(c.env);
@@ -1538,7 +2037,7 @@ app.get('/notam',
notam: notamData?.content || null,
type: notamData?.type || 'warning',
});
- }
+ },
);
/**
@@ -1593,7 +2092,7 @@ app.put('/notam', async (c) => {
}
// Update the NOTAM
- const { content, type } = await c.req.json() as { content: string; type?: string };
+ const { content, type } = (await c.req.json()) as { content: string; type?: string };
const notamService = ServicePool.getNotam(c.env);
const updated = await notamService.updateGlobalNotam(content, type, user.vatsim_id);
@@ -1604,17 +2103,15 @@ app.put('/notam', async (c) => {
return c.json({ success: true });
});
-
-// User management endpoints
const staffUsersApp = new Hono<{
Bindings: Env;
Variables: {
user?: any;
userService?: any;
+ clientIp?: string;
};
}>();
-// Middleware to authenticate staff users
staffUsersApp.use('*', async (c, next) => {
const vatsimToken = c.req.header('X-Vatsim-Token');
if (!vatsimToken) {
@@ -1637,7 +2134,6 @@ staffUsersApp.use('*', async (c, next) => {
await next();
});
-// GET /staff/users - Get all users with pagination
/**
* @openapi
* /staff/users:
@@ -1671,7 +2167,6 @@ staffUsersApp.get('/', async (c) => {
}
});
-// GET /staff/users/search - Search for users
/**
* @openapi
* /staff/users/search:
@@ -1697,9 +2192,12 @@ staffUsersApp.get('/search', async (c) => {
try {
const query = c.req.query('q') || '';
if (query.length < 3) {
- return c.json({
- error: 'Search query must be at least 3 characters',
- }, 400);
+ return c.json(
+ {
+ error: 'Search query must be at least 3 characters',
+ },
+ 400,
+ );
}
const user = c.get('user');
@@ -1714,7 +2212,6 @@ staffUsersApp.get('/search', async (c) => {
}
});
-// POST /staff/users/refresh-api-token - Refresh a user's API token by VATSIM ID
/**
* @openapi
* /staff/users/refresh-api-token:
@@ -1741,12 +2238,15 @@ staffUsersApp.get('/search', async (c) => {
*/
staffUsersApp.post('/refresh-api-token', async (c) => {
try {
- const { vatsimId } = await c.req.json() as { vatsimId: string };
+ const { vatsimId } = (await c.req.json()) as { vatsimId: string };
if (!vatsimId) {
- return c.json({
- error: 'VATSIM ID is required',
- }, 400);
+ return c.json(
+ {
+ error: 'VATSIM ID is required',
+ },
+ 400,
+ );
}
const user = c.get('user');
@@ -1812,6 +2312,126 @@ staffUsersApp.delete('/:id', async (c) => {
app.route('/staff/users', staffUsersApp);
+// Staff management (lead developer only) – manage staff roles
+const staffManageApp = new Hono<{ Bindings: Env }>();
+
+/**
+ * @openapi
+ * /staff/manage:
+ * get:
+ * x-hidden: true
+ * summary: List staff members
+ * tags: [Staff]
+ * security:
+ * - VatsimToken: []
+ * responses:
+ * 200: { description: Staff listed }
+ * 403: { description: Forbidden }
+ */
+staffManageApp.get('/', async (c) => {
+ const vatsimToken = c.req.header('X-Vatsim-Token');
+ if (!vatsimToken) return c.text('Unauthorized', 401);
+ const vatsim = ServicePool.getVatsim(c.env);
+ const auth = ServicePool.getAuth(c.env);
+ const roles = ServicePool.getRoles(c.env);
+ const vatsimUser = await vatsim.getUser(vatsimToken);
+ const user = await auth.getUserByVatsimId(vatsimUser.id);
+ if (!user) return c.text('User not found', 404);
+ const allowed = await roles.hasPermission(user.id, StaffRole.LEAD_DEVELOPER);
+ if (!allowed) return c.text('Forbidden', 403);
+ const staff = await roles.listStaff();
+ return c.json({ staff });
+});
+
+/**
+ * @openapi
+ * /staff/manage:
+ * post:
+ * x-hidden: true
+ * summary: Add or update a staff member
+ * tags: [Staff]
+ * security:
+ * - VatsimToken: []
+ * requestBody:
+ * required: true
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * required: [vatsimId, role]
+ * properties:
+ * vatsimId: { type: string }
+ * role: { type: string, enum: [LEAD_DEVELOPER, PRODUCT_MANAGER] }
+ * responses:
+ * 200: { description: Staff added/updated }
+ * 403: { description: Forbidden }
+ */
+staffManageApp.post('/', async (c) => {
+ const vatsimToken = c.req.header('X-Vatsim-Token');
+ if (!vatsimToken) return c.text('Unauthorized', 401);
+ let body: any; try { body = await c.req.json(); } catch { return c.json({ error: 'Invalid JSON' }, 400); }
+ const { vatsimId, role } = body || {};
+ if (!vatsimId || !role || !(role in StaffRole)) return c.json({ error: 'vatsimId and valid role required' }, 400);
+ const vatsim = ServicePool.getVatsim(c.env);
+ const auth = ServicePool.getAuth(c.env);
+ const roles = ServicePool.getRoles(c.env);
+ const vatsimUser = await vatsim.getUser(vatsimToken);
+ const user = await auth.getUserByVatsimId(vatsimUser.id);
+ if (!user) return c.text('User not found', 404);
+ const allowed = await roles.hasPermission(user.id, StaffRole.LEAD_DEVELOPER);
+ if (!allowed) return c.text('Forbidden', 403);
+ const targetUser = await auth.getUserByVatsimId(vatsimId);
+ if (!targetUser) return c.json({ error: 'Target user not found' }, 404);
+ try {
+ const staff = await roles.addStaff(targetUser.id, role as StaffRole);
+ return c.json({ success: true, staff });
+ } catch (e) {
+ return c.json({ error: e instanceof Error ? e.message : 'Failed to add/update staff' }, 400);
+ }
+});
+
+/**
+ * @openapi
+ * /staff/manage/{vatsimId}:
+ * delete:
+ * x-hidden: true
+ * summary: Remove staff member
+ * tags: [Staff]
+ * security:
+ * - VatsimToken: []
+ * parameters:
+ * - in: path
+ * name: vatsimId
+ * required: true
+ * schema: { type: string }
+ * responses:
+ * 200: { description: Staff removed }
+ * 403: { description: Forbidden }
+ */
+staffManageApp.delete('/:vatsimId', async (c) => {
+ const vatsimToken = c.req.header('X-Vatsim-Token');
+ if (!vatsimToken) return c.text('Unauthorized', 401);
+ const targetVatsimId = c.req.param('vatsimId');
+ const vatsim = ServicePool.getVatsim(c.env);
+ const auth = ServicePool.getAuth(c.env);
+ const roles = ServicePool.getRoles(c.env);
+ const vatsimUser = await vatsim.getUser(vatsimToken);
+ const user = await auth.getUserByVatsimId(vatsimUser.id);
+ if (!user) return c.text('User not found', 404);
+ const allowed = await roles.hasPermission(user.id, StaffRole.LEAD_DEVELOPER);
+ if (!allowed) return c.text('Forbidden', 403);
+ const targetUser = await auth.getUserByVatsimId(targetVatsimId);
+ if (!targetUser) return c.json({ error: 'Target user not found' }, 404);
+ try {
+ const removed = await roles.removeStaff(targetUser.id);
+ return c.json({ success: removed });
+ } catch (e) {
+ return c.json({ error: e instanceof Error ? e.message : 'Failed to remove staff' }, 400);
+ }
+});
+
+app.route('/staff/manage', staffManageApp);
+
// Contributions endpoints
const contributionsApp = new Hono<{ Bindings: Env }>();
@@ -1836,33 +2456,28 @@ const contributionsApp = new Hono<{ Bindings: Env }>();
* 200:
* description: Contributions listed
*/
-contributionsApp.get('/',
- withCache(CacheKeys.fromUrl, 7200, 'contributions'),
- async (c) => {
- const contributions = ServicePool.getContributions(c.env);
-
- // Parse query parameters for filtering
- const status = (c.req.query('status') as 'pending' | 'approved' | 'rejected' | 'outdated' | 'all') || 'all';
- const airportIcao = c.req.query('airport') || undefined;
- const userId = c.req.query('user') || undefined;
- const page = 1; // Default to page 1 for user contributions
- const limit = Number.MAX_SAFE_INTEGER;
+contributionsApp.get('/', async (c) => {
+ const contributions = ServicePool.getContributions(c.env);
- // Get contributions with filters
- const result = await contributions.listContributions({
- status,
- airportIcao,
- userId,
- page,
- limit,
- });
+ // Parse query parameters for filtering
+ const status = (c.req.query('status') as 'pending' | 'approved' | 'rejected' | 'outdated' | 'all') || 'all';
+ const airportIcao = c.req.query('airport') || undefined;
+ const userId = c.req.query('user') || undefined;
+ const page = 1; // Default to page 1 for user contributions
+ const limit = Number.MAX_SAFE_INTEGER;
- return c.json(result);
+ // Get contributions with filters
+ const result = await contributions.listContributions({
+ status,
+ airportIcao,
+ userId,
+ page,
+ limit,
});
-// (Removed) contribution statistics endpoint
+ return c.json(result);
+});
-// GET /contributions/leaderboard - Get top contributors
/**
* @openapi
* /contributions/leaderboard:
@@ -1874,15 +2489,16 @@ contributionsApp.get('/',
* 200:
* description: Leaderboard returned
*/
-contributionsApp.get('/leaderboard',
+contributionsApp.get(
+ '/leaderboard',
withCache(() => 'contribution-leaderboard', 1800, 'contributions'),
async (c) => {
const contributions = ServicePool.getContributions(c.env);
const leaderboard = await contributions.getContributionLeaderboard();
return c.json(leaderboard);
- });
+ },
+);
-// GET /contributions/top-packages - Get a list of most used packages
/**
* @openapi
* /contributions/top-packages:
@@ -1894,15 +2510,16 @@ contributionsApp.get('/leaderboard',
* 200:
* description: Package stats returned
*/
-contributionsApp.get('/top-packages',
+contributionsApp.get(
+ '/top-packages',
withCache(() => 'contribution-top-packages', 1800, 'contributions'),
async (c) => {
const contributions = ServicePool.getContributions(c.env);
const topPackages = await contributions.getTopPackages();
return c.json(topPackages);
- });
+ },
+);
-// POST /contributions - Create a new contribution
/**
* @openapi
* /contributions:
@@ -1920,7 +2537,6 @@ contributionsApp.get('/top-packages',
* type: object
* required: [airportIcao, packageName, submittedXml]
* properties:
- * userDisplayName: { type: string }
* airportIcao: { type: string }
* packageName: { type: string }
* submittedXml: { type: string }
@@ -1947,10 +2563,9 @@ contributionsApp.post('/', async (c) => {
try {
const contributions = ServicePool.getContributions(c.env);
- const payload = await c.req.json() as ContributionSubmissionPayload;
+ const payload = (await c.req.json()) as ContributionSubmissionPayload;
const result = await contributions.createContribution({
userId: user.vatsim_id,
- userDisplayName: payload.userDisplayName,
airportIcao: payload.airportIcao,
packageName: payload.packageName,
submittedXml: payload.submittedXml,
@@ -1964,7 +2579,6 @@ contributionsApp.post('/', async (c) => {
}
});
-// GET /contributions/user - Get user's contributions
/**
* @openapi
* /contributions/user:
@@ -2013,7 +2627,6 @@ contributionsApp.get('/user', async (c) => {
return c.json(result);
});
-// GET /contributions/:id - Get specific contribution
/**
* @openapi
* /contributions/{id}:
@@ -2096,7 +2709,7 @@ contributionsApp.post('/:id/decision', async (c) => {
try {
const contributionId = c.req.param('id');
const contributions = ServicePool.getContributions(c.env);
- const payload = await c.req.json() as ContributionDecisionPayload;
+ const payload = (await c.req.json()) as ContributionDecisionPayload;
const result = await contributions.processDecision(contributionId, user.vatsim_id, {
approved: payload.approved,
rejectionReason: payload.rejectionReason,
@@ -2111,54 +2724,22 @@ contributionsApp.post('/:id/decision', async (c) => {
}
});
-// GET /contributions/user/display-name - Get user's display name
+// DELETE /contributions/:id - Delete a contribution (admin only)
/**
* @openapi
- * /contributions/user/display-name:
- * get:
- * summary: Get display name for authenticated user
+ * /contributions/{id}:
+ * delete:
+ * x-hidden: true
+ * summary: Delete a contribution
* tags:
* - Contributions
* security:
* - VatsimToken: []
- * responses:
- * 200:
- * description: Display name returned
- */
-contributionsApp.get('/user/display-name', async (c) => {
- const token = c.req.header('X-Vatsim-Token');
- if (!token) {
- return c.text('Unauthorized', 401);
- }
-
- const vatsim = ServicePool.getVatsim(c.env);
- const vatsimUser = await vatsim.getUser(token);
- const contributions = ServicePool.getContributions(c.env);
- const displayName = await contributions.getUserDisplayName(vatsimUser.id);
-
- if (!displayName) {
- return c.text('User not found', 404);
- }
-
- return c.json({ displayName });
-});
-
-// DELETE /contributions/:id - Delete a contribution (admin only)
-/**
- * @openapi
- * /contributions/{id}:
- * delete:
- * x-hidden: true
- * summary: Delete a contribution
- * tags:
- * - Contributions
- * security:
- * - VatsimToken: []
- * parameters:
- * - in: path
- * name: id
- * required: true
- * schema: { type: string }
+ * parameters:
+ * - in: path
+ * name: id
+ * required: true
+ * schema: { type: string }
* responses:
* 200:
* description: Deletion result
@@ -2194,11 +2775,54 @@ contributionsApp.delete('/:id', async (c) => {
app.route('/contributions', contributionsApp);
-
-// CDN Endpoints
const cdnApp = new Hono<{ Bindings: Env }>();
-// Special case for direct file downloads
+/**
+ * @openapi
+ * /maps/{icao}/packages/{package}/latest:
+ * get:
+ * summary: Get latest approved BARS map XML (raw content) for an airport & package
+ * tags:
+ * - Generation
+ * parameters:
+ * - in: path
+ * name: icao
+ * required: true
+ * schema: { type: string }
+ * - in: path
+ * name: package
+ * required: true
+ * schema: { type: string }
+ * responses:
+ * 200:
+ * description: BARS XML document returned inline (application/xml)
+ * 404:
+ * description: Not found
+ */
+app.get('/maps/:icao/packages/:package/latest', withCache(CacheKeys.fromUrl, 900, 'airports'), async (c) => {
+ const icao = c.req.param('icao').toUpperCase();
+ const pkg = c.req.param('package');
+ const contributions = ServicePool.getContributions(c.env);
+ const storage = ServicePool.getStorage(c.env);
+
+ const latest = await contributions.getLatestApprovedContributionForAirportPackage(icao, pkg);
+ if (!latest) {
+ return c.text('No approved map found', 404);
+ }
+
+ const safePackageName = latest.packageName.replace(/[^a-zA-Z0-9.-]/g, '-');
+ const fileKey = `Maps/${icao}_${safePackageName}_bars.xml`;
+
+ const stored = await storage.getFile(fileKey);
+ if (!stored) {
+ return c.text('Map file not found', 404);
+ }
+ if (!stored.headers.get('content-type')) {
+ stored.headers.set('content-type', 'application/xml; charset=utf-8');
+ }
+ return stored;
+});
+
/**
* @openapi
* /cdn/files/{fileKey}:
@@ -2234,12 +2858,10 @@ cdnApp.get('/files/*', async (c) => {
return c.text('File not found', 404);
}
-
// Return the file directly with proper headers for caching
return fileResponse;
});
-// Handle file management endpoints
/**
* @openapi
* /cdn/upload:
@@ -2300,9 +2922,12 @@ cdnApp.post('/upload', async (c) => {
const customKey = formData.get('key')?.toString();
if (!file || !(file instanceof File)) {
- return c.json({
- error: 'File is required',
- }, 400);
+ return c.json(
+ {
+ error: 'File is required',
+ },
+ 400,
+ );
}
// Create file path - use custom key if provided, otherwise generate one
@@ -2324,23 +2949,28 @@ cdnApp.post('/upload', async (c) => {
// Stats tracking removed
// Return success with download URL
- return c.json({
- success: true,
- file: {
- key: result.key,
- etag: result.etag,
- url: new URL(`/cdn/files/${result.key}`, c.req.url).toString(),
+ return c.json(
+ {
+ success: true,
+ file: {
+ key: result.key,
+ etag: result.etag,
+ url: new URL(`/cdn/files/${result.key}`, c.req.url).toString(),
+ },
},
- }, 201);
+ 201,
+ );
} catch (error) {
console.error('File upload error:', error);
- return c.json({
- error: error instanceof Error ? error.message : 'Failed to upload file',
- }, 500);
+ return c.json(
+ {
+ error: error instanceof Error ? error.message : 'Failed to upload file',
+ },
+ 500,
+ );
}
});
-// List files
/**
* @openapi
* /cdn/files:
@@ -2404,13 +3034,15 @@ cdnApp.get('/files', async (c) => {
return c.json({ files });
} catch (error) {
console.error('File listing error:', error);
- return c.json({
- error: error instanceof Error ? error.message : 'Failed to list files',
- }, 500);
+ return c.json(
+ {
+ error: error instanceof Error ? error.message : 'Failed to list files',
+ },
+ 500,
+ );
}
});
-// Delete a file
/**
* @openapi
* /cdn/files/{fileKey}:
@@ -2458,9 +3090,12 @@ cdnApp.delete('/files/*', async (c) => {
const fileKey = c.req.param('*');
if (!fileKey) {
- return c.json({
- error: 'File not found',
- }, 404);
+ return c.json(
+ {
+ error: 'File not found',
+ },
+ 404,
+ );
}
// Delete the file
@@ -2468,9 +3103,12 @@ cdnApp.delete('/files/*', async (c) => {
const deleted = await storage.deleteFile(fileKey);
if (!deleted) {
- return c.json({
- error: 'File not found',
- }, 404);
+ return c.json(
+ {
+ error: 'File not found',
+ },
+ 404,
+ );
}
// Stats tracking removed
@@ -2478,15 +3116,17 @@ cdnApp.delete('/files/*', async (c) => {
return c.json({ success: true });
} catch (error) {
console.error('File deletion error:', error);
- return c.json({
- error: error instanceof Error ? error.message : 'Failed to delete file',
- }, 500);
+ return c.json(
+ {
+ error: error instanceof Error ? error.message : 'Failed to delete file',
+ },
+ 500,
+ );
}
});
app.route('/cdn', cdnApp);
-// EuroScope public file listing endpoint
/**
* @openapi
* /euroscope/files/{icao}:
@@ -2510,9 +3150,12 @@ app.get('/euroscope/files/:icao', async (c) => {
// Validate ICAO format
if (!icao.match(/^[A-Z0-9]{4}$/)) {
- return c.json({
- error: 'Invalid ICAO format. Must be exactly 4 uppercase letters/numbers.',
- }, 400);
+ return c.json(
+ {
+ error: 'Invalid ICAO format. Must be exactly 4 uppercase letters/numbers.',
+ },
+ 400,
+ );
}
try {
@@ -2535,13 +3178,15 @@ app.get('/euroscope/files/:icao', async (c) => {
});
} catch (error) {
console.error('EuroScope public file listing error:', error);
- return c.json({
- error: error instanceof Error ? error.message : 'Failed to list files',
- }, 500);
+ return c.json(
+ {
+ error: error instanceof Error ? error.message : 'Failed to list files',
+ },
+ 500,
+ );
}
});
-// EuroScope file management endpoints
const euroscopeApp = new Hono<{
Bindings: Env;
Variables: {
@@ -2550,7 +3195,6 @@ const euroscopeApp = new Hono<{
};
}>();
-// Middleware for EuroScope endpoints to authenticate users
euroscopeApp.use('*', async (c, next) => {
const vatsimToken = c.req.header('X-Vatsim-Token');
if (!vatsimToken) {
@@ -2572,7 +3216,6 @@ euroscopeApp.use('*', async (c, next) => {
await next();
});
-// POST /euroscope/upload - Upload files to ICAO-specific folders
/**
* @openapi
* /euroscope/upload:
@@ -2610,30 +3253,42 @@ euroscopeApp.post('/upload', async (c) => {
const icao = formData.get('icao')?.toString()?.toUpperCase();
if (!file || !(file instanceof File)) {
- return c.json({
- error: 'File is required',
- }, 400);
+ return c.json(
+ {
+ error: 'File is required',
+ },
+ 400,
+ );
}
if (!icao) {
- return c.json({
- error: 'ICAO code is required',
- }, 400);
+ return c.json(
+ {
+ error: 'ICAO code is required',
+ },
+ 400,
+ );
}
// Validate ICAO format (exactly 4 uppercase letters/numbers)
if (!icao.match(/^[A-Z0-9]{4}$/)) {
- return c.json({
- error: 'Invalid ICAO format. Must be exactly 4 uppercase letters/numbers.',
- }, 400);
+ return c.json(
+ {
+ error: 'Invalid ICAO format. Must be exactly 4 uppercase letters/numbers.',
+ },
+ 400,
+ );
}
// Check file size limit (10MB)
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB in bytes
if (file.size > MAX_FILE_SIZE) {
- return c.json({
- error: 'File size exceeds 10MB limit',
- }, 400);
+ return c.json(
+ {
+ error: 'File size exceeds 10MB limit',
+ },
+ 400,
+ );
}
// Check if user has access to upload files for this ICAO
@@ -2641,9 +3296,12 @@ euroscopeApp.post('/upload', async (c) => {
const hasAccess = await divisions.userHasAirportAccess(vatsimUser.id.toString(), icao);
if (!hasAccess) {
- return c.json({
- error: 'You do not have permission to upload files for this airport. Please ensure your division has approved access to this ICAO.',
- }, 403);
+ return c.json(
+ {
+ error: 'You do not have permission to upload files for this airport. Please ensure your division has approved access to this ICAO.',
+ },
+ 403,
+ );
}
// Create file path: EuroScope/ICAO/filename
@@ -2655,11 +3313,14 @@ euroscopeApp.post('/upload', async (c) => {
const existingFiles = await storage.listFiles(`EuroScope/${icao}/`, 10);
// Count files that are not the one being replaced
- const otherFiles = existingFiles.objects.filter(obj => obj.key !== fileKey);
+ const otherFiles = existingFiles.objects.filter((obj) => obj.key !== fileKey);
if (otherFiles.length >= 2) {
- return c.json({
- error: 'Maximum of 2 files per ICAO code allowed. Please delete an existing file before uploading a new one.',
- }, 400);
+ return c.json(
+ {
+ error: 'Maximum of 2 files per ICAO code allowed. Please delete an existing file before uploading a new one.',
+ },
+ 400,
+ );
}
// Extract file data
@@ -2673,27 +3334,31 @@ euroscopeApp.post('/upload', async (c) => {
size: file.size.toString(),
});
-
// Return success with download URL
- return c.json({
- success: true,
- file: {
- key: result.key,
- icao: icao,
- fileName: fileName,
- size: file.size,
- url: new URL(`https://dev-cdn.stopbars.com/${result.key}`, c.req.url).toString(),
+ return c.json(
+ {
+ success: true,
+ file: {
+ key: result.key,
+ icao: icao,
+ fileName: fileName,
+ size: file.size,
+ url: new URL(`https://dev-cdn.stopbars.com/${result.key}`, c.req.url).toString(),
+ },
},
- }, 201);
+ 201,
+ );
} catch (error) {
console.error('EuroScope file upload error:', error);
- return c.json({
- error: error instanceof Error ? error.message : 'Failed to upload file',
- }, 500);
+ return c.json(
+ {
+ error: error instanceof Error ? error.message : 'Failed to upload file',
+ },
+ 500,
+ );
}
});
-// DELETE /euroscope/files/:icao/:filename - Delete a specific file
/**
* @openapi
* /euroscope/files/{icao}/{filename}:
@@ -2725,9 +3390,12 @@ euroscopeApp.delete('/files/:icao/:filename', async (c) => {
// Validate ICAO format
if (!icao.match(/^[A-Z0-9]{4}$/)) {
- return c.json({
- error: 'Invalid ICAO format. Must be exactly 4 uppercase letters/numbers.',
- }, 400);
+ return c.json(
+ {
+ error: 'Invalid ICAO format. Must be exactly 4 uppercase letters/numbers.',
+ },
+ 400,
+ );
}
try {
@@ -2736,9 +3404,12 @@ euroscopeApp.delete('/files/:icao/:filename', async (c) => {
const hasAccess = await divisions.userHasAirportAccess(vatsimUser.id.toString(), icao);
if (!hasAccess) {
- return c.json({
- error: 'You do not have permission to delete files for this airport. Please ensure your division has approved access to this ICAO.',
- }, 403);
+ return c.json(
+ {
+ error: 'You do not have permission to delete files for this airport. Please ensure your division has approved access to this ICAO.',
+ },
+ 403,
+ );
}
// Construct the file key
@@ -2749,9 +3420,12 @@ euroscopeApp.delete('/files/:icao/:filename', async (c) => {
const deleted = await storage.deleteFile(fileKey);
if (!deleted) {
- return c.json({
- error: 'File not found',
- }, 404);
+ return c.json(
+ {
+ error: 'File not found',
+ },
+ 404,
+ );
}
return c.json({
@@ -2760,13 +3434,15 @@ euroscopeApp.delete('/files/:icao/:filename', async (c) => {
});
} catch (error) {
console.error('EuroScope file deletion error:', error);
- return c.json({
- error: error instanceof Error ? error.message : 'Failed to delete file',
- }, 500);
+ return c.json(
+ {
+ error: error instanceof Error ? error.message : 'Failed to delete file',
+ },
+ 500,
+ );
}
});
-// GET /euroscope/:icao/editable - Check if user has permission to edit files for an airport
/**
* @openapi
* /euroscope/{icao}/editable:
@@ -2792,9 +3468,12 @@ euroscopeApp.get('/:icao/editable', async (c) => {
// Validate ICAO format
if (!icao.match(/^[A-Z0-9]{4}$/)) {
- return c.json({
- error: 'Invalid ICAO format. Must be exactly 4 uppercase letters/numbers.',
- }, 400);
+ return c.json(
+ {
+ error: 'Invalid ICAO format. Must be exactly 4 uppercase letters/numbers.',
+ },
+ 400,
+ );
}
try {
@@ -2810,13 +3489,327 @@ euroscopeApp.get('/:icao/editable', async (c) => {
});
} catch (error) {
console.error('EuroScope access check error:', error);
- return c.json({
- error: error instanceof Error ? error.message : 'Failed to check airport access',
- }, 500);
+ return c.json(
+ {
+ error: error instanceof Error ? error.message : 'Failed to check airport access',
+ },
+ 500,
+ );
}
});
app.route('/euroscope', euroscopeApp);
+/**
+ * @openapi
+ * /releases:
+ * get:
+ * summary: List all product releases (optionally filtered)
+ * tags:
+ * - Installer
+ * parameters:
+ * - in: query
+ * name: product
+ * schema: { type: string, enum: [Pilot-Client, vatSys-Plugin, EuroScope-Plugin, Installer, SimConnect.NET] }
+ * responses:
+ * 200:
+ * description: Releases listed
+ */
+app.get(
+ '/releases',
+ withCache(CacheKeys.fromUrl, 300, 'installer'), // cache 5m
+ async (c) => {
+ const product = c.req.query('product') as InstallerProduct | undefined;
+ const releasesService = ServicePool.getReleases(c.env);
+ const releases = await releasesService.listReleases(product);
+ return c.json({ releases });
+ }
+);
+
+/**
+ * @openapi
+ * /releases/latest:
+ * get:
+ * summary: Get latest release for a product
+ * tags:
+ * - Installer
+ * parameters:
+ * - in: query
+ * name: product
+ * required: true
+ * schema: { type: string, enum: [Pilot-Client, vatSys-Plugin, EuroScope-Plugin, Installer, SimConnect.NET] }
+ * responses:
+ * 200:
+ * description: Latest release returned
+ * 404:
+ * description: Not found
+ */
+app.get('/releases/latest', withCache(CacheKeys.fromUrl, 120, 'installer'), async (c) => {
+ const product = c.req.query('product') as InstallerProduct | undefined;
+ if (!product) return c.text('product required', 400);
+ const releasesService = ServicePool.getReleases(c.env);
+ const latest = await releasesService.getLatest(product);
+ if (!latest) return c.text('Not found', 404);
+ const downloadUrl = product === 'SimConnect.NET'
+ ? `https://www.nuget.org/packages/SimConnect.NET/${latest.version}`
+ : new URL(`https://dev-cdn.stopbars.com/${latest.file_key}`, c.req.url).toString();
+ const imageUrl = latest.image_url ? new URL(latest.image_url, c.req.url).toString() : undefined;
+ const { image_url: _omitImage, ...rest } = latest as any;
+ return c.json({ ...rest, downloadUrl, imageUrl });
+});
+
+/**
+ * @openapi
+ * /releases/upload:
+ * post:
+ * x-hidden: true
+ * summary: Create a new product release (lead developer only)
+ * tags:
+ * - Installer
+ * security:
+ * - VatsimToken: []
+ * requestBody:
+ * required: true
+ * content:
+ * multipart/form-data:
+ * schema:
+ * type: object
+ * required: [file, product, version]
+ * properties:
+ * file:
+ * type: string
+ * format: binary
+ * product:
+ * type: string
+ * enum: [Pilot-Client, vatSys-Plugin, EuroScope-Plugin, Installer, SimConnect.NET]
+ * version:
+ * type: string
+ * changelog:
+ * type: string
+ * image:
+ * type: string
+ * format: binary
+ * description: Optional promotional image (PNG/JPEG, max 5MB)
+ * x-notes:
+ * - Product "Installer" requires an .exe file upload.
+ * - Product "SimConnect.NET" does not require a file upload (metadata + changelog only; version links to NuGet).
+ * responses:
+ * 201:
+ * description: Release created
+ * 403:
+ * description: Forbidden
+ */
+app.post('/releases/upload', async (c) => {
+ const vatsimToken = c.req.header('X-Vatsim-Token');
+ if (!vatsimToken) return c.text('Unauthorized', 401);
+ const vatsim = ServicePool.getVatsim(c.env);
+ const auth = ServicePool.getAuth(c.env);
+ const roles = ServicePool.getRoles(c.env);
+ const vatsimUserPromise = vatsim.getUser(vatsimToken);
+
+ let formData: FormData;
+ try {
+ formData = await c.req.formData();
+ } catch {
+ return c.json({ error: 'Invalid form-data' }, 400);
+ }
+
+ const file = formData.get('file');
+ const product = formData.get('product')?.toString() as InstallerProduct | undefined;
+ const version = formData.get('version')?.toString();
+ const changelog = formData.get('changelog')?.toString();
+ const image = formData.get('image');
+ let vatsimUser;
+ try {
+ vatsimUser = await vatsimUserPromise;
+ } catch (e) {
+ return c.text('Failed to validate user', 401);
+ }
+ const user = await auth.getUserByVatsimId(vatsimUser.id);
+ if (!user) return c.text('User not found', 404);
+ const isLeadDev = await roles.hasPermission(user.id, StaffRole.LEAD_DEVELOPER);
+ if (!isLeadDev) return c.text('Forbidden', 403);
+
+ if (!product || !version) return c.json({ error: 'product & version required' }, 400);
+
+ const isSimConnect = product === 'SimConnect.NET';
+ const isInstallerExe = product === 'Installer';
+
+ if (!isSimConnect) {
+ // For all products except SimConnect.NET a file is required
+ if (!file || !(file instanceof File)) return c.json({ error: 'file required' }, 400);
+ const MAX = 90 * 1024 * 1024;
+ if (file.size > MAX) return c.json({ error: 'File too large (90MB max)' }, 400);
+ if (isInstallerExe) {
+ // Enforce .exe extension for Installer product
+ const lower = file.name.toLowerCase();
+ if (!lower.endsWith('.exe')) return c.json({ error: 'Installer product must be a .exe file' }, 400);
+ }
+ }
+
+ if (isSimConnect && file && file instanceof File) {
+ return c.json({ error: 'SimConnect.NET releases do not accept file uploads' }, 400);
+ }
+ try {
+ const storage = ServicePool.getStorage(c.env);
+ let fileKey: string;
+ let bytes: ArrayBuffer | undefined;
+ if (!isSimConnect) {
+ // File upload path for normal products
+ const uploadFile = file as File; // already validated
+ fileKey = `releases/${product}/${version}/${uploadFile.name}`;
+ bytes = await uploadFile.arrayBuffer();
+ } else {
+ // Sentinel key for external NuGet package (no bytes)
+ fileKey = `releases/${product}/${version}/EXTERNAL`;
+ }
+ let imageBytesPromise: Promise | undefined;
+ if (image && image instanceof File) {
+ imageBytesPromise = image.arrayBuffer();
+ }
+
+ let sha256 = 'external';
+ if (bytes) {
+ const digest = await crypto.subtle.digest('SHA-256', bytes);
+ sha256 = Array.from(new Uint8Array(digest)).map(b => b.toString(16).padStart(2, '0')).join('');
+ }
+
+ // Validate image (after its bytes read started) before uploads
+ let imageUrl: string | undefined;
+ let imageUploadPromise: Promise | undefined;
+ let imageKey: string | undefined;
+ if (image && image instanceof File) {
+ const ALLOWED = ['image/png', 'image/jpeg'];
+ const MAX_IMAGE = 5 * 1024 * 1024; // 5MB
+ if (!ALLOWED.includes(image.type)) return c.json({ error: 'Invalid image type (png or jpeg only)' }, 400);
+ if (image.size > MAX_IMAGE) return c.json({ error: 'Image too large (5MB max)' }, 400);
+ const imageExt = image.type === 'image/png' ? 'png' : 'jpg';
+ imageKey = `releases/${product}/${version}/promo.${imageExt}`;
+ // Wait for image bytes only when needed (likely already resolved by now)
+ const imgBytes = await imageBytesPromise!;
+ imageUploadPromise = storage.uploadFile(imageKey, imgBytes, image.type || 'image/png', {
+ uploadedBy: user.vatsim_id,
+ product,
+ version,
+ });
+ imageUrl = `https://dev-cdn.stopbars.com/${imageKey}`;
+ }
+ if (!isSimConnect) {
+ const uploadFile = file as File;
+ const fileUploadPromise = storage.uploadFile(fileKey, bytes!, uploadFile.type || 'application/octet-stream', {
+ uploadedBy: user.vatsim_id,
+ product,
+ version,
+ size: uploadFile.size.toString(),
+ sha256
+ });
+ await Promise.all([fileUploadPromise, imageUploadPromise].filter(Boolean));
+ } else {
+ // Only image upload (if any) for external product
+ if (imageUploadPromise) await imageUploadPromise;
+ }
+ const releasesService = ServicePool.getReleases(c.env);
+ const release = await releasesService.createRelease({
+ product,
+ version,
+ fileKey,
+ fileSize: bytes ? (file as File).size : 0,
+ fileHash: sha256,
+ changelog,
+ imageUrl
+ });
+ const downloadUrl = isSimConnect ? `https://www.nuget.org/packages/SimConnect.NET/${version}` : `https://dev-cdn.stopbars.com/${fileKey}`;
+ return c.json({ success: true, release, downloadUrl, imageUrl }, 201);
+ } catch (err) {
+ console.error('Release upload error', err);
+ return c.json({ error: err instanceof Error ? err.message : 'upload failed' }, 500);
+ }
+});
+
+/**
+ * @openapi
+ * /releases/{id}/changelog:
+ * put:
+ * x-hidden: true
+ * summary: Update changelog content for a release
+ * description: Update only the changelog text of an existing release record.
+ * tags:
+ * - Installer
+ * security:
+ * - VatsimToken: []
+ * parameters:
+ * - in: path
+ * name: id
+ * required: true
+ * schema: { type: integer }
+ * requestBody:
+ * required: true
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * required: [changelog]
+ * properties:
+ * changelog:
+ * type: string
+ * maxLength: 20000
+ * responses:
+ * 200:
+ * description: Changelog updated
+ * 400:
+ * description: Validation error
+ * 401:
+ * description: Unauthorized
+ * 403:
+ * description: Forbidden
+ * 404:
+ * description: Release not found
+ */
+app.put('/releases/:id/changelog', async (c) => {
+ const vatsimToken = c.req.header('X-Vatsim-Token');
+ if (!vatsimToken) return c.text('Unauthorized', 401);
+ const idRaw = c.req.param('id');
+ const id = parseInt(idRaw, 10);
+ if (Number.isNaN(id) || id <= 0) return c.text('Invalid id', 400);
+
+ const vatsim = ServicePool.getVatsim(c.env);
+ const auth = ServicePool.getAuth(c.env);
+ const roles = ServicePool.getRoles(c.env);
+ const releasesService = ServicePool.getReleases(c.env);
+
+ try {
+ const vatsimUser = await vatsim.getUser(vatsimToken);
+ const user = await auth.getUserByVatsimId(vatsimUser.id);
+ if (!user) return c.text('User not found', 404);
+ // Allow Lead Developer or Product Manager
+ const canEdit = (await roles.hasPermission(user.id, StaffRole.LEAD_DEVELOPER)) || (await roles.hasPermission(user.id, StaffRole.PRODUCT_MANAGER));
+ if (!canEdit) return c.text('Forbidden', 403);
+
+ let body: any;
+ try { body = await c.req.json(); } catch { return c.json({ error: 'Invalid JSON body' }, 400); }
+ const changelog = typeof body?.changelog === 'string' ? body.changelog.trim() : '';
+ if (!changelog) return c.json({ error: 'changelog required' }, 400);
+ if (changelog.length > 20000) return c.json({ error: 'changelog too long (max 20000 chars)' }, 400);
+
+ // Ensure release exists first (so we differentiate 404 vs silent update)
+ // Reusing listReleases would be inefficient; perform direct lookup.
+ const dbContext = DatabaseContextFactory.createRequestContext(c.env.DB, c.req.raw);
+ try {
+ const existing = await dbContext.db.executeRead('SELECT * FROM installer_releases WHERE id = ?', [id]);
+ if (!existing.results[0]) return dbContext.textResponse('Release not found', { status: 404 });
+ } finally {
+ // close early; release update uses its own session service
+ // (ReleaseService internally manages its session.)
+ }
+
+ const updated = await releasesService.updateChangelog(id, changelog);
+ if (!updated) return c.text('Release not found', 404);
+ return c.json({ success: true, release: updated });
+ } catch (err) {
+ console.error('Changelog update error', err);
+ return c.json({ error: err instanceof Error ? err.message : 'update failed' }, 500);
+ }
+});
+
/**
* @openapi
* /purge-cache:
@@ -2868,7 +3861,7 @@ app.post('/purge-cache', async (c) => {
}
try {
- const { key, namespace } = await c.req.json() as { key: string; namespace?: string };
+ const { key, namespace } = (await c.req.json()) as { key: string; namespace?: string };
if (!key) {
return c.json({ error: 'Cache key is required' }, 400);
@@ -2883,16 +3876,17 @@ app.post('/purge-cache', async (c) => {
success: true,
message: `Cache key "${key}" purged successfully`,
});
-
} catch (error) {
console.error('Cache purge error:', error);
- return c.json({
- error: error instanceof Error ? error.message : 'Failed to purge cache',
- }, 500);
+ return c.json(
+ {
+ error: error instanceof Error ? error.message : 'Failed to purge cache',
+ },
+ 500,
+ );
}
});
-// Contributors endpoint
/**
* @openapi
* /contributors:
@@ -2904,7 +3898,8 @@ app.post('/purge-cache', async (c) => {
* 200:
* description: Contributors returned
*/
-app.get('/contributors',
+app.get(
+ '/contributors',
withCache(() => 'github-contributors', 3600, 'github'), // Cache for 1 hour
async (c) => {
try {
@@ -2913,14 +3908,217 @@ app.get('/contributors',
return c.json(contributorsData);
} catch (error) {
console.error('Contributors endpoint error:', error);
- return c.json({
- error: 'Failed to fetch contributors data',
- message: error instanceof Error ? error.message : 'Unknown error'
- }, 500);
+ return c.json(
+ {
+ error: 'Failed to fetch contributors data',
+ message: error instanceof Error ? error.message : 'Unknown error',
+ },
+ 500,
+ );
}
- }
+ },
);
+// FAQs public endpoint
+/**
+ * @openapi
+ * /faqs:
+ * get:
+ * summary: List public FAQs
+ * tags:
+ * - FAQ
+ * responses:
+ * 200:
+ * description: FAQs returned
+ */
+app.get(
+ '/faqs',
+ withCache(() => 'faqs-public', 900, 'faq'),
+ async (c) => {
+ const faqService = ServicePool.getFAQs(c.env);
+ const data = await faqService.list();
+ return c.json(data);
+ },
+);
+
+// Staff FAQ management endpoints
+const faqStaffApp = new Hono<{ Bindings: Env; Variables: { user?: any } }>();
+
+faqStaffApp.use('*', async (c, next) => {
+ const vatsimToken = c.req.header('X-Vatsim-Token');
+ if (!vatsimToken) return c.text('Unauthorized', 401);
+ const vatsim = ServicePool.getVatsim(c.env);
+ const auth = ServicePool.getAuth(c.env);
+ const roles = ServicePool.getRoles(c.env);
+ const vatsimUser = await vatsim.getUser(vatsimToken);
+ const user = await auth.getUserByVatsimId(vatsimUser.id);
+ if (!user) return c.text('User not found', 404);
+ // Require product manager or higher
+ const allowed = await roles.hasPermission(user.id, StaffRole.PRODUCT_MANAGER);
+ if (!allowed) return c.text('Forbidden', 403);
+ c.set('user', user);
+ await next();
+});
+
+/**
+ * @openapi
+ * /staff/faqs:
+ * post:
+ * x-hidden: true
+ * summary: Create FAQ
+ * tags:
+ * - Staff
+ * - FAQ
+ * security:
+ * - VatsimToken: []
+ * requestBody:
+ * required: true
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * required: [question, answer, order_position]
+ * properties:
+ * question: { type: string }
+ * answer: { type: string }
+ * order_position: { type: integer }
+ * responses:
+ * 201:
+ * description: Created
+ */
+faqStaffApp.post('/', async (c) => {
+ let body: any;
+ try { body = await c.req.json(); } catch { return c.json({ error: 'Invalid JSON' }, 400); }
+ const { question, answer } = body;
+ let order_position = Number(body.order_position);
+ if (!question || !answer || !Number.isInteger(order_position)) {
+ return c.json({ error: 'question, answer, order_position required' }, 400);
+ }
+ if (order_position < 0) order_position = 0;
+ const faqService = ServicePool.getFAQs(c.env);
+ const created = await faqService.create({ question, answer, order_position });
+ // Purge public cache
+ try { await ServicePool.getCache(c.env).delete('faqs-public', 'faq'); } catch { }
+ return c.json(created, 201);
+});
+
+/**
+ * @openapi
+ * /staff/faqs/{id}:
+ * put:
+ * x-hidden: true
+ * summary: Update FAQ
+ * tags:
+ * - Staff
+ * - FAQ
+ * security:
+ * - VatsimToken: []
+ * parameters:
+ * - in: path
+ * name: id
+ * required: true
+ * schema: { type: string }
+ * requestBody:
+ * required: true
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * question: { type: string }
+ * answer: { type: string }
+ * order_position: { type: integer }
+ * responses:
+ * 200:
+ * description: Updated
+ * 404:
+ * description: Not found
+ */
+faqStaffApp.put('/:id', async (c) => {
+ const id = c.req.param('id');
+ let body: any; try { body = await c.req.json(); } catch { body = {}; }
+ const faqService = ServicePool.getFAQs(c.env);
+ const updated = await faqService.update(id, {
+ question: body.question,
+ answer: body.answer,
+ order_position: Number.isInteger(body.order_position) ? body.order_position : undefined,
+ });
+ if (!updated) return c.text('Not found', 404);
+ try { await ServicePool.getCache(c.env).delete('faqs-public', 'faq'); } catch { }
+ return c.json(updated);
+});
+
+/**
+ * @openapi
+ * /staff/faqs/{id}:
+ * delete:
+ * x-hidden: true
+ * summary: Delete FAQ
+ * tags:
+ * - Staff
+ * - FAQ
+ * security:
+ * - VatsimToken: []
+ * parameters:
+ * - in: path
+ * name: id
+ * required: true
+ * schema: { type: string }
+ * responses:
+ * 200:
+ * description: Deletion result
+ */
+faqStaffApp.delete('/:id', async (c) => {
+ const id = c.req.param('id');
+ const faqService = ServicePool.getFAQs(c.env);
+ const success = await faqService.delete(id);
+ if (success) { try { await ServicePool.getCache(c.env).delete('faqs-public', 'faq'); } catch { } }
+ return c.json({ success });
+});
+
+/**
+ * @openapi
+ * /staff/faqs/reorder:
+ * post:
+ * x-hidden: true
+ * summary: Bulk reorder FAQs
+ * tags:
+ * - Staff
+ * - FAQ
+ * security:
+ * - VatsimToken: []
+ * requestBody:
+ * required: true
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * required: [updates]
+ * properties:
+ * updates:
+ * type: array
+ * items:
+ * type: object
+ * required: [id, order_position]
+ * properties:
+ * id: { type: string }
+ * order_position: { type: integer }
+ * responses:
+ * 200:
+ * description: Reordered
+ */
+faqStaffApp.post('/reorder', async (c) => {
+ let body: any; try { body = await c.req.json(); } catch { return c.json({ error: 'Invalid JSON' }, 400); }
+ if (!Array.isArray(body.updates)) return c.json({ error: 'updates array required' }, 400);
+ const updates = body.updates.filter((u: any) => typeof u.id === 'string' && Number.isInteger(u.order_position));
+ const faqService = ServicePool.getFAQs(c.env);
+ await faqService.reorder(updates);
+ try { await ServicePool.getCache(c.env).delete('faqs-public', 'faq'); } catch { }
+ return c.json({ success: true });
+});
+
+app.route('/staff/faqs', faqStaffApp);
+
// Health endpoint
/**
* @openapi
@@ -2940,84 +4138,84 @@ app.get('/contributors',
* 503:
* description: One or more services degraded
*/
-app.get('/health',
- withCache(CacheKeys.fromUrl, 60, 'health'),
- async (c) => {
- const requestedService = c.req.query('service');
- const validServices = ['database', 'storage', 'vatsim', 'auth'];
+app.get('/health', withCache(CacheKeys.fromUrl, 60, 'health'), async (c) => {
+ const requestedService = c.req.query('service');
+ const validServices = ['database', 'storage', 'vatsim', 'auth'];
- if (requestedService && !validServices.includes(requestedService)) {
- return c.json({
+ if (requestedService && !validServices.includes(requestedService)) {
+ return c.json(
+ {
error: 'Invalid service',
validServices: validServices,
- }, 400);
- }
+ },
+ 400,
+ );
+ }
- const healthChecks: Record = {};
- const servicesToCheck = requestedService ? [requestedService] : validServices;
+ const healthChecks: Record = {};
+ const servicesToCheck = requestedService ? [requestedService] : validServices;
- for (const service of servicesToCheck) {
- healthChecks[service] = 'ok';
- }
+ for (const service of servicesToCheck) {
+ healthChecks[service] = 'ok';
+ }
- try {
- if (servicesToCheck.includes('database')) {
- try {
- await c.env.DB.prepare('SELECT 1').first();
- } catch (error) {
- healthChecks.database = 'outage';
- }
+ try {
+ if (servicesToCheck.includes('database')) {
+ try {
+ await c.env.DB.prepare('SELECT 1').first();
+ } catch (error) {
+ healthChecks.database = 'outage';
}
+ }
- if (servicesToCheck.includes('storage')) {
- try {
- const storage = ServicePool.getStorage(c.env);
- await storage.listFiles(undefined, 1);
- } catch (error) {
- healthChecks.storage = 'outage';
- }
+ if (servicesToCheck.includes('storage')) {
+ try {
+ const storage = ServicePool.getStorage(c.env);
+ await storage.listFiles(undefined, 1);
+ } catch (error) {
+ healthChecks.storage = 'outage';
}
+ }
- if (servicesToCheck.includes('vatsim')) {
- try {
- const response = await fetch('https://auth.vatsim.net/api/user', {
- method: 'GET',
- headers: {
- 'Accept': 'application/json',
- 'User-Agent': 'BARS-Health-Check/1.0'
- },
- signal: AbortSignal.timeout(5000)
- });
-
- if (!response.ok && response.status !== 401) {
- throw new Error(`VATSIM API returned ${response.status}`);
- }
- } catch (error) {
- console.error('VATSIM health check failed:', error);
- healthChecks.vatsim = 'outage';
+ if (servicesToCheck.includes('vatsim')) {
+ try {
+ const response = await fetch('https://auth.vatsim.net/api/user', {
+ method: 'GET',
+ headers: {
+ Accept: 'application/json',
+ 'User-Agent': 'BARS-Health-Check/1.0',
+ },
+ signal: AbortSignal.timeout(5000),
+ });
+
+ if (!response.ok && response.status !== 401) {
+ throw new Error(`VATSIM API returned ${response.status}`);
}
+ } catch (error) {
+ console.error('VATSIM health check failed:', error);
+ healthChecks.vatsim = 'outage';
}
+ }
- if (servicesToCheck.includes('auth')) {
- try {
- const auth = ServicePool.getAuth(c.env);
- await auth.getUserByVatsimId('1658308');
- } catch (error) {
- healthChecks.auth = 'outage';
- }
+ if (servicesToCheck.includes('auth')) {
+ try {
+ const auth = ServicePool.getAuth(c.env);
+ await auth.getUserByVatsimId('1658308');
+ } catch (error) {
+ healthChecks.auth = 'outage';
}
-
- // Stats service removed
-
- } catch (error) {
- console.error('Health check error:', error);
}
- const hasOutages = Object.values(healthChecks).some(status => status === 'outage');
- const statusCode = hasOutages ? 503 : 200;
+ // Stats service removed
+ } catch (error) {
+ console.error('Health check error:', error);
+ }
+
+ const hasOutages = Object.values(healthChecks).some((status) => status === 'outage');
+ const statusCode = hasOutages ? 503 : 200;
- return c.json(healthChecks, statusCode);
- });
+ return c.json(healthChecks, statusCode);
+});
// Serve OpenAPI spec
/**
diff --git a/src/network/connection.ts b/src/network/connection.ts
index 9ef46cd..287232f 100644
--- a/src/network/connection.ts
+++ b/src/network/connection.ts
@@ -273,9 +273,7 @@ export class Connection {
}
// Ensure existing state is an object for merging
- const baseState = (typeof existingObject.state === 'object' && existingObject.state !== null)
- ? existingObject.state
- : {};
+ const baseState = typeof existingObject.state === 'object' && existingObject.state !== null ? existingObject.state : {};
// Apply patch using recursive merge with size limit
newState = recursivelyMergeObjects(baseState, packet.data.patch);
@@ -392,9 +390,11 @@ export class Connection {
const isPilot = this.vatsim.isPilot(status);
const isObserver = this.vatsim.isObserver(status);
- if ((socketInfo.type === 'controller' && !isController) ||
+ if (
+ (socketInfo.type === 'controller' && !isController) ||
(socketInfo.type === 'pilot' && !isPilot) ||
- (socketInfo.type === 'observer' && !isObserver)) {
+ (socketInfo.type === 'observer' && !isObserver)
+ ) {
console.log(`User ${socketInfo.controllerId} role changed on VATSIM, closing connection`);
socket.send(
JSON.stringify({
@@ -514,11 +514,7 @@ export class Connection {
return new Response('User not connected to VATSIM', { status: 403 });
}
// Auto-determine client type based on VATSIM status
- const clientType = this.vatsim.isController(status)
- ? 'controller'
- : this.vatsim.isObserver(status)
- ? 'observer'
- : 'pilot';
+ const clientType = this.vatsim.isController(status) ? 'controller' : this.vatsim.isObserver(status) ? 'observer' : 'pilot';
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
@@ -561,17 +557,13 @@ export class Connection {
);
} // Determine if there's an active state with controllers
const now = Date.now();
- // Only consider controllers for determining if state is active
const hasActiveControllers = state.controllers.size > 0;
- const hasRecentUpdates = now - state.lastUpdate <= this.TWO_MINUTES;
- const hasActiveState = hasActiveControllers && hasRecentUpdates;
+ const hasActiveState = hasActiveControllers;
let stateObjects;
let isOffline = false;
if (clientType === 'controller' || hasActiveState) {
- // Controllers always get the current state
- // Pilots get active state only if controllers are online and have recent updates
stateObjects = Array.from(state.objects.values());
isOffline = false;
} else {
@@ -647,6 +639,41 @@ export class Connection {
);
break;
+ case 'GET_STATE': {
+ // Provide current state snapshot (controllers + pilots can request; observers too)
+ const airport = packet.airport || socketInfo.airport;
+ const state = this.airportStates.get(airport);
+ let offline = false;
+ let objects: AirportObject[] = [];
+
+ // Determine if controllers currently connected for this airport
+ const hasControllers = Array.from(this.sockets.values()).some(
+ (c) => c.airport === airport && c.type === 'controller',
+ );
+
+ if (state && hasControllers) {
+ // If any controller currently connected, treat state as online regardless of recency
+ objects = Array.from(state.objects.values());
+ } else {
+ offline = true;
+ objects = await this.getOfflineStateFromPoints(airport);
+ }
+
+ const snapshot: Packet = {
+ type: 'STATE_SNAPSHOT',
+ airport,
+ data: {
+ objects,
+ sharedState: this.getSharedStateSnapshot(airport),
+ offline,
+ requestedAt: packet.timestamp || now,
+ },
+ timestamp: Date.now(),
+ };
+ server.send(JSON.stringify(snapshot));
+ break;
+ }
+
case 'STATE_UPDATE':
if (clientType === 'pilot') {
throw new Error('Pilots cannot send state updates');
@@ -665,7 +692,9 @@ export class Connection {
await this.broadcast(broadcastPacket, server);
await this.trackMessage(clientType);
} catch (updateError) {
- throw new Error(`State update failed: ${updateError instanceof Error ? updateError.message : String(updateError)}`);
+ throw new Error(
+ `State update failed: ${updateError instanceof Error ? updateError.message : String(updateError)}`,
+ );
}
break;
@@ -688,7 +717,9 @@ export class Connection {
try {
this.handleSharedStateUpdate(packet, user.vatsim_id, socketInfo.airport);
} catch (updateError) {
- throw new Error(`Shared state update failed: ${updateError instanceof Error ? updateError.message : String(updateError)}`);
+ throw new Error(
+ `Shared state update failed: ${updateError instanceof Error ? updateError.message : String(updateError)}`,
+ );
}
break;
@@ -834,7 +865,7 @@ export class Connection {
controllers: [] as string[],
pilots: [] as string[],
controllerSet: new Set(),
- pilotSet: new Set()
+ pilotSet: new Set(),
},
);
const state = this.airportStates.get(airport);
@@ -845,25 +876,13 @@ export class Connection {
const connectedControllers = connectedClients.controllers.length > 0;
if (state && connectedControllers) {
- const now = Date.now();
- // Check if there's a recent state from controllers
- const hasRecentState = now - state.lastUpdate <= this.TWO_MINUTES;
-
- if (hasRecentState) {
- // Return active state with actual objects
- objects = Array.from(state.objects.values())
- .filter((obj) => obj.state)
- .map((obj) => ({
- id: obj.id,
- state: obj.state,
- controllerId: obj.controllerId,
- timestamp: obj.timestamp,
- }));
- } else {
- // Recent controllers but no recent state, use offline
- isOffline = true;
- objects = await this.getOfflineStateFromPoints(airport);
- }
+ // Return active state with all objects regardless of recency since controllers are connected
+ objects = Array.from(state.objects.values()).map((obj) => ({
+ id: obj.id,
+ state: obj.state,
+ controllerId: obj.controllerId,
+ timestamp: obj.timestamp,
+ }));
} else {
// No controllers connected or no state exists, mark as offline
isOffline = true;
@@ -959,7 +978,7 @@ export class Connection {
airport: airport,
data: {
sharedStatePatch: patch,
- controllerId: controllerId
+ controllerId: controllerId,
},
timestamp: Date.now(),
};
@@ -974,7 +993,9 @@ export class Connection {
try {
socket.send(JSON.stringify(packet));
} catch (error) {
- console.error(`Failed to send packet over WebSocket: ${error instanceof Error ? error.message : String(error)}`);
+ console.error(
+ `Failed to send packet over WebSocket: ${error instanceof Error ? error.message : String(error)}`,
+ );
} finally {
resolve();
}
@@ -1011,7 +1032,9 @@ export class Connection {
'INITIAL_STATE',
'CONTROLLER_CONNECT',
'CONTROLLER_DISCONNECT',
- 'ERROR'
+ 'ERROR',
+ 'GET_STATE',
+ 'STATE_SNAPSHOT',
];
if (!validTypes.includes(packet.type)) {
diff --git a/src/services/airport.ts b/src/services/airport.ts
index f333cd6..2edaf9d 100644
--- a/src/services/airport.ts
+++ b/src/services/airport.ts
@@ -1,5 +1,6 @@
import { DatabaseSessionService } from './database-session';
import { PostHogService } from './posthog';
+import { calculateDistance } from './bars/geoUtils';
interface AirportData {
latitude_deg?: number;
@@ -33,18 +34,12 @@ export class AirportService {
const uppercaseIcao = icao.toUpperCase();
// First try to get from database using read-optimized query
- const airportResult = await this.dbSession.executeRead(
- 'SELECT * FROM airports WHERE icao = ?',
- [uppercaseIcao]
- );
+ const airportResult = await this.dbSession.executeRead('SELECT * FROM airports WHERE icao = ?', [uppercaseIcao]);
const airportFromDb = airportResult.results[0];
if (airportFromDb) {
// Get runways for this airport
- const runwaysResult = await this.dbSession.executeRead(
- 'SELECT * FROM runways WHERE airport_icao = ?',
- [uppercaseIcao]
- );
+ const runwaysResult = await this.dbSession.executeRead('SELECT * FROM runways WHERE airport_icao = ?', [uppercaseIcao]);
return { ...airportFromDb, runways: runwaysResult.results };
}
@@ -65,10 +60,13 @@ export class AirportService {
};
// Save airport to database using write-optimized operation
- await this.dbSession.executeWrite(
- 'INSERT INTO airports (icao, latitude, longitude, name, continent) VALUES (?, ?, ?, ?, ?)',
- [airport.icao, airport.latitude, airport.longitude, airport.name, airport.continent]
- );
+ await this.dbSession.executeWrite('INSERT INTO airports (icao, latitude, longitude, name, continent) VALUES (?, ?, ?, ?, ?)', [
+ airport.icao,
+ airport.latitude,
+ airport.longitude,
+ airport.name,
+ airport.continent,
+ ]);
// Save runway data if available
if (airportData.runways && airportData.runways.length > 0) {
@@ -90,24 +88,30 @@ export class AirportService {
runway.he_ident,
runway.he_latitude_deg,
runway.he_longitude_deg,
- ]
+ ],
}));
await this.dbSession.executeBatch(runwayStatements);
// Fetch the saved runways to return with the airport
- const runwaysResult = await this.dbSession.executeRead(
- 'SELECT * FROM runways WHERE airport_icao = ?',
- [uppercaseIcao]
- );
+ const runwaysResult = await this.dbSession.executeRead('SELECT * FROM runways WHERE airport_icao = ?', [
+ uppercaseIcao,
+ ]);
return { ...airport, runways: runwaysResult.results };
}
- try { this.posthog?.track('Airport Fetched From External API', { icao: uppercaseIcao, hasRunways: !!airportData.runways?.length }); } catch { }
+ try {
+ this.posthog?.track('Airport Fetched From External API', {
+ icao: uppercaseIcao,
+ hasRunways: !!airportData.runways?.length,
+ });
+ } catch {}
return airport;
} catch (error) {
- try { this.posthog?.track('Airport External Fetch Failed', { icao: uppercaseIcao }); } catch { }
+ try {
+ this.posthog?.track('Airport External Fetch Failed', { icao: uppercaseIcao });
+ } catch {}
return null;
}
}
@@ -133,10 +137,62 @@ export class AirportService {
}
async getAirportsByContinent(continent: string) {
- const result = await this.dbSession.executeRead(
- 'SELECT * FROM airports WHERE continent = ? ORDER BY icao',
- [continent.toUpperCase()]
- );
+ const result = await this.dbSession.executeRead('SELECT * FROM airports WHERE continent = ? ORDER BY icao', [
+ continent.toUpperCase(),
+ ]);
return { results: result.results };
}
+
+ /**
+ * Find the nearest airport to a latitude/longitude using a very fast approximate search
+ * followed by an exact distance refinement. Designed for high QPS usage.
+ */
+ async getNearestAirport(lat: number, lon: number) {
+ // Guard invalid input early
+ if (Number.isNaN(lat) || Number.isNaN(lon) || lat < -90 || lat > 90 || lon < -180 || lon > 180) {
+ return null;
+ }
+
+ // Use a small bounding box to reduce rows scanned (±1° ~ up to 60nm lat / 60nm * cos(lat) lon)
+ const LAT_BOX = 1; // degrees
+ const LON_BOX = 1; // degrees
+ const minLat = lat - LAT_BOX;
+ const maxLat = lat + LAT_BOX;
+ const minLon = lon - LON_BOX;
+ const maxLon = lon + LON_BOX;
+
+ // Pre-compute cos^2(lat) to weight longitudinal delta for planar approx distance ordering
+ const cosLat = Math.cos((lat * Math.PI) / 180);
+ const cosLatSq = cosLat * cosLat;
+ const approx = await this.dbSession.executeRead(
+ `SELECT icao, latitude, longitude, name, continent,
+ ((latitude - ?) * (latitude - ?) + ((longitude - ?) * (longitude - ?) * ?)) AS distance_score
+ FROM airports
+ WHERE latitude BETWEEN ? AND ? AND longitude BETWEEN ? AND ?
+ ORDER BY distance_score
+ LIMIT 1`,
+ [lat, lat, lon, lon, cosLatSq, minLat, maxLat, minLon, maxLon],
+ );
+
+ const row = approx.results?.[0];
+ if (!row) return null;
+
+ // Refine with precise geodesic distance (meters) and convert to NM
+ const distance_m = calculateDistance({ lat, lon }, { lat: row.latitude, lon: row.longitude });
+ const distance_nm = distance_m / 1852;
+
+ try {
+ this.posthog?.track('Nearest Airport Lookup', { icao: row.icao });
+ } catch {}
+
+ return {
+ icao: row.icao,
+ latitude: row.latitude,
+ longitude: row.longitude,
+ name: row.name,
+ continent: row.continent,
+ distance_m: Math.round(distance_m),
+ distance_nm: Number(distance_nm.toFixed(2)),
+ };
+ }
}
diff --git a/src/services/auth.ts b/src/services/auth.ts
index 01ec911..fd61f1b 100644
--- a/src/services/auth.ts
+++ b/src/services/auth.ts
@@ -27,7 +27,9 @@ export class AuthService {
isNewUser: created,
userId: user.id,
});
- } catch { /* ignore analytics errors */ }
+ } catch {
+ /* ignore analytics errors */
+ }
return { user, vatsimToken: auth.access_token };
}
@@ -35,10 +37,7 @@ export class AuthService {
// Use primary mode for authentication checks to ensure latest data
this.dbSession.startSession({ mode: 'first-primary' });
- const existingUserResult = await this.dbSession.executeRead(
- 'SELECT * FROM users WHERE vatsim_id = ?',
- [vatsimUser.id]
- );
+ const existingUserResult = await this.dbSession.executeRead('SELECT * FROM users WHERE vatsim_id = ?', [vatsimUser.id]);
const existingUser = existingUserResult.results[0];
if (existingUser) {
@@ -61,10 +60,9 @@ export class AuthService {
private async createNewUser(vatsimUser: VatsimUser) {
// Check for existing VATSIM user using session
- const existingVatsimUserResult = await this.dbSession.executeRead(
- 'SELECT id FROM users WHERE vatsim_id = ?',
- [vatsimUser.id]
- );
+ const existingVatsimUserResult = await this.dbSession.executeRead('SELECT id FROM users WHERE vatsim_id = ?', [
+ vatsimUser.id,
+ ]);
if (existingVatsimUserResult.results[0]) {
throw new Error('User with this VATSIM ID already exists');
@@ -73,18 +71,40 @@ export class AuthService {
let apiKey = this.generateApiKey();
while (true) {
- const existingKeyResult = await this.dbSession.executeRead(
- 'SELECT id FROM users WHERE api_key = ?',
- [apiKey]
- );
+ const existingKeyResult = await this.dbSession.executeRead('SELECT id FROM users WHERE api_key = ?', [apiKey]);
if (!existingKeyResult.results[0]) break;
apiKey = this.generateApiKey();
}
+ const fullName = [vatsimUser.first_name, vatsimUser.last_name].filter(Boolean).join(' ') || null;
+ const displayMode = 0;
+ const displayName = this.computeDisplayName(
+ {
+ id: 0,
+ vatsim_id: vatsimUser.id,
+ api_key: apiKey,
+ email: vatsimUser.email,
+ full_name: fullName,
+ display_mode: displayMode,
+ created_at: '',
+ last_login: '',
+ vatsimToken: '',
+ },
+ vatsimUser,
+ );
const result = await this.dbSession.executeWrite(
- 'INSERT INTO users (vatsim_id, api_key, email, created_at, last_login) VALUES (?, ?, ?, ?, ?) RETURNING *',
- [vatsimUser.id, apiKey, vatsimUser.email, new Date().toISOString(), new Date().toISOString()]
+ 'INSERT INTO users (vatsim_id, api_key, email, full_name, display_mode, display_name, created_at, last_login) VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING *',
+ [
+ vatsimUser.id,
+ apiKey,
+ vatsimUser.email,
+ fullName,
+ displayMode,
+ displayName,
+ new Date().toISOString(),
+ new Date().toISOString(),
+ ],
);
if (!result.results[0]) throw new Error('Failed to create user');
@@ -98,40 +118,93 @@ export class AuthService {
await this.dbSession.executeBatch([
{ query: 'DELETE FROM division_members WHERE vatsim_id = ?', params: [vatsimId] },
{ query: 'DELETE FROM staff WHERE user_id IN (SELECT id FROM users WHERE vatsim_id = ?)', params: [vatsimId] },
- { query: 'DELETE FROM users WHERE vatsim_id = ?', params: [vatsimId] }
+ { query: 'DELETE FROM users WHERE vatsim_id = ?', params: [vatsimId] },
]);
const userExists = await this.getUserByVatsimId(vatsimId);
const deleted = !userExists;
if (deleted) {
- try { this.posthog?.track('User Deleted', { vatsimId }); } catch { }
+ try {
+ this.posthog?.track('User Deleted', { vatsimId });
+ } catch {}
}
return deleted;
}
async getUserByApiKey(apiKey: string): Promise {
// Use unconstrained read for API key lookups (performance optimization)
- const result = await this.dbSession.executeRead(
- 'SELECT * FROM users WHERE api_key = ?',
- [apiKey]
- );
+ const result = await this.dbSession.executeRead('SELECT * FROM users WHERE api_key = ?', [apiKey]);
return result.results[0] || null;
}
async getUserByVatsimId(vatsimId: string): Promise {
// Use unconstrained read for VATSIM ID lookups
- const result = await this.dbSession.executeRead(
- 'SELECT * FROM users WHERE vatsim_id = ?',
- [vatsimId]
- );
+ const result = await this.dbSession.executeRead('SELECT * FROM users WHERE vatsim_id = ?', [vatsimId]);
return result.results[0] || null;
}
- private async updateUserLastLogin(userId: number) {
- await this.dbSession.executeWrite(
- 'UPDATE users SET last_login = ? WHERE id = ?',
- [new Date().toISOString(), userId]
+ computeDisplayName(user: UserRecord, vatsimUser?: VatsimUser): string {
+ const mode = user.display_mode ?? 0;
+ const fullName = user.full_name || [vatsimUser?.first_name, vatsimUser?.last_name].filter(Boolean).join(' ').trim();
+ if (mode === 2) return user.vatsim_id;
+ if (!fullName) return user.vatsim_id;
+ const parts = fullName.split(/\s+/);
+ if (mode === 0) return parts[0];
+ if (mode === 1) {
+ const first = parts[0];
+ const lastInitial = parts.length > 1 ? parts[parts.length - 1][0] : '';
+ return lastInitial ? `${first} ${lastInitial}` : first;
+ }
+ return fullName; // fallback
+ }
+
+ async updateDisplayMode(userId: number, mode: number) {
+ if (![0, 1, 2].includes(mode)) throw new Error('Invalid display mode');
+
+ // Use primary for consistency on write
+ this.dbSession.startSession({ mode: 'first-primary' });
+
+ const current = await this.dbSession.executeRead(
+ 'SELECT id, vatsim_id, email, full_name, display_mode, display_name FROM users WHERE id = ?',
+ [userId],
);
+ const user = current.results[0];
+ if (!user) return;
+
+ if (user.display_mode === mode) return; // nothing to do
+
+ const fullNameParts = (user.full_name || '').trim().split(/\s+/).filter(Boolean);
+ const vatsimUser: VatsimUser = {
+ id: user.vatsim_id,
+ email: user.email,
+ first_name: fullNameParts[0] || '',
+ last_name: fullNameParts.slice(1).join(' '),
+ };
+
+ const displayName = this.computeDisplayName({ ...user, display_mode: mode } as UserRecord, vatsimUser);
+
+ await this.dbSession.executeWrite('UPDATE users SET display_mode = ?, display_name = ? WHERE id = ?', [mode, displayName, userId]);
+ }
+
+ async updateFullName(userId: number, fullName: string) {
+ await this.dbSession.executeWrite('UPDATE users SET full_name = ? WHERE id = ?', [fullName, userId]);
+ // Recompute display_name after updating full_name using existing display_mode
+ const current = await this.dbSession.executeRead('SELECT * FROM users WHERE id = ?', [userId]);
+ const user = current.results[0];
+ if (user) {
+ const vatsimUser: VatsimUser = {
+ id: user.vatsim_id,
+ email: user.email,
+ first_name: fullName.split(' ')[0],
+ last_name: fullName.split(' ').slice(1).join(' '),
+ };
+ const displayName = this.computeDisplayName(user, vatsimUser);
+ await this.dbSession.executeWrite('UPDATE users SET display_name = ? WHERE id = ?', [displayName, userId]);
+ }
+ }
+
+ private async updateUserLastLogin(userId: number) {
+ await this.dbSession.executeWrite('UPDATE users SET last_login = ? WHERE id = ?', [new Date().toISOString(), userId]);
}
async regenerateApiKey(userId: number): Promise {
@@ -142,27 +215,26 @@ export class AuthService {
// Make sure the new API key is unique
while (true) {
- const existingKeyResult = await this.dbSession.executeRead(
- 'SELECT id FROM users WHERE api_key = ?',
- [newApiKey]
- );
+ const existingKeyResult = await this.dbSession.executeRead('SELECT id FROM users WHERE api_key = ?', [newApiKey]);
if (!existingKeyResult.results[0]) break;
newApiKey = this.generateApiKey();
}
// Update the user's API key in the database
- const result = await this.dbSession.executeWrite(
- 'UPDATE users SET api_key = ? WHERE id = ? RETURNING api_key',
- [newApiKey, userId]
- );
+ const result = await this.dbSession.executeWrite('UPDATE users SET api_key = ? WHERE id = ? RETURNING api_key', [
+ newApiKey,
+ userId,
+ ]);
if (!result.results[0]) {
throw new Error('Failed to update API key');
}
const apiKey = (result.results[0] as { api_key: string }).api_key;
- try { this.posthog?.track('User API Key Regenerated', { userId }); } catch { }
+ try {
+ this.posthog?.track('User API Key Regenerated', { userId });
+ } catch {}
return apiKey;
}
}
diff --git a/src/services/bars/handlers.ts b/src/services/bars/handlers.ts
index 48907a3..cedf196 100644
--- a/src/services/bars/handlers.ts
+++ b/src/services/bars/handlers.ts
@@ -76,75 +76,60 @@ export class StopbarHandler extends BarsTypeHandler {
if (points.length < 2) return [];
const lightPoints = generateEquidistantPoints(points, STOPBAR_SPACING);
- const headingAdjustment = this.getHeadingAdjustment(dbRecord.orientation) + 90;
-
- if (lightPoints.length >= 2) {
- const initialHeading = calculateHeading(lightPoints[0], lightPoints[1]);
- const needsReversal = initialHeading > 180 && initialHeading < 360;
-
- if (needsReversal) {
- const extraAdjustment = 180;
- const lightsWithHeading = this.addHeadingToPoints(lightPoints, headingAdjustment + extraAdjustment);
-
- const lightsWithProperties = lightsWithHeading.map(
- (light): BarsLightPoint => ({
- ...light,
- properties: {
- type: 'stopbar',
- color: dbRecord.color || 'red',
- orientation: dbRecord.orientation,
- elevated: false,
- ihp: dbRecord.ihp,
- },
- }),
- );
-
- // Generate IHP lights if needed
- let allLights = [...lightsWithProperties];
-
- if (dbRecord.ihp) {
- const ihpLights = this.generateIHPLights(lightPoints, lightsWithHeading, headingAdjustment + extraAdjustment, dbRecord);
- allLights = [...allLights, ...ihpLights];
- }
-
- // Handle elevated lights if needed
- if (dbRecord.elevated) {
- const elevatedLights = this.generateElevatedLights(lightPoints, lightsWithHeading, headingAdjustment + extraAdjustment);
- allLights = [...allLights, ...elevatedLights];
- }
-
- return allLights;
+ // First derive along-line headings without any adjustment
+ const alongHeadings = this.addHeadingToPoints(lightPoints, 0);
+
+ // Orientation mapping requirement (perpendicular to line):
+ // We compute perpendicular headings (seg - 90) and (seg + 90).
+ // Flipped per latest feedback:
+ // left -> choose perpendicular in north/east half (<180)
+ // right -> choose perpendicular in south/west half (>=180)
+ // both -> deterministic choice (south/west half) so stable output.
+ const orientation = dbRecord.orientation || 'both';
+
+ const lightsWithHeading: BarsLightPoint[] = alongHeadings.map((p) => {
+ const seg = ((p.heading % 360) + 360) % 360; // along-line heading
+ const perpA = (seg + 90) % 360; // right side relative to direction of drawing
+ const perpB = (seg + 270) % 360; // left side (seg - 90)
+ // Determine which candidate is north/east (<180) vs south/west (>=180)
+ const candidateNorthEast = perpA < 180 ? perpA : perpB < 180 ? perpB : perpA; // one <180 if possible
+ const candidateSouthWest = perpA >= 180 ? perpA : perpB >= 180 ? perpB : perpA; // one >=180 if possible
+ let chosen: number;
+ if (orientation === 'right') {
+ chosen = candidateSouthWest; // flipped
+ } else if (orientation === 'left') {
+ chosen = candidateNorthEast; // flipped
+ } else {
+ // both -> deterministic pick south/west
+ chosen = candidateSouthWest;
}
- }
-
- // If no reversal needed, proceed with normal processing
- const lightsWithHeading = this.addHeadingToPoints(lightPoints, headingAdjustment);
+ return { ...p, heading: chosen };
+ });
- // Add properties to all lights
- const lightsWithProperties = lightsWithHeading.map(
- (light): BarsLightPoint => ({
- ...light,
- properties: {
- type: 'stopbar',
- color: dbRecord.color || 'red',
- orientation: dbRecord.orientation,
- elevated: false,
- ihp: dbRecord.ihp,
- },
- }),
- );
+ // Add properties to base stopbar lights
+ const lightsWithProperties: BarsLightPoint[] = lightsWithHeading.map((light): BarsLightPoint => ({
+ ...light,
+ properties: {
+ type: 'stopbar',
+ color: dbRecord.color || 'red',
+ orientation: dbRecord.orientation,
+ elevated: false,
+ ihp: dbRecord.ihp,
+ },
+ }));
- // Generate IHP lights if needed
- let allLights = [...lightsWithProperties];
+ let allLights: BarsLightPoint[] = [...lightsWithProperties];
+ // IHP lights (inherit chosen heading at center)
if (dbRecord.ihp) {
- const ihpLights = this.generateIHPLights(lightPoints, lightsWithHeading, headingAdjustment, dbRecord);
+ const ihpLights = this.generateIHPLights(lightPoints, lightsWithHeading, 0, dbRecord);
allLights = [...allLights, ...ihpLights];
}
- // Handle elevated lights if needed
- if (dbRecord.elevated) {
- const elevatedLights = this.generateElevatedLights(lightPoints, lightsWithHeading, headingAdjustment);
+ // Elevated lights (need baseline line direction). Compute baseline from first segment.
+ if (dbRecord.elevated && lightPoints.length >= 2) {
+ const baseLineHeading = calculateHeading(lightPoints[0], lightPoints[1]);
+ const elevatedLights = this.generateElevatedLights(lightPoints, lightsWithHeading, baseLineHeading);
allLights = [...allLights, ...elevatedLights];
}
@@ -225,7 +210,7 @@ export class StopbarHandler extends BarsTypeHandler {
/**
* Generate elevated lights at the ends of a stopbar
*/
- private generateElevatedLights(points: GeoPoint[], lightsWithHeading: BarsLightPoint[], headingAdjustment: number): BarsLightPoint[] {
+ private generateElevatedLights(points: GeoPoint[], lightsWithHeading: BarsLightPoint[], baseLineHeading: number): BarsLightPoint[] {
if (points.length < 2 || lightsWithHeading.length < 2) return [];
const elevatedLights: BarsLightPoint[] = [];
@@ -234,9 +219,7 @@ export class StopbarHandler extends BarsTypeHandler {
const firstLight = lightsWithHeading[0];
const lastLight = lightsWithHeading[lightsWithHeading.length - 1];
- // Get the direction of the stopbar line
- // We need to adjust by -90 because the heading is perpendicular to the stopbar
- const baseLineHeading = (firstLight.heading - 90) % 360;
+ // baseLineHeading provided (direction along the stopbar line)
// Step 1: Calculate the extension points - placing them exactly 1 meter beyond each end of the stopbar
// First point - elevated light placed exactly 1 meter BEFORE the first light (extending the line)
@@ -254,23 +237,23 @@ export class StopbarHandler extends BarsTypeHandler {
);
// Step 2: Move the lights inward by the defined inward offset (0.3 meters)
- // We move perpendicular to the stopbar line
+ // Now place them on the OPPOSITE side of the stopbar (flip from previous -90 to +90)
const startInwardPoint = calculateDestinationPoint(
startElevatedPoint,
ELEVATED_LIGHT_INWARD_OFFSET,
- (baseLineHeading - 90) % 360, // 90 degrees right of stopbar direction
+ (baseLineHeading + 90) % 360, // opposite side perpendicular
);
const endInwardPoint = calculateDestinationPoint(
endElevatedPoint,
ELEVATED_LIGHT_INWARD_OFFSET,
- (baseLineHeading - 90) % 360, // 90 degrees right of stopbar direction
- ); // Step 3: Calculate the inward headings to make the lights point toward the center of the stopbar
- // For the first elevated light at the start of the stopbar: angle inward by the inward angle
- const firstElevatedHeading = (baseLineHeading + ELEVATED_LIGHT_INWARD_ANGLE) % 360;
+ (baseLineHeading + 90) % 360, // opposite side perpendicular
+ );
- // For the last elevated light at the end of the stopbar: angle inward by the inward angle (opposite direction)
- const lastElevatedHeading = (baseLineHeading + 180 - ELEVATED_LIGHT_INWARD_ANGLE) % 360;
+ // Step 3: Flip headings 180° so elevated lights face the correct (opposite) way after side switch
+ // Original inward headings: base+angle and base+180-angle. We add 180 to both to flip them.
+ const firstElevatedHeading = (baseLineHeading + ELEVATED_LIGHT_INWARD_ANGLE + 90) % 360;
+ const lastElevatedHeading = (baseLineHeading - ELEVATED_LIGHT_INWARD_ANGLE + 90) % 360;
// Add the elevated lights with correct positions and inward headings
elevatedLights.push({
diff --git a/src/services/cache.ts b/src/services/cache.ts
index 8caf72e..2f239d8 100644
--- a/src/services/cache.ts
+++ b/src/services/cache.ts
@@ -1,6 +1,6 @@
interface CacheOptions {
- ttl?: number; // Time to live in seconds
- namespace?: string;
+ ttl?: number; // Time to live in seconds
+ namespace?: string;
}
/**
@@ -8,67 +8,67 @@ interface CacheOptions {
* More efficient than KV for short-lived cached data
*/
export class CacheService {
- constructor(private env: Env) { }
-
- /**
- * Get data from cache
- * @param key - Cache key
- * @returns Cached data or null if not found
- */
- async get(key: string, namespace = 'default'): Promise {
- // Create a cache key with namespace
- const cacheKey = new Request(`https://cache.stopbars/${namespace}/${key}`);
-
- // Try to get from cache
- const cache = caches.default;
- const cachedResponse = await cache.match(cacheKey);
-
- if (!cachedResponse) {
- return null;
- }
-
- try {
- return await cachedResponse.json();
- } catch (e) {
- return null;
- }
- }
-
- /**
- * Set data in cache
- * @param key - Cache key
- * @param data - Data to cache
- * @param options - Cache options
- */
- async set(key: string, data: T, options: CacheOptions = {}): Promise {
- const { ttl = 60, namespace = 'default' } = options;
-
- // Create a cache key with namespace
- const cacheKey = new Request(`https://cache.stopbars/${namespace}/${key}`);
-
- // Create response with the data
- const response = new Response(JSON.stringify(data), {
- headers: {
- 'Content-Type': 'application/json',
- 'Cache-Control': `max-age=${ttl}`,
- },
- });
-
- // Store in cache
- const cache = caches.default;
- await cache.put(cacheKey, response);
- }
-
- /**
- * Delete data from cache
- * @param key - Cache key
- * @param namespace - Cache namespace
- */
- async delete(key: string, namespace = 'default'): Promise {
- const cacheKey = new Request(`https://cache.stopbars/${namespace}/${key}`);
- const cache = caches.default;
- await cache.delete(cacheKey);
- }
+ constructor(private env: Env) {}
+
+ /**
+ * Get data from cache
+ * @param key - Cache key
+ * @returns Cached data or null if not found
+ */
+ async get(key: string, namespace = 'default'): Promise {
+ // Create a cache key with namespace
+ const cacheKey = new Request(`https://cache.stopbars/${namespace}/${key}`);
+
+ // Try to get from cache
+ const cache = caches.default;
+ const cachedResponse = await cache.match(cacheKey);
+
+ if (!cachedResponse) {
+ return null;
+ }
+
+ try {
+ return await cachedResponse.json();
+ } catch (e) {
+ return null;
+ }
+ }
+
+ /**
+ * Set data in cache
+ * @param key - Cache key
+ * @param data - Data to cache
+ * @param options - Cache options
+ */
+ async set(key: string, data: T, options: CacheOptions = {}): Promise {
+ const { ttl = 60, namespace = 'default' } = options;
+
+ // Create a cache key with namespace
+ const cacheKey = new Request(`https://cache.stopbars/${namespace}/${key}`);
+
+ // Create response with the data
+ const response = new Response(JSON.stringify(data), {
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Cache-Control': `max-age=${ttl}`,
+ },
+ });
+
+ // Store in cache
+ const cache = caches.default;
+ await cache.put(cacheKey, response);
+ }
+
+ /**
+ * Delete data from cache
+ * @param key - Cache key
+ * @param namespace - Cache namespace
+ */
+ async delete(key: string, namespace = 'default'): Promise {
+ const cacheKey = new Request(`https://cache.stopbars/${namespace}/${key}`);
+ const cache = caches.default;
+ await cache.delete(cacheKey);
+ }
}
/**
@@ -77,82 +77,82 @@ export class CacheService {
* @param ttl - Time to live in seconds
* @param namespace - Cache namespace
*/
-export function withCache(
- cacheKeyFn: (req: Request) => string,
- ttl: number = 60,
- namespace: string = 'default'
-) {
- return async (c: any, next: () => Promise) => {
- // Skip caching for non-GET requests
- if (c.req.method !== 'GET') {
- return next();
- }
-
- const cacheService = new CacheService(c.env);
- const cacheKey = cacheKeyFn(c.req.raw);
-
- // Try to get from cache
- const cachedData = await cacheService.get(cacheKey, namespace);
- if (cachedData) {
- // Set header to indicate cache hit
- c.header('X-Cache', 'HIT');
- return c.json(cachedData);
- }
-
- // Cache miss, proceed to handler
- c.header('X-Cache', 'MISS');
- await next();
-
- // After handler executes, cache the response if it was successful
- // Don't cache error responses (4xx, 5xx) including 404 Not Found
- if (c.res && c.res.status >= 200 && c.res.status < 300) {
- try {
- // Clone the response to read it without consuming the original
- const clonedRes = c.res.clone();
- const contentType = clonedRes.headers.get('content-type');
-
- // Only cache JSON responses
- if (contentType && contentType.includes('application/json')) {
- const data = await clonedRes.json();
- // Cache the data
- await cacheService.set(cacheKey, data, { ttl, namespace });
- }
- } catch (e) {
- // Silently fail if we can't cache
- console.error('Failed to cache response:', e);
- }
- }
- };
+export function withCache(cacheKeyFn: (req: Request) => string, ttl: number = 60, namespace: string = 'default') {
+ return async (c: any, next: () => Promise) => {
+ // Skip caching for non-GET requests
+ if (c.req.method !== 'GET') {
+ return next();
+ }
+
+ const cacheService = new CacheService(c.env);
+ const cacheKey = cacheKeyFn(c.req.raw);
+
+ // Try to get from cache
+ const cachedData = await cacheService.get(cacheKey, namespace);
+ if (cachedData) {
+ // Set header to indicate cache hit
+ c.header('X-Cache', 'HIT');
+ return c.json(cachedData);
+ }
+
+ // Cache miss, proceed to handler
+ c.header('X-Cache', 'MISS');
+ await next();
+
+ // After handler executes, cache the response if it was successful
+ // Don't cache error responses (4xx, 5xx) including 404 Not Found
+ if (c.res && c.res.status >= 200 && c.res.status < 300) {
+ try {
+ // Clone the response to read it without consuming the original
+ const clonedRes = c.res.clone();
+ const contentType = clonedRes.headers.get('content-type');
+
+ // Only cache JSON responses
+ if (contentType && contentType.includes('application/json')) {
+ const data = await clonedRes.json();
+ // Cache the data
+ await cacheService.set(cacheKey, data, { ttl, namespace });
+ }
+ } catch (e) {
+ // Silently fail if we can't cache
+ console.error('Failed to cache response:', e);
+ }
+ }
+ };
}
/**
* Simple cache key generators for common patterns
*/
export const CacheKeys = {
- /**
- * Generate cache key from URL path and query params
- */
- fromUrl: (req: Request): string => {
- const url = new URL(req.url);
- return `${url.pathname}${url.search}`;
- },
-
- /**
- * Generate cache key from specific query parameters
- */
- fromParams: (...params: string[]) => (req: Request): string => {
- const url = new URL(req.url);
- const values = params.map(p => url.searchParams.get(p) || '').join('-');
- return `${url.pathname}-${values}`;
- },
-
- /**
- * Generate cache key with user context (for authenticated endpoints)
- */
- withUser: (baseKey: string) => (req: Request): string => {
- const token = req.headers.get('X-Vatsim-Token') || 'anonymous';
- // Use a hash of the token to avoid storing sensitive data in cache keys
- const userHash = token.substring(0, 8); // Simple approach, could use proper hashing
- return `${baseKey}-user-${userHash}`;
- },
+ /**
+ * Generate cache key from URL path and query params
+ */
+ fromUrl: (req: Request): string => {
+ const url = new URL(req.url);
+ return `${url.pathname}${url.search}`;
+ },
+
+ /**
+ * Generate cache key from specific query parameters
+ */
+ fromParams:
+ (...params: string[]) =>
+ (req: Request): string => {
+ const url = new URL(req.url);
+ const values = params.map((p) => url.searchParams.get(p) || '').join('-');
+ return `${url.pathname}-${values}`;
+ },
+
+ /**
+ * Generate cache key with user context (for authenticated endpoints)
+ */
+ withUser:
+ (baseKey: string) =>
+ (req: Request): string => {
+ const token = req.headers.get('X-Vatsim-Token') || 'anonymous';
+ // Use a hash of the token to avoid storing sensitive data in cache keys
+ const userHash = token.substring(0, 8); // Simple approach, could use proper hashing
+ return `${baseKey}-user-${userHash}`;
+ },
};
diff --git a/src/services/contact.ts b/src/services/contact.ts
new file mode 100644
index 0000000..476df32
--- /dev/null
+++ b/src/services/contact.ts
@@ -0,0 +1,76 @@
+import { DatabaseSessionService } from './database-session';
+
+export interface ContactMessageRecord {
+ id: string;
+ email: string;
+ topic: string;
+ message: string;
+ ip_address: string;
+ status: 'pending' | 'handling' | 'handled';
+ handled_by: string | null;
+ handled_at: string | null;
+ created_at: string;
+}
+
+export class ContactService {
+ private dbSession: DatabaseSessionService;
+ constructor(private db: D1Database) {
+ this.dbSession = new DatabaseSessionService(db);
+ }
+
+ async createMessage(email: string, topic: string, message: string, ip: string): Promise {
+ const id = crypto.randomUUID();
+ await this.dbSession.executeWrite(
+ `INSERT INTO contact_messages (id, email, topic, message, ip_address, status, created_at) VALUES (?, ?, ?, ?, ?, 'pending', datetime('now'))`,
+ [id, email, topic, message, ip],
+ );
+ const created = await this.getMessage(id);
+ if (!created) throw new Error('Failed to create contact message');
+ return created;
+ }
+
+ async getMessage(id: string): Promise {
+ const res = await this.dbSession.executeRead(
+ `SELECT id, email, topic, message, ip_address, status, handled_by, handled_at, created_at FROM contact_messages WHERE id = ?`,
+ [id],
+ );
+ return res.results[0] || null;
+ }
+
+ async listMessages(): Promise {
+ const res = await this.dbSession.executeRead(
+ `SELECT id, email, topic, message, ip_address, status, handled_by, handled_at, created_at FROM contact_messages ORDER BY datetime(created_at) DESC`,
+ [],
+ );
+ return res.results;
+ }
+
+ async updateStatus(id: string, status: 'pending' | 'handling' | 'handled', handlerVatsimId: string): Promise {
+ // handled_by/handled_at only set when moving to handled, if returning to pending/handling clear handled_at but keep who last handled
+ if (status === 'handled') {
+ await this.dbSession.executeWrite(
+ `UPDATE contact_messages SET status = ?, handled_by = ?, handled_at = datetime('now') WHERE id = ?`,
+ [status, handlerVatsimId, id],
+ );
+ } else {
+ await this.dbSession.executeWrite(
+ `UPDATE contact_messages SET status = ?, handled_at = NULL WHERE id = ?`,
+ [status, id],
+ );
+ }
+ return this.getMessage(id);
+ }
+
+ async deleteMessage(id: string): Promise {
+ const res = await this.dbSession.executeWrite(`DELETE FROM contact_messages WHERE id = ?`, [id]);
+ return !!res.success;
+ }
+
+ async hasRecentSubmissionFromIp(ip: string, withinHours = 24): Promise {
+ const res = await this.dbSession.executeRead<{ cnt: number }>(
+ `SELECT COUNT(*) as cnt FROM contact_messages WHERE ip_address = ? AND datetime(created_at) >= datetime('now', ?)`,
+ [ip, `-${withinHours} hours`],
+ );
+ return (res.results[0]?.cnt || 0) > 0;
+ }
+}
diff --git a/src/services/contributions.ts b/src/services/contributions.ts
index 6ff2709..39ece40 100644
--- a/src/services/contributions.ts
+++ b/src/services/contributions.ts
@@ -5,6 +5,7 @@ import { SupportService } from './support';
import { PolygonService } from './polygons';
import { ServicePool } from './service-pool';
import { PostHogService } from './posthog';
+import { sanitizeContributionXml } from './xml-sanitizer';
export interface Contribution {
id: string;
@@ -22,7 +23,6 @@ export interface Contribution {
export interface ContributionSubmission {
userId: string;
- userDisplayName?: string;
airportIcao: string;
packageName: string;
submittedXml: string;
@@ -79,15 +79,47 @@ export class ContributionService {
throw new Error(`Airport with ICAO ${submission.airportIcao} not found`);
}
- const trimmedXml = submission.submittedXml.trim();
- if (!trimmedXml || !trimmedXml.startsWith('
+ xml
+ .trim()
+ .replace(/\r/g, '')
+ .replace(/[\t ]+/g, ' ')
+ .replace(/>\s+<');
+ if (normalize(trimmedXml) === normalize(latestApproved.submittedXml)) {
+ throw new Error('Duplicate of current approved XML for this airport & package');
+ }
+ }
+ } catch (e) {
+ if (e instanceof Error && e.message.startsWith('Duplicate')) {
+ // Re-throw duplicate error directly
+ throw e;
+ }
}
const id = crypto.randomUUID();
const now = new Date().toISOString();
- await this.updateUserDisplayNameForAllContributions(submission.userId, submission.userDisplayName || null);
+ // Get authoritative display name from users table (ignore any client-provided value)
+ const userDisplayResult = await this.dbSession.executeRead<{ display_name: string | null }>(
+ 'SELECT display_name FROM users WHERE vatsim_id = ?',
+ [submission.userId],
+ );
+ const authoritativeDisplayName = userDisplayResult.results[0]?.display_name || null;
await this.dbSession.executeWrite(
`
INSERT INTO contributions (
@@ -99,21 +131,20 @@ export class ContributionService {
[
id,
submission.userId,
- submission.userDisplayName || null,
+ authoritativeDisplayName,
submission.airportIcao,
submission.packageName,
trimmedXml,
submission.notes || null,
now,
'pending',
- ]
+ ],
);
-
const contribution: Contribution = {
id,
userId: submission.userId,
- userDisplayName: submission.userDisplayName || null,
+ userDisplayName: authoritativeDisplayName,
airportIcao: submission.airportIcao,
packageName: submission.packageName,
submittedXml: trimmedXml,
@@ -123,7 +154,13 @@ export class ContributionService {
rejectionReason: null,
decisionDate: null,
};
- try { this.posthog?.track('Contribution Submitted', { airport: submission.airportIcao, packageName: submission.packageName, userId: submission.userId }); } catch { }
+ try {
+ this.posthog?.track('Contribution Submitted', {
+ airport: submission.airportIcao,
+ packageName: submission.packageName,
+ userId: submission.userId,
+ });
+ } catch { }
return contribution;
}
async getContribution(id: string): Promise {
@@ -138,7 +175,35 @@ export class ContributionService {
FROM contributions
WHERE id = ?
`,
- [id]
+ [id],
+ );
+ return result.results[0] || null;
+ }
+
+ /**
+ * Get the most recently approved contribution for an airport & package (by decision_date)
+ * Case-insensitive package name match.
+ * @param airportIcao ICAO code
+ * @param packageName Package name (case-insensitive)
+ */
+ async getLatestApprovedContributionForAirportPackage(
+ airportIcao: string,
+ packageName: string,
+ ): Promise {
+ const result = await this.dbSession.executeRead(
+ `
+ SELECT
+ id, user_id as userId, user_display_name as userDisplayName,
+ airport_icao as airportIcao, package_name as packageName,
+ submitted_xml as submittedXml, notes,
+ submission_date as submissionDate, status,
+ rejection_reason as rejectionReason, decision_date as decisionDate
+ FROM contributions
+ WHERE airport_icao = ? AND lower(package_name) = lower(?) AND status = 'approved'
+ ORDER BY datetime(decision_date) DESC
+ LIMIT 1
+ `,
+ [airportIcao, packageName],
);
return result.results[0] || null;
}
@@ -170,10 +235,7 @@ export class ContributionService {
const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(' AND ')}` : '';
const countQuery = `SELECT COUNT(*) as total FROM contributions ${whereClause}`;
- const countResult = await this.dbSession.executeRead<{ total: number }>(
- countQuery,
- params
- );
+ const countResult = await this.dbSession.executeRead<{ total: number }>(countQuery, params);
const total = countResult.results[0]?.total || 0;
const offset = (page - 1) * limit;
@@ -192,10 +254,7 @@ export class ContributionService {
LIMIT ? OFFSET ?
`;
- const contributionsResult = await this.dbSession.executeRead(
- query,
- [...params, limit, offset]
- );
+ const contributionsResult = await this.dbSession.executeRead(query, [...params, limit, offset]);
return {
contributions: contributionsResult.results,
total,
@@ -206,10 +265,7 @@ export class ContributionService {
}
async processDecision(id: string, userId: string, decision: ContributionDecision): Promise {
- const userInfoResult = await this.dbSession.executeRead<{ id: number }>(
- 'SELECT id FROM users WHERE vatsim_id = ?',
- [userId]
- );
+ const userInfoResult = await this.dbSession.executeRead<{ id: number }>('SELECT id FROM users WHERE vatsim_id = ?', [userId]);
const userInfo = userInfoResult.results[0];
if (!userInfo) {
@@ -249,12 +305,7 @@ export class ContributionService {
AND status = 'approved'
AND id != ?
`,
- [
- now,
- contribution.airportIcao,
- packageName,
- id,
- ]
+ [now, contribution.airportIcao, packageName, id],
);
// Generate and upload the XML files to CDN
@@ -304,10 +355,9 @@ export class ContributionService {
SET status = ?, rejection_reason = ?, decision_date = ?, package_name = ?
WHERE id = ?
`,
- [status, decision.approved ? null : decision.rejectionReason || 'No reason provided', now, packageName, id]
+ [status, decision.approved ? null : decision.rejectionReason || 'No reason provided', now, packageName, id],
);
-
const updated: Contribution = {
...contribution,
packageName,
@@ -338,25 +388,22 @@ export class ContributionService {
const oneWeekAgoStr = oneWeekAgo.toISOString();
// Get counts for different statuses
- const totalResult = await this.dbSession.executeRead<{ count: number }>(
- 'SELECT COUNT(*) as count FROM contributions',
- []
- );
+ const totalResult = await this.dbSession.executeRead<{ count: number }>('SELECT COUNT(*) as count FROM contributions', []);
const pendingResult = await this.dbSession.executeRead<{ count: number }>(
'SELECT COUNT(*) as count FROM contributions WHERE status = ?',
- ['pending']
+ ['pending'],
);
const approvedResult = await this.dbSession.executeRead<{ count: number }>(
'SELECT COUNT(*) as count FROM contributions WHERE status = ?',
- ['approved']
+ ['approved'],
);
const rejectedResult = await this.dbSession.executeRead<{ count: number }>(
'SELECT COUNT(*) as count FROM contributions WHERE status = ?',
- ['rejected']
+ ['rejected'],
);
const lastWeekResult = await this.dbSession.executeRead<{ count: number }>(
'SELECT COUNT(*) as count FROM contributions WHERE submission_date > ?',
- [oneWeekAgoStr]
+ [oneWeekAgoStr],
);
return {
total: totalResult.results[0]?.count || 0,
@@ -367,10 +414,7 @@ export class ContributionService {
};
}
async deleteContribution(id: string, userId: string): Promise {
- const userInfoResult = await this.dbSession.executeRead<{ id: number }>(
- 'SELECT id FROM users WHERE vatsim_id = ?',
- [userId]
- );
+ const userInfoResult = await this.dbSession.executeRead<{ id: number }>('SELECT id FROM users WHERE vatsim_id = ?', [userId]);
const userInfo = userInfoResult.results[0];
if (!userInfo) {
throw new Error('User not found');
@@ -379,11 +423,12 @@ export class ContributionService {
if (!hasPermission) {
throw new Error('Not authorized to delete contributions');
}
- const result = await this.dbSession.executeWrite(
- 'DELETE FROM contributions WHERE id = ?',
- [id]
- );
- if (result.success) { try { this.posthog?.track('Contribution Deleted', { id, userId }); } catch { } }
+ const result = await this.dbSession.executeWrite('DELETE FROM contributions WHERE id = ?', [id]);
+ if (result.success) {
+ try {
+ this.posthog?.track('Contribution Deleted', { id, userId });
+ } catch { }
+ }
return result.success;
}
/**
@@ -431,7 +476,7 @@ export class ContributionService {
FROM contributions
WHERE user_id = ?
`,
- [userId]
+ [userId],
);
const summaryRow = summaryResult.results[0] || { total: 0, approved: 0, pending: 0, rejected: 0 };
const summary = {
@@ -470,10 +515,7 @@ export class ContributionService {
LIMIT ? OFFSET ?
`;
- const contributionsResult = await this.dbSession.executeRead(
- query,
- [...params, limit, offset]
- );
+ const contributionsResult = await this.dbSession.executeRead(query, [...params, limit, offset]);
return {
contributions: contributionsResult.results,
summary,
@@ -503,9 +545,7 @@ export class ContributionService {
const results = await this.dbSession.executeRead<{
packageName: string;
count: number;
- }>(
- query
- );
+ }>(query);
return results.results;
}
async getContributionLeaderboard(): Promise<
@@ -515,51 +555,20 @@ export class ContributionService {
}>
> {
const query = `
- SELECT
- user_id,
- user_display_name,
- COUNT(*) as contribution_count
- FROM contributions
- WHERE status = 'approved'
- GROUP BY user_id
+ SELECT c.user_id, u.display_name, COUNT(*) as contribution_count
+ FROM contributions c
+ LEFT JOIN users u ON u.vatsim_id = c.user_id
+ WHERE c.status = 'approved'
+ GROUP BY c.user_id
ORDER BY contribution_count DESC
LIMIT 5
`;
-
const results = await this.dbSession.executeRead<{
user_id: string;
- user_display_name: string | null;
+ display_name: string | null;
contribution_count: number;
- }>(
- query
- );
- return results.results.map((item) => ({
- name: item.user_display_name || item.user_id,
- count: item.contribution_count,
- }));
- }
-
- private async updateUserDisplayNameForAllContributions(userId: string, displayName: string | null): Promise {
- await this.dbSession.executeWrite(
- `
- UPDATE contributions
- SET user_display_name = ?
- WHERE user_id = ?
- `,
- [displayName, userId]
- );
- }
- async getUserDisplayName(userId: string): Promise {
- const result = await this.dbSession.executeRead<{ userDisplayName: string | null }>(
- `
- SELECT user_display_name as userDisplayName
- FROM contributions
- WHERE user_id = ?
- ORDER BY submission_date DESC
- LIMIT 1
- `,
- [userId]
- );
- return result.results[0]?.userDisplayName || null;
+ }>(query);
+ return results.results.map((r) => ({ name: r.display_name || r.user_id, count: r.contribution_count }));
}
+ // Removed legacy user display name update + lookup helpers; display names now sourced directly from users table
}
diff --git a/src/services/database-context.ts b/src/services/database-context.ts
index e1b76a0..f716d21 100644
--- a/src/services/database-context.ts
+++ b/src/services/database-context.ts
@@ -5,45 +5,38 @@ import { DatabaseSessionService, SessionOptions } from './database-session';
* Handles extracting bookmarks from request headers and setting response headers
*/
export class BookmarkManager {
- private static readonly BOOKMARK_HEADER = 'x-d1-bookmark';
-
- /**
- * Extract bookmark from request headers
- */
- public static getBookmarkFromRequest(request: Request): string | undefined {
- return request.headers.get(BookmarkManager.BOOKMARK_HEADER) || undefined;
- }
-
- /**
- * Set bookmark in response headers
- */
- public static setBookmarkInResponse(response: Response, bookmark: string | null): void {
- if (bookmark) {
- response.headers.set(BookmarkManager.BOOKMARK_HEADER, bookmark);
- }
- }
-
- /**
- * Create a new Response with bookmark header set
- */
- public static responseWithBookmark(
- body: any,
- bookmark: string | null,
- init: ResponseInit = {}
- ): Response {
- const headers = new Headers(init.headers);
- if (bookmark) {
- headers.set(BookmarkManager.BOOKMARK_HEADER, bookmark);
- }
-
- return new Response(
- typeof body === 'string' ? body : JSON.stringify(body),
- {
- ...init,
- headers
- }
- );
- }
+ private static readonly BOOKMARK_HEADER = 'x-d1-bookmark';
+
+ /**
+ * Extract bookmark from request headers
+ */
+ public static getBookmarkFromRequest(request: Request): string | undefined {
+ return request.headers.get(BookmarkManager.BOOKMARK_HEADER) || undefined;
+ }
+
+ /**
+ * Set bookmark in response headers
+ */
+ public static setBookmarkInResponse(response: Response, bookmark: string | null): void {
+ if (bookmark) {
+ response.headers.set(BookmarkManager.BOOKMARK_HEADER, bookmark);
+ }
+ }
+
+ /**
+ * Create a new Response with bookmark header set
+ */
+ public static responseWithBookmark(body: any, bookmark: string | null, init: ResponseInit = {}): Response {
+ const headers = new Headers(init.headers);
+ if (bookmark) {
+ headers.set(BookmarkManager.BOOKMARK_HEADER, bookmark);
+ }
+
+ return new Response(typeof body === 'string' ? body : JSON.stringify(body), {
+ ...init,
+ headers,
+ });
+ }
}
/**
@@ -51,124 +44,112 @@ export class BookmarkManager {
* Automatically manages sessions and bookmarks for the request lifecycle
*/
export class RequestDatabaseContext {
- private sessionService: DatabaseSessionService;
- private request: Request;
- private isStarted: boolean = false;
-
- constructor(db: D1Database, request: Request) {
- this.sessionService = new DatabaseSessionService(db);
- this.request = request;
- }
-
- /**
- * Start a session using bookmark from request headers or specified options
- */
- public startSession(options: Omit = {}): void {
- if (this.isStarted) {
- return; // Already started
- }
-
- const bookmark = BookmarkManager.getBookmarkFromRequest(this.request);
- const sessionOptions: SessionOptions = {
- ...options,
- bookmark: bookmark || options.mode || 'first-unconstrained'
- };
-
- this.sessionService.startSession(sessionOptions);
- this.isStarted = true;
- }
-
- /**
- * Get the database session service
- */
- public get db(): DatabaseSessionService {
- if (!this.isStarted) {
- this.startSession();
- }
- return this.sessionService;
- }
-
- /**
- * Create a JSON response with bookmark header
- */
- public jsonResponse(data: any, init: ResponseInit = {}): Response {
- const bookmark = this.sessionService.getBookmark();
-
- const headers = new Headers(init.headers);
- headers.set('Content-Type', 'application/json');
-
- return BookmarkManager.responseWithBookmark(
- JSON.stringify(data),
- bookmark,
- { ...init, headers }
- );
- }
-
- /**
- * Create a text response with bookmark header
- */
- public textResponse(text: string, init: ResponseInit = {}): Response {
- const bookmark = this.sessionService.getBookmark();
- return BookmarkManager.responseWithBookmark(text, bookmark, init);
- }
-
- /**
- * Close the session and clean up
- */
- public close(): void {
- this.sessionService.closeSession();
- this.isStarted = false;
- }
-
- /**
- * Get current session info for debugging
- */
- public getSessionInfo() {
- return {
- ...this.sessionService.getSessionInfo(),
- isStarted: this.isStarted,
- requestBookmark: BookmarkManager.getBookmarkFromRequest(this.request)
- };
- }
+ private sessionService: DatabaseSessionService;
+ private request: Request;
+ private isStarted: boolean = false;
+
+ constructor(db: D1Database, request: Request) {
+ this.sessionService = new DatabaseSessionService(db);
+ this.request = request;
+ }
+
+ /**
+ * Start a session using bookmark from request headers or specified options
+ */
+ public startSession(options: Omit = {}): void {
+ if (this.isStarted) {
+ return; // Already started
+ }
+
+ const bookmark = BookmarkManager.getBookmarkFromRequest(this.request);
+ const sessionOptions: SessionOptions = {
+ ...options,
+ bookmark: bookmark || options.mode || 'first-unconstrained',
+ };
+
+ this.sessionService.startSession(sessionOptions);
+ this.isStarted = true;
+ }
+
+ /**
+ * Get the database session service
+ */
+ public get db(): DatabaseSessionService {
+ if (!this.isStarted) {
+ this.startSession();
+ }
+ return this.sessionService;
+ }
+
+ /**
+ * Create a JSON response with bookmark header
+ */
+ public jsonResponse(data: any, init: ResponseInit = {}): Response {
+ const bookmark = this.sessionService.getBookmark();
+
+ const headers = new Headers(init.headers);
+ headers.set('Content-Type', 'application/json');
+
+ return BookmarkManager.responseWithBookmark(JSON.stringify(data), bookmark, { ...init, headers });
+ }
+
+ /**
+ * Create a text response with bookmark header
+ */
+ public textResponse(text: string, init: ResponseInit = {}): Response {
+ const bookmark = this.sessionService.getBookmark();
+ return BookmarkManager.responseWithBookmark(text, bookmark, init);
+ }
+
+ /**
+ * Close the session and clean up
+ */
+ public close(): void {
+ this.sessionService.closeSession();
+ this.isStarted = false;
+ }
+
+ /**
+ * Get current session info for debugging
+ */
+ public getSessionInfo() {
+ return {
+ ...this.sessionService.getSessionInfo(),
+ isStarted: this.isStarted,
+ requestBookmark: BookmarkManager.getBookmarkFromRequest(this.request),
+ };
+ }
}
/**
* Factory for creating database contexts
*/
export class DatabaseContextFactory {
- /**
- * Create a new request database context
- */
- public static createRequestContext(db: D1Database, request: Request): RequestDatabaseContext {
- return new RequestDatabaseContext(db, request);
- }
-
- /**
- * Create a simple session service for background operations
- */
- public static createSessionService(db: D1Database): DatabaseSessionService {
- return new DatabaseSessionService(db);
- }
-
- /**
- * Quick read operation for simple queries
- */
- public static async quickRead(
- db: D1Database,
- query: string,
- params: any[] = []
- ) {
- return DatabaseSessionService.simpleRead(db, query, params);
- }
-
- /**
- * Quick write operation for simple queries
- */
- public static async quickWrite(
- db: D1Database,
- query: string,
- params: any[] = []
- ) {
- return DatabaseSessionService.simpleWrite(db, query, params);
- }
+ /**
+ * Create a new request database context
+ */
+ public static createRequestContext(db: D1Database, request: Request): RequestDatabaseContext {
+ return new RequestDatabaseContext(db, request);
+ }
+
+ /**
+ * Create a simple session service for background operations
+ */
+ public static createSessionService(db: D1Database): DatabaseSessionService {
+ return new DatabaseSessionService(db);
+ }
+
+ /**
+ * Quick read operation for simple queries
+ */
+ public static async quickRead(db: D1Database, query: string, params: any[] = []) {
+ return DatabaseSessionService.simpleRead(db, query, params);
+ }
+
+ /**
+ * Quick write operation for simple queries
+ */
+ public static async quickWrite(db: D1Database, query: string, params: any[] = []) {
+ return DatabaseSessionService.simpleWrite(db, query, params);
+ }
}
diff --git a/src/services/database-session.ts b/src/services/database-session.ts
index 16e0115..038ee9e 100644
--- a/src/services/database-session.ts
+++ b/src/services/database-session.ts
@@ -1,40 +1,40 @@
// D1 types are available globally in Cloudflare Workers environment
export interface SessionOptions {
- /**
- * Session mode for D1 read replication
- * - 'first-primary': Start with latest data from primary (use for writes or critical reads)
- * - 'first-unconstrained': Start with any available instance (use for non-critical reads)
- * - bookmark string: Start from a specific bookmark
- */
- mode?: 'first-primary' | 'first-unconstrained' | string;
-
- /**
- * Optional bookmark from a previous session for sequential consistency
- */
- bookmark?: string;
+ /**
+ * Session mode for D1 read replication
+ * - 'first-primary': Start with latest data from primary (use for writes or critical reads)
+ * - 'first-unconstrained': Start with any available instance (use for non-critical reads)
+ * - bookmark string: Start from a specific bookmark
+ */
+ mode?: 'first-primary' | 'first-unconstrained' | string;
+
+ /**
+ * Optional bookmark from a previous session for sequential consistency
+ */
+ bookmark?: string;
}
export interface DatabaseMeta {
- served_by_region?: string;
- served_by_primary?: boolean;
- duration?: number;
- changes?: number;
- last_row_id?: number;
- changed_db?: boolean;
- size_after?: number;
+ served_by_region?: string;
+ served_by_primary?: boolean;
+ duration?: number;
+ changes?: number;
+ last_row_id?: number;
+ changed_db?: boolean;
+ size_after?: number;
}
export interface DatabaseResult {
- results: T[];
- success: boolean;
- meta?: DatabaseMeta;
+ results: T[];
+ success: boolean;
+ meta?: DatabaseMeta;
}
export interface DatabaseResponse {
- results?: T | null;
- success: boolean;
- meta?: DatabaseMeta;
+ results?: T | null;
+ success: boolean;
+ meta?: DatabaseMeta;
}
export type DatabaseSerializable = null | number | string | boolean | ArrayBuffer;
@@ -49,194 +49,183 @@ export type DatabaseBinding = Record;
* - Optimized routing for read vs write operations
*/
export class DatabaseSessionService {
- private session: D1DatabaseSession | null = null;
- private currentBookmark: string | null = null;
- private readonly db: D1Database;
-
- constructor(db: D1Database) {
- this.db = db;
- }
-
- /**
- * Start a new database session with optional configuration
- */
- public startSession(options: SessionOptions = {}): void {
- let sessionParam: string | undefined;
-
- if (options.bookmark) {
- // Use provided bookmark for sequential consistency
- sessionParam = options.bookmark;
- } else if (options.mode === 'first-primary') {
- // Start with latest data from primary
- sessionParam = 'first-primary';
- } else {
- // Default to unconstrained for better performance
- sessionParam = 'first-unconstrained';
- }
-
- this.session = this.db.withSession(sessionParam);
- this.currentBookmark = null;
- }
-
- /**
- * Get the current session bookmark for maintaining consistency
- */
- public getBookmark(): string | null {
- if (!this.session) {
- return null;
- }
-
- const bookmark = this.session.getBookmark();
- if (bookmark) {
- this.currentBookmark = bookmark;
- }
- return bookmark;
- }
+ private session: D1DatabaseSession | null = null;
+ private currentBookmark: string | null = null;
+ private readonly db: D1Database;
+
+ constructor(db: D1Database) {
+ this.db = db;
+ }
+
+ /**
+ * Start a new database session with optional configuration
+ */
+ public startSession(options: SessionOptions = {}): void {
+ let sessionParam: string | undefined;
+
+ if (options.bookmark) {
+ // Use provided bookmark for sequential consistency
+ sessionParam = options.bookmark;
+ } else if (options.mode === 'first-primary') {
+ // Start with latest data from primary
+ sessionParam = 'first-primary';
+ } else {
+ // Default to unconstrained for better performance
+ sessionParam = 'first-unconstrained';
+ }
+
+ this.session = this.db.withSession(sessionParam);
+ this.currentBookmark = null;
+ }
+
+ /**
+ * Get the current session bookmark for maintaining consistency
+ */
+ public getBookmark(): string | null {
+ if (!this.session) {
+ return null;
+ }
+
+ const bookmark = this.session.getBookmark();
+ if (bookmark) {
+ this.currentBookmark = bookmark;
+ }
+ return bookmark;
+ }
/**
* Create a type-safe prepared statement
*/
- public prepare(
- query: string,
- bindings: (keyof T)[],
- ): PreparedStatement {
+ public prepare(query: string, bindings: (keyof T)[]): PreparedStatement {
return new PreparedStatement(this.db.prepare(query), bindings);
}
- /**
- * Execute a prepared statement with session awareness
- * Automatically starts a session if none exists
- */
- public async execute(
- query: string,
- params: any[] = [],
- options: SessionOptions = {}
- ): Promise> {
- // Start session if not already started
- if (!this.session) {
- this.startSession(options);
- }
-
- try {
- const stmt = this.session!.prepare(query);
- let boundStmt = stmt;
-
- // Bind parameters if provided
- if (params.length > 0) {
- boundStmt = stmt.bind(...params);
- }
-
- const result = await boundStmt.first();
-
- // Update bookmark after operation
- this.getBookmark();
-
- return {
- results: result,
- success: true,
- meta: {}
- };
- } catch (error) {
- console.error('Database execution error:', error);
- throw error;
- }
- }
-
- /**
- * Execute a query that returns all results
- */
- public async executeAll(
- query: string,
- params: any[] = [],
- options: SessionOptions = {}
- ): Promise> {
- // Start session if not already started
- if (!this.session) {
- this.startSession(options);
- }
-
- try {
- const stmt = this.session!.prepare(query);
- let boundStmt = stmt;
-
- // Bind parameters if provided
- if (params.length > 0) {
- boundStmt = stmt.bind(...params);
- }
-
- const result = await boundStmt.all();
-
- // Update bookmark after operation
- this.getBookmark();
-
- return {
- results: result.results || [],
- success: true,
- meta: result.meta || {}
- };
- } catch (error) {
- console.error('Database executeAll error:', error);
- throw error;
- }
- }
-
- /**
- * Execute a query that modifies data (INSERT, UPDATE, DELETE)
- * Always uses primary database for consistency
- */
- public async executeWrite(
- query: string,
- params: any[] = []
- ): Promise> {
- // Force primary mode for write operations
- if (!this.session) {
- this.startSession({ mode: 'first-primary' });
- }
-
- try {
- const stmt = this.session!.prepare(query);
- let boundStmt = stmt;
-
- // Bind parameters if provided
- if (params.length > 0) {
- boundStmt = stmt.bind(...params);
- }
-
- const result = await boundStmt.run();
-
- // Update bookmark after write operation
- this.getBookmark();
-
- return {
- results: result.results || null,
- success: result.success,
- meta: result.meta || {}
- };
- } catch (error) {
- console.error('Database write error:', error);
- throw error;
- }
- }
-
- /**
- * Execute multiple statements in a batch
- * Uses primary database for consistency
- */
- public async executeBatch(
- statements: Array<{
- query: string;
- params?: any[];
- } | D1PreparedStatement>
+ /**
+ * Execute a prepared statement with session awareness
+ * Automatically starts a session if none exists
+ */
+ public async execute(query: string, params: any[] = [], options: SessionOptions = {}): Promise> {
+ // Start session if not already started
+ if (!this.session) {
+ this.startSession(options);
+ }
+
+ try {
+ const stmt = this.session!.prepare(query);
+ let boundStmt = stmt;
+
+ // Bind parameters if provided
+ if (params.length > 0) {
+ boundStmt = stmt.bind(...params);
+ }
+
+ const result = await boundStmt.first();
+
+ // Update bookmark after operation
+ this.getBookmark();
+
+ return {
+ results: result,
+ success: true,
+ meta: {},
+ };
+ } catch (error) {
+ console.error('Database execution error:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * Execute a query that returns all results
+ */
+ public async executeAll(query: string, params: any[] = [], options: SessionOptions = {}): Promise> {
+ // Start session if not already started
+ if (!this.session) {
+ this.startSession(options);
+ }
+
+ try {
+ const stmt = this.session!.prepare(query);
+ let boundStmt = stmt;
+
+ // Bind parameters if provided
+ if (params.length > 0) {
+ boundStmt = stmt.bind(...params);
+ }
+
+ const result = await boundStmt.all();
+
+ // Update bookmark after operation
+ this.getBookmark();
+
+ return {
+ results: result.results || [],
+ success: true,
+ meta: result.meta || {},
+ };
+ } catch (error) {
+ console.error('Database executeAll error:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * Execute a query that modifies data (INSERT, UPDATE, DELETE)
+ * Always uses primary database for consistency
+ */
+ public async executeWrite(query: string, params: any[] = []): Promise> {
+ // Force primary mode for write operations
+ if (!this.session) {
+ this.startSession({ mode: 'first-primary' });
+ }
+
+ try {
+ const stmt = this.session!.prepare(query);
+ let boundStmt = stmt;
+
+ // Bind parameters if provided
+ if (params.length > 0) {
+ boundStmt = stmt.bind(...params);
+ }
+
+ const result = await boundStmt.run();
+
+ // Update bookmark after write operation
+ this.getBookmark();
+
+ return {
+ results: result.results || null,
+ success: result.success,
+ meta: result.meta || {},
+ };
+ } catch (error) {
+ console.error('Database write error:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * Execute multiple statements in a batch
+ * Uses primary database for consistency
+ */
+ public async executeBatch(
+ statements: Array<
+ | {
+ query: string;
+ params?: any[];
+ }
+ | D1PreparedStatement
+ >,
): Promise[]> {
if (statements.length === 0) return [];
- // Force primary mode for batch operations
- if (!this.session) {
- this.startSession({ mode: 'first-primary' });
- }
+ // Force primary mode for batch operations
+ if (!this.session) {
+ this.startSession({ mode: 'first-primary' });
+ }
- try {
- const preparedStatements = statements.map((statement) => {
+ try {
+ const preparedStatements = statements.map((statement) => {
if ('query' in statement && typeof statement.query === 'string') {
const { query, params = [] } = statement;
const stmt = this.session!.prepare(query);
@@ -244,109 +233,92 @@ export class DatabaseSessionService {
} else {
return statement as D1PreparedStatement;
}
- });
-
- const results = await this.session!.batch(preparedStatements);
-
- // Update bookmark after batch operation
- this.getBookmark();
-
- return results;
- } catch (error) {
- console.error('Database batch error:', error);
- throw error;
- }
- }
-
- /**
- * Execute a read-only query optimized for performance
- * Uses unconstrained mode for best performance
- */
- public async executeRead(
- query: string,
- params: any[] = [],
- bookmark?: string
- ): Promise> {
- // Use unconstrained mode for reads unless bookmark is provided
- const sessionOptions: SessionOptions = bookmark
- ? { bookmark }
- : { mode: 'first-unconstrained' };
-
- return this.executeAll(query, params, sessionOptions);
- }
-
- /**
- * Execute a query that requires the latest data
- * Uses primary mode to ensure fresh data
- */
- public async executeLatest(
- query: string,
- params: any[] = []
- ): Promise> {
- return this.executeAll(query, params, { mode: 'first-primary' });
- }
-
- /**
- * Close the current session and clean up resources
- */
- public closeSession(): void {
- this.session = null;
- this.currentBookmark = null;
- }
-
- /**
- * Get current session statistics for observability
- */
- public getSessionInfo(): {
- hasSession: boolean;
- hasBookmark: boolean;
- bookmark: string | null;
- } {
- return {
- hasSession: this.session !== null,
- hasBookmark: this.currentBookmark !== null,
- bookmark: this.currentBookmark
- };
- }
-
- /**
- * Static helper to create a session-aware database service
- */
- public static create(db: D1Database): DatabaseSessionService {
- return new DatabaseSessionService(db);
- }
-
- /**
- * Static helper for simple read operations
- */
- public static async simpleRead(
- db: D1Database,
- query: string,
- params: any[] = []
- ): Promise> {
- const session = new DatabaseSessionService(db);
- try {
- return await session.executeRead(query, params);
- } finally {
- session.closeSession();
- }
- }
-
- /**
- * Static helper for simple write operations
- */
- public static async simpleWrite(
- db: D1Database,
- query: string,
- params: any[] = []
- ): Promise> {
- const session = new DatabaseSessionService(db);
- try {
- return await session.executeWrite(query, params);
- } finally {
- session.closeSession();
- }
- }
+ });
+
+ const results = await this.session!.batch(preparedStatements);
+
+ // Update bookmark after batch operation
+ this.getBookmark();
+
+ return results;
+ } catch (error) {
+ console.error('Database batch error:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * Execute a read-only query optimized for performance
+ * Uses unconstrained mode for best performance
+ */
+ public async executeRead(query: string, params: any[] = [], bookmark?: string): Promise> {
+ // Use unconstrained mode for reads unless bookmark is provided
+ const sessionOptions: SessionOptions = bookmark ? { bookmark } : { mode: 'first-unconstrained' };
+
+ return this.executeAll(query, params, sessionOptions);
+ }
+
+ /**
+ * Execute a query that requires the latest data
+ * Uses primary mode to ensure fresh data
+ */
+ public async executeLatest(query: string, params: any[] = []): Promise> {
+ return this.executeAll(query, params, { mode: 'first-primary' });
+ }
+
+ /**
+ * Close the current session and clean up resources
+ */
+ public closeSession(): void {
+ this.session = null;
+ this.currentBookmark = null;
+ }
+
+ /**
+ * Get current session statistics for observability
+ */
+ public getSessionInfo(): {
+ hasSession: boolean;
+ hasBookmark: boolean;
+ bookmark: string | null;
+ } {
+ return {
+ hasSession: this.session !== null,
+ hasBookmark: this.currentBookmark !== null,
+ bookmark: this.currentBookmark,
+ };
+ }
+
+ /**
+ * Static helper to create a session-aware database service
+ */
+ public static create(db: D1Database): DatabaseSessionService {
+ return new DatabaseSessionService(db);
+ }
+
+ /**
+ * Static helper for simple read operations
+ */
+ public static async simpleRead(db: D1Database, query: string, params: any[] = []): Promise> {
+ const session = new DatabaseSessionService(db);
+ try {
+ return await session.executeRead(query, params);
+ } finally {
+ session.closeSession();
+ }
+ }
+
+ /**
+ * Static helper for simple write operations
+ */
+ public static async simpleWrite(db: D1Database, query: string, params: any[] = []): Promise> {
+ const session = new DatabaseSessionService(db);
+ try {
+ return await session.executeWrite(query, params);
+ } finally {
+ session.closeSession();
+ }
+ }
}
/**
@@ -356,10 +328,7 @@ export class PreparedStatement {
private statement: D1PreparedStatement;
private bindings: (keyof T)[];
- constructor(
- statement: D1PreparedStatement,
- bindings: (keyof T)[],
- ) {
+ constructor(statement: D1PreparedStatement, bindings: (keyof T)[]) {
this.statement = statement;
this.bindings = bindings;
}
diff --git a/src/services/divisions.ts b/src/services/divisions.ts
index 213397f..2f5a727 100644
--- a/src/services/divisions.ts
+++ b/src/services/divisions.ts
@@ -29,56 +29,77 @@ interface DivisionAirport {
export class DivisionService {
private dbSession: DatabaseSessionService;
- constructor(private db: D1Database, private posthog?: PostHogService) {
+ constructor(
+ private db: D1Database,
+ private posthog?: PostHogService,
+ ) {
this.dbSession = new DatabaseSessionService(db);
}
async createDivision(name: string, headVatsimId: string): Promise {
- const result = await this.dbSession.executeWrite(
- 'INSERT INTO divisions (name) VALUES (?) RETURNING *',
- [name]
- );
+ const result = await this.dbSession.executeWrite('INSERT INTO divisions (name) VALUES (?) RETURNING *', [name]);
const division = result.results[0] as Division;
if (!division) throw new Error('Failed to create division');
await this.addMember(division.id, headVatsimId, 'nav_head');
- try { this.posthog?.track('Division Created', { divisionId: division.id, name }); } catch { }
+ try {
+ this.posthog?.track('Division Created', { divisionId: division.id, name });
+ } catch {}
return division;
}
+ async updateDivisionName(id: number, newName: string): Promise {
+ const result = await this.dbSession.executeWrite('UPDATE divisions SET name = ? WHERE id = ? RETURNING *', [newName, id]);
+ const division = result.results[0] as Division;
+ if (!division) throw new Error('Division not found');
+ try {
+ this.posthog?.track('Division Renamed', { divisionId: id, name: newName });
+ } catch {}
+ return division;
+ }
+
+ async deleteDivision(id: number): Promise {
+ const result = await this.dbSession.executeWrite('DELETE FROM divisions WHERE id = ? RETURNING id', [id]);
+ const deleted = !!result.results[0];
+ if (deleted) {
+ try {
+ this.posthog?.track('Division Deleted', { divisionId: id });
+ } catch {}
+ }
+ return deleted;
+ }
+
async getDivision(id: number): Promise {
- const result = await this.dbSession.executeRead(
- 'SELECT * FROM divisions WHERE id = ?',
- [id]
- );
+ const result = await this.dbSession.executeRead('SELECT * FROM divisions WHERE id = ?', [id]);
return result.results[0] || null;
}
async addMember(divisionId: number, vatsimId: string, role: 'nav_head' | 'nav_member'): Promise {
const result = await this.dbSession.executeWrite(
'INSERT INTO division_members (division_id, vatsim_id, role) VALUES (?, ?, ?) RETURNING *',
- [divisionId, vatsimId, role]
+ [divisionId, vatsimId, role],
);
const member = result.results[0] as DivisionMember;
if (!member) throw new Error('Failed to add member to division');
- try { this.posthog?.track('Division Member Added', { divisionId, vatsimId, role }); } catch { }
+ try {
+ this.posthog?.track('Division Member Added', { divisionId, vatsimId, role });
+ } catch {}
return member;
}
async removeMember(divisionId: number, vatsimId: string): Promise {
- await this.dbSession.executeWrite(
- 'DELETE FROM division_members WHERE division_id = ? AND vatsim_id = ?',
- [divisionId, vatsimId]
- );
- try { this.posthog?.track('Division Member Removed', { divisionId, vatsimId }); } catch { }
+ await this.dbSession.executeWrite('DELETE FROM division_members WHERE division_id = ? AND vatsim_id = ?', [divisionId, vatsimId]);
+ try {
+ this.posthog?.track('Division Member Removed', { divisionId, vatsimId });
+ } catch {}
}
async getMemberRole(divisionId: number, vatsimId: string): Promise<'nav_head' | 'nav_member' | null> {
const result = await this.dbSession.executeRead<{ role: 'nav_head' | 'nav_member' }>(
'SELECT role FROM division_members WHERE division_id = ? AND vatsim_id = ?',
- [divisionId, vatsimId]
+ [divisionId, vatsimId],
);
return result.results[0]?.role || null;
@@ -89,12 +110,14 @@ export class DivisionService {
const result = await this.dbSession.executeWrite(
'INSERT INTO division_airports (division_id, icao, requested_by) VALUES (?, ?, ?) RETURNING *',
- [divisionId, icao, requestedBy]
+ [divisionId, icao, requestedBy],
);
const request = result.results[0] as DivisionAirport;
if (!request) throw new Error('Failed to create airport request');
- try { this.posthog?.track('Division Airport Access Requested', { divisionId, icao, requestedBy }); } catch { }
+ try {
+ this.posthog?.track('Division Airport Access Requested', { divisionId, icao, requestedBy });
+ } catch {}
return request;
}
async approveAirport(airportId: number, approvedBy: string, approved: boolean): Promise {
@@ -105,35 +128,43 @@ export class DivisionService {
WHERE id = ?
RETURNING *
`,
- [approved ? 'approved' : 'rejected', approvedBy, airportId]
+ [approved ? 'approved' : 'rejected', approvedBy, airportId],
);
const airport = result.results[0] as DivisionAirport;
if (!airport) throw new Error('Airport request not found');
- try { this.posthog?.track(approved ? 'Division Airport Request Approved' : 'Division Airport Request Rejected', { airportId, approvedBy, approved }); } catch { }
+ try {
+ this.posthog?.track(approved ? 'Division Airport Request Approved' : 'Division Airport Request Rejected', {
+ airportId,
+ approvedBy,
+ approved,
+ });
+ } catch {}
return airport;
}
async getDivisionAirports(divisionId: number): Promise {
- const result = await this.dbSession.executeRead(
- 'SELECT * FROM division_airports WHERE division_id = ?',
- [divisionId]
- );
+ const result = await this.dbSession.executeRead('SELECT * FROM division_airports WHERE division_id = ?', [
+ divisionId,
+ ]);
return result.results;
}
async getDivisionMembers(divisionId: number): Promise {
- const result = await this.dbSession.executeRead(
- 'SELECT * FROM division_members WHERE division_id = ?',
- [divisionId]
+ // Use cached display_name; fallback to vatsim_id if null
+ const result = await this.dbSession.executeRead(
+ `SELECT dm.id, dm.division_id, dm.vatsim_id, dm.role, dm.created_at,
+ COALESCE(u.display_name, dm.vatsim_id) AS display_name
+ FROM division_members dm
+ LEFT JOIN users u ON u.vatsim_id = dm.vatsim_id
+ WHERE dm.division_id = ?`,
+ [divisionId],
);
return result.results;
}
async getAllDivisions(): Promise {
- const result = await this.dbSession.executeRead(
- 'SELECT * FROM divisions'
- );
+ const result = await this.dbSession.executeRead('SELECT * FROM divisions');
return result.results;
}
@@ -145,7 +176,7 @@ export class DivisionService {
JOIN division_members dm ON d.id = dm.division_id
WHERE dm.vatsim_id = ?
`,
- [vatsimId]
+ [vatsimId],
);
return result.results;
}
@@ -157,7 +188,7 @@ export class DivisionService {
JOIN division_members dm ON da.division_id = dm.division_id
WHERE dm.vatsim_id = ? AND da.icao = ? AND da.status = 'approved'
`,
- [userId, airportIcao]
+ [userId, airportIcao],
);
return result.results.length > 0;
@@ -174,7 +205,7 @@ export class DivisionService {
AND da.status = 'approved'
LIMIT 1
`,
- [userId, airportIcao]
+ [userId, airportIcao],
);
return result.results[0]?.role || null;
diff --git a/src/services/faqs.ts b/src/services/faqs.ts
new file mode 100644
index 0000000..31347bb
--- /dev/null
+++ b/src/services/faqs.ts
@@ -0,0 +1,72 @@
+import { DatabaseSessionService } from './database-session';
+
+export interface FAQRecord {
+ id: string;
+ question: string;
+ answer: string;
+ order_position: number;
+ created_at: string;
+ updated_at: string;
+}
+
+export class FAQService {
+ private dbSession: DatabaseSessionService;
+ constructor(private db: D1Database) {
+ this.dbSession = new DatabaseSessionService(db);
+ }
+
+ async list(): Promise<{ faqs: FAQRecord[]; total: number }> {
+ const result = await this.dbSession.executeRead(
+ `SELECT id, question, answer, order_position, created_at, updated_at FROM faqs ORDER BY order_position ASC, datetime(created_at) ASC`,
+ [],
+ );
+ return { faqs: result.results, total: result.results.length };
+ }
+
+ async get(id: string): Promise {
+ const result = await this.dbSession.executeRead(
+ `SELECT id, question, answer, order_position, created_at, updated_at FROM faqs WHERE id = ?`,
+ [id],
+ );
+ return result.results[0] || null;
+ }
+
+ async create(data: { question: string; answer: string; order_position: number }): Promise {
+ const id = crypto.randomUUID();
+ await this.dbSession.executeWrite(
+ `INSERT INTO faqs (id, question, answer, order_position, created_at, updated_at) VALUES (?, ?, ?, ?, datetime('now'), datetime('now'))`,
+ [id, data.question, data.answer, data.order_position],
+ );
+ const created = await this.get(id);
+ if (!created) throw new Error('Failed to create FAQ');
+ return created;
+ }
+
+ async update(id: string, data: Partial<{ question: string; answer: string; order_position: number }>): Promise {
+ const existing = await this.get(id);
+ if (!existing) return null;
+ const question = data.question ?? existing.question;
+ const answer = data.answer ?? existing.answer;
+ const order_position = data.order_position ?? existing.order_position;
+ await this.dbSession.executeWrite(
+ `UPDATE faqs SET question = ?, answer = ?, order_position = ?, updated_at = datetime('now') WHERE id = ?`,
+ [question, answer, order_position, id],
+ );
+ return this.get(id);
+ }
+
+ async delete(id: string): Promise {
+ const result = await this.dbSession.executeWrite(`DELETE FROM faqs WHERE id = ?`, [id]);
+ return result.success; // DatabaseSessionService returns success boolean
+ }
+
+ async reorder(updates: { id: string; order_position: number }[]): Promise {
+ // Simple transactional reorder
+ for (const u of updates) {
+ await this.dbSession.executeWrite(`UPDATE faqs SET order_position = ?, updated_at = datetime('now') WHERE id = ?`, [
+ u.order_position,
+ u.id,
+ ]);
+ }
+ }
+}
diff --git a/src/services/github.ts b/src/services/github.ts
index 9e94d87..6b09366 100644
--- a/src/services/github.ts
+++ b/src/services/github.ts
@@ -3,207 +3,209 @@
*/
interface GitHubContributor {
- id: number;
- login: string;
- avatar_url: string;
- html_url: string;
- type: string;
- contributions: number;
- repositories: {
- name: string;
- contributions: number;
- }[];
+ id: number;
+ login: string;
+ avatar_url: string;
+ html_url: string;
+ type: string;
+ contributions: number;
+ repositories: {
+ name: string;
+ contributions: number;
+ }[];
}
interface GitHubRepository {
- name: string;
- full_name: string;
- html_url: string;
- description: string | null;
- stargazers_count: number;
- language: string | null;
- private: boolean;
- created_at: string;
- updated_at: string;
+ name: string;
+ full_name: string;
+ html_url: string;
+ description: string | null;
+ stargazers_count: number;
+ language: string | null;
+ private: boolean;
+ created_at: string;
+ updated_at: string;
}
interface GitHubContributorResponse {
- id: number;
- login: string;
- avatar_url: string;
- html_url: string;
- type: string;
- contributions: number;
+ id: number;
+ login: string;
+ avatar_url: string;
+ html_url: string;
+ type: string;
+ contributions: number;
}
interface ContributorsData {
- contributors: GitHubContributor[];
- repositories: {
- name: string;
- fullName: string;
- url: string;
- description: string | null;
- stars: number;
- language: string | null;
- contributorCount: number;
- createdAt: string;
- updatedAt: string;
- }[];
- statistics: {
- totalContributors: number;
- totalRepositories: number;
- totalContributions: number;
- };
+ contributors: GitHubContributor[];
+ repositories: {
+ name: string;
+ fullName: string;
+ url: string;
+ description: string | null;
+ stars: number;
+ language: string | null;
+ contributorCount: number;
+ createdAt: string;
+ updatedAt: string;
+ }[];
+ statistics: {
+ totalContributors: number;
+ totalRepositories: number;
+ totalContributions: number;
+ };
}
export class GitHubService {
- private readonly GITHUB_ORG = 'stopbars';
-
- constructor() { }
-
- /**
- * Get all public repositories for the organization
- */
- private async getOrganizationRepositories(): Promise {
- const repos: GitHubRepository[] = [];
- let page = 1;
- const perPage = 100;
-
- while (true) {
- const res = await fetch(`https://api.github.com/orgs/${this.GITHUB_ORG}/repos?page=${page}&per_page=${perPage}&type=public`, {
- headers: {
- "User-Agent": "BARS-API",
- "Accept": "application/vnd.github.v3+json",
- },
- });
-
- if (!res.ok) {
- throw new Error(`Failed to fetch GitHub org repos: ${res.status}`);
- }
-
- const pageRepos: GitHubRepository[] = await res.json();
-
- if (pageRepos.length === 0) {
- break;
- }
-
- repos.push(...pageRepos);
-
- if (pageRepos.length < perPage) {
- break;
- }
-
- page++;
- }
-
- return repos;
- }
-
- /**
- * Get contributors for a specific repository
- */
- private async getRepositoryContributors(repoFullName: string): Promise {
- try {
- const res = await fetch(`https://api.github.com/repos/${repoFullName}/contributors?per_page=100`, {
- headers: {
- "User-Agent": "BARS-API",
- "Accept": "application/vnd.github.v3+json",
- },
- });
-
- if (!res.ok) {
- if (res.status === 404) {
- // Repository might not exist or be accessible, skip it
- return [];
- }
- throw new Error(`Failed to fetch GitHub contributors: ${res.status}`);
- }
-
- const contributors: GitHubContributorResponse[] = await res.json();
- return contributors || [];
- } catch (error) {
- console.error(`Error fetching contributors for ${repoFullName}:`, error);
- return [];
- }
- }
-
- /**
- * Get all contributors across all organization repositories
- */
- async getAllContributors(): Promise {
- const allContributors = new Map();
- const repoData: ContributorsData['repositories'] = [];
-
- // Get all organization repositories
- const orgRepos = await this.getOrganizationRepositories();
-
- // Fetch contributors from each repository
- for (const repoInfo of orgRepos) {
- try {
- // Skip private repositories (should already be filtered but double-check)
- if (repoInfo.private) {
- continue;
- }
-
- const repoContributors = await this.getRepositoryContributors(repoInfo.full_name);
-
- repoData.push({
- name: repoInfo.name,
- fullName: repoInfo.full_name,
- url: repoInfo.html_url,
- description: repoInfo.description,
- stars: repoInfo.stargazers_count,
- language: repoInfo.language,
- contributorCount: repoContributors.length,
- createdAt: repoInfo.created_at,
- updatedAt: repoInfo.updated_at,
- });
-
- // Merge contributors (avoid duplicates)
- repoContributors.forEach(contributor => {
- if (contributor.type === 'User') { // Exclude bots
- if (allContributors.has(contributor.id)) {
- // Add contributions from this repo
- const existing = allContributors.get(contributor.id)!;
- existing.contributions += contributor.contributions;
- existing.repositories.push({
- name: repoInfo.name,
- contributions: contributor.contributions,
- });
- } else {
- // New contributor
- allContributors.set(contributor.id, {
- ...contributor,
- repositories: [{
- name: repoInfo.name,
- contributions: contributor.contributions,
- }],
- });
- }
- }
- });
- } catch (err) {
- console.error(`Error fetching ${repoInfo.full_name}:`, err);
- }
- }
-
- // Convert Map to Array and sort by contributions
- const contributorList = Array.from(allContributors.values())
- .sort((a, b) => b.contributions - a.contributions);
-
- // Sort repositories by stars
- repoData.sort((a, b) => b.stars - a.stars);
-
- const totalContributions = contributorList.reduce((sum, contributor) => sum + contributor.contributions, 0);
-
- return {
- contributors: contributorList,
- repositories: repoData,
- statistics: {
- totalContributors: contributorList.length,
- totalRepositories: repoData.length,
- totalContributions,
- },
- };
- }
+ private readonly GITHUB_ORG = 'stopbars';
+
+ constructor() {}
+
+ /**
+ * Get all public repositories for the organization
+ */
+ private async getOrganizationRepositories(): Promise {
+ const repos: GitHubRepository[] = [];
+ let page = 1;
+ const perPage = 100;
+
+ while (true) {
+ const res = await fetch(`https://api.github.com/orgs/${this.GITHUB_ORG}/repos?page=${page}&per_page=${perPage}&type=public`, {
+ headers: {
+ 'User-Agent': 'BARS-API',
+ Accept: 'application/vnd.github.v3+json',
+ },
+ });
+
+ if (!res.ok) {
+ throw new Error(`Failed to fetch GitHub org repos: ${res.status}`);
+ }
+
+ const pageRepos: GitHubRepository[] = await res.json();
+
+ if (pageRepos.length === 0) {
+ break;
+ }
+
+ repos.push(...pageRepos);
+
+ if (pageRepos.length < perPage) {
+ break;
+ }
+
+ page++;
+ }
+
+ return repos;
+ }
+
+ /**
+ * Get contributors for a specific repository
+ */
+ private async getRepositoryContributors(repoFullName: string): Promise {
+ try {
+ const res = await fetch(`https://api.github.com/repos/${repoFullName}/contributors?per_page=100`, {
+ headers: {
+ 'User-Agent': 'BARS-API',
+ Accept: 'application/vnd.github.v3+json',
+ },
+ });
+
+ if (!res.ok) {
+ if (res.status === 404) {
+ // Repository might not exist or be accessible, skip it
+ return [];
+ }
+ throw new Error(`Failed to fetch GitHub contributors: ${res.status}`);
+ }
+
+ const contributors: GitHubContributorResponse[] = await res.json();
+ return contributors || [];
+ } catch (error) {
+ console.error(`Error fetching contributors for ${repoFullName}:`, error);
+ return [];
+ }
+ }
+
+ /**
+ * Get all contributors across all organization repositories
+ */
+ async getAllContributors(): Promise {
+ const allContributors = new Map();
+ const repoData: ContributorsData['repositories'] = [];
+
+ // Get all organization repositories
+ const orgRepos = await this.getOrganizationRepositories();
+
+ // Fetch contributors from each repository
+ for (const repoInfo of orgRepos) {
+ try {
+ // Skip private repositories (should already be filtered but double-check)
+ if (repoInfo.private) {
+ continue;
+ }
+
+ const repoContributors = await this.getRepositoryContributors(repoInfo.full_name);
+
+ repoData.push({
+ name: repoInfo.name,
+ fullName: repoInfo.full_name,
+ url: repoInfo.html_url,
+ description: repoInfo.description,
+ stars: repoInfo.stargazers_count,
+ language: repoInfo.language,
+ contributorCount: repoContributors.length,
+ createdAt: repoInfo.created_at,
+ updatedAt: repoInfo.updated_at,
+ });
+
+ // Merge contributors (avoid duplicates)
+ repoContributors.forEach((contributor) => {
+ if (contributor.type === 'User') {
+ // Exclude bots
+ if (allContributors.has(contributor.id)) {
+ // Add contributions from this repo
+ const existing = allContributors.get(contributor.id)!;
+ existing.contributions += contributor.contributions;
+ existing.repositories.push({
+ name: repoInfo.name,
+ contributions: contributor.contributions,
+ });
+ } else {
+ // New contributor
+ allContributors.set(contributor.id, {
+ ...contributor,
+ repositories: [
+ {
+ name: repoInfo.name,
+ contributions: contributor.contributions,
+ },
+ ],
+ });
+ }
+ }
+ });
+ } catch (err) {
+ console.error(`Error fetching ${repoInfo.full_name}:`, err);
+ }
+ }
+
+ // Convert Map to Array and sort by contributions
+ const contributorList = Array.from(allContributors.values()).sort((a, b) => b.contributions - a.contributions);
+
+ // Sort repositories by stars
+ repoData.sort((a, b) => b.stars - a.stars);
+
+ const totalContributions = contributorList.reduce((sum, contributor) => sum + contributor.contributions, 0);
+
+ return {
+ contributors: contributorList,
+ repositories: repoData,
+ statistics: {
+ totalContributors: contributorList.length,
+ totalRepositories: repoData.length,
+ totalContributions,
+ },
+ };
+ }
}
diff --git a/src/services/id.ts b/src/services/id.ts
index 791f6a8..5fd45a5 100644
--- a/src/services/id.ts
+++ b/src/services/id.ts
@@ -17,10 +17,7 @@ export class IDService {
while (true) {
const uniqueId = nanoid();
const barsId = `${this.BARS_ID_PREFIX}_${uniqueId}`;
- const result = await this.dbSession.executeRead<{ id: string }>(
- 'SELECT id FROM points WHERE id = ?',
- [barsId]
- );
+ const result = await this.dbSession.executeRead<{ id: string }>('SELECT id FROM points WHERE id = ?', [barsId]);
if (!result.results[0]) {
return barsId;
}
diff --git a/src/services/notam.ts b/src/services/notam.ts
index ae9458d..53d1cd3 100644
--- a/src/services/notam.ts
+++ b/src/services/notam.ts
@@ -14,7 +14,7 @@ export class NotamService {
try {
const result = await this.dbSession.executeRead<{ content: string; type: string }>(
'SELECT id, content, type FROM notams WHERE id = ?',
- ['global']
+ ['global'],
);
if (!result.results[0]) {
return null;
@@ -40,7 +40,7 @@ export class NotamService {
}
await this.dbSession.executeWrite(
'INSERT OR REPLACE INTO notams (id, content, type, updated_by, updated_at) VALUES (?, ?, ?, ?, datetime("now"))',
- ['global', content, type, userId]
+ ['global', content, type, userId],
);
return true;
} catch (error) {
diff --git a/src/services/points.ts b/src/services/points.ts
index fb00106..eb35f61 100644
--- a/src/services/points.ts
+++ b/src/services/points.ts
@@ -13,10 +13,12 @@ export class PointsService {
id: string;
airportId: string;
}>;
- private stmtInsert: PreparedStatement<{
- coordinates: string;
- createdAt: string;
- } & Omit>;
+ private stmtInsert: PreparedStatement<
+ {
+ coordinates: string;
+ createdAt: string;
+ } & Omit
+ >;
private stmtUpdate: PreparedStatement<{
id: string;
airportId: string;
@@ -49,7 +51,7 @@ export class PointsService {
type, name, coordinates, directionality, orientation, color, elevated, ihp
FROM points
WHERE id = ? AND airport_id = ?;`,
- ['id', 'airportId']
+ ['id', 'airportId'],
);
this.stmtInsert = this.dbSession.prepare(
`INSERT
@@ -59,10 +61,20 @@ export class PointsService {
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`,
[
- 'id', 'airportId', 'type', 'name', 'coordinates', 'directionality',
- 'orientation', 'color', 'elevated', 'ihp', 'createdAt', 'createdAt',
- 'createdBy'
- ]
+ 'id',
+ 'airportId',
+ 'type',
+ 'name',
+ 'coordinates',
+ 'directionality',
+ 'orientation',
+ 'color',
+ 'elevated',
+ 'ihp',
+ 'createdAt',
+ 'createdAt',
+ 'createdBy',
+ ],
);
this.stmtUpdate = this.dbSession.prepare(
`UPDATE points
@@ -77,22 +89,12 @@ export class PointsService {
ihp = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND airport_id = ?;`,
- [
- 'type', 'name', 'coordinates', 'directionality', 'orientation', 'color',
- 'elevated', 'ihp', 'id', 'airportId'
- ]
- );
- this.stmtDelete = this.dbSession.prepare(
- 'DELETE FROM points WHERE id = ? AND airport_id = ?;',
- ['id', 'airportId']
+ ['type', 'name', 'coordinates', 'directionality', 'orientation', 'color', 'elevated', 'ihp', 'id', 'airportId'],
);
+ this.stmtDelete = this.dbSession.prepare('DELETE FROM points WHERE id = ? AND airport_id = ?;', ['id', 'airportId']);
}
- async createPoint(
- airportId: string,
- userId: string,
- point: PointData,
- ): Promise {
+ async createPoint(airportId: string, userId: string, point: PointData): Promise {
// Check if user has permission for this airport
const hasDivisionAccess = await this.divisions.userHasAirportAccess(userId, airportId);
if (!hasDivisionAccess) {
@@ -137,17 +139,15 @@ export class PointsService {
newPoint.createdAt,
newPoint.updatedAt,
newPoint.createdBy,
- ]
+ ],
);
- try { this.posthog?.track('Point Created', { airportId, userId, type: point.type }); } catch { }
+ try {
+ this.posthog?.track('Point Created', { airportId, userId, type: point.type });
+ } catch {}
return newPoint;
}
- async updatePoint(
- pointId: string,
- userId: string,
- updates: Partial,
- ): Promise {
+ async updatePoint(pointId: string, userId: string, updates: Partial): Promise {
// Get existing point
const point = await this.getPoint(pointId);
if (!point) {
@@ -165,10 +165,7 @@ export class PointsService {
this.validatePoint(mergedPoint);
// Define allowed fields for updates
- const allowedFields = [
- 'type', 'name', 'coordinates', 'directionality',
- 'orientation', 'color', 'elevated', 'ihp'
- ];
+ const allowedFields = ['type', 'name', 'coordinates', 'directionality', 'orientation', 'color', 'elevated', 'ihp'];
const processedUpdates: Record = {};
Object.entries(updates).forEach(([key, value]) => {
if (allowedFields.includes(key)) {
@@ -179,14 +176,14 @@ export class PointsService {
return this.getPoint(pointId) as Promise;
}
const fieldMappings: Record = {
- 'type': 'type',
- 'name': 'name',
- 'coordinates': 'coordinates',
- 'directionality': 'directionality',
- 'orientation': 'orientation',
- 'color': 'color',
- 'elevated': 'elevated',
- 'ihp': 'ihp'
+ type: 'type',
+ name: 'name',
+ coordinates: 'coordinates',
+ directionality: 'directionality',
+ orientation: 'orientation',
+ color: 'color',
+ elevated: 'elevated',
+ ihp: 'ihp',
};
const updateFields = Object.keys(processedUpdates)
@@ -199,11 +196,18 @@ export class PointsService {
SET ${updateFields}, updated_at = ?
WHERE id = ?
`,
- [...Object.values(processedUpdates), new Date().toISOString(), pointId]
+ [...Object.values(processedUpdates), new Date().toISOString(), pointId],
);
- const finalPoint = await this.getPoint(pointId) as Point;
- try { this.posthog?.track('Point Updated', { pointId, airportId: finalPoint.airportId, userId, fields: Object.keys(processedUpdates) }); } catch { }
+ const finalPoint = (await this.getPoint(pointId)) as Point;
+ try {
+ this.posthog?.track('Point Updated', {
+ pointId,
+ airportId: finalPoint.airportId,
+ userId,
+ fields: Object.keys(processedUpdates),
+ });
+ } catch {}
return finalPoint;
}
@@ -221,25 +225,19 @@ export class PointsService {
}
// Delete from database
- await this.dbSession.executeWrite(
- 'DELETE FROM points WHERE id = ?',
- [pointId]
- );
- try { this.posthog?.track('Point Deleted', { pointId, airportId: point.airportId, userId }); } catch { }
+ await this.dbSession.executeWrite('DELETE FROM points WHERE id = ?', [pointId]);
+ try {
+ this.posthog?.track('Point Deleted', { pointId, airportId: point.airportId, userId });
+ } catch {}
}
- async applyChangeset(
- airportId: string,
- userId: string,
- changeset: PointChangeset
- ): Promise {
+ async applyChangeset(airportId: string, userId: string, changeset: PointChangeset): Promise {
const hasDivisionAccess = await this.divisions.userHasAirportAccess(userId, airportId);
if (!hasDivisionAccess) {
throw new Error('User does not have permission to apply this changeset');
}
- const selects = Object.keys(changeset.modify ?? {})
- .map((id) => this.stmtSelect.bindAll({ id, airportId }));
+ const selects = Object.keys(changeset.modify ?? {}).map((id) => this.stmtSelect.bindAll({ id, airportId }));
const modifiedPoints = (await this.dbSession.executeBatch(selects))
.map((result) => {
if (!result.results || result.results.length === 0) {
@@ -268,16 +266,17 @@ export class PointsService {
createdAt: now,
updatedAt: now,
createdBy: userId,
- }))
+ })),
);
- const inserts = createdPoints
- .map((point) => this.stmtInsert.bindAll({
+ const inserts = createdPoints.map((point) =>
+ this.stmtInsert.bindAll({
...point,
- coordinates: JSON.stringify(point.coordinates)
- }));
- const updates = modifiedPoints
- .map((point) => this.stmtUpdate.bindAll({
+ coordinates: JSON.stringify(point.coordinates),
+ }),
+ );
+ const updates = modifiedPoints.map((point) =>
+ this.stmtUpdate.bindAll({
id: point.id,
airportId,
type: point.type ?? null,
@@ -288,30 +287,32 @@ export class PointsService {
color: point.color ?? null,
elevated: point.elevated ?? null,
ihp: point.ihp ?? null,
- }));
- const deletes = (changeset.delete ?? [])
- .map((id) => this.stmtDelete.bindAll({ id, airportId }));
+ }),
+ );
+ const deletes = (changeset.delete ?? []).map((id) => this.stmtDelete.bindAll({ id, airportId }));
await this.dbSession.executeBatch(inserts.concat(updates).concat(deletes));
- try { this.posthog?.track('Points Changeset Applied', { airportId, userId, created: createdPoints.length, modified: modifiedPoints.length, deleted: (changeset.delete ?? []).length }); } catch { }
+ try {
+ this.posthog?.track('Points Changeset Applied', {
+ airportId,
+ userId,
+ created: createdPoints.length,
+ modified: modifiedPoints.length,
+ deleted: (changeset.delete ?? []).length,
+ });
+ } catch {}
return createdPoints;
}
async getPoint(pointId: string): Promise {
- const result = await this.dbSession.executeRead(
- 'SELECT * FROM points WHERE id = ?',
- [pointId]
- );
+ const result = await this.dbSession.executeRead('SELECT * FROM points WHERE id = ?', [pointId]);
if (!result.results[0]) return null;
return this.mapPointFromDb(result.results[0]);
}
async getAirportPoints(airportId: string): Promise {
- const results = await this.dbSession.executeRead(
- 'SELECT * FROM points WHERE airport_id = ?',
- [airportId]
- );
+ const results = await this.dbSession.executeRead('SELECT * FROM points WHERE airport_id = ?', [airportId]);
return results.results.map(this.mapPointFromDb);
}
diff --git a/src/services/polygons.ts b/src/services/polygons.ts
index 4ac869e..76b63b0 100644
--- a/src/services/polygons.ts
+++ b/src/services/polygons.ts
@@ -69,7 +69,7 @@ export class PolygonService {
FROM points
WHERE id = ?
`,
- [barsId]
+ [barsId],
);
if (!result.results[0]) {
return null;
@@ -175,6 +175,7 @@ export class PolygonService {
// Add each object
for (const obj of processedObjects) {
+ // stateId moved to per-light level (previously on BarsObject)
xml += `\t\n`;
// Add properties
@@ -192,7 +193,14 @@ export class PolygonService {
// Add light points
for (const point of obj.points) {
- xml += '\t\t\n';
+ // Determine per-light orientation & color (point overrides object)
+ const lightOrientation: 'left' | 'right' | 'both' =
+ (point.properties?.orientation as any) || (obj.properties.orientation as any) || 'both';
+ const lightColor = (point.properties?.color || obj.properties.color || '').toLowerCase();
+ const isElevatedStopbar = obj.type === 'stopbar' && point.properties?.elevated === true;
+ const lightStateId = this.mapLightStateId(lightOrientation, lightColor, isElevatedStopbar);
+ const lightStateAttr = lightStateId !== undefined ? ` stateId="${lightStateId}"` : '';
+ xml += `\t\t\n`;
xml += `\t\t\t${point.lat},${point.lon}\n`;
xml += `\t\t\t${point.heading.toFixed(2)}\n`;
@@ -202,35 +210,30 @@ export class PolygonService {
const needsPropertiesTag = this.lightsNeedsPropertiesTag(point, props, obj.type);
if (needsPropertiesTag) {
- xml += '\t\t\t\n';
-
- // Include color if it differs from the object-level defaults
+ let lightPropsContent = '';
if (point.properties.color && point.properties.color !== props.color) {
- xml += `\t\t\t\t${point.properties.color}\n`;
+ lightPropsContent += `\t\t\t\t${point.properties.color}\n`;
}
-
if (point.properties.ihp === true && obj.type === 'stopbar' && point.properties.color === 'yellow') {
- xml += `\t\t\t\t${point.properties.ihp}\n`;
+ lightPropsContent += `\t\t\t\t${point.properties.ihp}\n`;
}
-
- // Only include elevated property when it's explicitly true
if (point.properties.elevated === true) {
- xml += `\t\t\t\ttrue\n`;
+ lightPropsContent += `\t\t\t\ttrue\n`;
}
-
- // For orientation, only output for stopbar type
if (
point.properties.orientation &&
obj.type === 'stopbar' &&
- // Don't include "both" for elevated stopbar lights
!(point.properties.elevated === true && point.properties.orientation === 'both') &&
- // Only include if it differs from the object's orientation
point.properties.orientation !== props.orientation
) {
- xml += `\t\t\t\t${point.properties.orientation}\n`;
+ lightPropsContent += `\t\t\t\t${point.properties.orientation}\n`;
}
- xml += '\t\t\t\n';
+ if (lightPropsContent.length > 0) {
+ xml += '\t\t\t\n';
+ xml += lightPropsContent;
+ xml += '\t\t\t\n';
+ }
}
}
@@ -246,6 +249,67 @@ export class PolygonService {
return xml;
}
+ /**
+ * Map a processed BARS object to a light stateId used by pilot client.
+ * Mapping provided:
+ * Uni (orientation !== 'both'):
+ * red=1, green=2, yellow=3, blue=4, orange=5
+ * Bi (orientation === 'both') same color both dirs:
+ * red=20, green=21, yellow=22, blue=23, orange=24
+ * Bi mixed (Dir2 green, Dir1 other):
+ * green-yellow=25, green-blue=26, green-orange=27
+ */
+ private mapLightStateId(orientation: 'left' | 'right' | 'both', rawColor: string, elevatedStopbar?: boolean): number | undefined {
+ // Elevated stopbar special state
+ if (elevatedStopbar) return 6;
+ if (!rawColor) return undefined;
+ // Normalize color string(s)
+ const color = rawColor.toLowerCase();
+ // For mapping, strip trailing -uni markers on entire string and on segments
+ const normalized = color
+ .split('-')
+ .map((seg) => seg.replace(/uni$/i, ''))
+ .join('-')
+ .replace(/--+/g, '-');
+
+ if (orientation === 'both') {
+ // Mixed combos first (order-insensitive)
+ if (/(green-yellow|yellow-green)/.test(normalized)) return 25;
+ if (/(green-blue|blue-green)/.test(normalized)) return 26;
+ if (/(green-orange|orange-green)/.test(normalized)) return 27;
+ // Same color both directions
+ switch (normalized) {
+ case 'red':
+ return 20;
+ case 'green':
+ return 21;
+ case 'yellow':
+ return 22;
+ case 'blue':
+ return 23;
+ case 'orange':
+ return 24;
+ }
+ return undefined;
+ }
+
+ // Uni-directional: take first segment (after normalization)
+ const base = normalized.split('-')[0];
+ switch (base) {
+ case 'red':
+ return 1;
+ case 'green':
+ return 2;
+ case 'yellow':
+ return 3;
+ case 'blue':
+ return 4;
+ case 'orange':
+ return 5;
+ default:
+ return undefined;
+ }
+ }
/**
* Helper method to determine if a light needs properties in its XML output
*/
@@ -392,8 +456,6 @@ export class PolygonService {
xml += '';
- // Stats tracking removed
-
return xml;
}
}
diff --git a/src/services/posthog.ts b/src/services/posthog.ts
index fe1dca5..323d893 100644
--- a/src/services/posthog.ts
+++ b/src/services/posthog.ts
@@ -5,125 +5,144 @@
import { waitUntil as cfWaitUntil } from 'cloudflare:workers';
interface PostHogCapturePayload {
- api_key: string;
- event: string;
- properties: Record;
- timestamp?: string; // ISO 8601
- $process_person_profile?: boolean;
+ api_key: string;
+ event: string;
+ properties: Record;
+ timestamp?: string; // ISO 8601
+ $process_person_profile?: boolean;
}
export interface TrackOptions {
- timestamp?: Date | string;
- product?: string;
- omitProduct?: boolean;
- inline?: boolean; // if true, don't background
+ timestamp?: Date | string;
+ product?: string;
+ omitProduct?: boolean;
+ inline?: boolean; // if true, don't background
}
export class PostHogService {
- private readonly apiKey: string | undefined;
- private readonly host: string;
- private readonly enabled: boolean;
- private readonly piiKeyMatchers: Array<(k: string) => boolean> = [
- (k) => k === 'userId',
- (k) => k === 'vatsimId',
- (k) => k === 'requestedBy',
- (k) => k === 'approvedBy',
- (k) => k === 'decidedBy',
- (k) => k === 'createdBy',
- (k) => k === 'email',
- (k) => k === 'cid',
- (k) => k === 'callsign',
- (k) => k.includes('vatsim'),
- ];
+ private readonly apiKey: string | undefined;
+ private readonly host: string;
+ private readonly enabled: boolean;
+ private readonly piiKeyMatchers: Array<(k: string) => boolean> = [
+ (k) => k === 'userId',
+ (k) => k === 'vatsimId',
+ (k) => k === 'requestedBy',
+ (k) => k === 'approvedBy',
+ (k) => k === 'decidedBy',
+ (k) => k === 'createdBy',
+ (k) => k === 'email',
+ (k) => k === 'cid',
+ (k) => k === 'callsign',
+ (k) => k.includes('vatsim'),
+ ];
- constructor(env: Env) {
- this.apiKey = (env as any).POSTHOG_API_KEY
- this.host = (env as any).POSTHOG_HOST || 'https://eu.i.posthog.com';
- this.enabled = !!this.apiKey;
- }
+ constructor(env: Env) {
+ this.apiKey = (env as any).POSTHOG_API_KEY;
+ this.host = (env as any).POSTHOG_HOST || 'https://eu.i.posthog.com';
+ this.enabled = !!this.apiKey;
+ }
- private isPIIKey(key: string): boolean {
- const lk = key.toLowerCase();
- return this.piiKeyMatchers.some(fn => fn(lk));
- }
+ private isPIIKey(key: string): boolean {
+ const lk = key.toLowerCase();
+ return this.piiKeyMatchers.some((fn) => fn(lk));
+ }
- private async hashValue(value: unknown): Promise {
- try {
- const encoder = new TextEncoder();
- const data = encoder.encode(String(value));
- const digest = await crypto.subtle.digest('SHA-256', data);
- return Array.from(new Uint8Array(digest)).map(b => b.toString(16).padStart(2, '0')).join('');
- } catch {
- // Fallback simple hash (non-crypto) if subtle fails
- const s = String(value);
- let h = 0; for (let i = 0; i < s.length; i++) { h = (h * 31 + s.charCodeAt(i)) >>> 0; }
- return h.toString(16);
- }
- }
+ private async hashValue(value: unknown): Promise {
+ try {
+ const encoder = new TextEncoder();
+ const data = encoder.encode(String(value));
+ const digest = await crypto.subtle.digest('SHA-256', data);
+ return Array.from(new Uint8Array(digest))
+ .map((b) => b.toString(16).padStart(2, '0'))
+ .join('');
+ } catch {
+ // Fallback simple hash (non-crypto) if subtle fails
+ const s = String(value);
+ let h = 0;
+ for (let i = 0; i < s.length; i++) {
+ h = (h * 31 + s.charCodeAt(i)) >>> 0;
+ }
+ return h.toString(16);
+ }
+ }
- private async sanitizeProperties(props: Record): Promise> {
- const entries = await Promise.all(Object.entries(props).map(async ([k, v]) => {
- if (v == null) return [k, v];
- if (this.isPIIKey(k)) {
- return [k, await this.hashValue(v)];
- }
- return [k, v];
- }));
- return Object.fromEntries(entries);
- }
+ private async sanitizeProperties(props: Record): Promise> {
+ const entries = await Promise.all(
+ Object.entries(props).map(async ([k, v]) => {
+ if (v == null) return [k, v];
+ if (this.isPIIKey(k)) {
+ return [k, await this.hashValue(v)];
+ }
+ return [k, v];
+ }),
+ );
+ return Object.fromEntries(entries);
+ }
- track(event: string, properties: Record = {}, distinctId = 'anonymous', options: TrackOptions = {}): void | Promise {
- if (!this.enabled) return;
- const mergedProps: Record = {
- ...properties,
- };
- if (!options.omitProduct) {
- if (mergedProps.product === undefined) mergedProps.product = options.product || 'Core';
- }
- try {
- const approxSize = JSON.stringify(mergedProps).length;
- if (approxSize > 45_000) {
- mergedProps._truncated = true;
- }
- } catch { /* ignore */ }
- const buildBody = async () => {
- const sanitized = await this.sanitizeProperties(mergedProps);
- const payload: PostHogCapturePayload = {
- api_key: this.apiKey!,
- event,
- properties: {
- distinct_id: distinctId,
- ...sanitized,
- },
- $process_person_profile: false,
- };
- if (options.timestamp) {
- payload.timestamp = typeof options.timestamp === 'string' ? options.timestamp : options.timestamp.toISOString();
- }
- return JSON.stringify(payload);
- };
+ track(event: string, properties: Record = {}, distinctId = 'anonymous', options: TrackOptions = {}): void | Promise {
+ if (!this.enabled) return;
+ const mergedProps: Record = {
+ ...properties,
+ };
+ if (!options.omitProduct) {
+ if (mergedProps.product === undefined) mergedProps.product = options.product || 'Core';
+ }
+ try {
+ const approxSize = JSON.stringify(mergedProps).length;
+ if (approxSize > 45_000) {
+ mergedProps._truncated = true;
+ }
+ } catch {
+ /* ignore */
+ }
+ const buildBody = async () => {
+ const sanitized = await this.sanitizeProperties(mergedProps);
+ const payload: PostHogCapturePayload = {
+ api_key: this.apiKey!,
+ event,
+ properties: {
+ distinct_id: distinctId,
+ ...sanitized,
+ },
+ $process_person_profile: false,
+ };
+ if (options.timestamp) {
+ payload.timestamp = typeof options.timestamp === 'string' ? options.timestamp : options.timestamp.toISOString();
+ }
+ return JSON.stringify(payload);
+ };
- const doFetch = () => buildBody().then(body => fetch(`${this.host.replace(/\/$/, '')}/capture/`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body,
- })).then(res => {
- if (!res.ok) {
- console.warn('[PostHog] Non-OK response', res.status);
- }
- }).catch(err => {
- console.warn('[PostHog] Track failed', err instanceof Error ? err.message : err);
- });
- if (options.inline) return doFetch();
- try {
- if (typeof cfWaitUntil === 'function') {
- cfWaitUntil(doFetch());
- return;
- }
- } catch { /* ignore */ }
- try {
- (globalThis as any).waitUntil?.(doFetch());
- } catch { /* ignore */ }
- return;
- }
+ const doFetch = () =>
+ buildBody()
+ .then((body) =>
+ fetch(`${this.host.replace(/\/$/, '')}/capture/`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body,
+ }),
+ )
+ .then((res) => {
+ if (!res.ok) {
+ console.warn('[PostHog] Non-OK response', res.status);
+ }
+ })
+ .catch((err) => {
+ console.warn('[PostHog] Track failed', err instanceof Error ? err.message : err);
+ });
+ if (options.inline) return doFetch();
+ try {
+ if (typeof cfWaitUntil === 'function') {
+ cfWaitUntil(doFetch());
+ return;
+ }
+ } catch {
+ /* ignore */
+ }
+ try {
+ (globalThis as any).waitUntil?.(doFetch());
+ } catch {
+ /* ignore */
+ }
+ return;
+ }
}
diff --git a/src/services/releases.ts b/src/services/releases.ts
new file mode 100644
index 0000000..db6fba5
--- /dev/null
+++ b/src/services/releases.ts
@@ -0,0 +1,71 @@
+import { DatabaseSessionService } from './database-session';
+import { StorageService } from './storage';
+
+export type InstallerProduct = 'Pilot-Client' | 'vatSys-Plugin' | 'EuroScope-Plugin' | 'Installer' | 'SimConnect.NET';
+export interface ReleaseRecord {
+ id: number;
+ product: InstallerProduct;
+ version: string;
+ file_key: string;
+ file_size: number;
+ file_hash: string;
+ changelog?: string;
+ image_url?: string;
+ created_at: string;
+}
+
+export interface CreateReleaseInput {
+ product: InstallerProduct;
+ version: string;
+ fileKey: string;
+ fileSize: number;
+ fileHash: string; // sha256 hex
+ changelog?: string;
+ imageUrl?: string;
+}
+
+export class ReleaseService {
+ private dbSession: DatabaseSessionService;
+ constructor(private db: D1Database, private storage: StorageService) {
+ this.dbSession = new DatabaseSessionService(db);
+ }
+
+ async createRelease(input: CreateReleaseInput): Promise {
+ const { product, version, fileKey, fileSize, fileHash, changelog, imageUrl } = input;
+ const result = await this.dbSession.executeWrite(
+ `INSERT INTO installer_releases (product, version, file_key, file_size, file_hash, changelog, image_url) VALUES (?,?,?,?,?,?,?) RETURNING *`,
+ [product, version, fileKey, fileSize, fileHash, changelog || null, imageUrl || null],
+ );
+ const release = result.results[0] as ReleaseRecord;
+ if (!release) throw new Error('Failed to create release');
+ return release;
+ }
+
+ async listReleases(product?: InstallerProduct): Promise {
+ if (product) {
+ const res = await this.dbSession.executeRead(
+ 'SELECT * FROM installer_releases WHERE product = ? ORDER BY created_at DESC',
+ [product],
+ );
+ return res.results;
+ }
+ const res = await this.dbSession.executeRead('SELECT * FROM installer_releases ORDER BY created_at DESC');
+ return res.results;
+ }
+
+ async getLatest(product: InstallerProduct): Promise {
+ const res = await this.dbSession.executeRead(
+ `SELECT * FROM installer_releases WHERE product = ? ORDER BY created_at DESC LIMIT 1`,
+ [product],
+ );
+ return res.results[0] || null;
+ }
+
+ async updateChangelog(id: number, changelog: string): Promise {
+ const res = await this.dbSession.executeWrite(
+ `UPDATE installer_releases SET changelog = ? WHERE id = ? RETURNING *`,
+ [changelog, id],
+ );
+ return (res.results[0] as ReleaseRecord) || null;
+ }
+}
diff --git a/src/services/roles.ts b/src/services/roles.ts
index 034ca96..f96d43a 100644
--- a/src/services/roles.ts
+++ b/src/services/roles.ts
@@ -1,13 +1,11 @@
export enum StaffRole {
LEAD_DEVELOPER = 'LEAD_DEVELOPER',
PRODUCT_MANAGER = 'PRODUCT_MANAGER',
- MAP_APPROVER = 'MAP_APPROVER', // For approving contributions.
}
export const roleHierarchy: Record = {
LEAD_DEVELOPER: 999,
PRODUCT_MANAGER: 500,
- MAP_APPROVER: 100,
};
export type Role = 'lead_developer' | 'product_manager' | 'nav_head' | 'nav_member';
@@ -33,19 +31,13 @@ export class RoleService {
}
async isStaff(userId: number): Promise {
- const staffResult = await this.dbSession.executeRead(
- 'SELECT * FROM staff WHERE user_id = ?',
- [userId]
- );
+ const staffResult = await this.dbSession.executeRead('SELECT * FROM staff WHERE user_id = ?', [userId]);
const staff = staffResult.results[0];
return !!staff && !!staff.role && staff.role in roleHierarchy;
}
async getUserRole(userId: number): Promise {
- const staffResult = await this.dbSession.executeRead(
- 'SELECT * FROM staff WHERE user_id = ?',
- [userId]
- );
+ const staffResult = await this.dbSession.executeRead('SELECT * FROM staff WHERE user_id = ?', [userId]);
const staff = staffResult.results[0];
if (!staff?.role) return null;
return staff.role as StaffRole;
@@ -73,7 +65,7 @@ export class RoleService {
JOIN users u ON u.vatsim_id = dm.vatsim_id
WHERE u.id = ?
`,
- [userId]
+ [userId],
);
return rolesResult.results.reduce(
(acc, { role }) => ({
@@ -83,4 +75,57 @@ export class RoleService {
{} as DivisionRoles,
);
}
+
+ // --- Staff management helpers (write) ---
+ private async getRoleCount(role: StaffRole): Promise {
+ const res = await this.dbSession.executeRead<{ cnt: number }>('SELECT COUNT(*) as cnt FROM staff WHERE role = ?', [role]);
+ return res.results[0]?.cnt || 0;
+ }
+
+ private async ensureNotLastLeadDeveloper(userId: number, changingToRole?: StaffRole | null) {
+ const existing = await this.dbSession.executeRead('SELECT * FROM staff WHERE user_id = ?', [userId]);
+ const current = existing.results[0];
+ if (!current) return; // not staff
+ if ((current.role as StaffRole) === StaffRole.LEAD_DEVELOPER && (changingToRole == null || changingToRole !== StaffRole.LEAD_DEVELOPER)) {
+ const count = await this.getRoleCount(StaffRole.LEAD_DEVELOPER);
+ if (count <= 1) throw new Error('Cannot modify or remove the last remaining lead developer');
+ }
+ }
+
+ async addStaff(userId: number, role: StaffRole): Promise<{ user_id: number; role: StaffRole; created_at: string }> {
+ const existing = await this.dbSession.executeRead('SELECT * FROM staff WHERE user_id = ?', [userId]);
+ if (existing.results[0]) {
+ await this.ensureNotLastLeadDeveloper(userId, role);
+ await this.dbSession.executeWrite('UPDATE staff SET role = ? WHERE user_id = ?', [role, userId]);
+ const updated = await this.dbSession.executeRead('SELECT * FROM staff WHERE user_id = ?', [userId]);
+ const row = updated.results[0]!;
+ return { user_id: row.user_id, role: row.role as StaffRole, created_at: row.created_at };
+ }
+ const createdAt = new Date().toISOString();
+ await this.dbSession.executeWrite('INSERT INTO staff (user_id, role, created_at) VALUES (?, ?, ?)', [userId, role, createdAt]);
+ return { user_id: userId, role, created_at: createdAt };
+ }
+
+ async updateStaffRole(userId: number, role: StaffRole): Promise {
+ await this.ensureNotLastLeadDeveloper(userId, role);
+ const result = await this.dbSession.executeWrite('UPDATE staff SET role = ? WHERE user_id = ?', [role, userId]);
+ return !!result.success;
+ }
+
+ async removeStaff(userId: number): Promise {
+ await this.ensureNotLastLeadDeveloper(userId, null);
+ const result = await this.dbSession.executeWrite('DELETE FROM staff WHERE user_id = ?', [userId]);
+ return !!result.success;
+ }
+
+ async listStaff(): Promise> {
+ const res = await this.dbSession.executeRead<{ user_id: number; role: string; created_at: string; vatsim_id: string; full_name: string | null }>(
+ `SELECT s.user_id, s.role, s.created_at, u.vatsim_id, u.full_name
+ FROM staff s
+ JOIN users u ON u.id = s.user_id
+ ORDER BY s.created_at DESC`,
+ [],
+ );
+ return res.results.map((r) => ({ user_id: r.user_id, role: r.role as StaffRole, created_at: r.created_at, vatsim_id: r.vatsim_id, full_name: r.full_name }));
+ }
}
diff --git a/src/services/service-pool.ts b/src/services/service-pool.ts
index 5bd031c..46a26d1 100644
--- a/src/services/service-pool.ts
+++ b/src/services/service-pool.ts
@@ -14,114 +14,144 @@ import { ContributionService } from './contributions';
import { StorageService } from './storage';
import { GitHubService } from './github';
import { PostHogService } from './posthog';
+import { FAQService } from './faqs';
+import { ReleaseService } from './releases';
+import { ContactService } from './contact';
export const ServicePool = (() => {
- let vatsim: VatsimService;
- let auth: AuthService;
- let roles: RoleService;
- let cache: CacheService;
- let airport: AirportService;
- let divisions: DivisionService;
- let id: IDService;
- let points: PointsService;
- let polygons: PolygonService;
- let support: SupportService;
- let notam: NotamService;
- let contributions: ContributionService;
- let storage: StorageService;
- let github: GitHubService;
- let posthog: PostHogService;
+ let vatsim: VatsimService;
+ let auth: AuthService;
+ let roles: RoleService;
+ let cache: CacheService;
+ let airport: AirportService;
+ let divisions: DivisionService;
+ let id: IDService;
+ let points: PointsService;
+ let polygons: PolygonService;
+ let support: SupportService;
+ let notam: NotamService;
+ let contributions: ContributionService;
+ let storage: StorageService;
+ let github: GitHubService;
+ let posthog: PostHogService;
+ let faqs: FAQService;
+ let releases: ReleaseService;
+ let contact: ContactService;
- return {
- getVatsim(env: Env) {
- if (!vatsim) {
- vatsim = new VatsimService(env.VATSIM_CLIENT_ID, env.VATSIM_CLIENT_SECRET);
- }
- return vatsim;
- },
- getAuth(env: Env) {
- if (!auth) {
- auth = new AuthService(env.DB, this.getVatsim(env), this.getPostHog(env));
- }
- return auth;
- },
- getRoles(env: Env) {
- if (!roles) {
- roles = new RoleService(env.DB);
- }
- return roles;
- },
- getCache(env: Env) {
- if (!cache) {
- cache = new CacheService(env);
- }
- return cache;
- },
- getAirport(env: Env) {
- if (!airport) {
- airport = new AirportService(env.DB, env.AIRPORTDB_API_KEY, this.getPostHog(env));
- }
- return airport;
- },
- getDivisions(env: Env) {
- if (!divisions) {
- divisions = new DivisionService(env.DB, this.getPostHog(env));
- }
- return divisions;
- },
- getID(env: Env) {
- if (!id) {
- id = new IDService(env.DB);
- }
- return id;
- },
- getPoints(env: Env) {
- if (!points) {
- points = new PointsService(env.DB, this.getID(env), this.getDivisions(env), this.getAuth(env), this.getPostHog(env));
- }
- return points;
- },
- getPolygons(env: Env) {
- if (!polygons) {
- polygons = new PolygonService(env.DB);
- }
- return polygons;
- },
- getSupport(env: Env) {
- if (!support) {
- support = new SupportService(env.DB);
- }
- return support;
- },
- getNotam(env: Env) {
- if (!notam) {
- notam = new NotamService(env.DB);
- }
- return notam;
- },
- getContributions(env: Env) {
- if (!contributions) {
- contributions = new ContributionService(env.DB, this.getRoles(env), env.AIRPORTDB_API_KEY, env.BARS_STORAGE, this.getPostHog(env));
- }
- return contributions;
- },
- getStorage(env: Env) {
- if (!storage) {
- storage = new StorageService(env.BARS_STORAGE);
- }
- return storage;
- },
- getGitHub(env: Env) {
- if (!github) {
- github = new GitHubService();
- }
- return github;
- },
- getPostHog(env: Env) {
- if (!posthog) {
- posthog = new PostHogService(env);
- }
- return posthog;
- }
- };
+ return {
+ getVatsim(env: Env) {
+ if (!vatsim) {
+ vatsim = new VatsimService(env.VATSIM_CLIENT_ID, env.VATSIM_CLIENT_SECRET);
+ }
+ return vatsim;
+ },
+ getAuth(env: Env) {
+ if (!auth) {
+ auth = new AuthService(env.DB, this.getVatsim(env), this.getPostHog(env));
+ }
+ return auth;
+ },
+ getRoles(env: Env) {
+ if (!roles) {
+ roles = new RoleService(env.DB);
+ }
+ return roles;
+ },
+ getCache(env: Env) {
+ if (!cache) {
+ cache = new CacheService(env);
+ }
+ return cache;
+ },
+ getAirport(env: Env) {
+ if (!airport) {
+ airport = new AirportService(env.DB, env.AIRPORTDB_API_KEY, this.getPostHog(env));
+ }
+ return airport;
+ },
+ getDivisions(env: Env) {
+ if (!divisions) {
+ divisions = new DivisionService(env.DB, this.getPostHog(env));
+ }
+ return divisions;
+ },
+ getID(env: Env) {
+ if (!id) {
+ id = new IDService(env.DB);
+ }
+ return id;
+ },
+ getPoints(env: Env) {
+ if (!points) {
+ points = new PointsService(env.DB, this.getID(env), this.getDivisions(env), this.getAuth(env), this.getPostHog(env));
+ }
+ return points;
+ },
+ getPolygons(env: Env) {
+ if (!polygons) {
+ polygons = new PolygonService(env.DB);
+ }
+ return polygons;
+ },
+ getSupport(env: Env) {
+ if (!support) {
+ support = new SupportService(env.DB);
+ }
+ return support;
+ },
+ getNotam(env: Env) {
+ if (!notam) {
+ notam = new NotamService(env.DB);
+ }
+ return notam;
+ },
+ getContributions(env: Env) {
+ if (!contributions) {
+ contributions = new ContributionService(
+ env.DB,
+ this.getRoles(env),
+ env.AIRPORTDB_API_KEY,
+ env.BARS_STORAGE,
+ this.getPostHog(env),
+ );
+ }
+ return contributions;
+ },
+ getStorage(env: Env) {
+ if (!storage) {
+ storage = new StorageService(env.BARS_STORAGE);
+ }
+ return storage;
+ },
+ getGitHub(env: Env) {
+ if (!github) {
+ github = new GitHubService();
+ }
+ return github;
+ },
+ getPostHog(env: Env) {
+ if (!posthog) {
+ posthog = new PostHogService(env);
+ }
+ return posthog;
+ },
+ getFAQs(env: Env) {
+ if (!faqs) {
+ faqs = new FAQService(env.DB);
+ }
+ return faqs;
+ },
+ getReleases(env: Env) {
+ if (!releases) {
+ releases = new ReleaseService(env.DB, this.getStorage(env));
+ }
+ return releases;
+ },
+ getContact(env: Env) {
+ if (!contact) {
+ contact = new ContactService(env.DB);
+ }
+ return contact;
+ },
+ };
})();
diff --git a/src/services/support.ts b/src/services/support.ts
index ee2c837..a527aea 100644
--- a/src/services/support.ts
+++ b/src/services/support.ts
@@ -136,7 +136,7 @@ export class SupportService {
public lat: number,
public lon: number,
public used: boolean = false,
- ) { }
+ ) {}
}
const bounds = getBounds();
diff --git a/src/services/users.ts b/src/services/users.ts
index c03379c..5b1ef74 100644
--- a/src/services/users.ts
+++ b/src/services/users.ts
@@ -30,19 +30,17 @@ export class UserService {
const [usersResult, countResult] = await Promise.all([
this.dbSession.executeRead(
`
- SELECT u.id, u.vatsim_id, u.email, u.created_at, u.last_login,
- CASE WHEN s.id IS NOT NULL THEN 1 ELSE 0 END as is_staff,
- s.role
- FROM users u
- LEFT JOIN staff s ON u.id = s.user_id
- ORDER BY u.created_at DESC
- LIMIT ? OFFSET ?
- `,
- [limit, offset]
- ),
- this.dbSession.executeRead<{ count: number }>(
- 'SELECT COUNT(*) as count FROM users'
+ SELECT u.id, u.vatsim_id, u.email, u.full_name, u.display_mode, u.created_at, u.last_login,
+ CASE WHEN s.id IS NOT NULL THEN 1 ELSE 0 END as is_staff,
+ s.role
+ FROM users u
+ LEFT JOIN staff s ON u.id = s.user_id
+ ORDER BY u.created_at DESC
+ LIMIT ? OFFSET ?
+ `,
+ [limit, offset],
),
+ this.dbSession.executeRead<{ count: number }>('SELECT COUNT(*) as count FROM users'),
]);
if (!usersResult || !countResult) {
throw new Error('Failed to fetch users');
@@ -67,7 +65,7 @@ export class UserService {
try {
const result = await this.dbSession.executeRead(
`
- SELECT u.id, u.vatsim_id, u.email, u.created_at, u.last_login,
+ SELECT u.id, u.vatsim_id, u.email, u.full_name, u.display_mode, u.created_at, u.last_login,
CASE WHEN s.id IS NOT NULL THEN 1 ELSE 0 END as is_staff,
s.role
FROM users u
@@ -76,7 +74,7 @@ export class UserService {
ORDER BY u.created_at DESC
LIMIT 50
`,
- [`%${query}%`, `%${query}%`]
+ [`%${query}%`, `%${query}%`],
);
if (!result) {
throw new Error('Failed to search users');
@@ -97,10 +95,9 @@ export class UserService {
try {
// Get the user to delete
- const userToDeleteResult = await this.dbSession.executeRead<{ vatsim_id: string }>(
- 'SELECT vatsim_id FROM users WHERE id = ?',
- [userId]
- );
+ const userToDeleteResult = await this.dbSession.executeRead<{ vatsim_id: string }>('SELECT vatsim_id FROM users WHERE id = ?', [
+ userId,
+ ]);
const userToDelete = userToDeleteResult.results[0];
if (!userToDelete) {
throw new Error('User not found');
@@ -110,7 +107,9 @@ export class UserService {
if (!deleted) {
throw new Error('Failed to delete user');
}
- try { this.posthog?.track('Admin Deleted User', { userId, requestingUserId }); } catch { }
+ try {
+ this.posthog?.track('Admin Deleted User', { userId, requestingUserId });
+ } catch {}
return true;
} catch (error) {
throw new Error('Failed to delete user');
@@ -127,17 +126,16 @@ export class UserService {
try {
// Get the user by VATSIM ID
- const userResult = await this.dbSession.executeRead<{ id: number }>(
- 'SELECT id FROM users WHERE vatsim_id = ?',
- [vatsimId]
- );
+ const userResult = await this.dbSession.executeRead<{ id: number }>('SELECT id FROM users WHERE vatsim_id = ?', [vatsimId]);
const user = userResult.results[0];
if (!user) {
throw new Error('User not found');
}
// Use the auth service to regenerate the API key
const newApiKey = await this.auth.regenerateApiKey(user.id);
- try { this.posthog?.track('Admin Regenerated User API Key', { vatsimId, requestingUserId }); } catch { }
+ try {
+ this.posthog?.track('Admin Regenerated User API Key', { vatsimId, requestingUserId });
+ } catch {}
return newApiKey;
} catch (error) {
console.error('Error refreshing user API token:', error);
diff --git a/src/services/vatsim.ts b/src/services/vatsim.ts
index 3687f65..f200e05 100644
--- a/src/services/vatsim.ts
+++ b/src/services/vatsim.ts
@@ -4,7 +4,7 @@ export class VatsimService {
constructor(
private clientId: string,
private clientSecret: string,
- ) { }
+ ) {}
async getToken(code: string): Promise {
const res = await fetch('https://auth.vatsim.net/oauth/token', {
@@ -39,6 +39,8 @@ export class VatsimService {
return {
id: userData.data.cid,
email: userData.data.personal.email,
+ first_name: (userData as any)?.data?.personal?.name_first || undefined,
+ last_name: (userData as any)?.data?.personal?.name_last || undefined,
};
}
async getUserStatus(userId: string): Promise<{ cid: string; callsign: string; type: string } | null> {
diff --git a/src/services/xml-sanitizer.ts b/src/services/xml-sanitizer.ts
new file mode 100644
index 0000000..30187e4
--- /dev/null
+++ b/src/services/xml-sanitizer.ts
@@ -0,0 +1,47 @@
+export function sanitizeContributionXml(raw: string, opts?: { maxBytes?: number }): string {
+ if (!raw) throw new Error('Empty XML');
+
+ const maxBytes = opts?.maxBytes ?? 200_000; // ~200 KB
+ // Size guard (counting UTF-16 code units approximates bytes for ASCII subset typical of these files)
+ if (raw.length > maxBytes) {
+ throw new Error(`Submitted XML too large (> ${maxBytes} chars)`);
+ }
+
+ const trimmed = raw.trim();
+ if (!trimmed.startsWith(' = [
+ { re: /]/.test(trimmed)) {
+ throw new Error('Invalid XML: Missing FSData root element');
+ }
+
+ // Remove any processing instructions after the first line (except XML declaration). Conservative approach.
+ let sanitized = trimmed.replace(/(<\?)(?!xml)([\s\S]*?\?>)/gi, '');
+
+ // Strip disallowed control chars (anything below 0x20 except TAB (0x09), LF (0x0A), CR (0x0D))
+ sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '');
+
+ // Optional: collapse repeated spaces between tags to keep storage predictable (small normalization)
+ sanitized = sanitized.replace(/>\s+<');
+
+ // Final sanity checks
+ if (sanitized.length === 0) throw new Error('Sanitized XML empty');
+ if (!sanitized.startsWith('; // New field for patch-based updates
@@ -107,6 +114,7 @@ export interface Packet {
message?: string; // For error messages
connectionType?: ClientType; // Add connection type to data
offline?: boolean; // Flag to indicate if state is offline (no controllers)
+ requestedAt?: number; // For STATE_SNAPSHOT - when request was made
};
timestamp?: number; // Optional since server will set it
}
@@ -142,7 +150,7 @@ export type PointData = Omit>; // Keyed by ID
delete?: string[]; // IDs
};
diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts
index 1f1a696..ae72318 100644
--- a/worker-configuration.d.ts
+++ b/worker-configuration.d.ts
@@ -1,10 +1,9 @@
/* eslint-disable */
-// Generated by Wrangler by running `wrangler types` (hash: e029e6e638bdf3cdc564247460c43241)
-// Runtime types generated with workerd@1.20250507.0 2024-12-18 nodejs_compat
+// Generated by Wrangler by running `wrangler types` (hash: add5d8fef79ab367bb6d84eba9005bf2)
+// Runtime types generated with workerd@1.20250803.0 2024-12-18 nodejs_compat
declare namespace Cloudflare {
interface Env {
VATSIM_CLIENT_ID: "1562";
- POSTHOG_API_KEY: "phc_d9RAnVNErmg4zZ4oxpmjbeuLp8oR3nu7iYoAH5vc43I";
POSTHOG_HOST: "https://eu.i.posthog.com";
VATSIM_CLIENT_SECRET: string;
AIRPORTDB_API_KEY: string;
@@ -90,7 +89,7 @@ declare abstract class WorkerGlobalScope extends EventTarget(type: Type, handler: EventListenerOrEventListenerObject, options?: EventTargetAddEventListenerOptions | boolean): void;
-declare function removeEventListener(type: Type, handler: EventListenerOrEventListenerObject, options?: EventTargetEventListenerOptions | boolean): void;
+declare function addEventListener(
+ type: Type,
+ handler: EventListenerOrEventListenerObject,
+ options?: EventTargetAddEventListenerOptions | boolean,
+): void;
+declare function removeEventListener(
+ type: Type,
+ handler: EventListenerOrEventListenerObject,
+ options?: EventTargetEventListenerOptions | boolean,
+): void;
/**
* Dispatches a synthetic event event to target and returns true if either event's cancelable attribute value is false or its preventDefault() method was not invoked, and false otherwise.
*
@@ -318,44 +336,59 @@ declare function reportError(error: any): void;
declare function fetch(input: RequestInfo | URL, init?: RequestInit): Promise;
declare const self: ServiceWorkerGlobalScope;
/**
-* The Web Crypto API provides a set of low-level functions for common cryptographic tasks.
-* The Workers runtime implements the full surface of this API, but with some differences in
-* the [supported algorithms](https://developers.cloudflare.com/workers/runtime-apis/web-crypto/#supported-algorithms)
-* compared to those implemented in most browsers.
-*
-* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/web-crypto/)
-*/
+ * The Web Crypto API provides a set of low-level functions for common cryptographic tasks.
+ * The Workers runtime implements the full surface of this API, but with some differences in
+ * the [supported algorithms](https://developers.cloudflare.com/workers/runtime-apis/web-crypto/#supported-algorithms)
+ * compared to those implemented in most browsers.
+ *
+ * [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/web-crypto/)
+ */
declare const crypto: Crypto;
/**
-* The Cache API allows fine grained control of reading and writing from the Cloudflare global network cache.
-*
-* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/cache/)
-*/
+ * The Cache API allows fine grained control of reading and writing from the Cloudflare global network cache.
+ *
+ * [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/cache/)
+ */
declare const caches: CacheStorage;
declare const scheduler: Scheduler;
/**
-* The Workers runtime supports a subset of the Performance API, used to measure timing and performance,
-* as well as timing of subrequests and other operations.
-*
-* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/performance/)
-*/
+ * The Workers runtime supports a subset of the Performance API, used to measure timing and performance,
+ * as well as timing of subrequests and other operations.
+ *
+ * [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/performance/)
+ */
declare const performance: Performance;
declare const Cloudflare: Cloudflare;
declare const origin: string;
declare const navigator: Navigator;
-interface TestController {
-}
+interface TestController {}
interface ExecutionContext {
waitUntil(promise: Promise): void;
passThroughOnException(): void;
props: any;
}
-type ExportedHandlerFetchHandler = (request: Request>, env: Env, ctx: ExecutionContext) => Response | Promise;
+type ExportedHandlerFetchHandler = (
+ request: Request>,
+ env: Env,
+ ctx: ExecutionContext,
+) => Response | Promise;
type ExportedHandlerTailHandler = (events: TraceItem[], env: Env, ctx: ExecutionContext) => void | Promise;
type ExportedHandlerTraceHandler = (traces: TraceItem[], env: Env, ctx: ExecutionContext) => void | Promise;
-type ExportedHandlerTailStreamHandler = (event: TailStream.TailEvent, env: Env, ctx: ExecutionContext) => TailStream.TailEventHandlerType | Promise;
-type ExportedHandlerScheduledHandler = (controller: ScheduledController, env: Env, ctx: ExecutionContext) => void | Promise;
-type ExportedHandlerQueueHandler = (batch: MessageBatch, env: Env, ctx: ExecutionContext) => void | Promise;
+type ExportedHandlerTailStreamHandler = (
+ event: TailStream.TailEvent,
+ env: Env,
+ ctx: ExecutionContext,
+) => TailStream.TailEventHandlerType | Promise;
+type ExportedHandlerScheduledHandler = (
+ controller: ScheduledController,
+ env: Env,
+ ctx: ExecutionContext,
+) => void | Promise;
+type ExportedHandlerQueueHandler = (
+ batch: MessageBatch,
+ env: Env,
+ ctx: ExecutionContext,
+) => void | Promise;
type ExportedHandlerTestHandler = (controller: TestController, env: Env, ctx: ExecutionContext) => void | Promise;
interface ExportedHandler {
fetch?: ExportedHandlerFetchHandler;
@@ -378,16 +411,19 @@ declare abstract class PromiseRejectionEvent extends Event {
readonly reason: any;
}
declare abstract class Navigator {
- sendBeacon(url: string, body?: (ReadableStream | string | (ArrayBuffer | ArrayBufferView) | Blob | FormData | URLSearchParams | URLSearchParams)): boolean;
+ sendBeacon(
+ url: string,
+ body?: ReadableStream | string | (ArrayBuffer | ArrayBufferView) | Blob | FormData | URLSearchParams | URLSearchParams,
+ ): boolean;
readonly userAgent: string;
readonly hardwareConcurrency: number;
}
/**
-* The Workers runtime supports a subset of the Performance API, used to measure timing and performance,
-* as well as timing of subrequests and other operations.
-*
-* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/performance/)
-*/
+ * The Workers runtime supports a subset of the Performance API, used to measure timing and performance,
+ * as well as timing of subrequests and other operations.
+ *
+ * [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/performance/)
+ */
interface Performance {
/* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/performance/#performancetimeorigin) */
readonly timeOrigin: number;
@@ -408,7 +444,10 @@ interface DurableObject {
webSocketClose?(ws: WebSocket, code: number, reason: string, wasClean: boolean): void | Promise;
webSocketError?(ws: WebSocket, error: unknown): void | Promise;
}
-type DurableObjectStub = Fetcher