From c492e62d6061f202e1b532be8329ed2cd7135fe2 Mon Sep 17 00:00:00 2001 From: Jannik Date: Fri, 3 Oct 2025 14:13:31 +0200 Subject: [PATCH] feat: friend system + shared stores --- src/db.ts | 3 +- src/entities/Follow.ts | 32 ++++++++ src/entities/User.ts | 7 ++ src/main.ts | 177 +++++++++++++++++++++++++++++++++++++++-- src/stores.ts | 35 +++++++- 5 files changed, 247 insertions(+), 7 deletions(-) create mode 100644 src/entities/Follow.ts diff --git a/src/db.ts b/src/db.ts index 2b4948b..26305bf 100644 --- a/src/db.ts +++ b/src/db.ts @@ -5,6 +5,7 @@ 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; @@ -14,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, Hazard, Store], + entities: [User, Review, Saved, Hazard, Store, Follow], }); } return db; diff --git a/src/entities/Follow.ts b/src/entities/Follow.ts new file mode 100644 index 0000000..71c8c7e --- /dev/null +++ b/src/entities/Follow.ts @@ -0,0 +1,32 @@ +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 { + 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; +} diff --git a/src/entities/User.ts b/src/entities/User.ts index 95e7c99..da941a8 100644 --- a/src/entities/User.ts +++ b/src/entities/User.ts @@ -9,6 +9,7 @@ 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 { @@ -33,6 +34,12 @@ export class User extends BaseEntity { @OneToMany("Hazard", (h: Hazard) => h.user) hazards: Hazard[]; + @OneToMany("Follow", (f: Follow) => f.follower) + following: Promise; + + @OneToMany("Follow", (f: Follow) => f.following) + followers: Promise; + @Column({ default: () => "NOW()", }) diff --git a/src/main.ts b/src/main.ts index 18fd57b..8bb0c0d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -12,6 +12,7 @@ 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(); @@ -102,15 +103,181 @@ 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); }); diff --git a/src/stores.ts b/src/stores.ts index 9e794e0..cab974e 100644 --- a/src/stores.ts +++ b/src/stores.ts @@ -1,6 +1,7 @@ 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({ @@ -53,6 +54,8 @@ export const storeTypes: Record = { route: routeStore, }; +const sharedStoreTypes: StoreType[] = ["vehicle"]; + export const SyncPayload = z.object({ changes: z.array( z.object({ @@ -127,14 +130,44 @@ export async function sync(payload: SyncPayload, user: User) { 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.findBy({ user: { id: user.id } }); // TODO: include friends' public stores, TODO: use SQL query to only get modified stores + 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 }; }