Add comprehensive OpenAPI/Swagger API documentation

- Install swagger-ui-express and swagger-jsdoc dependencies
- Create comprehensive OpenAPI 3.0 specification with detailed schemas
- Add interactive Swagger UI at /api-docs endpoint
- Document all public API endpoints (/api/config, /api/locations)
- Document admin authentication and management endpoints
- Include comprehensive request/response schemas and examples
- Add authentication documentation for admin endpoints
- Update CLAUDE.md with API documentation information

Features:
- Complete API specification with OpenAPI 3.0 standard
- Interactive documentation interface with Swagger UI
- Detailed request/response examples for all endpoints
- Authentication flows for admin functionality
- Error response documentation with examples
- Type-safe integration with existing TypeScript architecture

API Documentation available at: /api-docs

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Claude Code 2025-07-05 21:21:31 -04:00
parent 13c0b8b457
commit 612475727e
8 changed files with 906 additions and 14 deletions

View file

@ -21,6 +21,17 @@ npm run dev-with-css # Legacy JS + CSS watching
The application runs on port 3000 by default. Visit http://localhost:3000 to view the website. The application runs on port 3000 by default. Visit http://localhost:3000 to view the website.
### API Documentation
Interactive OpenAPI/Swagger documentation is available at `/api-docs` when the server is running:
- **Development**: http://localhost:3000/api-docs
- **Production**: https://yourapp.com/api-docs
The documentation includes:
- Complete API endpoint specifications
- Request/response schemas and examples
- Authentication requirements
- Interactive testing interface
### TypeScript Development ### TypeScript Development
The backend is written in TypeScript and compiles to `dist/` directory. The backend is written in TypeScript and compiles to `dist/` directory.
```bash ```bash

300
package-lock.json generated
View file

@ -14,7 +14,9 @@
"dotenv": "^17.0.1", "dotenv": "^17.0.1",
"express": "^4.18.2", "express": "^4.18.2",
"node-cron": "^3.0.3", "node-cron": "^3.0.3",
"sqlite3": "^5.1.6" "sqlite3": "^5.1.6",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1"
}, },
"devDependencies": { "devDependencies": {
"@types/cors": "^2.8.19", "@types/cors": "^2.8.19",
@ -22,6 +24,8 @@
"@types/node": "^24.0.10", "@types/node": "^24.0.10",
"@types/node-cron": "^3.0.11", "@types/node-cron": "^3.0.11",
"@types/sqlite3": "^3.1.11", "@types/sqlite3": "^3.1.11",
"@types/swagger-jsdoc": "^6.0.4",
"@types/swagger-ui-express": "^4.1.8",
"concurrently": "^9.2.0", "concurrently": "^9.2.0",
"jest": "^29.7.0", "jest": "^29.7.0",
"nodemon": "^3.1.10", "nodemon": "^3.1.10",
@ -45,6 +49,68 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/@apidevtools/json-schema-ref-parser": {
"version": "9.1.2",
"resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.1.2.tgz",
"integrity": "sha512-r1w81DpR+KyRWd3f+rk6TNqMgedmAxZP5v5KWlXQWlgMUUtyEJch0DKEci1SorPMiSeM8XPl7MZ3miJ60JIpQg==",
"license": "MIT",
"dependencies": {
"@jsdevtools/ono": "^7.1.3",
"@types/json-schema": "^7.0.6",
"call-me-maybe": "^1.0.1",
"js-yaml": "^4.1.0"
}
},
"node_modules/@apidevtools/json-schema-ref-parser/node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
"node_modules/@apidevtools/json-schema-ref-parser/node_modules/js-yaml": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/@apidevtools/openapi-schemas": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz",
"integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/@apidevtools/swagger-methods": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz",
"integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==",
"license": "MIT"
},
"node_modules/@apidevtools/swagger-parser": {
"version": "10.0.3",
"resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-10.0.3.tgz",
"integrity": "sha512-sNiLY51vZOmSPFZA5TF35KZ2HbgYklQnTSDnkghamzLb3EkNtcQnrBQEj5AOCxHpTtXpqMCRM1CrmV2rG6nw4g==",
"license": "MIT",
"dependencies": {
"@apidevtools/json-schema-ref-parser": "^9.0.6",
"@apidevtools/openapi-schemas": "^2.0.4",
"@apidevtools/swagger-methods": "^3.0.2",
"@jsdevtools/ono": "^7.1.3",
"call-me-maybe": "^1.0.1",
"z-schema": "^5.0.1"
},
"peerDependencies": {
"openapi-types": ">=7"
}
},
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
"version": "7.27.1", "version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
@ -1017,6 +1083,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@jsdevtools/ono": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz",
"integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==",
"license": "MIT"
},
"node_modules/@noble/hashes": { "node_modules/@noble/hashes": {
"version": "1.8.0", "version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
@ -1390,6 +1462,13 @@
"node": ">=0.10" "node": ">=0.10"
} }
}, },
"node_modules/@scarf/scarf": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz",
"integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==",
"hasInstallScript": true,
"license": "Apache-2.0"
},
"node_modules/@sinclair/typebox": { "node_modules/@sinclair/typebox": {
"version": "0.27.8", "version": "0.27.8",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz",
@ -1600,6 +1679,12 @@
"@types/istanbul-lib-report": "*" "@types/istanbul-lib-report": "*"
} }
}, },
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
"license": "MIT"
},
"node_modules/@types/mime": { "node_modules/@types/mime": {
"version": "1.3.5", "version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
@ -1678,6 +1763,24 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/swagger-jsdoc": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/@types/swagger-jsdoc/-/swagger-jsdoc-6.0.4.tgz",
"integrity": "sha512-W+Xw5epcOZrF/AooUM/PccNMSAFOKWZA5dasNyMujTwsBkU74njSJBpvCCJhHAJ95XRMzQrrW844Btu0uoetwQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/swagger-ui-express": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@types/swagger-ui-express/-/swagger-ui-express-4.1.8.tgz",
"integrity": "sha512-AhZV8/EIreHFmBV5wAs0gzJUNq9JbbSXgJLQubCC0jtIo6prnI9MIRRxnU4MZX9RB9yXxF1V4R7jtLl/Wcj31g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*",
"@types/serve-static": "*"
}
},
"node_modules/@types/yargs": { "node_modules/@types/yargs": {
"version": "17.0.33", "version": "17.0.33",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz",
@ -2058,7 +2161,6 @@
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/base64-js": { "node_modules/base64-js": {
@ -2142,7 +2244,6 @@
"version": "1.1.12", "version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"balanced-match": "^1.0.0", "balanced-match": "^1.0.0",
@ -2304,6 +2405,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/call-me-maybe": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz",
"integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==",
"license": "MIT"
},
"node_modules/callsites": { "node_modules/callsites": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@ -2538,6 +2645,15 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/commander": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz",
"integrity": "sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q==",
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/component-emitter": { "node_modules/component-emitter": {
"version": "1.3.1", "version": "1.3.1",
"resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz",
@ -2552,7 +2668,6 @@
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/concurrently": { "node_modules/concurrently": {
@ -2865,6 +2980,18 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0" "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
} }
}, },
"node_modules/doctrine": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
"integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
"license": "Apache-2.0",
"dependencies": {
"esutils": "^2.0.2"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/dotenv": { "node_modules/dotenv": {
"version": "17.0.1", "version": "17.0.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.0.1.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.0.1.tgz",
@ -3078,6 +3205,15 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/esutils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/etag": { "node_modules/etag": {
"version": "1.8.1", "version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
@ -3340,7 +3476,6 @@
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
"devOptional": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/function-bind": { "node_modules/function-bind": {
@ -3797,7 +3932,6 @@
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
"deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
"devOptional": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"once": "^1.3.0", "once": "^1.3.0",
@ -4788,6 +4922,26 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash.get": {
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
"integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==",
"deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.",
"license": "MIT"
},
"node_modules/lodash.isequal": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
"license": "MIT"
},
"node_modules/lodash.mergewith": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz",
"integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==",
"license": "MIT"
},
"node_modules/lru-cache": { "node_modules/lru-cache": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
@ -4978,7 +5132,6 @@
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"devOptional": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"brace-expansion": "^1.1.7" "brace-expansion": "^1.1.7"
@ -5374,6 +5527,13 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/openapi-types": {
"version": "12.1.3",
"resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz",
"integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==",
"license": "MIT",
"peer": true
},
"node_modules/p-limit": { "node_modules/p-limit": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
@ -5487,7 +5647,6 @@
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@ -6574,6 +6733,83 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/swagger-jsdoc": {
"version": "6.2.8",
"resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.2.8.tgz",
"integrity": "sha512-VPvil1+JRpmJ55CgAtn8DIcpBs0bL5L3q5bVQvF4tAW/k/9JYSj7dCpaYCAv5rufe0vcCbBRQXGvzpkWjvLklQ==",
"license": "MIT",
"dependencies": {
"commander": "6.2.0",
"doctrine": "3.0.0",
"glob": "7.1.6",
"lodash.mergewith": "^4.6.2",
"swagger-parser": "^10.0.3",
"yaml": "2.0.0-1"
},
"bin": {
"swagger-jsdoc": "bin/swagger-jsdoc.js"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/swagger-jsdoc/node_modules/glob": {
"version": "7.1.6",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz",
"integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==",
"deprecated": "Glob versions prior to v9 are no longer supported",
"license": "ISC",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.0.4",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/swagger-parser": {
"version": "10.0.3",
"resolved": "https://registry.npmjs.org/swagger-parser/-/swagger-parser-10.0.3.tgz",
"integrity": "sha512-nF7oMeL4KypldrQhac8RyHerJeGPD1p2xDh900GPvc+Nk7nWP6jX2FcC7WmkinMoAmoO774+AFXcWsW8gMWEIg==",
"license": "MIT",
"dependencies": {
"@apidevtools/swagger-parser": "10.0.3"
},
"engines": {
"node": ">=10"
}
},
"node_modules/swagger-ui-dist": {
"version": "5.26.0",
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.26.0.tgz",
"integrity": "sha512-U8m1LruHrk33gIIT5qDKhXMygT4FonRGBE92zMbxP4i9ULolPlKISy5Pd3RCES8pWdbGzXhvm/Q6jdA/HsrClg==",
"license": "Apache-2.0",
"dependencies": {
"@scarf/scarf": "=1.4.0"
}
},
"node_modules/swagger-ui-express": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz",
"integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==",
"license": "MIT",
"dependencies": {
"swagger-ui-dist": ">=5.0.0"
},
"engines": {
"node": ">= v0.10.32"
},
"peerDependencies": {
"express": ">=4.0.0 || >=5.0.0-beta"
}
},
"node_modules/tar": { "node_modules/tar": {
"version": "6.2.1", "version": "6.2.1",
"resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz",
@ -6931,6 +7167,15 @@
"node": ">=10.12.0" "node": ">=10.12.0"
} }
}, },
"node_modules/validator": {
"version": "13.15.15",
"resolved": "https://registry.npmjs.org/validator/-/validator-13.15.15.tgz",
"integrity": "sha512-BgWVbCI72aIQy937xbawcs+hrVaN/CZ2UwutgaJ36hGqRrLNM+f5LUT/YPRbo8IV/ASeFzXszezV+y2+rq3l8A==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/vary": { "node_modules/vary": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
@ -7030,6 +7275,15 @@
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/yaml": {
"version": "2.0.0-1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.0.0-1.tgz",
"integrity": "sha512-W7h5dEhywMKenDJh2iX/LABkbFnBxasD27oyXWDS/feDsxiw0dD5ncXdYXgkvAsXIY2MpW/ZKkr9IU30DBdMNQ==",
"license": "ISC",
"engines": {
"node": ">= 6"
}
},
"node_modules/yargs": { "node_modules/yargs": {
"version": "17.7.2", "version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
@ -7081,6 +7335,36 @@
"funding": { "funding": {
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
},
"node_modules/z-schema": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/z-schema/-/z-schema-5.0.5.tgz",
"integrity": "sha512-D7eujBWkLa3p2sIpJA0d1pr7es+a7m0vFAnZLlCEKq/Ij2k0MLi9Br2UPxoxdYystm5K1yeBGzub0FlYUEWj2Q==",
"license": "MIT",
"dependencies": {
"lodash.get": "^4.4.2",
"lodash.isequal": "^4.5.0",
"validator": "^13.7.0"
},
"bin": {
"z-schema": "bin/z-schema"
},
"engines": {
"node": ">=8.0.0"
},
"optionalDependencies": {
"commander": "^9.4.1"
}
},
"node_modules/z-schema/node_modules/commander": {
"version": "9.5.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz",
"integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": "^12.20.0 || >=14"
}
} }
} }
} }

View file

@ -24,7 +24,9 @@
"dotenv": "^17.0.1", "dotenv": "^17.0.1",
"express": "^4.18.2", "express": "^4.18.2",
"node-cron": "^3.0.3", "node-cron": "^3.0.3",
"sqlite3": "^5.1.6" "sqlite3": "^5.1.6",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1"
}, },
"devDependencies": { "devDependencies": {
"@types/cors": "^2.8.19", "@types/cors": "^2.8.19",
@ -32,6 +34,8 @@
"@types/node": "^24.0.10", "@types/node": "^24.0.10",
"@types/node-cron": "^3.0.11", "@types/node-cron": "^3.0.11",
"@types/sqlite3": "^3.1.11", "@types/sqlite3": "^3.1.11",
"@types/swagger-jsdoc": "^6.0.4",
"@types/swagger-ui-express": "^4.1.8",
"concurrently": "^9.2.0", "concurrently": "^9.2.0",
"jest": "^29.7.0", "jest": "^29.7.0",
"nodemon": "^3.1.10", "nodemon": "^3.1.10",

View file

@ -79,7 +79,41 @@ export default (
): Router => { ): Router => {
const router = express.Router(); const router = express.Router();
// Admin login /**
* @swagger
* /api/admin/login:
* post:
* tags:
* - Admin - Authentication
* summary: Admin login
* description: Authenticate as administrator to access protected admin endpoints
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/AdminLoginRequest'
* example:
* password: "your_admin_password"
* responses:
* 200:
* description: Login successful
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/AdminLoginResponse'
* example:
* token: "admin_token_here"
* message: "Login successful"
* 401:
* description: Invalid credentials
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* example:
* error: "Invalid password"
*/
router.post('/login', (req: AdminLoginRequest, res: Response): void => { router.post('/login', (req: AdminLoginRequest, res: Response): void => {
console.log('Admin login attempt'); console.log('Admin login attempt');
const { password } = req.body; const { password } = req.body;
@ -94,7 +128,61 @@ export default (
} }
}); });
// Get all locations for admin (including expired ones) /**
* @swagger
* /api/admin/locations:
* get:
* tags:
* - Admin - Locations
* summary: Get all location reports (including expired)
* description: Retrieve all location reports for admin management, including expired and persistent reports
* security:
* - BearerAuth: []
* responses:
* 200:
* description: All locations retrieved successfully
* content:
* application/json:
* schema:
* type: array
* items:
* allOf:
* - $ref: '#/components/schemas/Location'
* - type: object
* properties:
* isActive:
* type: boolean
* description: Whether the report is still active (< 48 hours)
* example:
* - id: 123
* address: "Main St & Oak Ave, Grand Rapids, MI"
* description: "Black ice present"
* latitude: 42.9634
* longitude: -85.6681
* persistent: false
* created_at: "2025-01-15T10:30:00.000Z"
* isActive: true
* - id: 122
* address: "Old expired report"
* description: "No longer relevant"
* latitude: 42.9500
* longitude: -85.6700
* persistent: false
* created_at: "2025-01-10T08:00:00.000Z"
* isActive: false
* 401:
* description: Unauthorized - admin authentication required
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 500:
* description: Internal server error
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
router.get('/locations', authenticateAdmin, async (req: Request, res: Response): Promise<void> => { router.get('/locations', authenticateAdmin, async (req: Request, res: Response): Promise<void> => {
try { try {
const rows = await locationModel.getAll(); const rows = await locationModel.getAll();

View file

@ -3,7 +3,39 @@ import express, { Request, Response, Router } from 'express';
export default (): Router => { export default (): Router => {
const router = express.Router(); const router = express.Router();
// Get API configuration /**
* @swagger
* /api/config:
* get:
* tags:
* - Public API
* summary: Get API configuration
* description: Returns public API configuration including MapBox access token for geocoding
* responses:
* 200:
* description: API configuration retrieved successfully
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ApiConfig'
* examples:
* with_mapbox:
* summary: Configuration with MapBox token
* value:
* mapboxAccessToken: "pk.eyJ1IjoiZXhhbXBsZSIsImEiOiJhYmNkZWZnIn0.example"
* hasMapbox: true
* without_mapbox:
* summary: Configuration without MapBox token
* value:
* mapboxAccessToken: null
* hasMapbox: false
* 500:
* description: Internal server error
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
router.get('/', (req: Request, res: Response): void => { router.get('/', (req: Request, res: Response): void => {
console.log('📡 API Config requested'); console.log('📡 API Config requested');
const MAPBOX_ACCESS_TOKEN: string | undefined = process.env.MAPBOX_ACCESS_TOKEN || undefined; const MAPBOX_ACCESS_TOKEN: string | undefined = process.env.MAPBOX_ACCESS_TOKEN || undefined;

View file

@ -22,7 +22,51 @@ interface LocationDeleteRequest extends Request {
export default (locationModel: Location, profanityFilter: ProfanityFilterService | any): Router => { export default (locationModel: Location, profanityFilter: ProfanityFilterService | any): Router => {
const router = express.Router(); const router = express.Router();
// Get all active locations (within 48 hours OR persistent) /**
* @swagger
* /api/locations:
* get:
* tags:
* - Public API
* summary: Get active ice condition reports
* description: |
* Retrieves all active ice condition reports. Reports are considered active if they are:
* - Less than 48 hours old, OR
* - Marked as persistent by an administrator
* responses:
* 200:
* description: Active locations retrieved successfully
* content:
* application/json:
* schema:
* type: array
* items:
* $ref: '#/components/schemas/Location'
* examples:
* active_locations:
* summary: Example active locations
* value:
* - id: 123
* address: "Main St & Oak Ave, Grand Rapids, MI"
* latitude: 42.9634
* longitude: -85.6681
* description: "Black ice present, multiple vehicles stuck"
* persistent: false
* created_at: "2025-01-15T10:30:00.000Z"
* - id: 124
* address: "I-96 & US-131, Grand Rapids, MI"
* latitude: 42.9584
* longitude: -85.6706
* description: "Icy on-ramp conditions"
* persistent: true
* created_at: "2025-01-14T08:15:00.000Z"
* 500:
* description: Internal server error
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
router.get('/', async (req: Request, res: Response): Promise<void> => { router.get('/', async (req: Request, res: Response): Promise<void> => {
console.log('Fetching active locations'); console.log('Fetching active locations');
@ -36,7 +80,80 @@ export default (locationModel: Location, profanityFilter: ProfanityFilterService
} }
}); });
// Add a new location /**
* @swagger
* /api/locations:
* post:
* tags:
* - Public API
* summary: Report a new ice condition
* description: |
* Submit a new ice condition report. The system will:
* - Validate the address is provided
* - Check description for profanity and reject if found
* - Geocode the address if coordinates not provided
* - Store the report for 48 hours (unless made persistent by admin)
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/LocationInput'
* examples:
* basic_report:
* summary: Basic ice report with address only
* value:
* address: "Main St & Oak Ave, Grand Rapids, MI"
* description: "Black ice spotted, drive carefully"
* detailed_report:
* summary: Detailed report with coordinates
* value:
* address: "I-96 westbound at Exit 85"
* latitude: 42.9634
* longitude: -85.6681
* description: "Multiple vehicles stuck, road salt needed"
* responses:
* 200:
* description: Location report created successfully
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Location'
* example:
* id: 125
* address: "Main St & Oak Ave, Grand Rapids, MI"
* latitude: 42.9634
* longitude: -85.6681
* description: "Black ice spotted, drive carefully"
* persistent: false
* created_at: "2025-01-15T12:00:00.000Z"
* 400:
* description: Bad request - invalid input or profanity detected
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* examples:
* missing_address:
* summary: Missing required address field
* value:
* error: "Address is required"
* profanity_detected:
* summary: Profanity detected in description
* value:
* error: "Submission rejected"
* message: "Your description contains inappropriate language and cannot be posted. Please revise your description to focus on road conditions and keep it professional."
* details:
* severity: "medium"
* wordCount: 1
* detectedCategories: ["general"]
* 500:
* description: Internal server error
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
router.post('/', async (req: LocationPostRequest, res: Response): Promise<void> => { router.post('/', async (req: LocationPostRequest, res: Response): Promise<void> => {
const { address, latitude, longitude } = req.body; const { address, latitude, longitude } = req.body;
let { description } = req.body; let { description } = req.body;

View file

@ -3,6 +3,8 @@ import express, { Request, Response, NextFunction, Application } from 'express';
import cors from 'cors'; import cors from 'cors';
import path from 'path'; import path from 'path';
import cron from 'node-cron'; import cron from 'node-cron';
import swaggerUi from 'swagger-ui-express';
import { swaggerSpec } from './swagger';
// Load environment variables // Load environment variables
dotenv.config({ path: '.env.local' }); dotenv.config({ path: '.env.local' });
@ -167,6 +169,12 @@ function setupRoutes(): void {
const locationModel = databaseService.getLocationModel(); const locationModel = databaseService.getLocationModel();
const profanityWordModel = databaseService.getProfanityWordModel(); const profanityWordModel = databaseService.getProfanityWordModel();
// API Documentation
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec, {
customCss: '.swagger-ui .topbar { display: none }',
customSiteTitle: 'Great Lakes Ice Report API Documentation'
}));
// API Routes // API Routes
app.use('/api/config', configRoutes()); app.use('/api/config', configRoutes());
app.use('/api/locations', locationRoutes(locationModel, profanityFilter)); app.use('/api/locations', locationRoutes(locationModel, profanityFilter));

348
src/swagger.ts Normal file
View file

@ -0,0 +1,348 @@
import swaggerJsdoc from 'swagger-jsdoc';
const options: swaggerJsdoc.Options = {
definition: {
openapi: '3.0.0',
info: {
title: 'Great Lakes Ice Report API',
version: '1.0.0',
description: `
Community-driven winter road conditions and icy hazards tracker for the Great Lakes region.
This API allows users to report and retrieve information about icy road conditions,
with automatic cleanup after 48 hours and administrative management capabilities.
**Features:**
- Report ice conditions with location and description
- Retrieve active ice reports (< 48 hours or persistent)
- Admin panel for content moderation and management
- Profanity filtering for content safety
- Geographic data with MapBox integration
`,
contact: {
name: 'Great Lakes Ice Report',
url: 'https://github.com/deco/ice'
},
license: {
name: 'MIT',
url: 'https://opensource.org/licenses/MIT'
}
},
servers: [
{
url: 'http://localhost:3000',
description: 'Development server'
},
{
url: 'https://ice.example.com',
description: 'Production server'
}
],
components: {
securitySchemes: {
BearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'Admin authentication using bearer token (admin password)'
}
},
schemas: {
Location: {
type: 'object',
properties: {
id: {
type: 'integer',
description: 'Unique identifier for the location report',
example: 123
},
address: {
type: 'string',
description: 'Street address, intersection, or landmark description',
example: 'Main St & Oak Ave, Grand Rapids, MI'
},
latitude: {
type: 'number',
format: 'float',
description: 'Geographic latitude coordinate',
example: 42.9634,
nullable: true
},
longitude: {
type: 'number',
format: 'float',
description: 'Geographic longitude coordinate',
example: -85.6681,
nullable: true
},
description: {
type: 'string',
description: 'Additional details about the ice conditions',
example: 'Multiple vehicles stuck, black ice present',
nullable: true
},
persistent: {
type: 'boolean',
description: 'Whether this report persists beyond 48 hours (admin only)',
example: false
},
created_at: {
type: 'string',
format: 'date-time',
description: 'ISO timestamp when the report was created',
example: '2025-01-15T10:30:00.000Z'
},
timestamp: {
type: 'string',
format: 'date-time',
description: 'Legacy timestamp field',
example: '2025-01-15T10:30:00.000Z'
}
},
required: ['address']
},
LocationInput: {
type: 'object',
properties: {
address: {
type: 'string',
description: 'Street address, intersection, or landmark description',
example: 'Main St & Oak Ave, Grand Rapids, MI'
},
latitude: {
type: 'number',
format: 'float',
description: 'Geographic latitude coordinate (optional, will be geocoded if not provided)',
example: 42.9634
},
longitude: {
type: 'number',
format: 'float',
description: 'Geographic longitude coordinate (optional, will be geocoded if not provided)',
example: -85.6681
},
description: {
type: 'string',
description: 'Additional details about the ice conditions',
example: 'Black ice spotted, 3 vehicles stuck'
}
},
required: ['address']
},
ProfanityWord: {
type: 'object',
properties: {
id: {
type: 'integer',
description: 'Unique identifier for the profanity word',
example: 1
},
word: {
type: 'string',
description: 'The profanity word or phrase',
example: 'badword'
},
severity: {
type: 'string',
enum: ['low', 'medium', 'high'],
description: 'Severity level of the profanity',
example: 'medium'
},
category: {
type: 'string',
description: 'Category classification of the word',
example: 'general'
},
created_at: {
type: 'string',
format: 'date-time',
description: 'ISO timestamp when the word was added',
example: '2025-01-15T10:30:00.000Z'
},
created_by: {
type: 'string',
description: 'User who added the word',
example: 'admin'
}
},
required: ['word', 'severity', 'category']
},
ProfanityWordInput: {
type: 'object',
properties: {
word: {
type: 'string',
description: 'The profanity word or phrase to add',
example: 'badword'
},
severity: {
type: 'string',
enum: ['low', 'medium', 'high'],
description: 'Severity level of the profanity',
example: 'medium',
default: 'medium'
},
category: {
type: 'string',
description: 'Category classification of the word',
example: 'custom',
default: 'custom'
}
},
required: ['word']
},
AdminLoginRequest: {
type: 'object',
properties: {
password: {
type: 'string',
description: 'Admin password for authentication',
example: 'admin_password_here'
}
},
required: ['password']
},
AdminLoginResponse: {
type: 'object',
properties: {
token: {
type: 'string',
description: 'Bearer token for authenticated requests',
example: 'admin_password_here'
},
message: {
type: 'string',
description: 'Success message',
example: 'Login successful'
}
}
},
ApiConfig: {
type: 'object',
properties: {
mapboxAccessToken: {
type: 'string',
description: 'MapBox API token for geocoding (null if not configured)',
example: 'pk.eyJ1IjoiZXhhbXBsZSIsImEiOiJhYmNkZWZnIn0.example',
nullable: true
},
hasMapbox: {
type: 'boolean',
description: 'Whether MapBox token is configured',
example: true
}
}
},
ProfanityTestRequest: {
type: 'object',
properties: {
text: {
type: 'string',
description: 'Text to test for profanity',
example: 'This is a test message'
}
},
required: ['text']
},
ProfanityTestResponse: {
type: 'object',
properties: {
original: {
type: 'string',
description: 'Original input text',
example: 'This is a test message'
},
analysis: {
type: 'object',
properties: {
hasProfanity: {
type: 'boolean',
description: 'Whether profanity was detected',
example: false
},
matches: {
type: 'array',
description: 'Array of detected profanity matches',
items: {
type: 'object',
properties: {
word: { type: 'string' },
found: { type: 'string' },
index: { type: 'integer' },
severity: { type: 'string', enum: ['low', 'medium', 'high'] },
category: { type: 'string' }
}
}
},
severity: {
type: 'string',
enum: ['none', 'low', 'medium', 'high'],
description: 'Overall severity level',
example: 'none'
},
count: {
type: 'integer',
description: 'Number of profanity matches found',
example: 0
},
filtered: {
type: 'string',
description: 'Text with profanity filtered out',
example: 'This is a test message'
}
}
},
filtered: {
type: 'string',
description: 'Filtered text with profanity removed',
example: 'This is a test message'
}
}
},
Error: {
type: 'object',
properties: {
error: {
type: 'string',
description: 'Error message',
example: 'Invalid request'
},
message: {
type: 'string',
description: 'Detailed error description',
example: 'The request contains invalid data'
},
details: {
type: 'object',
description: 'Additional error details',
additionalProperties: true
}
},
required: ['error']
}
}
},
tags: [
{
name: 'Public API',
description: 'Public endpoints for location reporting and configuration'
},
{
name: 'Admin - Authentication',
description: 'Admin authentication endpoints'
},
{
name: 'Admin - Locations',
description: 'Admin endpoints for location management'
},
{
name: 'Admin - Profanity',
description: 'Admin endpoints for profanity filter management'
}
]
},
apis: ['./src/routes/*.ts', './src/server.ts'], // Paths to files containing OpenAPI definitions
};
export const swaggerSpec = swaggerJsdoc(options);
export default swaggerSpec;