This commit is contained in:
@@ -6,6 +6,7 @@ import { errorHandler } from "./middleware/errorHandler.js";
|
||||
import { authRouter } from "./modules/auth/routes.js";
|
||||
import { usersRouter } from "./modules/users/routes.js";
|
||||
import { syncRouter } from "./modules/sync/routes.js";
|
||||
import { friendsRouter } from "./modules/friends/routes.js";
|
||||
|
||||
export function createApp() {
|
||||
const app = express();
|
||||
@@ -24,6 +25,7 @@ export function createApp() {
|
||||
app.use("/api/v1/auth", authRouter);
|
||||
app.use("/api/v1", usersRouter);
|
||||
app.use("/api/v1/sync", syncRouter);
|
||||
app.use("/api/v1/friends", friendsRouter);
|
||||
|
||||
app.use(errorHandler);
|
||||
|
||||
|
||||
@@ -3,6 +3,12 @@ export const env = {
|
||||
databaseUrl: process.env.DATABASE_URL || "",
|
||||
jwtSecret: process.env.JWT_SECRET || "dev-secret-change-me",
|
||||
isProduction: process.env.NODE_ENV === "production",
|
||||
// Whether the refresh cookie is flagged Secure (HTTPS only). Defaults to
|
||||
// production, but can be forced off for a plain-HTTP LAN deployment.
|
||||
cookieSecure:
|
||||
process.env.COOKIE_SECURE !== undefined
|
||||
? process.env.COOKIE_SECURE === "true"
|
||||
: process.env.NODE_ENV === "production",
|
||||
corsOrigins: (process.env.CORS_ORIGINS || "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
|
||||
@@ -61,6 +61,19 @@ const STATEMENTS = [
|
||||
deleted_at bigint
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS sessions_owner_updated_idx ON sessions (owner_id, updated_at)`,
|
||||
`CREATE TABLE IF NOT EXISTS friendships (
|
||||
id uuid PRIMARY KEY,
|
||||
requester_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
addressee_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
status text NOT NULL,
|
||||
created_at bigint NOT NULL,
|
||||
updated_at bigint NOT NULL
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS friendships_requester_idx ON friendships (requester_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS friendships_addressee_idx ON friendships (addressee_id)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS friendships_pair_uidx ON friendships (
|
||||
LEAST(requester_id, addressee_id), GREATEST(requester_id, addressee_id)
|
||||
)`,
|
||||
];
|
||||
|
||||
export async function ensureSchema(db: AppDatabase) {
|
||||
|
||||
@@ -74,6 +74,27 @@ export const players = pgTable(
|
||||
}),
|
||||
);
|
||||
|
||||
export const friendships = pgTable(
|
||||
"friendships",
|
||||
{
|
||||
id: uuid("id").primaryKey(),
|
||||
requesterId: uuid("requester_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
addresseeId: uuid("addressee_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
// "pending" | "accepted" | "blocked"
|
||||
status: text("status").notNull(),
|
||||
createdAt: ms("created_at").notNull(),
|
||||
updatedAt: ms("updated_at").notNull(),
|
||||
},
|
||||
(t) => ({
|
||||
requesterIdx: index("friendships_requester_idx").on(t.requesterId),
|
||||
addresseeIdx: index("friendships_addressee_idx").on(t.addresseeId),
|
||||
}),
|
||||
);
|
||||
|
||||
export const sessions = pgTable(
|
||||
"sessions",
|
||||
{
|
||||
|
||||
@@ -29,7 +29,7 @@ const loginSchema = z.object({
|
||||
function setRefreshCookie(res: Response, token: string) {
|
||||
res.cookie(REFRESH_COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
secure: env.isProduction,
|
||||
secure: env.cookieSecure,
|
||||
sameSite: "strict",
|
||||
maxAge: REFRESH_TOKEN_TTL_MS,
|
||||
path: "/api/v1/auth",
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { Request, Response } from "express";
|
||||
import { z } from "zod";
|
||||
import * as friends from "./service.js";
|
||||
|
||||
const sendSchema = z.object({ username: z.string().min(1) });
|
||||
|
||||
export async function list(req: Request, res: Response) {
|
||||
res.json({ friends: await friends.listFriends(req.userId!) });
|
||||
}
|
||||
|
||||
export async function requests(req: Request, res: Response) {
|
||||
res.json(await friends.listRequests(req.userId!));
|
||||
}
|
||||
|
||||
export async function search(req: Request, res: Response) {
|
||||
const q = String(req.query.q ?? "");
|
||||
res.json({ results: await friends.searchUsers(req.userId!, q) });
|
||||
}
|
||||
|
||||
export async function sendRequest(req: Request, res: Response) {
|
||||
const { username } = sendSchema.parse(req.body);
|
||||
const result = await friends.sendRequest(req.userId!, username);
|
||||
res.status(201).json(result);
|
||||
}
|
||||
|
||||
export async function accept(req: Request, res: Response) {
|
||||
await friends.acceptRequest(req.userId!, req.params.id);
|
||||
res.json({ ok: true });
|
||||
}
|
||||
|
||||
export async function decline(req: Request, res: Response) {
|
||||
await friends.declineRequest(req.userId!, req.params.id);
|
||||
res.json({ ok: true });
|
||||
}
|
||||
|
||||
export async function unfriend(req: Request, res: Response) {
|
||||
await friends.removeFriend(req.userId!, req.params.id);
|
||||
res.json({ ok: true });
|
||||
}
|
||||
|
||||
export async function block(req: Request, res: Response) {
|
||||
await friends.blockFriend(req.userId!, req.params.id);
|
||||
res.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Router } from "express";
|
||||
import { requireAuth } from "../../middleware/auth.js";
|
||||
import { asyncHandler } from "../../middleware/errorHandler.js";
|
||||
import * as controller from "./controller.js";
|
||||
|
||||
export const friendsRouter = Router();
|
||||
|
||||
friendsRouter.use(requireAuth);
|
||||
|
||||
friendsRouter.get("/", asyncHandler(controller.list));
|
||||
friendsRouter.get("/requests", asyncHandler(controller.requests));
|
||||
friendsRouter.get("/search", asyncHandler(controller.search));
|
||||
friendsRouter.post("/requests", asyncHandler(controller.sendRequest));
|
||||
friendsRouter.post("/requests/:id/accept", asyncHandler(controller.accept));
|
||||
friendsRouter.post("/requests/:id/decline", asyncHandler(controller.decline));
|
||||
friendsRouter.delete("/:id", asyncHandler(controller.unfriend));
|
||||
friendsRouter.post("/:id/block", asyncHandler(controller.block));
|
||||
@@ -0,0 +1,220 @@
|
||||
import { and, eq, ilike, inArray, or, ne } from "drizzle-orm";
|
||||
import { getDb } from "../../db/client.js";
|
||||
import { users, friendships } from "../../db/schema.js";
|
||||
import { HttpError } from "../../middleware/errorHandler.js";
|
||||
import { newId } from "../../utils/tokens.js";
|
||||
|
||||
export interface FriendUser {
|
||||
id: string;
|
||||
username: string | null;
|
||||
displayName: string;
|
||||
avatarUrl: string | null;
|
||||
}
|
||||
|
||||
function toFriendUser(u: typeof users.$inferSelect): FriendUser {
|
||||
return {
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
displayName: u.displayName,
|
||||
avatarUrl: u.avatarUrl,
|
||||
};
|
||||
}
|
||||
|
||||
async function usersByIds(ids: string[]): Promise<Map<string, FriendUser>> {
|
||||
if (ids.length === 0) return new Map();
|
||||
const db = getDb();
|
||||
const rows = await db.select().from(users).where(inArray(users.id, ids));
|
||||
return new Map(rows.map((u) => [u.id, toFriendUser(u)]));
|
||||
}
|
||||
|
||||
// All friendship rows involving `meId`, in either direction.
|
||||
async function myFriendships(meId: string) {
|
||||
const db = getDb();
|
||||
return db
|
||||
.select()
|
||||
.from(friendships)
|
||||
.where(
|
||||
or(eq(friendships.requesterId, meId), eq(friendships.addresseeId, meId)),
|
||||
);
|
||||
}
|
||||
|
||||
export async function listFriends(meId: string) {
|
||||
const rows = (await myFriendships(meId)).filter(
|
||||
(f) => f.status === "accepted",
|
||||
);
|
||||
const otherIds = rows.map((f) =>
|
||||
f.requesterId === meId ? f.addresseeId : f.requesterId,
|
||||
);
|
||||
const userMap = await usersByIds(otherIds);
|
||||
|
||||
return rows
|
||||
.map((f) => {
|
||||
const otherId = f.requesterId === meId ? f.addresseeId : f.requesterId;
|
||||
const user = userMap.get(otherId);
|
||||
return user ? { friendshipId: f.id, user } : null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export async function listRequests(meId: string) {
|
||||
const rows = (await myFriendships(meId)).filter(
|
||||
(f) => f.status === "pending",
|
||||
);
|
||||
const otherIds = rows.map((f) =>
|
||||
f.requesterId === meId ? f.addresseeId : f.requesterId,
|
||||
);
|
||||
const userMap = await usersByIds(otherIds);
|
||||
|
||||
const incoming: any[] = [];
|
||||
const outgoing: any[] = [];
|
||||
for (const f of rows) {
|
||||
const otherId = f.requesterId === meId ? f.addresseeId : f.requesterId;
|
||||
const user = userMap.get(otherId);
|
||||
if (!user) continue;
|
||||
if (f.addresseeId === meId) incoming.push({ friendshipId: f.id, user });
|
||||
else outgoing.push({ friendshipId: f.id, user });
|
||||
}
|
||||
return { incoming, outgoing };
|
||||
}
|
||||
|
||||
export async function searchUsers(meId: string, query: string) {
|
||||
const db = getDb();
|
||||
const q = query.trim();
|
||||
if (q.length < 2) return [];
|
||||
|
||||
const found = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(and(ilike(users.username, `${q}%`), ne(users.id, meId)))
|
||||
.limit(10);
|
||||
|
||||
// Annotate each result with the current relationship (for button state).
|
||||
const rels = await myFriendships(meId);
|
||||
const relByOther = new Map<string, string>();
|
||||
for (const f of rels) {
|
||||
const otherId = f.requesterId === meId ? f.addresseeId : f.requesterId;
|
||||
const label =
|
||||
f.status === "accepted"
|
||||
? "friends"
|
||||
: f.status === "blocked"
|
||||
? "blocked"
|
||||
: f.requesterId === meId
|
||||
? "pending_out"
|
||||
: "pending_in";
|
||||
relByOther.set(otherId, label);
|
||||
}
|
||||
|
||||
return found.map((u) => ({
|
||||
...toFriendUser(u),
|
||||
relationship: relByOther.get(u.id) ?? "none",
|
||||
}));
|
||||
}
|
||||
|
||||
export async function sendRequest(meId: string, username: string) {
|
||||
const db = getDb();
|
||||
const [target] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.username, username))
|
||||
.limit(1);
|
||||
|
||||
if (!target) throw new HttpError(404, "user_not_found");
|
||||
if (target.id === meId) throw new HttpError(400, "cannot_add_self");
|
||||
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(friendships)
|
||||
.where(
|
||||
or(
|
||||
and(
|
||||
eq(friendships.requesterId, meId),
|
||||
eq(friendships.addresseeId, target.id),
|
||||
),
|
||||
and(
|
||||
eq(friendships.requesterId, target.id),
|
||||
eq(friendships.addresseeId, meId),
|
||||
),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existing) {
|
||||
if (existing.status === "accepted")
|
||||
throw new HttpError(409, "already_friends");
|
||||
if (existing.status === "blocked") throw new HttpError(403, "blocked");
|
||||
throw new HttpError(409, "request_already_exists");
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const [row] = await db
|
||||
.insert(friendships)
|
||||
.values({
|
||||
id: newId(),
|
||||
requesterId: meId,
|
||||
addresseeId: target.id,
|
||||
status: "pending",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return { friendshipId: row.id, user: toFriendUser(target) };
|
||||
}
|
||||
|
||||
export async function acceptRequest(meId: string, friendshipId: string) {
|
||||
const db = getDb();
|
||||
const [f] = await db
|
||||
.select()
|
||||
.from(friendships)
|
||||
.where(eq(friendships.id, friendshipId))
|
||||
.limit(1);
|
||||
if (!f || f.addresseeId !== meId || f.status !== "pending") {
|
||||
throw new HttpError(404, "request_not_found");
|
||||
}
|
||||
await db
|
||||
.update(friendships)
|
||||
.set({ status: "accepted", updatedAt: Date.now() })
|
||||
.where(eq(friendships.id, friendshipId));
|
||||
}
|
||||
|
||||
export async function declineRequest(meId: string, friendshipId: string) {
|
||||
const db = getDb();
|
||||
const [f] = await db
|
||||
.select()
|
||||
.from(friendships)
|
||||
.where(eq(friendships.id, friendshipId))
|
||||
.limit(1);
|
||||
if (!f || f.addresseeId !== meId || f.status !== "pending") {
|
||||
throw new HttpError(404, "request_not_found");
|
||||
}
|
||||
await db.delete(friendships).where(eq(friendships.id, friendshipId));
|
||||
}
|
||||
|
||||
export async function removeFriend(meId: string, friendshipId: string) {
|
||||
const db = getDb();
|
||||
const [f] = await db
|
||||
.select()
|
||||
.from(friendships)
|
||||
.where(eq(friendships.id, friendshipId))
|
||||
.limit(1);
|
||||
if (!f || (f.requesterId !== meId && f.addresseeId !== meId)) {
|
||||
throw new HttpError(404, "friendship_not_found");
|
||||
}
|
||||
await db.delete(friendships).where(eq(friendships.id, friendshipId));
|
||||
}
|
||||
|
||||
export async function blockFriend(meId: string, friendshipId: string) {
|
||||
const db = getDb();
|
||||
const [f] = await db
|
||||
.select()
|
||||
.from(friendships)
|
||||
.where(eq(friendships.id, friendshipId))
|
||||
.limit(1);
|
||||
if (!f || (f.requesterId !== meId && f.addresseeId !== meId)) {
|
||||
throw new HttpError(404, "friendship_not_found");
|
||||
}
|
||||
await db
|
||||
.update(friendships)
|
||||
.set({ status: "blocked", updatedAt: Date.now() })
|
||||
.where(eq(friendships.id, friendshipId));
|
||||
}
|
||||
@@ -160,6 +160,78 @@ async function main() {
|
||||
// --- H. Auth required on sync ---
|
||||
const noAuth = await req("GET", "/sync?since=0");
|
||||
check("sync without token is 401", noAuth.status === 401);
|
||||
|
||||
// --- I. Friends: two accounts befriend each other ---
|
||||
const emailF = `friend${Date.now()}@test.dev`;
|
||||
const regF = await req("POST", "/auth/register", {
|
||||
email: emailF,
|
||||
password: "supersecret",
|
||||
displayName: "Zoe",
|
||||
username: `zoe_${Date.now().toString(36)}`,
|
||||
});
|
||||
check("second account registers with username", regF.status === 201);
|
||||
const tokenF = regF.json.accessToken;
|
||||
const zoeUsername = regF.json.user.username;
|
||||
|
||||
// Alice needs a username too so Zoe could find her back (not required here)
|
||||
await req("PATCH", "/me", { username: `alice_${Date.now().toString(36)}` }, tokenA);
|
||||
|
||||
// Alice searches for Zoe by username
|
||||
const searchRes = await req(
|
||||
"GET",
|
||||
`/friends/search?q=${zoeUsername}`,
|
||||
undefined,
|
||||
tokenA,
|
||||
);
|
||||
check("search finds the user by username", searchRes.json?.results?.some((u: any) => u.id === regF.json.user.id));
|
||||
|
||||
// Alice sends a friend request to Zoe
|
||||
const sendRes = await req(
|
||||
"POST",
|
||||
"/friends/requests",
|
||||
{ username: zoeUsername },
|
||||
tokenA,
|
||||
);
|
||||
check("friend request created", sendRes.status === 201);
|
||||
const friendshipId = sendRes.json.friendshipId;
|
||||
|
||||
// Duplicate request is rejected
|
||||
const dupRes = await req(
|
||||
"POST",
|
||||
"/friends/requests",
|
||||
{ username: zoeUsername },
|
||||
tokenA,
|
||||
);
|
||||
check("duplicate friend request rejected", dupRes.status === 409);
|
||||
|
||||
// Zoe sees the incoming request
|
||||
const zoeReqs = await req("GET", "/friends/requests", undefined, tokenF);
|
||||
check("addressee sees incoming request", zoeReqs.json?.incoming?.some((r: any) => r.friendshipId === friendshipId));
|
||||
|
||||
// Alice sees it as outgoing
|
||||
const aliceReqs = await req("GET", "/friends/requests", undefined, tokenA);
|
||||
check("requester sees outgoing request", aliceReqs.json?.outgoing?.some((r: any) => r.friendshipId === friendshipId));
|
||||
|
||||
// Zoe accepts
|
||||
const acceptRes = await req(
|
||||
"POST",
|
||||
`/friends/requests/${friendshipId}/accept`,
|
||||
undefined,
|
||||
tokenF,
|
||||
);
|
||||
check("accept returns ok", acceptRes.status === 200);
|
||||
|
||||
// Both now list each other as friends
|
||||
const aliceFriends = await req("GET", "/friends", undefined, tokenA);
|
||||
const zoeFriends = await req("GET", "/friends", undefined, tokenF);
|
||||
check("requester now has the friend", aliceFriends.json?.friends?.some((f: any) => f.user.id === regF.json.user.id));
|
||||
check("addressee now has the friend", zoeFriends.json?.friends?.some((f: any) => f.friendshipId === friendshipId));
|
||||
|
||||
// Unfriend
|
||||
const unfriendRes = await req("DELETE", `/friends/${friendshipId}`, undefined, tokenA);
|
||||
check("unfriend returns ok", unfriendRes.status === 200);
|
||||
const aliceAfter = await req("GET", "/friends", undefined, tokenA);
|
||||
check("friend removed after unfriend", !aliceAfter.json?.friends?.some((f: any) => f.friendshipId === friendshipId));
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user