Compare commits

...

14 Commits

Author SHA1 Message Date
aff447e741 style: run prettier
Some checks failed
TrafficCue Server CI / check (push) Failing after 3m33s
TrafficCue Server CD / build (push) Successful in 2m0s
2025-09-29 19:00:47 +02:00
546ca0ddc7 fix(stores): allow null data
Some checks failed
TrafficCue Server CI / check (push) Failing after 52s
TrafficCue Server CD / build (push) Successful in 2m1s
2025-09-27 20:38:41 +02:00
30334d13d1 style: run prettier
All checks were successful
TrafficCue Server CI / check (push) Successful in 54s
TrafficCue Server CD / build (push) Successful in 2m0s
2025-09-27 19:47:41 +02:00
4d8c3aef68 feat: ping endpoint
Some checks failed
TrafficCue Server CI / check (push) Failing after 52s
TrafficCue Server CD / build (push) Has been cancelled
2025-09-27 19:46:28 +02:00
9940a86ac0 feat(stores): Verify store data
Some checks failed
TrafficCue Server CI / check (push) Failing after 51s
TrafficCue Server CD / build (push) Successful in 2m2s
2025-09-27 13:42:50 +02:00
a0d08e5041 fix(stores): error with undefined user
Some checks failed
TrafficCue Server CI / check (push) Failing after 59s
TrafficCue Server CD / build (push) Successful in 2m30s
2025-09-27 12:12:42 +02:00
c59df0bf6a feat: more work on stores
Some checks failed
TrafficCue Server CI / check (push) Failing after 56s
TrafficCue Server CD / build (push) Successful in 2m26s
2025-09-26 20:08:40 +02:00
8d2eb1d9da feat: further work on stores
Some checks failed
TrafficCue Server CI / check (push) Has been cancelled
TrafficCue Server CD / build (push) Has been cancelled
2025-09-26 19:49:39 +02:00
ef3debcdb3 feat: initial work on stores
Some checks failed
TrafficCue Server CI / check (push) Failing after 27s
TrafficCue Server CD / build (push) Successful in 1m40s
2025-09-22 20:14:32 +02:00
a2f30118ef style: run prettier
All checks were successful
TrafficCue Server CI / check (push) Successful in 37s
TrafficCue Server CD / build (push) Successful in 1m34s
2025-09-19 21:54:48 +02:00
03c2e8995b feat: add default hazard votes
Some checks failed
TrafficCue Server CI / check (push) Failing after 24s
TrafficCue Server CD / build (push) Successful in 1m24s
2025-09-18 12:37:11 +02:00
4fa96da842 fix: incorrect name in user relation
Some checks failed
TrafficCue Server CI / check (push) Failing after 21s
TrafficCue Server CD / build (push) Successful in 1m30s
2025-09-18 11:55:59 +02:00
867a5644c7 feat: add hazard to db
Some checks failed
TrafficCue Server CI / check (push) Failing after 29s
TrafficCue Server CD / build (push) Successful in 1m19s
2025-09-18 11:52:32 +02:00
fb8e3ae8d5 feat: add hazards
Some checks failed
TrafficCue Server CI / check (push) Failing after 27s
TrafficCue Server CD / build (push) Successful in 1m48s
2025-09-18 11:48:37 +02:00
10 changed files with 385 additions and 3 deletions

View File

@ -64,5 +64,7 @@ You can run this yourself to host your own instance, or contribute to the offici
- `OIDC_TOKEN_URL` (the Token URL of your OIDC server) - `OIDC_TOKEN_URL` (the Token URL of your OIDC server)
- `OIDC_JWKS_URL` (the JWKS/Certificate URL of your OIDC server) - `OIDC_JWKS_URL` (the JWKS/Certificate URL of your OIDC server)
- `REVIEWS_ENABLED` (optional, set to `true` to enable POI reviews by users, requires OIDC) - `REVIEWS_ENABLED` (optional, set to `true` to enable POI reviews by users, requires OIDC)
- `HAZARDS_ENABLED` (optional, set to `true` to enable hazard reporting by users, requires OIDC)
- `STORES_ENALED` (optional, set to `true` to enable user stores, requires OIDC)
When configuring your OIDC server, make sure to enable Public Client and PCKE support. When configuring your OIDC server, make sure to enable Public Client and PCKE support.

View File

@ -20,6 +20,7 @@
"testcontainers": "^11.5.1", "testcontainers": "^11.5.1",
"typeorm": "^0.3.26", "typeorm": "^0.3.26",
"typescript-eslint": "^8.34.1", "typescript-eslint": "^8.34.1",
"zod": "^4.1.11",
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "latest", "@types/bun": "latest",
@ -715,7 +716,7 @@
"zip-stream": ["zip-stream@6.0.1", "", { "dependencies": { "archiver-utils": "^5.0.0", "compress-commons": "^6.0.2", "readable-stream": "^4.0.0" } }, "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA=="], "zip-stream": ["zip-stream@6.0.1", "", { "dependencies": { "archiver-utils": "^5.0.0", "compress-commons": "^6.0.2", "readable-stream": "^4.0.0" } }, "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA=="],
"zod": ["zod@3.24.4", "", {}, "sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg=="], "zod": ["zod@4.1.11", "", {}, "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="],
"zod-to-json-schema": ["zod-to-json-schema@3.24.5", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g=="], "zod-to-json-schema": ["zod-to-json-schema@3.24.5", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g=="],

View File

@ -28,6 +28,7 @@
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"testcontainers": "^11.5.1", "testcontainers": "^11.5.1",
"typeorm": "^0.3.26", "typeorm": "^0.3.26",
"typescript-eslint": "^8.34.1" "typescript-eslint": "^8.34.1",
"zod": "^4.1.11"
} }
} }

View File

@ -3,6 +3,8 @@ import { DataSource } from "typeorm";
import { User } from "./entities/User"; import { User } from "./entities/User";
import { Review } from "./entities/Review"; import { Review } from "./entities/Review";
import { Saved } from "./entities/Saved"; import { Saved } from "./entities/Saved";
import { Hazard } from "./entities/Hazard";
import { Store } from "./entities/Stores";
let db: DataSource | null = null; let db: DataSource | null = null;
@ -12,7 +14,7 @@ export function getDb(forceSync = false): DataSource {
type: "postgres", type: "postgres",
url: process.env.DATABASE_URL, url: process.env.DATABASE_URL,
synchronize: process.argv.includes("sync") || forceSync, synchronize: process.argv.includes("sync") || forceSync,
entities: [User, Review, Saved], entities: [User, Review, Saved, Hazard, Store],
}); });
} }
return db; return db;

38
src/entities/Hazard.ts Normal file
View File

@ -0,0 +1,38 @@
import {
BaseEntity,
Column,
Entity,
ManyToOne,
PrimaryGeneratedColumn,
} from "typeorm";
import type { User } from "./User";
@Entity()
export class Hazard extends BaseEntity {
@PrimaryGeneratedColumn("uuid")
id: string;
@Column({
type: "float",
})
latitude: number;
@Column({
type: "float",
})
longitude: number;
@Column()
type: string;
@Column()
votes: number;
@Column({
default: () => "NOW()",
})
created_at: Date;
@ManyToOne("User", (u: User) => u.hazards)
user: User;
}

View File

@ -7,6 +7,9 @@ import {
} from "typeorm"; } from "typeorm";
import { type User } from "./User"; import { type User } from "./User";
/**
* @deprecated Use stores instead
*/
@Entity() @Entity()
export class Saved extends BaseEntity { export class Saved extends BaseEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()

44
src/entities/Stores.ts Normal file
View File

@ -0,0 +1,44 @@
import {
BaseEntity,
Column,
Entity,
ManyToOne,
PrimaryGeneratedColumn,
} from "typeorm";
import { type User } from "./User";
export const STORE_TYPE = ["route", "location", "vehicle"] as const;
export type StoreType = (typeof STORE_TYPE)[number];
@Entity()
export class Store extends BaseEntity {
@PrimaryGeneratedColumn("uuid")
id: string;
@ManyToOne("User", (u: User) => u.stores)
user: User;
@Column()
name: string;
@Column({
type: "enum",
enum: STORE_TYPE,
})
type: StoreType;
@Column({
type: "text",
})
data: string;
@Column({
default: () => "NOW()",
})
modified_at: Date;
@Column({
default: () => "NOW()",
})
created_at: Date;
}

View File

@ -7,6 +7,8 @@ import {
} from "typeorm"; } from "typeorm";
import { type Review } from "./Review"; import { type Review } from "./Review";
import { type Saved } from "./Saved"; import { type Saved } from "./Saved";
import type { Hazard } from "./Hazard";
import type { Store } from "./Stores";
@Entity() @Entity()
export class User extends BaseEntity { export class User extends BaseEntity {
@ -19,9 +21,18 @@ export class User extends BaseEntity {
@OneToMany("Review", (r: Review) => r.user) @OneToMany("Review", (r: Review) => r.user)
reviews: Review[]; reviews: Review[];
/**
* @deprecated Use stores instead
*/
@OneToMany("Saved", (s: Saved) => s.user) @OneToMany("Saved", (s: Saved) => s.user)
saved: Saved[]; saved: Saved[];
@OneToMany("Store", (s: Store) => s.user)
stores: Store[];
@OneToMany("Hazard", (h: Hazard) => h.user)
hazards: Hazard[];
@Column({ @Column({
default: () => "NOW()", default: () => "NOW()",
}) })

View File

@ -10,6 +10,8 @@ import { Review } from "./entities/Review";
import { User } from "./entities/User"; import { User } from "./entities/User";
import { Saved } from "./entities/Saved"; import { Saved } from "./entities/Saved";
import { ILike } from "typeorm"; import { ILike } from "typeorm";
import { Hazard } from "./entities/Hazard";
import { sync, SyncPayload } from "./stores";
const app = new Hono(); const app = new Hono();
const { upgradeWebSocket, websocket } = createBunWebSocket<ServerWebSocket>(); const { upgradeWebSocket, websocket } = createBunWebSocket<ServerWebSocket>();
@ -45,6 +47,14 @@ app.get("/api/config", (c) => {
capabilities.push("fuel"); capabilities.push("fuel");
} }
if (process.env.HAZARDS_ENABLED) {
capabilities.push("hazards");
}
if (process.env.STORES_ENABLED) {
capabilities.push("stores");
}
return c.json({ return c.json({
name: "TrafficCue Server", name: "TrafficCue Server",
env: process.env.ENV || "dev", env: process.env.ENV || "dev",
@ -60,6 +70,10 @@ app.get("/api/config", (c) => {
}); });
}); });
app.get("/api/ping", (c) => {
return c.json({ pong: true });
});
app.post("/api/user", async (c) => { app.post("/api/user", async (c) => {
const { token } = await c.req.json(); const { token } = await c.req.json();
if (!token) { if (!token) {
@ -165,6 +179,94 @@ if (process.env.REVIEWS_ENABLED) {
}); });
} }
const VALID_HAZARD_TYPES = ["bumpy-road"];
if (process.env.HAZARDS_ENABLED) {
app.get("/api/hazards", async (c) => {
const { lat, lon, radius } = c.req.query();
if (!lat || !lon || !radius) {
return c.json(
{ error: "Latitude, longitude, and radius are required" },
400,
);
}
// Remove unnecessary precision from lat/lon
const nlat = Number(parseFloat(lat).toFixed(4));
const nlon = Number(parseFloat(lon).toFixed(4));
const nradius = Number(radius);
if (isNaN(nradius) || nradius <= 0) {
return c.json({ error: "Invalid radius" }, 400);
}
if (nradius > 1000) {
return c.json({ error: "Radius too large, max is 1000 km" }, 400);
}
console.log(
`Fetching hazards for lat: ${lat}, lon: ${lon}, radius: ${radius} km`,
);
const hazards = await Hazard.createQueryBuilder("hazard") // Crazy math to get a rough bounding box for the radius in km
.where("hazard.latitude BETWEEN :minLat AND :maxLat", {
minLat: nlat - nradius / 110.574,
maxLat: nlat + nradius / 110.574,
})
.andWhere("hazard.longitude BETWEEN :minLon AND :maxLon", {
minLon: nlon - nradius / (111.32 * Math.cos(nlat * (Math.PI / 180))),
maxLon: nlon + nradius / (111.32 * Math.cos(nlat * (Math.PI / 180))),
})
.getMany();
return c.json(hazards);
});
app.post("/api/hazards", async (c) => {
const { type, lat, lon } = await c.req.json();
if (!type || !lat || !lon) {
return c.json(
{ error: "Type, latitude, and longitude are required" },
400,
);
}
if (!VALID_HAZARD_TYPES.includes(type)) {
return c.json({ error: "Invalid hazard type" }, 400);
}
const authHeader = c.req.header("Authorization");
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return c.json({ error: "Unauthorized" }, 401);
}
const token = authHeader.split(" ")[1];
if (!token) {
return c.json({ error: "Unauthorized" }, 401);
}
const isValid = await verifyToken(token);
if (!isValid) {
return c.json({ error: "Unauthorized" }, 401);
}
const uid = await getTokenUID(token);
if (!uid) {
return c.json({ error: "Unauthorized" }, 401);
}
const user = await User.findOneBy({ id: uid });
if (!user) {
return c.json({ error: "Invalid user ID" }, 400);
}
// Remove unnecessary precision from lat/lon
const nlat = Number(parseFloat(lat).toFixed(4));
const nlon = Number(parseFloat(lon).toFixed(4));
const hazard = new Hazard();
hazard.latitude = nlat;
hazard.longitude = nlon;
hazard.type = type;
hazard.votes = 1;
hazard.user = user;
await hazard.save();
return c.json({ success: true });
});
}
app.get("/api/saved", async (c) => { app.get("/api/saved", async (c) => {
const authHeader = c.req.header("Authorization"); const authHeader = c.req.header("Authorization");
if (!authHeader || !authHeader.startsWith("Bearer ")) { if (!authHeader || !authHeader.startsWith("Bearer ")) {
@ -273,6 +375,44 @@ app.delete("/api/saved", async (c) => {
return c.json({ success: true }); return c.json({ success: true });
}); });
if (process.env.STORES_ENABLED) {
app.post("/api/stores/sync", async (c) => {
const authHeader = c.req.header("Authorization");
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return c.json({ error: "Unauthorized" }, 401);
}
const token = authHeader.split(" ")[1];
if (!token) {
return c.json({ error: "Unauthorized" }, 401);
}
const isValid = await verifyToken(token);
if (!isValid) {
return c.json({ error: "Unauthorized" }, 401);
}
const uid = await getTokenUID(token);
if (!uid) {
return c.json({ error: "Unauthorized" }, 401);
}
const user = await User.findOneBy(
c.req.param("user") ? { username: c.req.param("user") } : { id: uid },
);
if (!user) {
return c.json({ error: "Invalid user ID" }, 400);
}
const payload = SyncPayload.safeParse(await c.req.json());
if (!payload.success) {
return c.json({ error: "Invalid payload", details: payload.error }, 400);
}
const { changes } = await sync(payload.data, user);
return c.json({ changes });
});
}
if (process.env.TANKERKOENIG_API_KEY) { if (process.env.TANKERKOENIG_API_KEY) {
app.get("/api/fuel/list", async (c) => { app.get("/api/fuel/list", async (c) => {
// pass GET query parameters to the tankerkoenig API // pass GET query parameters to the tankerkoenig API

140
src/stores.ts Normal file
View File

@ -0,0 +1,140 @@
import z from "zod";
import { Store, type StoreType } from "./entities/Stores";
import type { User } from "./entities/User";
export const locationStore = z
.object({
lat: z.number().min(-90).max(90),
lng: z.number().min(-180).max(180),
name: z.string().min(1).max(100),
})
.strict();
export const vehicleStore = z
.object({
name: z.string().min(1).max(100),
legalMaxSpeed: z.number().min(0).max(300),
actualMaxSpeed: z.number().min(0).max(300),
type: z.enum(["car", "truck", "motorcycle", "bicycle", "motor_scooter"]),
weight: z.number().min(0).max(100000).optional(),
width: z.number().min(0).max(10).optional(),
axisLoad: z.number().min(0).max(100000).optional(),
height: z.number().min(0).max(20).optional(),
length: z.number().min(0).max(100).optional(),
emissionClass: z.string().min(1).max(50),
fuelType: z.enum(["petrol", "diesel", "electric"]),
preferredFuel: z.string().min(1).max(50),
})
.strict();
export const routeStore = z.object({
name: z.string().min(1).max(100),
locations: z.array(
z.object({
lat: z.number().min(-90).max(90),
lon: z.number().min(-180).max(180),
}),
),
legs: z.array(
z.object({
shape: z.string(),
maneuvers: z.array(
z.object({
type: z.number(),
}),
),
}),
),
});
export const storeTypes: Record<StoreType, z.ZodSchema> = {
location: locationStore,
vehicle: vehicleStore,
route: routeStore,
};
export const SyncPayload = z.object({
changes: z.array(
z.object({
id: z.uuid(),
operation: z.enum(["create", "update", "delete"]),
data: z.string(),
modified_at: z
.string()
.refine((val) => !isNaN(Date.parse(val)), { message: "Invalid date" }),
type: z.enum(["location", "vehicle", "route"]),
name: z.string().min(1).max(100),
}),
),
stores: z.array(
z.object({
id: z.uuid(),
modified_at: z
.string()
.refine((val) => !isNaN(Date.parse(val)), { message: "Invalid date" }),
}),
),
});
export type SyncPayload = z.infer<typeof SyncPayload>;
export function verifyStoreData(type: StoreType, data: string) {
const schema = storeTypes[type];
if (!schema) return false;
if (data === "null") return true; // allow null data
try {
const parsedData = JSON.parse(data);
schema.parse(parsedData);
return true;
} catch {
return false;
}
}
export async function sync(payload: SyncPayload, user: User) {
const changes: Store[] = [];
// Apply changes from client
for (const change of payload.changes) {
const store = await Store.findOne({
where: { id: change.id },
relations: { user: true },
});
if (!verifyStoreData(change.type, change.data)) {
// Invalid data
if (store) changes.push(store); // Send back the store to the client to overwrite their changes
continue;
}
if (!store) {
// Store doesn't exist, create it
const newStore = new Store();
newStore.id = change.id;
newStore.user = user;
newStore.data = change.data;
newStore.modified_at = new Date(change.modified_at);
newStore.created_at = new Date();
newStore.type = change.type;
newStore.name = change.name;
await newStore.save();
continue;
}
if (store.user.id !== user.id) {
// Not the owner of this store
changes.push(store); // Send back the store to the client to overwrite their changes
continue;
}
store.data = change.data;
store.modified_at = new Date(change.modified_at);
await store.save();
}
// Find stores that are out of date on the client
const allStores = await Store.findBy({ user: { id: user.id } }); // TODO: include friends' public stores, TODO: use SQL query to only get modified stores
for (const store of allStores) {
const clientStore = payload.stores.find((s) => s.id === store.id);
if (!clientStore || new Date(clientStore.modified_at) < store.modified_at) {
changes.push(store); // Client doesn't have this store or it's out of date
}
}
return { changes };
}