Spaces:
Running
Running
Abhinay commited on
Commit Β·
d3477e4
1
Parent(s): 9dc0e21
Deploy Bolt Run backend (Express + Firestore) as Docker Space
Browse files- .dockerignore +9 -0
- .gitignore +6 -0
- Dockerfile +21 -0
- README.md +20 -5
- package-lock.json +0 -0
- package.json +26 -0
- src/db/cleanup-demo.js +49 -0
- src/db/demo-run.js +99 -0
- src/db/firestore.js +50 -0
- src/db/migrate.js +22 -0
- src/db/overlap-test.js +87 -0
- src/db/seed.js +91 -0
- src/lib/territory.js +34 -0
- src/middleware/auth.js +18 -0
- src/routes/auth.js +141 -0
- src/routes/clubs.js +244 -0
- src/routes/programs.js +147 -0
- src/routes/runs.js +237 -0
- src/routes/territory.js +154 -0
- src/server.js +33 -0
.dockerignore
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
node_modules
|
| 2 |
+
npm-debug.log
|
| 3 |
+
.env
|
| 4 |
+
.env.local
|
| 5 |
+
firebase-service-account.json
|
| 6 |
+
*.log
|
| 7 |
+
.git
|
| 8 |
+
.gitignore
|
| 9 |
+
.DS_Store
|
.gitignore
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
node_modules/
|
| 2 |
+
.env
|
| 3 |
+
.env.local
|
| 4 |
+
firebase-service-account.json
|
| 5 |
+
*.log
|
| 6 |
+
.DS_Store
|
Dockerfile
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Bolt Run backend β Hugging Face Docker Space
|
| 2 |
+
FROM node:20-slim
|
| 3 |
+
|
| 4 |
+
WORKDIR /app
|
| 5 |
+
|
| 6 |
+
# Install production deps first (better layer caching)
|
| 7 |
+
COPY package.json package-lock.json* ./
|
| 8 |
+
RUN npm install --omit=dev
|
| 9 |
+
|
| 10 |
+
# App source
|
| 11 |
+
COPY src ./src
|
| 12 |
+
|
| 13 |
+
ENV NODE_ENV=production
|
| 14 |
+
# Hugging Face Spaces route to port 7860 by default (see app_port in README.md)
|
| 15 |
+
ENV PORT=7860
|
| 16 |
+
EXPOSE 7860
|
| 17 |
+
|
| 18 |
+
# HF Spaces run the container as uid 1000 β node:20 ships a matching `node` user
|
| 19 |
+
USER node
|
| 20 |
+
|
| 21 |
+
CMD ["node", "src/server.js"]
|
README.md
CHANGED
|
@@ -1,10 +1,25 @@
|
|
| 1 |
---
|
| 2 |
-
title: Bolt
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Bolt Run Backend
|
| 3 |
+
emoji: π
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: blue
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
pinned: false
|
| 9 |
---
|
| 10 |
|
| 11 |
+
# Bolt Run Backend
|
| 12 |
+
|
| 13 |
+
Express + Cloud Firestore API for the Bolt Run app, deployed as a Hugging Face Docker Space.
|
| 14 |
+
|
| 15 |
+
## Required Space secrets
|
| 16 |
+
|
| 17 |
+
Set these under **Settings β Variables and secrets** (as **Secrets**):
|
| 18 |
+
|
| 19 |
+
| Name | Value |
|
| 20 |
+
|------|-------|
|
| 21 |
+
| `FIREBASE_SERVICE_ACCOUNT_JSON` | The full contents of your `firebase-service-account.json` file (paste the whole JSON). |
|
| 22 |
+
| `JWT_SECRET` | Your JWT signing secret. |
|
| 23 |
+
| `JWT_EXPIRES_IN` | e.g. `7d`. |
|
| 24 |
+
|
| 25 |
+
The container listens on port `7860`. Health check: `GET /health`. API base: `/api`.
|
package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "bolt-run-backend",
|
| 3 |
+
"version": "1.0.0",
|
| 4 |
+
"description": "BOLT Running App Backend API (Firestore)",
|
| 5 |
+
"main": "src/server.js",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"start": "node src/server.js",
|
| 8 |
+
"dev": "nodemon src/server.js",
|
| 9 |
+
"db:migrate": "node src/db/migrate.js",
|
| 10 |
+
"db:seed": "node src/db/seed.js"
|
| 11 |
+
},
|
| 12 |
+
"dependencies": {
|
| 13 |
+
"bcryptjs": "^2.4.3",
|
| 14 |
+
"cors": "^2.8.5",
|
| 15 |
+
"dotenv": "^16.4.5",
|
| 16 |
+
"express": "^4.19.2",
|
| 17 |
+
"express-validator": "^7.2.0",
|
| 18 |
+
"firebase-admin": "^12.6.0",
|
| 19 |
+
"h3-js": "^4.4.0",
|
| 20 |
+
"jsonwebtoken": "^9.0.2",
|
| 21 |
+
"uuid": "^10.0.0"
|
| 22 |
+
},
|
| 23 |
+
"devDependencies": {
|
| 24 |
+
"nodemon": "^3.1.4"
|
| 25 |
+
}
|
| 26 |
+
}
|
src/db/cleanup-demo.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// One-off cleanup: remove fake/test accounts that pollute the leaderboard,
|
| 2 |
+
// along with their runs and any territory cells they captured.
|
| 3 |
+
// Keeps the seeded "Demo Runner" (an active login) and all real users.
|
| 4 |
+
const { collections, db } = require('./firestore');
|
| 5 |
+
|
| 6 |
+
// Emails of the accounts to purge. Edit this list to change what gets removed.
|
| 7 |
+
const FAKE_EMAILS = [
|
| 8 |
+
'alice+8075@bolt.run',
|
| 9 |
+
'bob+12638@bolt.run',
|
| 10 |
+
'terr+21395@bolt.run', // Territory Test
|
| 11 |
+
'test4251@bolt.dev', // Test User
|
| 12 |
+
'smoke+17074@bolt.run', // Smoke Test
|
| 13 |
+
];
|
| 14 |
+
|
| 15 |
+
async function deleteInBatches(refs) {
|
| 16 |
+
for (let i = 0; i < refs.length; i += 450) {
|
| 17 |
+
const batch = db.batch();
|
| 18 |
+
for (const ref of refs.slice(i, i + 450)) batch.delete(ref);
|
| 19 |
+
await batch.commit();
|
| 20 |
+
}
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
(async () => {
|
| 24 |
+
try {
|
| 25 |
+
for (const email of FAKE_EMAILS) {
|
| 26 |
+
const snap = await collections.users.where('email', '==', email).limit(1).get();
|
| 27 |
+
if (snap.empty) {
|
| 28 |
+
console.log(` Β· no user for ${email} (already gone)`);
|
| 29 |
+
continue;
|
| 30 |
+
}
|
| 31 |
+
const userDoc = snap.docs[0];
|
| 32 |
+
const userId = userDoc.id;
|
| 33 |
+
|
| 34 |
+
const runsSnap = await collections.runs.where('userId', '==', userId).get();
|
| 35 |
+
await deleteInBatches(runsSnap.docs.map((d) => d.ref));
|
| 36 |
+
|
| 37 |
+
const cellsSnap = await collections.territoryCells.where('ownerUserId', '==', userId).get();
|
| 38 |
+
await deleteInBatches(cellsSnap.docs.map((d) => d.ref));
|
| 39 |
+
|
| 40 |
+
await userDoc.ref.delete();
|
| 41 |
+
console.log(` - removed ${userDoc.data().name} <${email}> β ${runsSnap.size} run(s), ${cellsSnap.size} cell(s)`);
|
| 42 |
+
}
|
| 43 |
+
console.log('Cleanup complete.');
|
| 44 |
+
process.exit(0);
|
| 45 |
+
} catch (err) {
|
| 46 |
+
console.error('Cleanup failed:', err);
|
| 47 |
+
process.exit(1);
|
| 48 |
+
}
|
| 49 |
+
})();
|
src/db/demo-run.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Push a demo run for the Demo Runner so captured territory is visible on the
|
| 2 |
+
// map. Builds a route that walks through a contiguous k-ring of H3 cells near
|
| 3 |
+
// a center point, then saves it through the real /api/runs capture pipeline.
|
| 4 |
+
const h3 = require('h3-js');
|
| 5 |
+
|
| 6 |
+
const API = process.env.API_BASE || 'http://localhost:5000/api';
|
| 7 |
+
const DEMO = { email: 'demo@bolt.run', password: 'password123' };
|
| 8 |
+
|
| 9 |
+
// Center of the territory blob β Mehdipatnam, Hyderabad (matches the map view).
|
| 10 |
+
const CENTER = { lat: 17.3955, lng: 78.4400 };
|
| 11 |
+
const RES = 9; // must match backend DEFAULT_RES
|
| 12 |
+
const K = 2; // ring size: k=2 -> 19 cells
|
| 13 |
+
|
| 14 |
+
function haversineKm(a, b) {
|
| 15 |
+
const R = 6371, toRad = (d) => (d * Math.PI) / 180;
|
| 16 |
+
const dLat = toRad(b.lat - a.lat), dLng = toRad(b.lng - a.lng);
|
| 17 |
+
const s = Math.sin(dLat / 2) ** 2 +
|
| 18 |
+
Math.cos(toRad(a.lat)) * Math.cos(toRad(b.lat)) * Math.sin(dLng / 2) ** 2;
|
| 19 |
+
return R * 2 * Math.atan2(Math.sqrt(s), Math.sqrt(1 - s));
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
(async () => {
|
| 23 |
+
try {
|
| 24 |
+
// 1. Log in as demo
|
| 25 |
+
const loginRes = await fetch(`${API}/auth/login`, {
|
| 26 |
+
method: 'POST',
|
| 27 |
+
headers: { 'Content-Type': 'application/json' },
|
| 28 |
+
body: JSON.stringify(DEMO),
|
| 29 |
+
});
|
| 30 |
+
if (!loginRes.ok) throw new Error(`login failed: ${loginRes.status}`);
|
| 31 |
+
const { token } = await loginRes.json();
|
| 32 |
+
|
| 33 |
+
// 1b. Idempotency β remove any prior demo run so re-runs don't stack distance.
|
| 34 |
+
const listRes = await fetch(`${API}/runs?limit=50`, {
|
| 35 |
+
headers: { Authorization: `Bearer ${token}` },
|
| 36 |
+
});
|
| 37 |
+
const { runs = [] } = await listRes.json();
|
| 38 |
+
for (const r of runs.filter((x) => x.title === 'Demo territory run')) {
|
| 39 |
+
await fetch(`${API}/runs/${r.id}`, {
|
| 40 |
+
method: 'DELETE', headers: { Authorization: `Bearer ${token}` },
|
| 41 |
+
});
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
// 2. Build a route from the centers of a k-ring of H3 cells, ordered as a
|
| 45 |
+
// nearest-neighbor tour so the path (and distance) reads like a real run.
|
| 46 |
+
const centerCell = h3.latLngToCell(CENTER.lat, CENTER.lng, RES);
|
| 47 |
+
const cells = h3.gridDisk(centerCell, K);
|
| 48 |
+
const all = cells.map((c) => { const [lat, lng] = h3.cellToLatLng(c); return { lat, lng }; });
|
| 49 |
+
|
| 50 |
+
const points = [all.shift()];
|
| 51 |
+
while (all.length) {
|
| 52 |
+
const last = points[points.length - 1];
|
| 53 |
+
let bi = 0, bd = Infinity;
|
| 54 |
+
all.forEach((p, i) => { const d = haversineKm(last, p); if (d < bd) { bd = d; bi = i; } });
|
| 55 |
+
points.push(all.splice(bi, 1)[0]);
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
const route = points.map((p) => ({ latitude: p.lat, longitude: p.lng }));
|
| 59 |
+
|
| 60 |
+
// 3. Distance + timing from the path
|
| 61 |
+
let distanceKm = 0;
|
| 62 |
+
for (let i = 1; i < points.length; i++) distanceKm += haversineKm(points[i - 1], points[i]);
|
| 63 |
+
const durationSec = Math.round((distanceKm / 10) * 3600); // ~10 km/h pace
|
| 64 |
+
const endedAt = new Date();
|
| 65 |
+
const startedAt = new Date(endedAt.getTime() - durationSec * 1000);
|
| 66 |
+
|
| 67 |
+
// 4. Save via the real capture pipeline
|
| 68 |
+
const runRes = await fetch(`${API}/runs`, {
|
| 69 |
+
method: 'POST',
|
| 70 |
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
| 71 |
+
body: JSON.stringify({
|
| 72 |
+
title: 'Demo territory run',
|
| 73 |
+
started_at: startedAt.toISOString(),
|
| 74 |
+
ended_at: endedAt.toISOString(),
|
| 75 |
+
duration_sec: durationSec,
|
| 76 |
+
distance_km: parseFloat(distanceKm.toFixed(3)),
|
| 77 |
+
avg_pace_sec_per_km: Math.round(durationSec / distanceKm),
|
| 78 |
+
calories: Math.round(70 * distanceKm * 1.036),
|
| 79 |
+
route,
|
| 80 |
+
start_lat: route[0].latitude,
|
| 81 |
+
start_lng: route[0].longitude,
|
| 82 |
+
end_lat: route[route.length - 1].latitude,
|
| 83 |
+
end_lng: route[route.length - 1].longitude,
|
| 84 |
+
}),
|
| 85 |
+
});
|
| 86 |
+
const body = await runRes.json();
|
| 87 |
+
if (!runRes.ok) throw new Error(`run save failed: ${runRes.status} ${JSON.stringify(body)}`);
|
| 88 |
+
|
| 89 |
+
console.log(`Saved demo run near ${CENTER.lat},${CENTER.lng}`);
|
| 90 |
+
console.log(` route points: ${route.length}`);
|
| 91 |
+
console.log(` distance_km : ${distanceKm.toFixed(3)}`);
|
| 92 |
+
console.log(` cellsCaptured: ${body.cellsCaptured}`);
|
| 93 |
+
console.log('Open the World Map on the demo login to see the painted territory.');
|
| 94 |
+
process.exit(0);
|
| 95 |
+
} catch (err) {
|
| 96 |
+
console.error(err);
|
| 97 |
+
process.exit(1);
|
| 98 |
+
}
|
| 99 |
+
})();
|
src/db/firestore.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const path = require('path');
|
| 2 |
+
const admin = require('firebase-admin');
|
| 3 |
+
require('dotenv').config();
|
| 4 |
+
|
| 5 |
+
// On hosted platforms (e.g. Hugging Face Spaces) the whole service-account JSON
|
| 6 |
+
// is injected as a secret env var instead of a committed file. Fall back to the
|
| 7 |
+
// local file for development.
|
| 8 |
+
let serviceAccount;
|
| 9 |
+
if (process.env.FIREBASE_SERVICE_ACCOUNT_JSON) {
|
| 10 |
+
serviceAccount = JSON.parse(process.env.FIREBASE_SERVICE_ACCOUNT_JSON);
|
| 11 |
+
} else {
|
| 12 |
+
const credPath =
|
| 13 |
+
process.env.FIREBASE_SERVICE_ACCOUNT ||
|
| 14 |
+
path.join(__dirname, '..', '..', 'firebase-service-account.json');
|
| 15 |
+
serviceAccount = require(credPath);
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
if (!admin.apps.length) {
|
| 19 |
+
admin.initializeApp({
|
| 20 |
+
credential: admin.credential.cert(serviceAccount),
|
| 21 |
+
projectId: serviceAccount.project_id,
|
| 22 |
+
});
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
const db = admin.firestore();
|
| 26 |
+
db.settings({ ignoreUndefinedProperties: true });
|
| 27 |
+
|
| 28 |
+
const { FieldValue, Timestamp } = admin.firestore;
|
| 29 |
+
|
| 30 |
+
const collections = {
|
| 31 |
+
users: db.collection('users'),
|
| 32 |
+
runs: db.collection('runs'),
|
| 33 |
+
programs: db.collection('programs'),
|
| 34 |
+
userPrograms: db.collection('userPrograms'),
|
| 35 |
+
clubs: db.collection('clubs'),
|
| 36 |
+
clubMembers: db.collection('clubMembers'),
|
| 37 |
+
clubActivities: db.collection('clubActivities'),
|
| 38 |
+
territoryCells: db.collection('territoryCells'),
|
| 39 |
+
};
|
| 40 |
+
|
| 41 |
+
function docToObject(doc) {
|
| 42 |
+
if (!doc.exists) return null;
|
| 43 |
+
const data = doc.data();
|
| 44 |
+
for (const k of Object.keys(data)) {
|
| 45 |
+
if (data[k] instanceof Timestamp) data[k] = data[k].toDate().toISOString();
|
| 46 |
+
}
|
| 47 |
+
return { id: doc.id, ...data };
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
module.exports = { admin, db, FieldValue, Timestamp, collections, docToObject };
|
src/db/migrate.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Firestore is schemaless β no DDL to run.
|
| 2 |
+
// Composite indexes are defined in firestore.indexes.json (deploy with the Firebase CLI:
|
| 3 |
+
// firebase deploy --only firestore:indexes
|
| 4 |
+
// ). For local dev the missing-index errors include a console URL you can click to
|
| 5 |
+
// auto-create the index.
|
| 6 |
+
//
|
| 7 |
+
// This script exists so `npm run db:migrate` still resolves; it just smoke-tests
|
| 8 |
+
// that the service account can reach Firestore.
|
| 9 |
+
|
| 10 |
+
const { db } = require('./firestore');
|
| 11 |
+
|
| 12 |
+
(async () => {
|
| 13 |
+
try {
|
| 14 |
+
await db.collection('__healthcheck').doc('ping').set({ at: new Date() });
|
| 15 |
+
await db.collection('__healthcheck').doc('ping').delete();
|
| 16 |
+
console.log('Firestore reachable. No schema migration needed.');
|
| 17 |
+
process.exit(0);
|
| 18 |
+
} catch (err) {
|
| 19 |
+
console.error('Firestore connection failed:', err.message);
|
| 20 |
+
process.exit(1);
|
| 21 |
+
}
|
| 22 |
+
})();
|
src/db/overlap-test.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Demonstrate territory takeover: a second runner runs through PART of the
|
| 2 |
+
// Demo Runner's territory; those cells should flip to the new owner while the
|
| 3 |
+
// rest stay with Demo. Prints a before/after ownership summary.
|
| 4 |
+
const h3 = require('h3-js');
|
| 5 |
+
|
| 6 |
+
const API = 'http://localhost:5000/api';
|
| 7 |
+
const CENTER = { lat: 17.3955, lng: 78.4400 };
|
| 8 |
+
const RES = 9;
|
| 9 |
+
const BBOX = { sw_lat: 17.388, sw_lng: 78.432, ne_lat: 17.403, ne_lng: 78.448 };
|
| 10 |
+
|
| 11 |
+
const jget = async (path, token) =>
|
| 12 |
+
(await fetch(`${API}${path}`, { headers: { Authorization: `Bearer ${token}` } })).json();
|
| 13 |
+
|
| 14 |
+
async function login(email, password) {
|
| 15 |
+
const r = await fetch(`${API}/auth/login`, {
|
| 16 |
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
| 17 |
+
body: JSON.stringify({ email, password }),
|
| 18 |
+
});
|
| 19 |
+
if (!r.ok) return null;
|
| 20 |
+
return (await r.json()).token;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
async function ownersInBbox(token) {
|
| 24 |
+
const q = `?sw_lat=${BBOX.sw_lat}&sw_lng=${BBOX.sw_lng}&ne_lat=${BBOX.ne_lat}&ne_lng=${BBOX.ne_lng}`;
|
| 25 |
+
const { cells } = await jget(`/territory${q}`, token);
|
| 26 |
+
const by = {};
|
| 27 |
+
for (const c of cells) by[c.id] = c.ownerName || c.ownerUserId;
|
| 28 |
+
return by;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
(async () => {
|
| 32 |
+
try {
|
| 33 |
+
// 1. Demo owns the territory
|
| 34 |
+
const demoToken = await login('demo@bolt.run', 'password123');
|
| 35 |
+
const before = await ownersInBbox(demoToken);
|
| 36 |
+
console.log(`\nBEFORE: ${Object.keys(before).length} cells in view`);
|
| 37 |
+
const beforeCounts = {};
|
| 38 |
+
Object.values(before).forEach((o) => { beforeCounts[o] = (beforeCounts[o] || 0) + 1; });
|
| 39 |
+
console.log(' owners:', beforeCounts);
|
| 40 |
+
|
| 41 |
+
// 2. Create a rival runner (fresh account)
|
| 42 |
+
const rivalEmail = `rival+${Math.floor(Math.random() * 100000)}@bolt.run`;
|
| 43 |
+
const regRes = await fetch(`${API}/auth/register`, {
|
| 44 |
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
| 45 |
+
body: JSON.stringify({ name: 'Rival Runner', email: rivalEmail, password: 'password123' }),
|
| 46 |
+
});
|
| 47 |
+
const rivalToken = (await regRes.json()).token;
|
| 48 |
+
|
| 49 |
+
// 3. Rival runs through the center cell + its immediate ring (7 cells) β
|
| 50 |
+
// a partial incursion into Demo's ~20-cell territory.
|
| 51 |
+
const centerCell = h3.latLngToCell(CENTER.lat, CENTER.lng, RES);
|
| 52 |
+
const incursion = h3.gridDisk(centerCell, 1); // 7 cells
|
| 53 |
+
const route = incursion.map((c) => { const [lat, lng] = h3.cellToLatLng(c); return { latitude: lat, longitude: lng }; });
|
| 54 |
+
|
| 55 |
+
const runRes = await fetch(`${API}/runs`, {
|
| 56 |
+
method: 'POST',
|
| 57 |
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${rivalToken}` },
|
| 58 |
+
body: JSON.stringify({
|
| 59 |
+
title: 'Rival incursion',
|
| 60 |
+
started_at: new Date(Date.now() - 600000).toISOString(),
|
| 61 |
+
ended_at: new Date().toISOString(),
|
| 62 |
+
duration_sec: 600,
|
| 63 |
+
distance_km: 1.5,
|
| 64 |
+
route,
|
| 65 |
+
start_lat: route[0].latitude, start_lng: route[0].longitude,
|
| 66 |
+
end_lat: route[route.length - 1].latitude, end_lng: route[route.length - 1].longitude,
|
| 67 |
+
}),
|
| 68 |
+
});
|
| 69 |
+
const runBody = await runRes.json();
|
| 70 |
+
console.log(`\nRival ran through ${incursion.length} cells β cellsCaptured: ${runBody.cellsCaptured}`);
|
| 71 |
+
|
| 72 |
+
// 4. After
|
| 73 |
+
const after = await ownersInBbox(rivalToken);
|
| 74 |
+
const afterCounts = {};
|
| 75 |
+
Object.values(after).forEach((o) => { afterCounts[o] = (afterCounts[o] || 0) + 1; });
|
| 76 |
+
console.log(`\nAFTER: ${Object.keys(after).length} cells in view`);
|
| 77 |
+
console.log(' owners:', afterCounts);
|
| 78 |
+
|
| 79 |
+
const flipped = Object.keys(before).filter((id) => before[id] !== after[id]);
|
| 80 |
+
console.log(`\nFlipped ${flipped.length} cells from Demo Runner -> Rival Runner. The rest stayed Demo's.`);
|
| 81 |
+
console.log('Takeover on overlap: WORKS β
');
|
| 82 |
+
process.exit(0);
|
| 83 |
+
} catch (err) {
|
| 84 |
+
console.error(err);
|
| 85 |
+
process.exit(1);
|
| 86 |
+
}
|
| 87 |
+
})();
|
src/db/seed.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const bcrypt = require('bcryptjs');
|
| 2 |
+
const { collections, FieldValue } = require('./firestore');
|
| 3 |
+
|
| 4 |
+
function slug(s) {
|
| 5 |
+
return s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
async function seed() {
|
| 9 |
+
try {
|
| 10 |
+
// Demo user
|
| 11 |
+
const demoEmail = 'demo@bolt.run';
|
| 12 |
+
const existing = await collections.users.where('email', '==', demoEmail).limit(1).get();
|
| 13 |
+
if (existing.empty) {
|
| 14 |
+
const hash = await bcrypt.hash('password123', 10);
|
| 15 |
+
const ref = collections.users.doc();
|
| 16 |
+
await ref.set({
|
| 17 |
+
name: 'Demo Runner',
|
| 18 |
+
email: demoEmail,
|
| 19 |
+
password_hash: hash,
|
| 20 |
+
avatar_url: null,
|
| 21 |
+
weight_kg: 70,
|
| 22 |
+
height_cm: 175,
|
| 23 |
+
date_of_birth: null,
|
| 24 |
+
total_runs: 0,
|
| 25 |
+
total_distance_km: 0,
|
| 26 |
+
total_duration_sec: 0,
|
| 27 |
+
total_calories: 0,
|
| 28 |
+
created_at: FieldValue.serverTimestamp(),
|
| 29 |
+
updated_at: FieldValue.serverTimestamp(),
|
| 30 |
+
});
|
| 31 |
+
console.log(' + demo user created');
|
| 32 |
+
} else {
|
| 33 |
+
console.log(' Β· demo user exists');
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
// Programs (deterministic doc IDs from title slug for idempotent seed)
|
| 37 |
+
const programs = [
|
| 38 |
+
{ title: '5K Beginner Plan', description: 'Go from couch to 5K in 8 weeks', coach_name: 'Eliud Kipchoge', duration_weeks: 8, sessions_per_week: 3, difficulty: 'beginner', category: '5K', is_featured: true },
|
| 39 |
+
{ title: '10K Speed Builder', description: 'Build speed and endurance for 10K', coach_name: 'Joshua Cheptegei', duration_weeks: 10, sessions_per_week: 4, difficulty: 'intermediate', category: '10K', is_featured: true },
|
| 40 |
+
{ title: 'Half Marathon Prep', description: 'Complete your first half marathon', coach_name: 'Brigid Kosgei', duration_weeks: 12, sessions_per_week: 5, difficulty: 'intermediate', category: 'Half Marathon', is_featured: false },
|
| 41 |
+
{ title: 'Marathon Masters', description: 'Advanced marathon training plan', coach_name: 'Kenenisa Bekele', duration_weeks: 16, sessions_per_week: 6, difficulty: 'advanced', category: 'Marathon', is_featured: false },
|
| 42 |
+
];
|
| 43 |
+
for (const p of programs) {
|
| 44 |
+
const id = slug(p.title);
|
| 45 |
+
const ref = collections.programs.doc(id);
|
| 46 |
+
const snap = await ref.get();
|
| 47 |
+
if (!snap.exists) {
|
| 48 |
+
await ref.set({
|
| 49 |
+
...p,
|
| 50 |
+
coach_avatar_url: null,
|
| 51 |
+
image_url: null,
|
| 52 |
+
total_enrolled: 0,
|
| 53 |
+
created_at: FieldValue.serverTimestamp(),
|
| 54 |
+
});
|
| 55 |
+
console.log(` + program "${p.title}"`);
|
| 56 |
+
}
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
// Clubs
|
| 60 |
+
const clubs = [
|
| 61 |
+
{ name: 'Morning Milers', description: 'Early birds who love the sunrise run', location: 'New York, NY', member_count: 124 },
|
| 62 |
+
{ name: 'Trail Blazers', description: 'Off-road and trail running enthusiasts', location: 'Denver, CO', member_count: 89 },
|
| 63 |
+
{ name: 'Speed Demons', description: 'Track and interval training focus', location: 'Los Angeles, CA', member_count: 203 },
|
| 64 |
+
{ name: 'Weekend Warriors', description: 'Casual runners, serious fun', location: 'Chicago, IL', member_count: 156 },
|
| 65 |
+
];
|
| 66 |
+
for (const c of clubs) {
|
| 67 |
+
const id = slug(c.name);
|
| 68 |
+
const ref = collections.clubs.doc(id);
|
| 69 |
+
const snap = await ref.get();
|
| 70 |
+
if (!snap.exists) {
|
| 71 |
+
await ref.set({
|
| 72 |
+
...c,
|
| 73 |
+
avatar_url: null,
|
| 74 |
+
cover_url: null,
|
| 75 |
+
is_public: true,
|
| 76 |
+
created_by: null,
|
| 77 |
+
created_at: FieldValue.serverTimestamp(),
|
| 78 |
+
});
|
| 79 |
+
console.log(` + club "${c.name}"`);
|
| 80 |
+
}
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
console.log('Seed complete.');
|
| 84 |
+
process.exit(0);
|
| 85 |
+
} catch (err) {
|
| 86 |
+
console.error('Seed failed:', err);
|
| 87 |
+
process.exit(1);
|
| 88 |
+
}
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
seed();
|
src/lib/territory.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const h3 = require('h3-js');
|
| 2 |
+
|
| 3 |
+
const DEFAULT_RES = 9; // ~150m hex edge β good for foot-traffic granularity
|
| 4 |
+
|
| 5 |
+
function cellsForRoute(route, resolution = DEFAULT_RES) {
|
| 6 |
+
if (!Array.isArray(route) || route.length === 0) return [];
|
| 7 |
+
const set = new Set();
|
| 8 |
+
for (const pt of route) {
|
| 9 |
+
const lat = Number(pt?.latitude);
|
| 10 |
+
const lng = Number(pt?.longitude);
|
| 11 |
+
if (!isFinite(lat) || !isFinite(lng)) continue;
|
| 12 |
+
set.add(h3.latLngToCell(lat, lng, resolution));
|
| 13 |
+
}
|
| 14 |
+
return [...set];
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
function cellsForBbox(sw, ne, resolution = DEFAULT_RES) {
|
| 18 |
+
if (!sw || !ne) return [];
|
| 19 |
+
const polygon = [[
|
| 20 |
+
[sw.lat, sw.lng],
|
| 21 |
+
[sw.lat, ne.lng],
|
| 22 |
+
[ne.lat, ne.lng],
|
| 23 |
+
[ne.lat, sw.lng],
|
| 24 |
+
[sw.lat, sw.lng],
|
| 25 |
+
]];
|
| 26 |
+
return h3.polygonToCells(polygon, resolution);
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
function boundary(cellId) {
|
| 30 |
+
// returns array of [lat, lng] vertices
|
| 31 |
+
return h3.cellToBoundary(cellId);
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
module.exports = { DEFAULT_RES, cellsForRoute, cellsForBbox, boundary };
|
src/middleware/auth.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const jwt = require('jsonwebtoken');
|
| 2 |
+
|
| 3 |
+
function authMiddleware(req, res, next) {
|
| 4 |
+
const authHeader = req.headers.authorization;
|
| 5 |
+
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
| 6 |
+
return res.status(401).json({ message: 'No token provided' });
|
| 7 |
+
}
|
| 8 |
+
const token = authHeader.split(' ')[1];
|
| 9 |
+
try {
|
| 10 |
+
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
| 11 |
+
req.userId = decoded.userId;
|
| 12 |
+
next();
|
| 13 |
+
} catch {
|
| 14 |
+
return res.status(401).json({ message: 'Invalid or expired token' });
|
| 15 |
+
}
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
module.exports = authMiddleware;
|
src/routes/auth.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const express = require('express');
|
| 2 |
+
const bcrypt = require('bcryptjs');
|
| 3 |
+
const jwt = require('jsonwebtoken');
|
| 4 |
+
const { body, validationResult } = require('express-validator');
|
| 5 |
+
const { collections, FieldValue, docToObject } = require('../db/firestore');
|
| 6 |
+
const authMiddleware = require('../middleware/auth');
|
| 7 |
+
|
| 8 |
+
const router = express.Router();
|
| 9 |
+
|
| 10 |
+
function signToken(userId) {
|
| 11 |
+
return jwt.sign({ userId }, process.env.JWT_SECRET, {
|
| 12 |
+
expiresIn: process.env.JWT_EXPIRES_IN || '30d',
|
| 13 |
+
});
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
function publicUser(user) {
|
| 17 |
+
if (!user) return null;
|
| 18 |
+
const { password_hash, ...safe } = user;
|
| 19 |
+
return safe;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
// POST /api/auth/register
|
| 23 |
+
router.post(
|
| 24 |
+
'/register',
|
| 25 |
+
[
|
| 26 |
+
body('name').trim().notEmpty().withMessage('Name is required'),
|
| 27 |
+
body('email').isEmail().normalizeEmail().withMessage('Valid email required'),
|
| 28 |
+
body('password').isLength({ min: 6 }).withMessage('Password min 6 chars'),
|
| 29 |
+
],
|
| 30 |
+
async (req, res) => {
|
| 31 |
+
const errors = validationResult(req);
|
| 32 |
+
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
|
| 33 |
+
|
| 34 |
+
const { name, email, password, weight_kg, height_cm, date_of_birth } = req.body;
|
| 35 |
+
try {
|
| 36 |
+
const existing = await collections.users.where('email', '==', email).limit(1).get();
|
| 37 |
+
if (!existing.empty)
|
| 38 |
+
return res.status(409).json({ message: 'Email already registered' });
|
| 39 |
+
|
| 40 |
+
const hash = await bcrypt.hash(password, 10);
|
| 41 |
+
const now = FieldValue.serverTimestamp();
|
| 42 |
+
const ref = collections.users.doc();
|
| 43 |
+
await ref.set({
|
| 44 |
+
name,
|
| 45 |
+
email,
|
| 46 |
+
password_hash: hash,
|
| 47 |
+
avatar_url: null,
|
| 48 |
+
display_name: null,
|
| 49 |
+
map_color: null,
|
| 50 |
+
weight_kg: weight_kg ?? null,
|
| 51 |
+
height_cm: height_cm ?? null,
|
| 52 |
+
date_of_birth: date_of_birth ?? null,
|
| 53 |
+
total_runs: 0,
|
| 54 |
+
total_distance_km: 0,
|
| 55 |
+
total_duration_sec: 0,
|
| 56 |
+
total_calories: 0,
|
| 57 |
+
created_at: now,
|
| 58 |
+
updated_at: now,
|
| 59 |
+
});
|
| 60 |
+
|
| 61 |
+
const snap = await ref.get();
|
| 62 |
+
const user = publicUser(docToObject(snap));
|
| 63 |
+
const token = signToken(ref.id);
|
| 64 |
+
res.status(201).json({ token, user });
|
| 65 |
+
} catch (err) {
|
| 66 |
+
console.error(err);
|
| 67 |
+
res.status(500).json({ message: 'Server error' });
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
);
|
| 71 |
+
|
| 72 |
+
// POST /api/auth/login
|
| 73 |
+
router.post(
|
| 74 |
+
'/login',
|
| 75 |
+
[body('email').isEmail().normalizeEmail(), body('password').notEmpty()],
|
| 76 |
+
async (req, res) => {
|
| 77 |
+
const errors = validationResult(req);
|
| 78 |
+
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
|
| 79 |
+
|
| 80 |
+
const { email, password } = req.body;
|
| 81 |
+
try {
|
| 82 |
+
const snap = await collections.users.where('email', '==', email).limit(1).get();
|
| 83 |
+
if (snap.empty) return res.status(401).json({ message: 'Invalid credentials' });
|
| 84 |
+
|
| 85 |
+
const doc = snap.docs[0];
|
| 86 |
+
const user = docToObject(doc);
|
| 87 |
+
const valid = await bcrypt.compare(password, user.password_hash);
|
| 88 |
+
if (!valid) return res.status(401).json({ message: 'Invalid credentials' });
|
| 89 |
+
|
| 90 |
+
const token = signToken(doc.id);
|
| 91 |
+
res.json({ token, user: publicUser(user) });
|
| 92 |
+
} catch (err) {
|
| 93 |
+
console.error(err);
|
| 94 |
+
res.status(500).json({ message: 'Server error' });
|
| 95 |
+
}
|
| 96 |
+
}
|
| 97 |
+
);
|
| 98 |
+
|
| 99 |
+
// GET /api/auth/me
|
| 100 |
+
router.get('/me', authMiddleware, async (req, res) => {
|
| 101 |
+
try {
|
| 102 |
+
const snap = await collections.users.doc(req.userId).get();
|
| 103 |
+
if (!snap.exists) return res.status(404).json({ message: 'User not found' });
|
| 104 |
+
res.json({ user: publicUser(docToObject(snap)) });
|
| 105 |
+
} catch (err) {
|
| 106 |
+
console.error(err);
|
| 107 |
+
res.status(500).json({ message: 'Server error' });
|
| 108 |
+
}
|
| 109 |
+
});
|
| 110 |
+
|
| 111 |
+
// PATCH /api/auth/profile
|
| 112 |
+
router.patch('/profile', authMiddleware, async (req, res) => {
|
| 113 |
+
const { name, weight_kg, height_cm, date_of_birth, avatar_url, display_name, map_color } = req.body;
|
| 114 |
+
try {
|
| 115 |
+
const ref = collections.users.doc(req.userId);
|
| 116 |
+
const update = { updated_at: FieldValue.serverTimestamp() };
|
| 117 |
+
if (name !== undefined) update.name = name;
|
| 118 |
+
if (weight_kg !== undefined) update.weight_kg = weight_kg;
|
| 119 |
+
if (height_cm !== undefined) update.height_cm = height_cm;
|
| 120 |
+
if (date_of_birth !== undefined) update.date_of_birth = date_of_birth;
|
| 121 |
+
if (avatar_url !== undefined) update.avatar_url = avatar_url;
|
| 122 |
+
// Display name shown on the world map (falls back to `name` when empty).
|
| 123 |
+
if (display_name !== undefined) update.display_name = display_name || null;
|
| 124 |
+
// Hex color (e.g. "#7C5CFF") this runner's territory is painted with.
|
| 125 |
+
if (map_color !== undefined) {
|
| 126 |
+
if (map_color !== null && !/^#[0-9A-Fa-f]{6}$/.test(map_color)) {
|
| 127 |
+
return res.status(400).json({ message: 'map_color must be a #RRGGBB hex string' });
|
| 128 |
+
}
|
| 129 |
+
update.map_color = map_color;
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
await ref.update(update);
|
| 133 |
+
const snap = await ref.get();
|
| 134 |
+
res.json({ user: publicUser(docToObject(snap)) });
|
| 135 |
+
} catch (err) {
|
| 136 |
+
console.error(err);
|
| 137 |
+
res.status(500).json({ message: 'Server error' });
|
| 138 |
+
}
|
| 139 |
+
});
|
| 140 |
+
|
| 141 |
+
module.exports = router;
|
src/routes/clubs.js
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const express = require('express');
|
| 2 |
+
const { db, collections, FieldValue, docToObject } = require('../db/firestore');
|
| 3 |
+
const authMiddleware = require('../middleware/auth');
|
| 4 |
+
|
| 5 |
+
const router = express.Router();
|
| 6 |
+
router.use(authMiddleware);
|
| 7 |
+
|
| 8 |
+
function membershipDocId(clubId, userId) {
|
| 9 |
+
return `${clubId}_${userId}`;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
async function annotateMembership(clubDocs, userId) {
|
| 13 |
+
if (clubDocs.length === 0) return [];
|
| 14 |
+
const memberSnaps = await Promise.all(
|
| 15 |
+
clubDocs.map((d) =>
|
| 16 |
+
collections.clubMembers.doc(membershipDocId(d.id, userId)).get()
|
| 17 |
+
)
|
| 18 |
+
);
|
| 19 |
+
return clubDocs.map((doc, i) => {
|
| 20 |
+
const club = docToObject(doc);
|
| 21 |
+
const m = memberSnaps[i];
|
| 22 |
+
club.is_member = m.exists;
|
| 23 |
+
club.my_role = m.exists ? m.data().role : null;
|
| 24 |
+
return club;
|
| 25 |
+
});
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
// GET /api/clubs/me β clubs user has joined (must come before /:id)
|
| 29 |
+
router.get('/me', async (req, res) => {
|
| 30 |
+
try {
|
| 31 |
+
const memberSnap = await collections.clubMembers
|
| 32 |
+
.where('userId', '==', req.userId)
|
| 33 |
+
.get();
|
| 34 |
+
|
| 35 |
+
if (memberSnap.empty) return res.json({ clubs: [] });
|
| 36 |
+
|
| 37 |
+
const memberDocs = memberSnap.docs.sort(
|
| 38 |
+
(a, b) => (b.data().joinedAt?.toMillis?.() ?? 0) - (a.data().joinedAt?.toMillis?.() ?? 0)
|
| 39 |
+
);
|
| 40 |
+
|
| 41 |
+
const clubs = await Promise.all(
|
| 42 |
+
memberDocs.map(async (m) => {
|
| 43 |
+
const data = m.data();
|
| 44 |
+
const clubSnap = await collections.clubs.doc(data.clubId).get();
|
| 45 |
+
if (!clubSnap.exists) return null;
|
| 46 |
+
const club = docToObject(clubSnap);
|
| 47 |
+
club.my_role = data.role;
|
| 48 |
+
club.joined_at = data.joinedAt?.toDate?.().toISOString() ?? null;
|
| 49 |
+
return club;
|
| 50 |
+
})
|
| 51 |
+
);
|
| 52 |
+
|
| 53 |
+
res.json({ clubs: clubs.filter(Boolean) });
|
| 54 |
+
} catch (err) {
|
| 55 |
+
console.error(err);
|
| 56 |
+
res.status(500).json({ message: 'Server error' });
|
| 57 |
+
}
|
| 58 |
+
});
|
| 59 |
+
|
| 60 |
+
// GET /api/clubs β discover public clubs
|
| 61 |
+
router.get('/', async (req, res) => {
|
| 62 |
+
try {
|
| 63 |
+
const snap = await collections.clubs
|
| 64 |
+
.where('is_public', '==', true)
|
| 65 |
+
.get();
|
| 66 |
+
const clubs = await annotateMembership(snap.docs, req.userId);
|
| 67 |
+
clubs.sort((a, b) => (b.member_count || 0) - (a.member_count || 0));
|
| 68 |
+
res.json({ clubs });
|
| 69 |
+
} catch (err) {
|
| 70 |
+
console.error(err);
|
| 71 |
+
res.status(500).json({ message: 'Server error' });
|
| 72 |
+
}
|
| 73 |
+
});
|
| 74 |
+
|
| 75 |
+
// GET /api/clubs/:id
|
| 76 |
+
router.get('/:id', async (req, res) => {
|
| 77 |
+
try {
|
| 78 |
+
const clubSnap = await collections.clubs.doc(req.params.id).get();
|
| 79 |
+
if (!clubSnap.exists) return res.status(404).json({ message: 'Club not found' });
|
| 80 |
+
|
| 81 |
+
const [annotated] = await annotateMembership([clubSnap], req.userId);
|
| 82 |
+
|
| 83 |
+
const memberSnap = await collections.clubMembers
|
| 84 |
+
.where('clubId', '==', req.params.id)
|
| 85 |
+
.get();
|
| 86 |
+
|
| 87 |
+
const memberDocs = memberSnap.docs
|
| 88 |
+
.sort((a, b) => (a.data().joinedAt?.toMillis?.() ?? 0) - (b.data().joinedAt?.toMillis?.() ?? 0))
|
| 89 |
+
.slice(0, 20);
|
| 90 |
+
|
| 91 |
+
const members = await Promise.all(
|
| 92 |
+
memberDocs.map(async (m) => {
|
| 93 |
+
const md = m.data();
|
| 94 |
+
const u = await collections.users.doc(md.userId).get();
|
| 95 |
+
if (!u.exists) return null;
|
| 96 |
+
const ud = u.data();
|
| 97 |
+
return {
|
| 98 |
+
id: u.id,
|
| 99 |
+
name: ud.name,
|
| 100 |
+
avatar_url: ud.avatar_url,
|
| 101 |
+
total_runs: ud.total_runs,
|
| 102 |
+
total_distance_km: ud.total_distance_km,
|
| 103 |
+
role: md.role,
|
| 104 |
+
joined_at: md.joinedAt?.toDate?.().toISOString() ?? null,
|
| 105 |
+
};
|
| 106 |
+
})
|
| 107 |
+
);
|
| 108 |
+
|
| 109 |
+
res.json({ club: annotated, members: members.filter(Boolean) });
|
| 110 |
+
} catch (err) {
|
| 111 |
+
console.error(err);
|
| 112 |
+
res.status(500).json({ message: 'Server error' });
|
| 113 |
+
}
|
| 114 |
+
});
|
| 115 |
+
|
| 116 |
+
// POST /api/clubs/:id/join
|
| 117 |
+
router.post('/:id/join', async (req, res) => {
|
| 118 |
+
try {
|
| 119 |
+
const clubRef = collections.clubs.doc(req.params.id);
|
| 120 |
+
const memberRef = collections.clubMembers.doc(membershipDocId(req.params.id, req.userId));
|
| 121 |
+
|
| 122 |
+
await db.runTransaction(async (tx) => {
|
| 123 |
+
const clubSnap = await tx.get(clubRef);
|
| 124 |
+
if (!clubSnap.exists) throw Object.assign(new Error('Club not found'), { code: 404 });
|
| 125 |
+
const memberSnap = await tx.get(memberRef);
|
| 126 |
+
if (memberSnap.exists) return;
|
| 127 |
+
tx.set(memberRef, {
|
| 128 |
+
clubId: req.params.id,
|
| 129 |
+
userId: req.userId,
|
| 130 |
+
role: 'member',
|
| 131 |
+
joinedAt: FieldValue.serverTimestamp(),
|
| 132 |
+
});
|
| 133 |
+
tx.update(clubRef, { member_count: FieldValue.increment(1) });
|
| 134 |
+
});
|
| 135 |
+
|
| 136 |
+
res.json({ message: 'Joined club' });
|
| 137 |
+
} catch (err) {
|
| 138 |
+
if (err.code === 404) return res.status(404).json({ message: err.message });
|
| 139 |
+
console.error(err);
|
| 140 |
+
res.status(500).json({ message: 'Server error' });
|
| 141 |
+
}
|
| 142 |
+
});
|
| 143 |
+
|
| 144 |
+
// DELETE /api/clubs/:id/join β leave club
|
| 145 |
+
router.delete('/:id/join', async (req, res) => {
|
| 146 |
+
try {
|
| 147 |
+
const clubRef = collections.clubs.doc(req.params.id);
|
| 148 |
+
const memberRef = collections.clubMembers.doc(membershipDocId(req.params.id, req.userId));
|
| 149 |
+
|
| 150 |
+
await db.runTransaction(async (tx) => {
|
| 151 |
+
const memberSnap = await tx.get(memberRef);
|
| 152 |
+
if (!memberSnap.exists) return;
|
| 153 |
+
tx.delete(memberRef);
|
| 154 |
+
tx.update(clubRef, { member_count: FieldValue.increment(-1) });
|
| 155 |
+
});
|
| 156 |
+
|
| 157 |
+
res.json({ message: 'Left club' });
|
| 158 |
+
} catch (err) {
|
| 159 |
+
console.error(err);
|
| 160 |
+
res.status(500).json({ message: 'Server error' });
|
| 161 |
+
}
|
| 162 |
+
});
|
| 163 |
+
|
| 164 |
+
// POST /api/clubs β create a club
|
| 165 |
+
router.post('/', async (req, res) => {
|
| 166 |
+
const { name, description, location, is_public } = req.body;
|
| 167 |
+
if (!name) return res.status(400).json({ message: 'Club name is required' });
|
| 168 |
+
try {
|
| 169 |
+
const clubRef = collections.clubs.doc();
|
| 170 |
+
const memberRef = collections.clubMembers.doc(membershipDocId(clubRef.id, req.userId));
|
| 171 |
+
const now = FieldValue.serverTimestamp();
|
| 172 |
+
|
| 173 |
+
const batch = db.batch();
|
| 174 |
+
batch.set(clubRef, {
|
| 175 |
+
name,
|
| 176 |
+
description: description ?? null,
|
| 177 |
+
location: location ?? null,
|
| 178 |
+
avatar_url: null,
|
| 179 |
+
cover_url: null,
|
| 180 |
+
member_count: 1,
|
| 181 |
+
is_public: is_public !== false,
|
| 182 |
+
created_by: req.userId,
|
| 183 |
+
created_at: now,
|
| 184 |
+
});
|
| 185 |
+
batch.set(memberRef, {
|
| 186 |
+
clubId: clubRef.id,
|
| 187 |
+
userId: req.userId,
|
| 188 |
+
role: 'owner',
|
| 189 |
+
joinedAt: now,
|
| 190 |
+
});
|
| 191 |
+
await batch.commit();
|
| 192 |
+
|
| 193 |
+
const snap = await clubRef.get();
|
| 194 |
+
res.status(201).json({ club: docToObject(snap) });
|
| 195 |
+
} catch (err) {
|
| 196 |
+
console.error(err);
|
| 197 |
+
res.status(500).json({ message: 'Server error' });
|
| 198 |
+
}
|
| 199 |
+
});
|
| 200 |
+
|
| 201 |
+
// GET /api/clubs/:id/feed
|
| 202 |
+
router.get('/:id/feed', async (req, res) => {
|
| 203 |
+
try {
|
| 204 |
+
const snap = await collections.clubActivities
|
| 205 |
+
.where('clubId', '==', req.params.id)
|
| 206 |
+
.get();
|
| 207 |
+
|
| 208 |
+
const activityDocs = snap.docs
|
| 209 |
+
.sort((a, b) => (b.data().createdAt?.toMillis?.() ?? 0) - (a.data().createdAt?.toMillis?.() ?? 0))
|
| 210 |
+
.slice(0, 30);
|
| 211 |
+
|
| 212 |
+
const activities = await Promise.all(
|
| 213 |
+
activityDocs.map(async (a) => {
|
| 214 |
+
const data = a.data();
|
| 215 |
+
const [userSnap, runSnap] = await Promise.all([
|
| 216 |
+
collections.users.doc(data.userId).get(),
|
| 217 |
+
data.runId ? collections.runs.doc(data.runId).get() : Promise.resolve(null),
|
| 218 |
+
]);
|
| 219 |
+
const userData = userSnap.exists ? userSnap.data() : {};
|
| 220 |
+
const runData = runSnap?.exists ? runSnap.data() : null;
|
| 221 |
+
return {
|
| 222 |
+
id: a.id,
|
| 223 |
+
club_id: data.clubId,
|
| 224 |
+
user_id: data.userId,
|
| 225 |
+
run_id: data.runId ?? null,
|
| 226 |
+
message: data.message ?? null,
|
| 227 |
+
created_at: data.createdAt?.toDate?.().toISOString() ?? null,
|
| 228 |
+
user_name: userData.name ?? null,
|
| 229 |
+
avatar_url: userData.avatar_url ?? null,
|
| 230 |
+
distance_km: runData?.distance_km ?? null,
|
| 231 |
+
duration_sec: runData?.duration_sec ?? null,
|
| 232 |
+
avg_pace_sec_per_km: runData?.avg_pace_sec_per_km ?? null,
|
| 233 |
+
};
|
| 234 |
+
})
|
| 235 |
+
);
|
| 236 |
+
|
| 237 |
+
res.json({ activities });
|
| 238 |
+
} catch (err) {
|
| 239 |
+
console.error(err);
|
| 240 |
+
res.status(500).json({ message: 'Server error' });
|
| 241 |
+
}
|
| 242 |
+
});
|
| 243 |
+
|
| 244 |
+
module.exports = router;
|
src/routes/programs.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const express = require('express');
|
| 2 |
+
const { collections, FieldValue, docToObject } = require('../db/firestore');
|
| 3 |
+
const authMiddleware = require('../middleware/auth');
|
| 4 |
+
|
| 5 |
+
const router = express.Router();
|
| 6 |
+
router.use(authMiddleware);
|
| 7 |
+
|
| 8 |
+
function enrollmentDocId(userId, programId) {
|
| 9 |
+
return `${userId}_${programId}`;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
async function annotateEnrollment(programDocs, userId) {
|
| 13 |
+
if (programDocs.length === 0) return [];
|
| 14 |
+
const enrollSnaps = await Promise.all(
|
| 15 |
+
programDocs.map((d) =>
|
| 16 |
+
collections.userPrograms.doc(enrollmentDocId(userId, d.id)).get()
|
| 17 |
+
)
|
| 18 |
+
);
|
| 19 |
+
return programDocs.map((doc, i) => {
|
| 20 |
+
const program = docToObject(doc);
|
| 21 |
+
const e = enrollSnaps[i];
|
| 22 |
+
program.is_enrolled = e.exists;
|
| 23 |
+
if (e.exists) {
|
| 24 |
+
const ed = e.data();
|
| 25 |
+
program.current_week = ed.current_week ?? 1;
|
| 26 |
+
program.is_active = ed.is_active ?? true;
|
| 27 |
+
program.enrolled_at = ed.enrolled_at?.toDate?.().toISOString() ?? null;
|
| 28 |
+
} else {
|
| 29 |
+
program.current_week = null;
|
| 30 |
+
program.is_active = null;
|
| 31 |
+
}
|
| 32 |
+
return program;
|
| 33 |
+
});
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
// GET /api/programs/me/active β must come before /:id
|
| 37 |
+
router.get('/me/active', async (req, res) => {
|
| 38 |
+
try {
|
| 39 |
+
const enrollSnap = await collections.userPrograms
|
| 40 |
+
.where('userId', '==', req.userId)
|
| 41 |
+
.where('is_active', '==', true)
|
| 42 |
+
.get();
|
| 43 |
+
|
| 44 |
+
const programs = await Promise.all(
|
| 45 |
+
enrollSnap.docs.map(async (e) => {
|
| 46 |
+
const ed = e.data();
|
| 47 |
+
const p = await collections.programs.doc(ed.programId).get();
|
| 48 |
+
if (!p.exists) return null;
|
| 49 |
+
const program = docToObject(p);
|
| 50 |
+
program.current_week = ed.current_week ?? 1;
|
| 51 |
+
program.enrolled_at = ed.enrolled_at?.toDate?.().toISOString() ?? null;
|
| 52 |
+
return program;
|
| 53 |
+
})
|
| 54 |
+
);
|
| 55 |
+
|
| 56 |
+
res.json({ programs: programs.filter(Boolean) });
|
| 57 |
+
} catch (err) {
|
| 58 |
+
console.error(err);
|
| 59 |
+
res.status(500).json({ message: 'Server error' });
|
| 60 |
+
}
|
| 61 |
+
});
|
| 62 |
+
|
| 63 |
+
// GET /api/programs
|
| 64 |
+
router.get('/', async (req, res) => {
|
| 65 |
+
const { difficulty, category } = req.query;
|
| 66 |
+
try {
|
| 67 |
+
let query = collections.programs;
|
| 68 |
+
if (difficulty) query = query.where('difficulty', '==', difficulty);
|
| 69 |
+
// Firestore can't do case-insensitive substring; for category we filter exact match.
|
| 70 |
+
if (category) query = query.where('category', '==', category);
|
| 71 |
+
|
| 72 |
+
const snap = await query.get();
|
| 73 |
+
const sorted = snap.docs.slice().sort((a, b) => {
|
| 74 |
+
const ad = a.data(), bd = b.data();
|
| 75 |
+
if ((bd.is_featured ? 1 : 0) !== (ad.is_featured ? 1 : 0))
|
| 76 |
+
return (bd.is_featured ? 1 : 0) - (ad.is_featured ? 1 : 0);
|
| 77 |
+
const at = ad.created_at?.toMillis?.() ?? 0;
|
| 78 |
+
const bt = bd.created_at?.toMillis?.() ?? 0;
|
| 79 |
+
return bt - at;
|
| 80 |
+
});
|
| 81 |
+
|
| 82 |
+
const programs = await annotateEnrollment(sorted, req.userId);
|
| 83 |
+
res.json({ programs });
|
| 84 |
+
} catch (err) {
|
| 85 |
+
console.error(err);
|
| 86 |
+
res.status(500).json({ message: 'Server error' });
|
| 87 |
+
}
|
| 88 |
+
});
|
| 89 |
+
|
| 90 |
+
// GET /api/programs/:id
|
| 91 |
+
router.get('/:id', async (req, res) => {
|
| 92 |
+
try {
|
| 93 |
+
const snap = await collections.programs.doc(req.params.id).get();
|
| 94 |
+
if (!snap.exists) return res.status(404).json({ message: 'Program not found' });
|
| 95 |
+
const [program] = await annotateEnrollment([snap], req.userId);
|
| 96 |
+
res.json({ program });
|
| 97 |
+
} catch (err) {
|
| 98 |
+
console.error(err);
|
| 99 |
+
res.status(500).json({ message: 'Server error' });
|
| 100 |
+
}
|
| 101 |
+
});
|
| 102 |
+
|
| 103 |
+
// POST /api/programs/:id/enroll
|
| 104 |
+
router.post('/:id/enroll', async (req, res) => {
|
| 105 |
+
try {
|
| 106 |
+
const enrollRef = collections.userPrograms.doc(enrollmentDocId(req.userId, req.params.id));
|
| 107 |
+
const programRef = collections.programs.doc(req.params.id);
|
| 108 |
+
|
| 109 |
+
const enrollSnap = await enrollRef.get();
|
| 110 |
+
const wasEnrolled = enrollSnap.exists;
|
| 111 |
+
|
| 112 |
+
await enrollRef.set(
|
| 113 |
+
{
|
| 114 |
+
userId: req.userId,
|
| 115 |
+
programId: req.params.id,
|
| 116 |
+
enrolled_at: enrollSnap.exists ? enrollSnap.data().enrolled_at : FieldValue.serverTimestamp(),
|
| 117 |
+
current_week: 1,
|
| 118 |
+
is_active: true,
|
| 119 |
+
completed_at: null,
|
| 120 |
+
},
|
| 121 |
+
{ merge: true }
|
| 122 |
+
);
|
| 123 |
+
|
| 124 |
+
if (!wasEnrolled) {
|
| 125 |
+
await programRef.update({ total_enrolled: FieldValue.increment(1) });
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
res.json({ message: 'Enrolled successfully' });
|
| 129 |
+
} catch (err) {
|
| 130 |
+
console.error(err);
|
| 131 |
+
res.status(500).json({ message: 'Server error' });
|
| 132 |
+
}
|
| 133 |
+
});
|
| 134 |
+
|
| 135 |
+
// DELETE /api/programs/:id/enroll
|
| 136 |
+
router.delete('/:id/enroll', async (req, res) => {
|
| 137 |
+
try {
|
| 138 |
+
const enrollRef = collections.userPrograms.doc(enrollmentDocId(req.userId, req.params.id));
|
| 139 |
+
await enrollRef.set({ is_active: false }, { merge: true });
|
| 140 |
+
res.json({ message: 'Unenrolled' });
|
| 141 |
+
} catch (err) {
|
| 142 |
+
console.error(err);
|
| 143 |
+
res.status(500).json({ message: 'Server error' });
|
| 144 |
+
}
|
| 145 |
+
});
|
| 146 |
+
|
| 147 |
+
module.exports = router;
|
src/routes/runs.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const express = require('express');
|
| 2 |
+
const { db, collections, FieldValue, Timestamp, docToObject } = require('../db/firestore');
|
| 3 |
+
const { cellsForRoute } = require('../lib/territory');
|
| 4 |
+
const authMiddleware = require('../middleware/auth');
|
| 5 |
+
|
| 6 |
+
// Firestore batches cap at 500 writes
|
| 7 |
+
const BATCH_SIZE = 450;
|
| 8 |
+
|
| 9 |
+
async function captureCells(cellIds, userId, runId) {
|
| 10 |
+
if (cellIds.length === 0) return 0;
|
| 11 |
+
const now = FieldValue.serverTimestamp();
|
| 12 |
+
let captured = 0;
|
| 13 |
+
for (let i = 0; i < cellIds.length; i += BATCH_SIZE) {
|
| 14 |
+
const slice = cellIds.slice(i, i + BATCH_SIZE);
|
| 15 |
+
const batch = db.batch();
|
| 16 |
+
for (const cellId of slice) {
|
| 17 |
+
batch.set(
|
| 18 |
+
collections.territoryCells.doc(cellId),
|
| 19 |
+
{
|
| 20 |
+
ownerUserId: userId,
|
| 21 |
+
ownerClubId: null,
|
| 22 |
+
capturedAt: now,
|
| 23 |
+
runId,
|
| 24 |
+
captureCount: FieldValue.increment(1),
|
| 25 |
+
},
|
| 26 |
+
{ merge: true }
|
| 27 |
+
);
|
| 28 |
+
}
|
| 29 |
+
await batch.commit();
|
| 30 |
+
captured += slice.length;
|
| 31 |
+
}
|
| 32 |
+
return captured;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
const router = express.Router();
|
| 36 |
+
router.use(authMiddleware);
|
| 37 |
+
|
| 38 |
+
function toTimestamp(value) {
|
| 39 |
+
if (!value) return null;
|
| 40 |
+
const d = value instanceof Date ? value : new Date(value);
|
| 41 |
+
return isNaN(d.getTime()) ? null : Timestamp.fromDate(d);
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
// POST /api/runs β save a completed run
|
| 45 |
+
router.post('/', async (req, res) => {
|
| 46 |
+
const {
|
| 47 |
+
title, started_at, ended_at, duration_sec, distance_km,
|
| 48 |
+
avg_pace_sec_per_km, max_pace_sec_per_km, calories,
|
| 49 |
+
avg_heart_rate, max_heart_rate, elevation_gain_m,
|
| 50 |
+
route, start_lat, start_lng, end_lat, end_lng,
|
| 51 |
+
map_snapshot_url, notes, weather,
|
| 52 |
+
} = req.body;
|
| 53 |
+
|
| 54 |
+
if (!started_at || !duration_sec || distance_km === undefined)
|
| 55 |
+
return res.status(400).json({ message: 'started_at, duration_sec and distance_km are required' });
|
| 56 |
+
|
| 57 |
+
const startedAtTs = toTimestamp(started_at);
|
| 58 |
+
if (!startedAtTs) return res.status(400).json({ message: 'started_at must be a valid date' });
|
| 59 |
+
|
| 60 |
+
try {
|
| 61 |
+
const userRef = collections.users.doc(req.userId);
|
| 62 |
+
const runRef = collections.runs.doc();
|
| 63 |
+
|
| 64 |
+
await db.runTransaction(async (tx) => {
|
| 65 |
+
const userSnap = await tx.get(userRef);
|
| 66 |
+
if (!userSnap.exists) throw new Error('User not found');
|
| 67 |
+
|
| 68 |
+
tx.set(runRef, {
|
| 69 |
+
userId: req.userId,
|
| 70 |
+
title: title ?? null,
|
| 71 |
+
started_at: startedAtTs,
|
| 72 |
+
ended_at: toTimestamp(ended_at),
|
| 73 |
+
duration_sec,
|
| 74 |
+
distance_km,
|
| 75 |
+
avg_pace_sec_per_km: avg_pace_sec_per_km ?? null,
|
| 76 |
+
max_pace_sec_per_km: max_pace_sec_per_km ?? null,
|
| 77 |
+
calories: calories ?? 0,
|
| 78 |
+
avg_heart_rate: avg_heart_rate ?? null,
|
| 79 |
+
max_heart_rate: max_heart_rate ?? null,
|
| 80 |
+
elevation_gain_m: elevation_gain_m ?? 0,
|
| 81 |
+
route: route ?? null,
|
| 82 |
+
start_lat: start_lat ?? null,
|
| 83 |
+
start_lng: start_lng ?? null,
|
| 84 |
+
end_lat: end_lat ?? null,
|
| 85 |
+
end_lng: end_lng ?? null,
|
| 86 |
+
map_snapshot_url: map_snapshot_url ?? null,
|
| 87 |
+
notes: notes ?? null,
|
| 88 |
+
weather: weather ?? null,
|
| 89 |
+
created_at: FieldValue.serverTimestamp(),
|
| 90 |
+
});
|
| 91 |
+
|
| 92 |
+
tx.update(userRef, {
|
| 93 |
+
total_runs: FieldValue.increment(1),
|
| 94 |
+
total_distance_km: FieldValue.increment(Number(distance_km) || 0),
|
| 95 |
+
total_duration_sec: FieldValue.increment(Number(duration_sec) || 0),
|
| 96 |
+
total_calories: FieldValue.increment(Number(calories) || 0),
|
| 97 |
+
updated_at: FieldValue.serverTimestamp(),
|
| 98 |
+
});
|
| 99 |
+
});
|
| 100 |
+
|
| 101 |
+
let cellsCaptured = 0;
|
| 102 |
+
try {
|
| 103 |
+
const cellIds = cellsForRoute(route);
|
| 104 |
+
cellsCaptured = await captureCells(cellIds, req.userId, runRef.id);
|
| 105 |
+
} catch (capErr) {
|
| 106 |
+
console.error('territory capture failed:', capErr.message);
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
const saved = await runRef.get();
|
| 110 |
+
res.status(201).json({ run: docToObject(saved), cellsCaptured });
|
| 111 |
+
} catch (err) {
|
| 112 |
+
console.error(err);
|
| 113 |
+
res.status(500).json({ message: 'Server error' });
|
| 114 |
+
}
|
| 115 |
+
});
|
| 116 |
+
|
| 117 |
+
// GET /api/runs β list user's runs with pagination
|
| 118 |
+
router.get('/', async (req, res) => {
|
| 119 |
+
const page = Math.max(1, parseInt(req.query.page) || 1);
|
| 120 |
+
const limit = Math.min(50, parseInt(req.query.limit) || 20);
|
| 121 |
+
const offset = (page - 1) * limit;
|
| 122 |
+
|
| 123 |
+
try {
|
| 124 |
+
// Query by userId only (single-field, auto-indexed β no composite index
|
| 125 |
+
// required); sort by start time and paginate in JS.
|
| 126 |
+
const snap = await collections.runs.where('userId', '==', req.userId).get();
|
| 127 |
+
const all = snap.docs
|
| 128 |
+
.map(docToObject)
|
| 129 |
+
.sort((a, b) => new Date(b.started_at) - new Date(a.started_at));
|
| 130 |
+
|
| 131 |
+
res.json({
|
| 132 |
+
runs: all.slice(offset, offset + limit),
|
| 133 |
+
total: all.length,
|
| 134 |
+
page,
|
| 135 |
+
limit,
|
| 136 |
+
});
|
| 137 |
+
} catch (err) {
|
| 138 |
+
console.error(err);
|
| 139 |
+
res.status(500).json({ message: 'Server error' });
|
| 140 |
+
}
|
| 141 |
+
});
|
| 142 |
+
|
| 143 |
+
// GET /api/runs/stats/weekly β must come before /:id
|
| 144 |
+
router.get('/stats/weekly', async (req, res) => {
|
| 145 |
+
try {
|
| 146 |
+
const cutoffMs = Date.now() - 12 * 7 * 24 * 60 * 60 * 1000;
|
| 147 |
+
// Query by userId only (no composite index); apply the date cutoff in JS.
|
| 148 |
+
const snap = await collections.runs
|
| 149 |
+
.where('userId', '==', req.userId)
|
| 150 |
+
.get();
|
| 151 |
+
|
| 152 |
+
const buckets = new Map();
|
| 153 |
+
for (const doc of snap.docs) {
|
| 154 |
+
const data = doc.data();
|
| 155 |
+
const startedAt = data.started_at instanceof Timestamp
|
| 156 |
+
? data.started_at.toDate()
|
| 157 |
+
: new Date(data.started_at);
|
| 158 |
+
if (startedAt.getTime() < cutoffMs) continue;
|
| 159 |
+
const weekStart = new Date(startedAt);
|
| 160 |
+
const day = weekStart.getUTCDay();
|
| 161 |
+
const diff = (day === 0 ? -6 : 1 - day);
|
| 162 |
+
weekStart.setUTCDate(weekStart.getUTCDate() + diff);
|
| 163 |
+
weekStart.setUTCHours(0, 0, 0, 0);
|
| 164 |
+
const key = weekStart.toISOString();
|
| 165 |
+
|
| 166 |
+
const acc = buckets.get(key) || {
|
| 167 |
+
week: key, run_count: 0, total_km: 0, total_sec: 0, total_calories: 0, _paceSum: 0, _paceN: 0,
|
| 168 |
+
};
|
| 169 |
+
acc.run_count += 1;
|
| 170 |
+
acc.total_km += Number(data.distance_km) || 0;
|
| 171 |
+
acc.total_sec += Number(data.duration_sec) || 0;
|
| 172 |
+
acc.total_calories += Number(data.calories) || 0;
|
| 173 |
+
if (data.avg_pace_sec_per_km != null) {
|
| 174 |
+
acc._paceSum += Number(data.avg_pace_sec_per_km);
|
| 175 |
+
acc._paceN += 1;
|
| 176 |
+
}
|
| 177 |
+
buckets.set(key, acc);
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
const weekly = Array.from(buckets.values())
|
| 181 |
+
.map(({ _paceSum, _paceN, ...rest }) => ({
|
| 182 |
+
...rest,
|
| 183 |
+
avg_pace: _paceN ? _paceSum / _paceN : null,
|
| 184 |
+
}))
|
| 185 |
+
.sort((a, b) => b.week.localeCompare(a.week));
|
| 186 |
+
|
| 187 |
+
res.json({ weekly });
|
| 188 |
+
} catch (err) {
|
| 189 |
+
console.error(err);
|
| 190 |
+
res.status(500).json({ message: 'Server error' });
|
| 191 |
+
}
|
| 192 |
+
});
|
| 193 |
+
|
| 194 |
+
// GET /api/runs/:id β single run detail
|
| 195 |
+
router.get('/:id', async (req, res) => {
|
| 196 |
+
try {
|
| 197 |
+
const snap = await collections.runs.doc(req.params.id).get();
|
| 198 |
+
if (!snap.exists || snap.data().userId !== req.userId)
|
| 199 |
+
return res.status(404).json({ message: 'Run not found' });
|
| 200 |
+
res.json({ run: docToObject(snap) });
|
| 201 |
+
} catch (err) {
|
| 202 |
+
console.error(err);
|
| 203 |
+
res.status(500).json({ message: 'Server error' });
|
| 204 |
+
}
|
| 205 |
+
});
|
| 206 |
+
|
| 207 |
+
// DELETE /api/runs/:id
|
| 208 |
+
router.delete('/:id', async (req, res) => {
|
| 209 |
+
try {
|
| 210 |
+
const runRef = collections.runs.doc(req.params.id);
|
| 211 |
+
const userRef = collections.users.doc(req.userId);
|
| 212 |
+
|
| 213 |
+
await db.runTransaction(async (tx) => {
|
| 214 |
+
const runSnap = await tx.get(runRef);
|
| 215 |
+
if (!runSnap.exists || runSnap.data().userId !== req.userId) {
|
| 216 |
+
throw Object.assign(new Error('Run not found'), { code: 404 });
|
| 217 |
+
}
|
| 218 |
+
const { distance_km = 0, duration_sec = 0, calories = 0 } = runSnap.data();
|
| 219 |
+
tx.delete(runRef);
|
| 220 |
+
tx.update(userRef, {
|
| 221 |
+
total_runs: FieldValue.increment(-1),
|
| 222 |
+
total_distance_km: FieldValue.increment(-Number(distance_km)),
|
| 223 |
+
total_duration_sec: FieldValue.increment(-Number(duration_sec)),
|
| 224 |
+
total_calories: FieldValue.increment(-Number(calories)),
|
| 225 |
+
updated_at: FieldValue.serverTimestamp(),
|
| 226 |
+
});
|
| 227 |
+
});
|
| 228 |
+
|
| 229 |
+
res.json({ message: 'Run deleted' });
|
| 230 |
+
} catch (err) {
|
| 231 |
+
if (err.code === 404) return res.status(404).json({ message: err.message });
|
| 232 |
+
console.error(err);
|
| 233 |
+
res.status(500).json({ message: 'Server error' });
|
| 234 |
+
}
|
| 235 |
+
});
|
| 236 |
+
|
| 237 |
+
module.exports = router;
|
src/routes/territory.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const express = require('express');
|
| 2 |
+
const { db, collections, FieldValue, Timestamp } = require('../db/firestore');
|
| 3 |
+
const { cellsForBbox, boundary } = require('../lib/territory');
|
| 4 |
+
const authMiddleware = require('../middleware/auth');
|
| 5 |
+
|
| 6 |
+
const router = express.Router();
|
| 7 |
+
router.use(authMiddleware);
|
| 8 |
+
|
| 9 |
+
const MAX_BBOX_CELLS = 4000;
|
| 10 |
+
const FIRESTORE_GET_ALL_CHUNK = 300;
|
| 11 |
+
|
| 12 |
+
// GET /api/territory?sw_lat=&sw_lng=&ne_lat=&ne_lng=
|
| 13 |
+
// Returns captured cells within the bounding box.
|
| 14 |
+
router.get('/', async (req, res) => {
|
| 15 |
+
const sw = { lat: parseFloat(req.query.sw_lat), lng: parseFloat(req.query.sw_lng) };
|
| 16 |
+
const ne = { lat: parseFloat(req.query.ne_lat), lng: parseFloat(req.query.ne_lng) };
|
| 17 |
+
if ([sw.lat, sw.lng, ne.lat, ne.lng].some((v) => !isFinite(v))) {
|
| 18 |
+
return res.status(400).json({ message: 'sw_lat, sw_lng, ne_lat, ne_lng required' });
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
try {
|
| 22 |
+
const cellIds = cellsForBbox(sw, ne);
|
| 23 |
+
if (cellIds.length === 0) return res.json({ cells: [] });
|
| 24 |
+
if (cellIds.length > MAX_BBOX_CELLS) {
|
| 25 |
+
return res.status(413).json({
|
| 26 |
+
message: `bbox covers ${cellIds.length} cells (max ${MAX_BBOX_CELLS}); zoom in.`,
|
| 27 |
+
});
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
const refs = cellIds.map((id) => collections.territoryCells.doc(id));
|
| 31 |
+
const rawCells = [];
|
| 32 |
+
for (let i = 0; i < refs.length; i += FIRESTORE_GET_ALL_CHUNK) {
|
| 33 |
+
const chunk = refs.slice(i, i + FIRESTORE_GET_ALL_CHUNK);
|
| 34 |
+
const snaps = await db.getAll(...chunk);
|
| 35 |
+
for (const snap of snaps) {
|
| 36 |
+
if (!snap.exists) continue;
|
| 37 |
+
rawCells.push({ id: snap.id, data: snap.data() });
|
| 38 |
+
}
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
// Enrich with owner name (one batched user fetch per unique owner)
|
| 42 |
+
const ownerIds = [...new Set(rawCells.map((c) => c.data.ownerUserId).filter(Boolean))];
|
| 43 |
+
const ownerById = {};
|
| 44 |
+
if (ownerIds.length > 0) {
|
| 45 |
+
const userRefs = ownerIds.map((id) => collections.users.doc(id));
|
| 46 |
+
const userSnaps = await db.getAll(...userRefs);
|
| 47 |
+
for (const u of userSnaps) {
|
| 48 |
+
if (!u.exists) continue;
|
| 49 |
+
const ud = u.data();
|
| 50 |
+
ownerById[u.id] = {
|
| 51 |
+
// Prefer the map display name; fall back to the account name.
|
| 52 |
+
name: ud.display_name || ud.name || null,
|
| 53 |
+
avatar_url: ud.avatar_url ?? null,
|
| 54 |
+
map_color: ud.map_color ?? null,
|
| 55 |
+
};
|
| 56 |
+
}
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
const cells = rawCells.map(({ id, data }) => ({
|
| 60 |
+
id,
|
| 61 |
+
ownerUserId: data.ownerUserId ?? null,
|
| 62 |
+
ownerClubId: data.ownerClubId ?? null,
|
| 63 |
+
ownerName: data.ownerUserId ? ownerById[data.ownerUserId]?.name ?? null : null,
|
| 64 |
+
ownerColor: data.ownerUserId ? ownerById[data.ownerUserId]?.map_color ?? null : null,
|
| 65 |
+
ownerAvatar: data.ownerUserId ? ownerById[data.ownerUserId]?.avatar_url ?? null : null,
|
| 66 |
+
isMine: data.ownerUserId === req.userId,
|
| 67 |
+
capturedAt: data.capturedAt instanceof Timestamp ? data.capturedAt.toDate().toISOString() : null,
|
| 68 |
+
boundary: boundary(id),
|
| 69 |
+
}));
|
| 70 |
+
|
| 71 |
+
res.json({ cells });
|
| 72 |
+
} catch (err) {
|
| 73 |
+
console.error(err);
|
| 74 |
+
res.status(500).json({ message: 'Server error' });
|
| 75 |
+
}
|
| 76 |
+
});
|
| 77 |
+
|
| 78 |
+
// GET /api/territory/leaderboard?limit=20
|
| 79 |
+
router.get('/leaderboard', async (req, res) => {
|
| 80 |
+
const limit = Math.min(50, Math.max(1, parseInt(req.query.limit) || 20));
|
| 81 |
+
try {
|
| 82 |
+
// Pull a candidate pool of users (sorted by total_distance_km as a cheap proxy
|
| 83 |
+
// for "active runners"), then count their owned cells in parallel.
|
| 84 |
+
const candidatePoolSize = Math.min(100, limit * 3);
|
| 85 |
+
const userSnap = await collections.users
|
| 86 |
+
.orderBy('total_distance_km', 'desc')
|
| 87 |
+
.limit(candidatePoolSize)
|
| 88 |
+
.get();
|
| 89 |
+
|
| 90 |
+
const users = userSnap.docs.map((d) => ({
|
| 91 |
+
id: d.id,
|
| 92 |
+
name: d.data().display_name || d.data().name || null,
|
| 93 |
+
avatar_url: d.data().avatar_url ?? null,
|
| 94 |
+
map_color: d.data().map_color ?? null,
|
| 95 |
+
total_distance_km: d.data().total_distance_km ?? 0,
|
| 96 |
+
}));
|
| 97 |
+
|
| 98 |
+
const counts = await Promise.all(
|
| 99 |
+
users.map(async (u) => {
|
| 100 |
+
const c = await collections.territoryCells.where('ownerUserId', '==', u.id).count().get();
|
| 101 |
+
return c.data().count;
|
| 102 |
+
})
|
| 103 |
+
);
|
| 104 |
+
|
| 105 |
+
const ranked = users
|
| 106 |
+
.map((u, i) => ({ ...u, cells_owned: counts[i] }))
|
| 107 |
+
.filter((u) => u.cells_owned > 0)
|
| 108 |
+
.sort((a, b) => b.cells_owned - a.cells_owned)
|
| 109 |
+
.slice(0, limit)
|
| 110 |
+
.map((u, i) => ({ ...u, rank: i + 1, isMe: u.id === req.userId }));
|
| 111 |
+
|
| 112 |
+
res.json({ leaderboard: ranked });
|
| 113 |
+
} catch (err) {
|
| 114 |
+
console.error(err);
|
| 115 |
+
res.status(500).json({ message: 'Server error' });
|
| 116 |
+
}
|
| 117 |
+
});
|
| 118 |
+
|
| 119 |
+
// GET /api/territory/me/stats β quick numeric overview
|
| 120 |
+
router.get('/me/stats', async (req, res) => {
|
| 121 |
+
try {
|
| 122 |
+
const snap = await collections.territoryCells
|
| 123 |
+
.where('ownerUserId', '==', req.userId)
|
| 124 |
+
.count()
|
| 125 |
+
.get();
|
| 126 |
+
res.json({ cells_owned: snap.data().count });
|
| 127 |
+
} catch (err) {
|
| 128 |
+
console.error(err);
|
| 129 |
+
res.status(500).json({ message: 'Server error' });
|
| 130 |
+
}
|
| 131 |
+
});
|
| 132 |
+
|
| 133 |
+
// GET /api/territory/me/cells β all cells the user owns (ungeometric β for stats/export)
|
| 134 |
+
router.get('/me/cells', async (req, res) => {
|
| 135 |
+
try {
|
| 136 |
+
const snap = await collections.territoryCells
|
| 137 |
+
.where('ownerUserId', '==', req.userId)
|
| 138 |
+
.limit(2000)
|
| 139 |
+
.get();
|
| 140 |
+
const cells = snap.docs.map((d) => ({
|
| 141 |
+
id: d.id,
|
| 142 |
+
boundary: boundary(d.id),
|
| 143 |
+
capturedAt: d.data().capturedAt instanceof Timestamp
|
| 144 |
+
? d.data().capturedAt.toDate().toISOString()
|
| 145 |
+
: null,
|
| 146 |
+
}));
|
| 147 |
+
res.json({ cells });
|
| 148 |
+
} catch (err) {
|
| 149 |
+
console.error(err);
|
| 150 |
+
res.status(500).json({ message: 'Server error' });
|
| 151 |
+
}
|
| 152 |
+
});
|
| 153 |
+
|
| 154 |
+
module.exports = router;
|
src/server.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
require('dotenv').config();
|
| 2 |
+
const express = require('express');
|
| 3 |
+
const cors = require('cors');
|
| 4 |
+
|
| 5 |
+
const authRoutes = require('./routes/auth');
|
| 6 |
+
const runsRoutes = require('./routes/runs');
|
| 7 |
+
const programsRoutes = require('./routes/programs');
|
| 8 |
+
const clubsRoutes = require('./routes/clubs');
|
| 9 |
+
const territoryRoutes = require('./routes/territory');
|
| 10 |
+
|
| 11 |
+
const app = express();
|
| 12 |
+
const PORT = process.env.PORT || 5000;
|
| 13 |
+
|
| 14 |
+
app.use(cors());
|
| 15 |
+
app.use(express.json({ limit: '10mb' }));
|
| 16 |
+
app.use(express.urlencoded({ extended: true }));
|
| 17 |
+
|
| 18 |
+
app.get('/health', (_, res) => res.json({ status: 'ok', time: new Date().toISOString() }));
|
| 19 |
+
|
| 20 |
+
app.use('/api/auth', authRoutes);
|
| 21 |
+
app.use('/api/runs', runsRoutes);
|
| 22 |
+
app.use('/api/programs', programsRoutes);
|
| 23 |
+
app.use('/api/clubs', clubsRoutes);
|
| 24 |
+
app.use('/api/territory', territoryRoutes);
|
| 25 |
+
|
| 26 |
+
app.use((err, req, res, next) => {
|
| 27 |
+
console.error(err.stack);
|
| 28 |
+
res.status(500).json({ message: 'Internal server error' });
|
| 29 |
+
});
|
| 30 |
+
|
| 31 |
+
app.listen(PORT, () => {
|
| 32 |
+
console.log(`π BOLT API running on http://localhost:${PORT}`);
|
| 33 |
+
});
|