feat: friend system + shared stores
This commit is contained in:
@ -5,6 +5,7 @@ import { Review } from "./entities/Review";
|
|||||||
import { Saved } from "./entities/Saved";
|
import { Saved } from "./entities/Saved";
|
||||||
import { Hazard } from "./entities/Hazard";
|
import { Hazard } from "./entities/Hazard";
|
||||||
import { Store } from "./entities/Stores";
|
import { Store } from "./entities/Stores";
|
||||||
|
import { Follow } from "./entities/Follow";
|
||||||
|
|
||||||
let db: DataSource | null = null;
|
let db: DataSource | null = null;
|
||||||
|
|
||||||
@ -14,7 +15,7 @@ export function getDb(forceSync = false): DataSource {
|
|||||||
type: "postgres",
|
type: "postgres",
|
||||||
url: process.env.DATABASE_URL,
|
url: process.env.DATABASE_URL,
|
||||||
synchronize: process.argv.includes("sync") || forceSync,
|
synchronize: process.argv.includes("sync") || forceSync,
|
||||||
entities: [User, Review, Saved, Hazard, Store],
|
entities: [User, Review, Saved, Hazard, Store, Follow],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return db;
|
return db;
|
||||||
|
|||||||
32
src/entities/Follow.ts
Normal file
32
src/entities/Follow.ts
Normal file
@ -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<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;
|
||||||
|
}
|
||||||
@ -9,6 +9,7 @@ import { type Review } from "./Review";
|
|||||||
import { type Saved } from "./Saved";
|
import { type Saved } from "./Saved";
|
||||||
import type { Hazard } from "./Hazard";
|
import type { Hazard } from "./Hazard";
|
||||||
import type { Store } from "./Stores";
|
import type { Store } from "./Stores";
|
||||||
|
import type { Follow } from "./Follow";
|
||||||
|
|
||||||
@Entity()
|
@Entity()
|
||||||
export class User extends BaseEntity {
|
export class User extends BaseEntity {
|
||||||
@ -33,6 +34,12 @@ export class User extends BaseEntity {
|
|||||||
@OneToMany("Hazard", (h: Hazard) => h.user)
|
@OneToMany("Hazard", (h: Hazard) => h.user)
|
||||||
hazards: Hazard[];
|
hazards: Hazard[];
|
||||||
|
|
||||||
|
@OneToMany("Follow", (f: Follow) => f.follower)
|
||||||
|
following: Promise<Follow[]>;
|
||||||
|
|
||||||
|
@OneToMany("Follow", (f: Follow) => f.following)
|
||||||
|
followers: Promise<Follow[]>;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
default: () => "NOW()",
|
default: () => "NOW()",
|
||||||
})
|
})
|
||||||
|
|||||||
177
src/main.ts
177
src/main.ts
@ -12,6 +12,7 @@ import { Saved } from "./entities/Saved";
|
|||||||
import { ILike } from "typeorm";
|
import { ILike } from "typeorm";
|
||||||
import { Hazard } from "./entities/Hazard";
|
import { Hazard } from "./entities/Hazard";
|
||||||
import { sync, SyncPayload } from "./stores";
|
import { sync, SyncPayload } from "./stores";
|
||||||
|
import { Follow } from "./entities/Follow";
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
const { upgradeWebSocket, websocket } = createBunWebSocket<ServerWebSocket>();
|
const { upgradeWebSocket, websocket } = createBunWebSocket<ServerWebSocket>();
|
||||||
@ -102,15 +103,181 @@ app.post("/api/user", async (c) => {
|
|||||||
return c.json({ success: true });
|
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) => {
|
app.get("/api/user", async (c) => {
|
||||||
const name = c.req.query("name");
|
const name = c.req.query("name");
|
||||||
if (!name) return c.json({ sucess: false }, 400);
|
if (!name) return c.json({ sucess: false }, 400);
|
||||||
const users = await User.findBy({
|
const users = await User.find({
|
||||||
username: ILike("%" + name + "%"),
|
where: {
|
||||||
});
|
username: ILike("%" + name + "%"),
|
||||||
const mapped = users.map((u) => {
|
},
|
||||||
return { username: u.username };
|
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);
|
return c.json(mapped);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { Store, type StoreType } from "./entities/Stores";
|
import { Store, type StoreType } from "./entities/Stores";
|
||||||
import type { User } from "./entities/User";
|
import type { User } from "./entities/User";
|
||||||
|
import { getFriends } from "./entities/Follow";
|
||||||
|
|
||||||
export const locationStore = z
|
export const locationStore = z
|
||||||
.object({
|
.object({
|
||||||
@ -53,6 +54,8 @@ export const storeTypes: Record<StoreType, z.ZodSchema> = {
|
|||||||
route: routeStore,
|
route: routeStore,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sharedStoreTypes: StoreType[] = ["vehicle"];
|
||||||
|
|
||||||
export const SyncPayload = z.object({
|
export const SyncPayload = z.object({
|
||||||
changes: z.array(
|
changes: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
@ -127,14 +130,44 @@ export async function sync(payload: SyncPayload, user: User) {
|
|||||||
await store.save();
|
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
|
// 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) {
|
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);
|
const clientStore = payload.stores.find((s) => s.id === store.id);
|
||||||
if (!clientStore || new Date(clientStore.modified_at) < store.modified_at) {
|
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
|
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 };
|
return { changes };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user