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 = { 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; 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 }; }