Compare commits
14 Commits
7b8210ab7e
...
aff447e741
| Author | SHA1 | Date | |
|---|---|---|---|
| aff447e741 | |||
| 546ca0ddc7 | |||
| 30334d13d1 | |||
| 4d8c3aef68 | |||
| 9940a86ac0 | |||
| a0d08e5041 | |||
| c59df0bf6a | |||
| 8d2eb1d9da | |||
| ef3debcdb3 | |||
| a2f30118ef | |||
| 03c2e8995b | |||
| 4fa96da842 | |||
| 867a5644c7 | |||
| fb8e3ae8d5 |
@ -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.
|
||||||
|
|||||||
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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
38
src/entities/Hazard.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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
44
src/entities/Stores.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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()",
|
||||||
})
|
})
|
||||||
|
|||||||
140
src/main.ts
140
src/main.ts
@ -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
140
src/stores.ts
Normal 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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user