Files
streba/src/lib/server/db.ts
T

311 lines
8.8 KiB
TypeScript

import Database from 'better-sqlite3';
import fs from 'node:fs';
import path from 'node:path';
import { PEAKS } from './peaks-seed';
import { assignMinzoom, scorePeak } from './peak-score';
const DATA_DIR = process.env.STREBA_DATA_DIR ?? 'data';
fs.mkdirSync(DATA_DIR, { recursive: true });
export const db = new Database(path.join(DATA_DIR, 'streba.db'));
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS activities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'outdoor',
date TEXT,
distance_m REAL NOT NULL,
duration_s REAL,
moving_s REAL,
elev_gain_m REAL NOT NULL DEFAULT 0,
elev_loss_m REAL NOT NULL DEFAULT 0,
elev_min_m REAL,
elev_max_m REAL,
points TEXT NOT NULL,
bounds TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_activities_user ON activities(user_id);
CREATE TABLE IF NOT EXISTS peaks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
osm_id TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
elevation_m REAL,
lat REAL NOT NULL,
lon REAL NOT NULL,
country TEXT,
region TEXT,
wikipedia TEXT,
wikidata TEXT,
score REAL NOT NULL DEFAULT 0,
minzoom INTEGER NOT NULL DEFAULT 14
);
CREATE INDEX IF NOT EXISTS idx_peaks_lat_zoom ON peaks(minzoom, lat, lon);
CREATE TABLE IF NOT EXISTS ascents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
peak_id INTEGER NOT NULL REFERENCES peaks(id) ON DELETE CASCADE,
climbed_at TEXT,
activity_id INTEGER REFERENCES activities(id) ON DELETE SET NULL,
note TEXT,
UNIQUE (user_id, peak_id)
);
CREATE INDEX IF NOT EXISTS idx_ascents_user ON ascents(user_id);
`);
// Seed the catalog with the curated list while no OSM import has run.
// Seed rows use osm_id "seed:<name>"; scripts/import-peaks.js upgrades them
// to real OSM nodes (preserving ascents) and fills in the rest of the Alps.
const peakCount = (db.prepare('SELECT count(*) n FROM peaks').get() as { n: number }).n;
if (peakCount === 0) {
const seeded = PEAKS.map((p) => ({
...p,
lat: p.lat,
lon: p.lon,
wikipedia: 'seed',
score: 0,
minzoom: 14
}));
for (const p of seeded) p.score = scorePeak(p);
assignMinzoom(seeded);
const insert = db.prepare(`
INSERT INTO peaks (osm_id, name, elevation_m, lat, lon, country, region, score, minzoom)
VALUES (@osm_id, @name, @elevation_m, @lat, @lon, @country, @region, @score, @minzoom)
`);
db.transaction(() => {
for (const p of seeded) {
insert.run({
osm_id: `seed:${p.name}`,
name: p.name,
elevation_m: p.elevation_m,
lat: p.lat,
lon: p.lon,
country: p.country,
region: p.region,
score: p.score,
minzoom: p.minzoom
});
}
})();
}
export interface ActivityRow {
id: number;
user_id: number;
name: string;
type: string;
date: string | null;
distance_m: number;
duration_s: number | null;
moving_s: number | null;
elev_gain_m: number;
elev_loss_m: number;
elev_min_m: number | null;
elev_max_m: number | null;
points: string;
bounds: string;
created_at: string;
}
export interface PeakRow {
id: number;
osm_id: string;
name: string;
elevation_m: number | null;
lat: number;
lon: number;
country: string | null;
region: string | null;
wikipedia: string | null;
wikidata: string | null;
score: number;
minzoom: number;
}
export interface AscentRow {
id: number;
user_id: number;
peak_id: number;
climbed_at: string | null;
activity_id: number | null;
note: string | null;
}
export function listActivities(userId: number): Omit<ActivityRow, 'points'>[] {
return db
.prepare(
`SELECT id, user_id, name, type, date, distance_m, duration_s, moving_s,
elev_gain_m, elev_loss_m, elev_min_m, elev_max_m, bounds, created_at
FROM activities WHERE user_id = ? ORDER BY date DESC, id DESC`
)
.all(userId) as Omit<ActivityRow, 'points'>[];
}
export function getActivity(id: number): (ActivityRow & { username: string }) | undefined {
return db
.prepare(
`SELECT a.*, u.username FROM activities a JOIN users u ON u.id = a.user_id WHERE a.id = ?`
)
.get(id) as (ActivityRow & { username: string }) | undefined;
}
/** All users' activities, newest first, with uploader attribution. */
export function listAllActivities(): (Omit<ActivityRow, 'points'> & { username: string })[] {
return db
.prepare(
`SELECT a.id, a.user_id, a.name, a.type, a.date, a.distance_m, a.duration_s, a.moving_s,
a.elev_gain_m, a.elev_loss_m, a.elev_min_m, a.elev_max_m, a.bounds, a.created_at,
u.username
FROM activities a JOIN users u ON u.id = a.user_id
ORDER BY a.date DESC, a.id DESC`
)
.all() as (Omit<ActivityRow, 'points'> & { username: string })[];
}
export function communityTotals(): {
users: number;
activities: number;
distance_m: number;
elev_gain_m: number;
} {
return db
.prepare(
`SELECT (SELECT count(*) FROM users) users,
count(*) activities,
coalesce(sum(distance_m), 0) distance_m,
coalesce(sum(elev_gain_m), 0) elev_gain_m
FROM activities`
)
.get() as { users: number; activities: number; distance_m: number; elev_gain_m: number };
}
export function deleteActivity(id: number, userId: number): void {
db.prepare('DELETE FROM activities WHERE id = ? AND user_id = ?').run(id, userId);
}
/** Notable peaks in a bounding box for a zoom level, with the user's climbed state. */
export function peaksInView(
userId: number,
bbox: { minLat: number; minLon: number; maxLat: number; maxLon: number },
zoom: number,
limit = 300
): (PeakRow & { climbed_at: string | null; ascent_activity_id: number | null })[] {
return db
.prepare(
`SELECT p.*, a.climbed_at, a.activity_id ascent_activity_id
FROM peaks p
LEFT JOIN ascents a ON a.peak_id = p.id AND a.user_id = @userId
WHERE p.minzoom <= @zoom
AND p.lat BETWEEN @minLat AND @maxLat
AND p.lon BETWEEN @minLon AND @maxLon
ORDER BY p.score DESC
LIMIT @limit`
)
.all({ userId, zoom: Math.floor(zoom), ...bbox, limit }) as (PeakRow & {
climbed_at: string | null;
ascent_activity_id: number | null;
})[];
}
/** Peaks near a track's bounding box, for summit detection on upload. */
export function peaksNearBounds(
bounds: [[number, number], [number, number]],
marginDeg = 0.01
): PeakRow[] {
return db
.prepare(
`SELECT * FROM peaks
WHERE lat BETWEEN ? AND ? AND lon BETWEEN ? AND ?`
)
.all(
bounds[0][0] - marginDeg,
bounds[1][0] + marginDeg,
bounds[0][1] - marginDeg,
bounds[1][1] + marginDeg
) as PeakRow[];
}
export function listAscents(
userId: number
): (AscentRow & { name: string; elevation_m: number | null; lat: number; lon: number })[] {
return db
.prepare(
`SELECT a.*, p.name, p.elevation_m, p.lat, p.lon
FROM ascents a JOIN peaks p ON p.id = a.peak_id
WHERE a.user_id = ?
ORDER BY p.elevation_m DESC`
)
.all(userId) as (AscentRow & {
name: string;
elevation_m: number | null;
lat: number;
lon: number;
})[];
}
export function countAscents(userId: number): number {
return (db.prepare('SELECT count(*) n FROM ascents WHERE user_id = ?').get(userId) as { n: number }).n;
}
/** Toggle an ascent; returns the new climbed_at (or null if now unclimbed). */
export function toggleAscent(userId: number, peakId: number): string | null {
const existing = db
.prepare('SELECT id FROM ascents WHERE user_id = ? AND peak_id = ?')
.get(userId, peakId) as { id: number } | undefined;
if (existing) {
db.prepare('DELETE FROM ascents WHERE id = ?').run(existing.id);
return null;
}
const date = new Date().toISOString().slice(0, 10);
db.prepare('INSERT INTO ascents (user_id, peak_id, climbed_at) VALUES (?, ?, ?)').run(
userId,
peakId,
date
);
return date;
}
export function recordAscent(
userId: number,
peakId: number,
activityId: number,
date: string | null
): boolean {
const result = db
.prepare(
`INSERT INTO ascents (user_id, peak_id, climbed_at, activity_id)
VALUES (?, ?, ?, ?)
ON CONFLICT (user_id, peak_id) DO NOTHING`
)
.run(userId, peakId, date ?? new Date().toISOString().slice(0, 10), activityId);
return result.changes > 0;
}
export function baggedPeaks(activityId: number, userId: number): (PeakRow & { climbed_at: string | null })[] {
return db
.prepare(
`SELECT p.*, a.climbed_at FROM ascents a JOIN peaks p ON p.id = a.peak_id
WHERE a.activity_id = ? AND a.user_id = ?`
)
.all(activityId, userId) as (PeakRow & { climbed_at: string | null })[];
}