feat: initial work on stores
Some checks failed
TrafficCue Server CI / check (push) Failing after 27s
TrafficCue Server CD / build (push) Successful in 1m40s

This commit is contained in:
2025-09-22 20:14:32 +02:00
parent a2f30118ef
commit ef3debcdb3
8 changed files with 283 additions and 4 deletions

View File

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

View File

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

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

@ -0,0 +1,54 @@
import {
BaseEntity,
Column,
Entity,
ManyToOne,
PrimaryGeneratedColumn,
} from "typeorm";
import { type User } from "./User";
export const STORE_PRIVACY = ["public", "friends", "private"] as const;
export type StorePrivacy = (typeof STORE_PRIVACY)[number];
export const STORE_TYPE = ["route", "location", "vehicle"] as const;
export type StoreType = (typeof STORE_TYPE)[number];
@Entity()
export class Store extends BaseEntity {
@PrimaryGeneratedColumn()
id: number;
@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({
type: "enum",
enum: STORE_PRIVACY,
default: "private",
})
privacy: StorePrivacy;
@Column({
default: () => "NOW()",
})
modified_at: Date;
@Column({
default: () => "NOW()",
})
created_at: Date;
}

View File

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

View File

@ -9,8 +9,10 @@ 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";
import { ILike, type FindOptionsWhere } from "typeorm";
import { Hazard } from "./entities/Hazard";
import { Store, STORE_PRIVACY, STORE_TYPE } from "./entities/Stores";
import { storeTypes } from "./stores";
const app = new Hono();
const { upgradeWebSocket, websocket } = createBunWebSocket<ServerWebSocket>();
@ -46,6 +48,14 @@ app.get("/api/config", (c) => {
capabilities.push("fuel");
}
if (process.env.HAZARDS_ENABLED) {
capabilities.push("hazards");
}
if (process.env.STORES_ENABLED) {
capabilities.push("stores");
}
return c.json({
name: "TrafficCue Server",
env: process.env.ENV || "dev",
@ -362,6 +372,164 @@ app.delete("/api/saved", async (c) => {
return c.json({ success: true });
});
if (process.env.STORES_ENABLED) {
app.get("/api/stores/:user?", 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 where: FindOptionsWhere<Store> = c.req.param("user")
? { user: { username: c.req.param("user") }, privacy: "public" }
: { user: { id: uid } };
const stores = await Store.find({
where
});
return c.json(stores);
});
app.get("/api/store/:user?/:name", 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 where: FindOptionsWhere<Store> = c.req.param("user")
? { name: c.req.param("name"), user: { username: c.req.param("user") }, privacy: "public" }
: { name: c.req.param("name"), user: { id: uid } };
const store = await Store.findOne({
where
});
return c.json(store);
});
app.put("/api/store", async (c) => {
const { name, data, privacy, type } = await c.req.json();
if (!name || !data || !privacy || !type) {
return c.json({ error: "name, data, privacy and type are required" }, 400);
}
if(!STORE_PRIVACY.includes(privacy)) {
return c.json({ error: "Invalid privacy setting" }, 400);
}
if(!STORE_TYPE.includes(type)) {
return c.json({ error: "Invalid store 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);
}
// Verify the store data is valid (prevent abusing the store as cloud storage)
const verifier = storeTypes[type as keyof typeof storeTypes];
if(!(await verifier.safeParseAsync(JSON.parse(data))).success) {
return c.json({ error: "Invalid store data" }, 400);
}
const user = await User.findOneBy({ id: uid });
if (!user) {
return c.json({ error: "Invalid user ID" }, 400);
}
const existingStore = await Store.findOneBy({ user: { id: uid }, name, type });
const store = existingStore || new Store();
store.user = user;
store.name = name;
store.data = JSON.stringify(JSON.parse(data)); // Ensure data is minified JSON
store.privacy = privacy;
store.type = type;
await store.save();
return c.json({ success: true });
});
app.delete("/api/store", async (c) => {
const { name } = await c.req.json();
if (!name) {
return c.json({ error: "name is required" }, 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 store = await Store.findOneBy({ user: { id: uid }, name });
if (!store) {
return c.json({ error: "No such store found" }, 400);
}
await store.remove();
return c.json({ success: true });
});
}
if (process.env.TANKERKOENIG_API_KEY) {
app.get("/api/fuel/list", async (c) => {
// pass GET query parameters to the tankerkoenig API

44
src/stores.ts Normal file
View File

@ -0,0 +1,44 @@
import z from "zod";
import type { StoreType } from "./entities/Stores";
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({
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,
};