feat: add hazards
Some checks failed
TrafficCue Server CI / check (push) Failing after 27s
TrafficCue Server CD / build (push) Successful in 1m48s

This commit is contained in:
2025-09-18 11:48:37 +02:00
parent 7b8210ab7e
commit fb8e3ae8d5
4 changed files with 119 additions and 0 deletions

View File

@ -64,5 +64,6 @@ 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)
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.

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

@ -0,0 +1,32 @@
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,7 @@ 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";
@Entity() @Entity()
export class User extends BaseEntity { export class User extends BaseEntity {
@ -22,6 +23,9 @@ export class User extends BaseEntity {
@OneToMany("Saved", (s: Saved) => s.user) @OneToMany("Saved", (s: Saved) => s.user)
saved: Saved[]; saved: Saved[];
@OneToMany("Hazards", (h: Hazard) => h.user)
hazards: Hazard[];
@Column({ @Column({
default: () => "NOW()", default: () => "NOW()",
}) })

View File

@ -10,6 +10,7 @@ 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";
const app = new Hono(); const app = new Hono();
const { upgradeWebSocket, websocket } = createBunWebSocket<ServerWebSocket>(); const { upgradeWebSocket, websocket } = createBunWebSocket<ServerWebSocket>();
@ -165,6 +166,87 @@ 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.320 * Math.cos(nlat * (Math.PI / 180))), maxLon: nlon + nradius / (111.320 * 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.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 ")) {