This repository has been archived on 2025-11-09. You can view files and clone it, but cannot push or open issues or pull requests.
Files
trafficcue-server/src/stores.ts
Jannik 94b4de4920
Some checks failed
TrafficCue Server CI / check (push) Failing after 51s
TrafficCue Server CD / build (push) Successful in 1m51s
feat(stores): location stores
2025-10-03 15:17:35 +02:00

175 lines
4.9 KiB
TypeScript

import z from "zod";
import { Store, type StoreType } from "./entities/Stores";
import type { User } from "./entities/User";
import { getFriends } from "./entities/Follow";
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),
icon: z.string().min(1).max(100).optional(),
})
.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,
};
const sharedStoreTypes: StoreType[] = ["vehicle"];
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();
}
const friends = await getFriends(user);
const selector = [{ id: user.id }];
for (const friend of friends) {
selector.push({ id: friend.id });
}
// Find stores that are out of date on the client
const allStores = await Store.find({ where: { user: selector }, relations: { user: true } }); // TODO: use SQL query to only get modified stores
for (const store of allStores) {
if (store.user.id !== user.id && !sharedStoreTypes.includes(store.type)) {
// Not the owner of this store and not a shared type
continue;
}
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
}
}
// Send "null" change for stores that aren't owned by the user or their friends
for (const clientStore of payload.stores) {
if (
!allStores.find((s) => s.id === clientStore.id) &&
(!user.id ||
!friends.find((f) => f.id === clientStore.id)) // Not owned by user or their friends
) {
const deletedStore = new Store();
deletedStore.id = clientStore.id;
deletedStore.data = "null";
deletedStore.modified_at = new Date();
deletedStore.created_at = new Date();
deletedStore.type = "location"; // Type doesn't matter, data is null
deletedStore.name = "";
changes.push(deletedStore);
}
}
return { changes };
}