feat: move to typeorm
Some checks failed
TrafficCue Server CI / check (push) Failing after 23s

This commit is contained in:
Cfp
2025-08-21 12:18:56 +02:00
parent b8549c6deb
commit 03e602c814
9 changed files with 380 additions and 64 deletions

View File

@ -1,3 +1,4 @@
import type { JWTPayload } from "hono/utils/jwt/types";
import { decode, verify, type Algorithm } from "jsonwebtoken";
import jwkToPem, { type JWK } from "jwk-to-pem";
@ -44,3 +45,7 @@ export function getTokenUID(token: string): string | null {
}
return null;
}
export function getTokenData(token: string): JWTPayload {
return decode(token) as JWTPayload;
}

View File

@ -1,5 +1,13 @@
import { Pool } from "pg";
import "reflect-metadata";
import { DataSource } from "typeorm";
import { User } from "./entities/User";
import { Review } from "./entities/Review";
import { Saved } from "./entities/Saved";
export const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
export const db = new DataSource({
type: "postgres",
url: process.env.DATABASE_URL,
synchronize: process.argv.includes("sync"),
entities: [User, Review, Saved]
})

34
src/entities/Review.ts Normal file
View File

@ -0,0 +1,34 @@
import { BaseEntity, Column, Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm";
import { type User } from "./User";
@Entity()
export class Review extends BaseEntity {
@PrimaryGeneratedColumn()
id: number;
@ManyToOne("User", (u: User) => u.reviews)
user: User;
@Column({
type: "float"
})
latitude: number;
@Column({
type: "float"
})
longitude: number;
@Column()
rating: number;
@Column({
type: "text"
})
comment: string;
@Column({
default: () => "NOW()"
})
created_at: Date;
}

24
src/entities/Saved.ts Normal file
View File

@ -0,0 +1,24 @@
import { BaseEntity, Column, Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm";
import { type User } from "./User";
@Entity()
export class Saved extends BaseEntity {
@PrimaryGeneratedColumn()
id: number;
@ManyToOne("User", (u: User) => u.saved)
user: User;
@Column()
name: string;
@Column({
type: "text",
})
data: string;
@Column({
default: () => "NOW()"
})
created_at: Date;
}

28
src/entities/User.ts Normal file
View File

@ -0,0 +1,28 @@
import { BaseEntity, Column, Entity, ManyToOne, OneToMany, PrimaryGeneratedColumn } from "typeorm";
import { type Review } from "./Review";
import { type Saved } from "./Saved";
@Entity()
export class User extends BaseEntity {
@PrimaryGeneratedColumn("uuid")
id: string;
@Column()
username: string;
@OneToMany("Review", (r: Review) => r.user)
reviews: Review[];
@OneToMany("Saved", (s: Saved) => s.user)
saved: Saved[];
@Column({
default: () => "NOW()"
})
updated_at: Date;
@Column({
default: () => "NOW()"
})
created_at: Date;
}

View File

@ -1,40 +1,21 @@
import { Hono } from "hono";
import { cors } from "hono/cors";
import { pool } from "./db";
import { db } from "./db";
import { post } from "./ai";
import { rateLimiter } from "hono-rate-limiter";
import { getTokenUID, verifyToken } from "./auth";
import { getTokenData, getTokenUID, verifyToken } from "./auth";
import { createBunWebSocket } from "hono/bun";
import type { ServerWebSocket } from "bun";
import type { WSContext } from "hono/ws";
import { Review } from "./entities/Review";
import { User } from "./entities/User";
import { Saved } from "./entities/Saved";
import { ILike } from "typeorm";
const app = new Hono();
const { upgradeWebSocket, websocket } = createBunWebSocket<ServerWebSocket>();
async function setupDB() {
await pool.query(`
CREATE TABLE IF NOT EXISTS reviews (
id SERIAL PRIMARY KEY,
user_id TEXT NOT NULL,
latitude FLOAT NOT NULL,
longitude FLOAT NOT NULL,
rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 5),
comment TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS saved (
id SERIAL PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
data TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
`);
}
await setupDB();
await db.initialize();
app.use(
"/api/*", // or replace with "*" to enable cors for all routes
@ -81,6 +62,38 @@ app.get("/api/config", (c) => {
});
});
app.post("/api/user", async (c) => {
const { token } = await c.req.json();
if(!token) {
return c.json({ error: "Invalid request" }, 400);
}
if(!verifyToken(token)) {
return c.json({ error: "Invalid token" }, 400);
}
const tokenData = getTokenData(token);
if(!tokenData.sub || typeof tokenData.sub != "string") return c.json({ error: "Invalid token" }, 400);
if(!tokenData.preferred_username || typeof tokenData.preferred_username != "string") return c.json({ error: "Invalid token" }, 400);
const user = await User.findOneBy({ id: tokenData.sub }) || new User();
user.id = tokenData.sub;
user.username = tokenData.preferred_username;
user.updated_at = new Date();
await user.save();
return c.json({ success: true });
})
app.get("/api/user", async (c) => {
const name = c.req.query("name");
if(!name) return c.json({ sucess: false }, 400);
const users = await User.findBy({
username: ILike("%" + name + "%")
});
const mapped = users.map(u => { return { username: u.username }; });
return c.json(mapped);
})
if (process.env.REVIEWS_ENABLED) {
app.get("/api/reviews", async (c) => {
let { lat, lon } = c.req.query();
@ -88,27 +101,14 @@ if (process.env.REVIEWS_ENABLED) {
return c.json({ error: "Latitude and longitude are required" }, 400);
}
// Remove unnecessary precision from lat/lon
lat = parseFloat(lat).toFixed(6);
lon = parseFloat(lon).toFixed(6);
let nlat = Number(parseFloat(lat).toFixed(6));
let nlon = Number(parseFloat(lon).toFixed(6));
console.log(`Fetching reviews for lat: ${lat}, lon: ${lon}`);
const res = await pool.query(
"SELECT * FROM reviews WHERE latitude = $1 AND longitude = $2",
[lat, lon],
);
return c.json(
await Promise.all(
res.rows.map(async (row) => {
return {
id: row.id,
user_id: row.user_id,
rating: row.rating,
comment: row.comment,
created_at: row.created_at,
username: "Me", // TODO: Sync OIDC users with the database
};
}),
),
);
const reviews = await Review.findBy({
latitude: nlat,
longitude: nlon
});
return c.json(reviews);
});
app.post("/api/review", async (c) => {
@ -138,12 +138,31 @@ if (process.env.REVIEWS_ENABLED) {
return c.json({ error: "Unauthorized" }, 401);
}
const res = await pool.query(
/* const res = await pool.query(
"INSERT INTO reviews (user_id, latitude, longitude, rating, comment) VALUES ($1, $2, $3, $4, $5) RETURNING *",
[uid, lat, lon, rating, comment],
);
return c.json(res.rows[0]);
return c.json(res.rows[0]); */
let user = await User.findOneBy({ id: uid });
if(!user) {
return c.json({ error: "Invalid user ID" }, 400);
}
// Remove unnecessary precision from lat/lon
let nlat = Number(parseFloat(lat).toFixed(6));
let nlon = Number(parseFloat(lon).toFixed(6));
const review = new Review();
review.latitude = nlat;
review.longitude = nlon;
review.rating = rating;
review.comment = comment;
review.user = user;
await review.save();
return c.json({ success: true });
});
}
@ -166,12 +185,20 @@ app.get("/api/saved", async (c) => {
return c.json({ error: "Unauthorized" }, 401);
}
const res = await pool.query(
/* const res = await pool.query(
"SELECT * FROM saved WHERE user_id = $1",
[uid],
);
return c.json(res.rows);
return c.json(res.rows); */
let user = await User.findOneBy({ id: uid });
if(!user) {
return c.json({ error: "Invalid user ID" }, 400);
}
const saved = await Saved.findBy({ user });
return c.json(saved);
})
app.put("/api/saved", async (c) => {
@ -201,12 +228,25 @@ app.put("/api/saved", async (c) => {
return c.json({ error: "Unauthorized" }, 401);
}
const res = await pool.query(
/* const res = await pool.query(
"INSERT INTO saved (user_id, name, data) VALUES ($1, $2, $3) RETURNING *",
[uid, name, data],
);
return c.json(res.rows[0]);
return c.json(res.rows[0]); */
let user = await User.findOneBy({ id: uid });
if(!user) {
return c.json({ error: "Invalid user ID" }, 400);
}
const saved = new Saved();
saved.user = user;
saved.name = name;
saved.data = data;
await saved.save();
return c.json({ success: true })
})
app.delete("/api/saved", async (c) => {
@ -236,12 +276,25 @@ app.delete("/api/saved", async (c) => {
return c.json({ error: "Unauthorized" }, 401);
}
const res = await pool.query(
/* const res = await pool.query(
"DELETE FROM saved WHERE user_id = $1 AND name = $2",
[uid, name],
);
return c.json(res.rows[0]);
return c.json(res.rows[0]); */
let user = await User.findOneBy({ id: uid });
if(!user) {
return c.json({ error: "Invalid user ID" }, 400);
}
const saved = await Saved.findOneBy({ user, name });
if(!saved) {
return c.json({ error: "No such save found" }, 400);
}
await saved.remove();
return c.json({ success: true });
})
if (process.env.TANKERKOENIG_API_KEY) {