feat: initial work on stores
This commit is contained in:
3
bun.lock
3
bun.lock
@ -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=="],
|
||||||
|
|
||||||
|
|||||||
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ 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 { Hazard } from "./entities/Hazard";
|
||||||
|
import { Store } from "./entities/Stores";
|
||||||
|
|
||||||
let db: DataSource | null = null;
|
let db: DataSource | null = null;
|
||||||
|
|
||||||
@ -13,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, Hazard],
|
entities: [User, Review, Saved, Hazard, Store],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return db;
|
return db;
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
54
src/entities/Stores.ts
Normal file
54
src/entities/Stores.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -8,6 +8,7 @@ import {
|
|||||||
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 { Hazard } from "./Hazard";
|
||||||
|
import type { Store } from "./Stores";
|
||||||
|
|
||||||
@Entity()
|
@Entity()
|
||||||
export class User extends BaseEntity {
|
export class User extends BaseEntity {
|
||||||
@ -20,9 +21,15 @@ 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)
|
@OneToMany("Hazard", (h: Hazard) => h.user)
|
||||||
hazards: Hazard[];
|
hazards: Hazard[];
|
||||||
|
|
||||||
|
|||||||
170
src/main.ts
170
src/main.ts
@ -9,8 +9,10 @@ import type { WSContext } from "hono/ws";
|
|||||||
import { Review } from "./entities/Review";
|
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, type FindOptionsWhere } from "typeorm";
|
||||||
import { Hazard } from "./entities/Hazard";
|
import { Hazard } from "./entities/Hazard";
|
||||||
|
import { Store, STORE_PRIVACY, STORE_TYPE } from "./entities/Stores";
|
||||||
|
import { storeTypes } from "./stores";
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
const { upgradeWebSocket, websocket } = createBunWebSocket<ServerWebSocket>();
|
const { upgradeWebSocket, websocket } = createBunWebSocket<ServerWebSocket>();
|
||||||
@ -46,6 +48,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",
|
||||||
@ -362,6 +372,164 @@ app.delete("/api/saved", async (c) => {
|
|||||||
return c.json({ success: true });
|
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) {
|
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
|
||||||
|
|||||||
44
src/stores.ts
Normal file
44
src/stores.ts
Normal 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,
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user