Compare commits

...

18 Commits

Author SHA1 Message Date
35a1447164 style: run prettier
All checks were successful
TrafficCue Server CI / check (push) Successful in 54s
TrafficCue Server CD / build (push) Successful in 1m52s
2025-10-03 19:54:13 +02:00
3de797ec1b feat(hazards): snap to road
Some checks failed
TrafficCue Server CI / check (push) Failing after 53s
TrafficCue Server CD / build (push) Successful in 1m47s
2025-10-03 18:12:56 +02:00
94b4de4920 feat(stores): location stores
Some checks failed
TrafficCue Server CI / check (push) Failing after 51s
TrafficCue Server CD / build (push) Successful in 1m51s
2025-10-03 15:17:35 +02:00
c492e62d60 feat: friend system + shared stores
Some checks failed
TrafficCue Server CI / check (push) Failing after 56s
TrafficCue Server CD / build (push) Successful in 2m5s
2025-10-03 14:13:31 +02:00
aff447e741 style: run prettier
Some checks failed
TrafficCue Server CI / check (push) Failing after 3m33s
TrafficCue Server CD / build (push) Successful in 2m0s
2025-09-29 19:00:47 +02:00
546ca0ddc7 fix(stores): allow null data
Some checks failed
TrafficCue Server CI / check (push) Failing after 52s
TrafficCue Server CD / build (push) Successful in 2m1s
2025-09-27 20:38:41 +02:00
30334d13d1 style: run prettier
All checks were successful
TrafficCue Server CI / check (push) Successful in 54s
TrafficCue Server CD / build (push) Successful in 2m0s
2025-09-27 19:47:41 +02:00
4d8c3aef68 feat: ping endpoint
Some checks failed
TrafficCue Server CI / check (push) Failing after 52s
TrafficCue Server CD / build (push) Has been cancelled
2025-09-27 19:46:28 +02:00
9940a86ac0 feat(stores): Verify store data
Some checks failed
TrafficCue Server CI / check (push) Failing after 51s
TrafficCue Server CD / build (push) Successful in 2m2s
2025-09-27 13:42:50 +02:00
a0d08e5041 fix(stores): error with undefined user
Some checks failed
TrafficCue Server CI / check (push) Failing after 59s
TrafficCue Server CD / build (push) Successful in 2m30s
2025-09-27 12:12:42 +02:00
c59df0bf6a feat: more work on stores
Some checks failed
TrafficCue Server CI / check (push) Failing after 56s
TrafficCue Server CD / build (push) Successful in 2m26s
2025-09-26 20:08:40 +02:00
8d2eb1d9da feat: further work on stores
Some checks failed
TrafficCue Server CI / check (push) Has been cancelled
TrafficCue Server CD / build (push) Has been cancelled
2025-09-26 19:49:39 +02:00
ef3debcdb3 feat: initial work on stores
Some checks failed
TrafficCue Server CI / check (push) Failing after 27s
TrafficCue Server CD / build (push) Successful in 1m40s
2025-09-22 20:14:32 +02:00
a2f30118ef style: run prettier
All checks were successful
TrafficCue Server CI / check (push) Successful in 37s
TrafficCue Server CD / build (push) Successful in 1m34s
2025-09-19 21:54:48 +02:00
03c2e8995b feat: add default hazard votes
Some checks failed
TrafficCue Server CI / check (push) Failing after 24s
TrafficCue Server CD / build (push) Successful in 1m24s
2025-09-18 12:37:11 +02:00
4fa96da842 fix: incorrect name in user relation
Some checks failed
TrafficCue Server CI / check (push) Failing after 21s
TrafficCue Server CD / build (push) Successful in 1m30s
2025-09-18 11:55:59 +02:00
867a5644c7 feat: add hazard to db
Some checks failed
TrafficCue Server CI / check (push) Failing after 29s
TrafficCue Server CD / build (push) Successful in 1m19s
2025-09-18 11:52:32 +02:00
fb8e3ae8d5 feat: add hazards
Some checks failed
TrafficCue Server CI / check (push) Failing after 27s
TrafficCue Server CD / build (push) Successful in 1m48s
2025-09-18 11:48:37 +02:00
11 changed files with 679 additions and 8 deletions

View File

@ -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_JWKS_URL` (the JWKS/Certificate URL of your OIDC server)
- `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.

View File

@ -20,6 +20,7 @@
"testcontainers": "^11.5.1",
"typeorm": "^0.3.26",
"typescript-eslint": "^8.34.1",
"zod": "^4.1.11",
},
"devDependencies": {
"@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=="],
"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=="],

View File

@ -28,6 +28,7 @@
"reflect-metadata": "^0.2.2",
"testcontainers": "^11.5.1",
"typeorm": "^0.3.26",
"typescript-eslint": "^8.34.1"
"typescript-eslint": "^8.34.1",
"zod": "^4.1.11"
}
}

View File

@ -3,6 +3,9 @@ import { DataSource } from "typeorm";
import { User } from "./entities/User";
import { Review } from "./entities/Review";
import { Saved } from "./entities/Saved";
import { Hazard } from "./entities/Hazard";
import { Store } from "./entities/Stores";
import { Follow } from "./entities/Follow";
let db: DataSource | null = null;
@ -12,7 +15,7 @@ export function getDb(forceSync = false): DataSource {
type: "postgres",
url: process.env.DATABASE_URL,
synchronize: process.argv.includes("sync") || forceSync,
entities: [User, Review, Saved],
entities: [User, Review, Saved, Hazard, Store, Follow],
});
}
return db;

41
src/entities/Follow.ts Normal file
View File

@ -0,0 +1,41 @@
import {
BaseEntity,
CreateDateColumn,
Entity,
ManyToOne,
PrimaryGeneratedColumn,
} from "typeorm";
import type { User } from "./User";
@Entity()
export class Follow extends BaseEntity {
@PrimaryGeneratedColumn("uuid")
id: string; // ID of the follow relationship, not a user ID
@ManyToOne("User", (u: User) => u.following, { eager: true })
follower: User;
@ManyToOne("User", (u: User) => u.followers, { eager: true })
following: User;
@CreateDateColumn()
created_at: Date;
}
export async function isFriends(user: User, other: User) {
return (
(await user.following).some((u) => u.following.id === other.id) &&
(await other.following).some((u) => u.following.id === user.id)
);
}
export async function getFriends(user: User): Promise<User[]> {
const friends: User[] = [];
for (const f of await user.following) {
const followedFollowings = await f.following.following;
if (followedFollowings.some((f) => f.following.id === user.id)) {
friends.push(f.following);
}
}
return friends;
}

38
src/entities/Hazard.ts Normal file
View 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;
}

View File

@ -7,6 +7,9 @@ import {
} from "typeorm";
import { type User } from "./User";
/**
* @deprecated Use stores instead
*/
@Entity()
export class Saved extends BaseEntity {
@PrimaryGeneratedColumn()

44
src/entities/Stores.ts Normal file
View 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;
}

View File

@ -7,6 +7,9 @@ import {
} from "typeorm";
import { type Review } from "./Review";
import { type Saved } from "./Saved";
import type { Hazard } from "./Hazard";
import type { Store } from "./Stores";
import type { Follow } from "./Follow";
@Entity()
export class User extends BaseEntity {
@ -19,9 +22,24 @@ export class User extends BaseEntity {
@OneToMany("Review", (r: Review) => r.user)
reviews: Review[];
/**
* @deprecated Use stores instead
*/
@OneToMany("Saved", (s: Saved) => s.user)
saved: Saved[];
@OneToMany("Store", (s: Store) => s.user)
stores: Store[];
@OneToMany("Hazard", (h: Hazard) => h.user)
hazards: Hazard[];
@OneToMany("Follow", (f: Follow) => f.follower)
following: Promise<Follow[]>;
@OneToMany("Follow", (f: Follow) => f.following)
followers: Promise<Follow[]>;
@Column({
default: () => "NOW()",
})

View File

@ -10,6 +10,9 @@ import { Review } from "./entities/Review";
import { User } from "./entities/User";
import { Saved } from "./entities/Saved";
import { ILike } from "typeorm";
import { Hazard } from "./entities/Hazard";
import { sync, SyncPayload } from "./stores";
import { Follow } from "./entities/Follow";
const app = new Hono();
const { upgradeWebSocket, websocket } = createBunWebSocket<ServerWebSocket>();
@ -45,6 +48,14 @@ app.get("/api/config", (c) => {
capabilities.push("fuel");
}
if (process.env.HAZARDS_ENABLED) {
capabilities.push("hazards");
}
if (process.env.STORES_ENABLED) {
capabilities.push("stores");
}
return c.json({
name: "TrafficCue Server",
env: process.env.ENV || "dev",
@ -60,6 +71,10 @@ app.get("/api/config", (c) => {
});
});
app.get("/api/ping", (c) => {
return c.json({ pong: true });
});
app.post("/api/user", async (c) => {
const { token } = await c.req.json();
if (!token) {
@ -88,15 +103,183 @@ app.post("/api/user", async (c) => {
return c.json({ success: true });
});
app.get("/api/user/me", 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.findOne({
where: {
id: uid,
},
relations: {
reviews: true,
hazards: true,
followers: true,
following: true,
},
});
if (!user) {
return c.json({ error: "Invalid user ID" }, 400);
}
return c.json({
username: user.username,
createdAt: user.created_at,
reviewsCount: user.reviews.length,
hazardsCount: user.hazards.length,
followers: (await user.followers).length,
following: (await user.following).length,
});
});
app.post("/api/user/follow", async (c) => {
const { username } = await c.req.json();
if (!username) {
return c.json({ error: "username 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 user = await User.findOne({
where: { id: uid },
});
if (!user) {
return c.json({ error: "Invalid user ID" }, 400);
}
const toFollow = await User.findOne({ where: { username } });
if (!toFollow) {
return c.json({ error: "User not found" }, 404);
}
if (toFollow.id === user.id) {
return c.json({ error: "You cannot follow yourself" }, 400);
}
if ((await user.following).find((u) => u.following.id === toFollow.id)) {
return c.json({ error: "You are already following this user" }, 400);
}
const follow = new Follow();
follow.follower = user;
follow.following = toFollow;
await follow.save();
return c.json({ success: true });
});
app.post("/api/user/unfollow", async (c) => {
const { username } = await c.req.json();
if (!username) {
return c.json({ error: "username 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 user = await User.findOne({
where: { id: uid },
});
if (!user) {
return c.json({ error: "Invalid user ID" }, 400);
}
const toUnfollow = await User.findOne({ where: { username } });
if (!toUnfollow) {
return c.json({ error: "User not found" }, 404);
}
if (toUnfollow.id === user.id) {
return c.json({ error: "You cannot unfollow yourself" }, 400);
}
if (!(await user.following).find((u) => u.following.id === toUnfollow.id)) {
return c.json({ error: "You are not following this user" }, 400);
}
const follow = await Follow.findOne({
where: {
follower: { id: user.id },
following: { id: toUnfollow.id },
},
});
if (follow) {
await follow.remove();
}
return c.json({ success: true });
});
app.get("/api/user", async (c) => {
const name = c.req.query("name");
if (!name) return c.json({ sucess: false }, 400);
const users = await User.findBy({
username: ILike("%" + name + "%"),
});
const mapped = users.map((u) => {
return { username: u.username };
const users = await User.find({
where: {
username: ILike("%" + name + "%"),
},
relations: {
reviews: true,
hazards: true,
followers: true,
following: true,
},
});
const mapped = await Promise.all(
users.map(async (u) => {
return {
username: u.username,
reviewsCount: u.reviews.length,
hazardsCount: u.hazards.length,
followers: (await u.followers).length,
following: (await u.following).length,
createdAt: u.created_at,
};
}),
);
return c.json(mapped);
});
@ -165,6 +348,129 @@ 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 mapMatched = await fetch(
(process.env.VALHALLA || "https://valhalla1.openstreetmap.de") +
"/locate",
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
locations: [
{
lat: nlat,
lon: nlon,
},
],
}),
},
)
.then((res) => res.json())
.catch(() => null);
let mmlat = nlat;
let mmlon = nlon;
if (
mapMatched &&
Array.isArray(mapMatched) &&
mapMatched[0] &&
mapMatched[0].edges &&
mapMatched[0].edges[0]
) {
mmlat = mapMatched[0].edges[0].correlated_lat;
mmlon = mapMatched[0].edges[0].correlated_lon;
}
const hazard = new Hazard();
hazard.latitude = mmlat;
hazard.longitude = mmlon;
hazard.type = type;
hazard.votes = 1;
hazard.user = user;
await hazard.save();
return c.json({ success: true });
});
}
app.get("/api/saved", async (c) => {
const authHeader = c.req.header("Authorization");
if (!authHeader || !authHeader.startsWith("Bearer ")) {
@ -273,6 +579,44 @@ app.delete("/api/saved", async (c) => {
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) {
app.get("/api/fuel/list", async (c) => {
// pass GET query parameters to the tankerkoenig API

176
src/stores.ts Normal file
View File

@ -0,0 +1,176 @@
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 };
}