175 lines
4.9 KiB
TypeScript
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 };
|
|
}
|