docker test
Build and Publish Docker Image / build-and-push-image (push) Successful in 1m9s

This commit is contained in:
Zed
2026-07-11 02:24:08 +02:00
parent ce1db937f2
commit 9a63240113
18 changed files with 938 additions and 6 deletions
+2
View File
@@ -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);
+6
View File
@@ -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())
+13
View File
@@ -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) {
+21
View File
@@ -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",
{
+1 -1
View File
@@ -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",
+44
View File
@@ -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 });
}
+17
View File
@@ -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));
+220
View File
@@ -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));
}
+72
View File
@@ -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();
}