Abhinay commited on
Commit
d3477e4
Β·
1 Parent(s): 9dc0e21

Deploy Bolt Run backend (Express + Firestore) as Docker Space

Browse files
.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: red
5
- colorTo: red
6
  sdk: docker
 
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ });