오대리ㅣㅣㅣㅣ

This commit is contained in:
송원형
2026-04-07 16:17:03 +09:00
commit 5bb54fdefe
63 changed files with 7897 additions and 0 deletions

BIN
server/data/msn.db Normal file

Binary file not shown.

1738
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
server/package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "msn-server",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"express": "^4.21.0",
"jsonwebtoken": "^9.0.2",
"ws": "^8.18.0"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.7",
"@types/node": "^22.10.0",
"@types/ws": "^8.5.13",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
}
}

14
server/src/auth.ts Normal file
View File

@@ -0,0 +1,14 @@
import jwt from "jsonwebtoken";
const JWT_SECRET = process.env.JWT_SECRET ?? "msn-dev-secret-change-me";
export type JwtPayload = { sub: string; email: string };
export function signToken(userId: string, email: string): string {
return jwt.sign({ sub: userId, email }, JWT_SECRET, { expiresIn: "30d" });
}
export function verifyToken(token: string): JwtPayload {
const decoded = jwt.verify(token, JWT_SECRET) as JwtPayload;
return decoded;
}

View File

@@ -0,0 +1,17 @@
import { db } from "./db.js";
export function isMemberOfContext(userId: string, contextId: string): boolean {
const row = db
.prepare(
`SELECT 1 FROM context_members WHERE user_id = ? AND context_id = ?`
)
.get(userId, contextId) as { 1: number } | undefined;
return !!row;
}
export function requireContextMember(
userId: string,
contextId: string
): boolean {
return isMemberOfContext(userId, contextId);
}

89
server/src/db.ts Normal file
View File

@@ -0,0 +1,89 @@
import { DatabaseSync } from "node:sqlite";
import path from "node:path";
import { fileURLToPath } from "node:url";
import fs from "node:fs";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const dataDir = path.join(__dirname, "..", "data");
fs.mkdirSync(dataDir, { recursive: true });
const dbPath = process.env.SQLITE_PATH ?? path.join(dataDir, "msn.db");
export const db = new DatabaseSync(dbPath);
db.exec("PRAGMA foreign_keys = ON");
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS contexts (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('personal','work','other')),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
retention_days INTEGER,
screenshot_blocked INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS context_members (
context_id TEXT NOT NULL REFERENCES contexts(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'member',
joined_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (context_id, user_id)
);
CREATE TABLE IF NOT EXISTS profiles (
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
context_id TEXT NOT NULL REFERENCES contexts(id) ON DELETE CASCADE,
display_name TEXT NOT NULL,
avatar_url TEXT,
status_message TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (user_id, context_id)
);
CREATE TABLE IF NOT EXISTS rooms (
id TEXT PRIMARY KEY,
context_id TEXT NOT NULL REFERENCES contexts(id) ON DELETE CASCADE,
is_group INTEGER NOT NULL DEFAULT 0,
name TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS room_members (
room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
PRIMARY KEY (room_id, user_id)
);
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
sender_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
body TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
kind TEXT NOT NULL DEFAULT 'text'
);
CREATE TABLE IF NOT EXISTS message_reads (
message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
read_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (message_id, user_id)
);
CREATE TABLE IF NOT EXISTS push_tokens (
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token TEXT NOT NULL,
platform TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (user_id, token)
);
CREATE INDEX IF NOT EXISTS idx_messages_room ON messages(room_id, created_at);
CREATE INDEX IF NOT EXISTS idx_rooms_context ON rooms(context_id);
`);

42
server/src/index.ts Normal file
View File

@@ -0,0 +1,42 @@
import express from "express";
import cors from "cors";
import http from "node:http";
import { authRouter } from "./routes/authRoutes.js";
import { contextRouter } from "./routes/contextRoutes.js";
import { profileRouter } from "./routes/profileRoutes.js";
import { roomRouter } from "./routes/roomRoutes.js";
import { messageRouter } from "./routes/messageRoutes.js";
import { pushRouter } from "./routes/pushRoutes.js";
import "./db.js";
import {
backfillDemoAvatarsIfNeeded,
extendDemoDataIfNeeded,
seedDemoIfNeeded,
} from "./seed.js";
import { attachWebSocket } from "./realtime.js";
seedDemoIfNeeded();
backfillDemoAvatarsIfNeeded();
extendDemoDataIfNeeded();
const app = express();
app.use(cors());
app.use(express.json());
app.get("/health", (_req, res) => res.json({ ok: true }));
app.use("/api/auth", authRouter);
app.use("/api/contexts", contextRouter);
app.use("/api/profiles", profileRouter);
app.use("/api/rooms", roomRouter);
app.use("/api/messages", messageRouter);
app.use("/api/push", pushRouter);
const port = Number(process.env.PORT ?? 8787);
const server = http.createServer(app);
attachWebSocket(server);
server.listen(port, () => {
console.log(`msn-server listening on http://localhost:${port}`);
console.log(`WebSocket: ws://localhost:${port}/ws?token=JWT`);
});

21
server/src/middleware.ts Normal file
View File

@@ -0,0 +1,21 @@
import type { Request, Response, NextFunction } from "express";
import { verifyToken } from "./auth.js";
export type AuthedRequest = Request & { userId?: string; userEmail?: string };
export function requireAuth(req: AuthedRequest, res: Response, next: NextFunction) {
const header = req.headers.authorization;
const token = header?.startsWith("Bearer ") ? header.slice(7) : undefined;
if (!token) {
res.status(401).json({ error: "Unauthorized" });
return;
}
try {
const p = verifyToken(token);
req.userId = p.sub;
req.userEmail = p.email;
next();
} catch {
res.status(401).json({ error: "Invalid token" });
}
}

197
server/src/realtime.ts Normal file
View File

@@ -0,0 +1,197 @@
import type { Server } from "node:http";
import { WebSocketServer, WebSocket } from "ws";
import { randomUUID } from "node:crypto";
import { verifyToken } from "./auth.js";
import { db } from "./db.js";
export type ClientMessage =
| { type: "subscribe"; roomId: string }
| { type: "send"; roomId: string; body: string; kind?: string }
| { type: "typing"; roomId: string; active: boolean };
export type ServerMessage =
| { type: "message"; message: OutMessage }
| { type: "error"; message: string }
| { type: "typing"; roomId: string; userId: string; active: boolean }
| { type: "read"; roomId: string; userId: string; upToMessageId: string };
export type OutMessage = {
id: string;
roomId: string;
contextId: string;
senderId: string;
body: string;
createdAt: string;
kind: string;
};
const roomSubscribers = new Map<string, Set<WebSocket>>();
function userInRoom(userId: string, roomId: string): boolean {
const r = db
.prepare(`SELECT 1 FROM room_members WHERE user_id = ? AND room_id = ?`)
.get(userId, roomId) as { 1: number } | undefined;
return !!r;
}
function getRoomContext(roomId: string): string | null {
const row = db
.prepare(`SELECT context_id FROM rooms WHERE id = ?`)
.get(roomId) as { context_id: string } | undefined;
return row?.context_id ?? null;
}
function broadcast(roomId: string, payload: ServerMessage) {
const set = roomSubscribers.get(roomId);
if (!set) return;
const data = JSON.stringify(payload);
for (const ws of set) {
if (ws.readyState === WebSocket.OPEN) ws.send(data);
}
}
export function broadcastToRoom(roomId: string, payload: ServerMessage) {
broadcast(roomId, payload);
}
/** Stub: log push payload shape for FCM (contextId + roomId for deep link). */
function notifyPushStub(
targetUserIds: string[],
contextId: string,
roomId: string,
preview: string
) {
for (const uid of targetUserIds) {
console.log(
`[push stub] to=${uid} contextId=${contextId} roomId=${roomId} preview=${preview.slice(0, 40)}`
);
}
}
export function attachWebSocket(httpServer: Server) {
const wss = new WebSocketServer({ server: httpServer, path: "/ws" });
wss.on("connection", (ws, req) => {
const url = new URL(req.url ?? "", "http://localhost");
const token = url.searchParams.get("token");
if (!token) {
ws.close(4001, "token required");
return;
}
let userId: string;
try {
userId = verifyToken(token).sub;
} catch {
ws.close(4002, "invalid token");
return;
}
const subscribed = new Set<string>();
ws.on("message", (raw) => {
let msg: ClientMessage;
try {
msg = JSON.parse(String(raw)) as ClientMessage;
} catch {
ws.send(JSON.stringify({ type: "error", message: "invalid json" } satisfies ServerMessage));
return;
}
if (msg.type === "typing") {
if (!userInRoom(userId, msg.roomId)) {
ws.send(
JSON.stringify({ type: "error", message: "forbidden" } satisfies ServerMessage)
);
return;
}
const payload: ServerMessage = {
type: "typing",
roomId: msg.roomId,
userId,
active: !!msg.active,
};
const set = roomSubscribers.get(msg.roomId);
if (!set) return;
const data = JSON.stringify(payload);
for (const client of set) {
if (client === ws) continue;
if (client.readyState === WebSocket.OPEN) client.send(data);
}
return;
}
if (msg.type === "subscribe") {
if (!userInRoom(userId, msg.roomId)) {
ws.send(
JSON.stringify({ type: "error", message: "forbidden" } satisfies ServerMessage)
);
return;
}
subscribed.add(msg.roomId);
let set = roomSubscribers.get(msg.roomId);
if (!set) {
set = new Set();
roomSubscribers.set(msg.roomId, set);
}
set.add(ws);
return;
}
if (msg.type === "send") {
if (!userInRoom(userId, msg.roomId)) {
ws.send(
JSON.stringify({ type: "error", message: "forbidden" } satisfies ServerMessage)
);
return;
}
const id = randomUUID();
const kind = msg.kind && msg.kind !== "text" ? msg.kind : "text";
db.prepare(
`INSERT INTO messages (id, room_id, sender_id, body, kind) VALUES (?, ?, ?, ?, ?)`
).run(id, msg.roomId, userId, msg.body, kind);
const row = db
.prepare(
`SELECT id, room_id, sender_id, body, created_at, kind FROM messages WHERE id = ?`
)
.get(id) as {
id: string;
room_id: string;
sender_id: string;
body: string;
created_at: string;
kind: string;
};
const contextId = getRoomContext(msg.roomId);
if (!contextId) return;
const out: OutMessage = {
id: row.id,
roomId: row.room_id,
contextId,
senderId: row.sender_id,
body: row.body,
createdAt: row.created_at,
kind: row.kind,
};
broadcast(msg.roomId, { type: "message", message: out });
const others = db
.prepare(`SELECT user_id FROM room_members WHERE room_id = ? AND user_id != ?`)
.all(msg.roomId, userId) as { user_id: string }[];
notifyPushStub(
others.map((o) => o.user_id),
contextId,
msg.roomId,
msg.body
);
return;
}
});
ws.on("close", () => {
for (const roomId of subscribed) {
const set = roomSubscribers.get(roomId);
if (set) {
set.delete(ws);
if (set.size === 0) roomSubscribers.delete(roomId);
}
}
});
});
}

View File

@@ -0,0 +1,85 @@
import { Router } from "express";
import bcrypt from "bcryptjs";
import { randomUUID } from "node:crypto";
import { db } from "../db.js";
import { signToken } from "../auth.js";
import type { AuthedRequest } from "../middleware.js";
import { requireAuth } from "../middleware.js";
export const authRouter = Router();
authRouter.post("/register", (req, res) => {
const { email, password, displayName } = req.body as {
email?: string;
password?: string;
displayName?: string;
};
if (!email || !password) {
res.status(400).json({ error: "email and password required" });
return;
}
const existing = db.prepare(`SELECT id FROM users WHERE email = ?`).get(email) as
| { id: string }
| undefined;
if (existing) {
res.status(409).json({ error: "Email already registered" });
return;
}
const userId = randomUUID();
const hash = bcrypt.hashSync(password, 10);
db.prepare(`INSERT INTO users (id, email, password_hash) VALUES (?, ?, ?)`).run(
userId,
email,
hash
);
const personalContextId = randomUUID();
db.prepare(
`INSERT INTO contexts (id, name, kind) VALUES (?, ?, 'personal')`
).run(personalContextId, "일상");
db.prepare(
`INSERT INTO context_members (context_id, user_id, role) VALUES (?, ?, 'owner')`
).run(personalContextId, userId);
db.prepare(
`INSERT INTO profiles (user_id, context_id, display_name) VALUES (?, ?, ?)`
).run(userId, personalContextId, displayName ?? email.split("@")[0]);
const token = signToken(userId, email);
res.status(201).json({
token,
user: { id: userId, email },
defaultContextId: personalContextId,
});
});
authRouter.post("/login", (req, res) => {
const { email, password } = req.body as { email?: string; password?: string };
if (!email || !password) {
res.status(400).json({ error: "email and password required" });
return;
}
const row = db
.prepare(`SELECT id, email, password_hash FROM users WHERE email = ?`)
.get(email) as { id: string; email: string; password_hash: string } | undefined;
if (!row || !bcrypt.compareSync(password, row.password_hash)) {
res.status(401).json({ error: "Invalid credentials" });
return;
}
const token = signToken(row.id, row.email);
const personal = db
.prepare(
`SELECT c.id FROM contexts c
JOIN context_members m ON m.context_id = c.id
WHERE m.user_id = ? AND c.kind = 'personal' LIMIT 1`
)
.get(row.id) as { id: string } | undefined;
res.json({
token,
user: { id: row.id, email: row.email },
defaultContextId: personal?.id ?? null,
});
});
authRouter.get("/me", requireAuth, (req: AuthedRequest, res) => {
res.json({ id: req.userId, email: req.userEmail });
});

View File

@@ -0,0 +1,125 @@
import { Router } from "express";
import { randomUUID } from "node:crypto";
import { db } from "../db.js";
import type { AuthedRequest } from "../middleware.js";
import { requireAuth } from "../middleware.js";
import { isMemberOfContext } from "../contextAccess.js";
export const contextRouter = Router();
contextRouter.use(requireAuth);
contextRouter.get("/", (req: AuthedRequest, res) => {
const rows = db
.prepare(
`SELECT c.id, c.name, c.kind, c.retention_days, c.screenshot_blocked
FROM contexts c
JOIN context_members m ON m.context_id = c.id
WHERE m.user_id = ?
ORDER BY c.kind = 'personal' DESC, c.name`
)
.all(req.userId!) as Array<{
id: string;
name: string;
kind: string;
retention_days: number | null;
screenshot_blocked: number;
}>;
res.json({
contexts: rows.map((r) => ({
id: r.id,
name: r.name,
kind: r.kind,
policy: {
retentionDays: r.retention_days,
screenshotBlocked: !!r.screenshot_blocked,
},
})),
});
});
contextRouter.post("/", (req: AuthedRequest, res) => {
const { name, kind } = req.body as { name?: string; kind?: string };
if (!name) {
res.status(400).json({ error: "name required" });
return;
}
const k = kind === "work" || kind === "other" ? kind : "work";
const id = randomUUID();
db.prepare(`INSERT INTO contexts (id, name, kind) VALUES (?, ?, ?)`).run(id, name, k);
db.prepare(
`INSERT INTO context_members (context_id, user_id, role) VALUES (?, ?, 'owner')`
).run(id, req.userId!);
const email = req.userEmail!;
const display = email.split("@")[0];
db.prepare(
`INSERT INTO profiles (user_id, context_id, display_name) VALUES (?, ?, ?)`
).run(req.userId!, id, display);
res.status(201).json({ id, name, kind: k });
});
/** List members of a context with profile fields for this context only. */
contextRouter.get("/:contextId/members", (req: AuthedRequest, res) => {
const { contextId } = req.params;
if (!isMemberOfContext(req.userId!, contextId)) {
res.status(403).json({ error: "Forbidden" });
return;
}
const rows = db
.prepare(
`SELECT m.user_id, m.role, p.display_name, p.avatar_url, p.status_message
FROM context_members m
JOIN profiles p ON p.user_id = m.user_id AND p.context_id = m.context_id
WHERE m.context_id = ?
ORDER BY p.display_name COLLATE NOCASE`
)
.all(contextId) as Array<{
user_id: string;
role: string;
display_name: string;
avatar_url: string | null;
status_message: string | null;
}>;
res.json({
members: rows.map((r) => ({
userId: r.user_id,
role: r.role,
displayName: r.display_name,
avatarUrl: r.avatar_url,
statusMessage: r.status_message,
})),
});
});
/** Add an existing user to this context by email (workplace registration). */
contextRouter.post("/:contextId/members", (req: AuthedRequest, res) => {
const { contextId } = req.params;
const { email } = req.body as { email?: string };
if (!email) {
res.status(400).json({ error: "email required" });
return;
}
if (!isMemberOfContext(req.userId!, contextId)) {
res.status(403).json({ error: "Forbidden" });
return;
}
const target = db.prepare(`SELECT id FROM users WHERE email = ?`).get(email) as
| { id: string }
| undefined;
if (!target) {
res.status(404).json({ error: "User not found" });
return;
}
try {
db.prepare(
`INSERT INTO context_members (context_id, user_id, role) VALUES (?, ?, 'member')`
).run(contextId, target.id);
} catch {
res.status(409).json({ error: "Already a member" });
return;
}
const display = email.split("@")[0];
db.prepare(
`INSERT OR IGNORE INTO profiles (user_id, context_id, display_name) VALUES (?, ?, ?)`
).run(target.id, contextId, display);
res.status(201).json({ userId: target.id });
});

View File

@@ -0,0 +1,162 @@
import { Router } from "express";
import { db } from "../db.js";
import type { AuthedRequest } from "../middleware.js";
import { requireAuth } from "../middleware.js";
import { broadcastToRoom } from "../realtime.js";
export const messageRouter = Router();
messageRouter.use(requireAuth);
function userInRoom(userId: string, roomId: string): boolean {
const r = db
.prepare(`SELECT 1 FROM room_members WHERE user_id = ? AND room_id = ?`)
.get(userId, roomId) as { 1: number } | undefined;
return !!r;
}
/** Latest message id read by the other member in a 1:1 room (null if not 1:1 or none read). */
messageRouter.get("/read-state", (req: AuthedRequest, res) => {
const roomId = req.query.roomId as string | undefined;
if (!roomId) {
res.status(400).json({ error: "roomId required" });
return;
}
if (!userInRoom(req.userId!, roomId)) {
res.status(403).json({ error: "Forbidden" });
return;
}
const members = db
.prepare(`SELECT user_id FROM room_members WHERE room_id = ?`)
.all(roomId) as { user_id: string }[];
const others = members.map((m) => m.user_id).filter((id) => id !== req.userId);
if (others.length !== 1) {
res.json({ lastReadMessageId: null });
return;
}
const other = others[0];
const row = db
.prepare(
`SELECT m.id FROM messages m
INNER JOIN message_reads mr ON mr.message_id = m.id AND mr.user_id = ?
WHERE m.room_id = ?
ORDER BY m.created_at DESC LIMIT 1`
)
.get(other, roomId) as { id: string } | undefined;
res.json({ lastReadMessageId: row?.id ?? null });
});
messageRouter.get("/", (req: AuthedRequest, res) => {
const roomId = req.query.roomId as string | undefined;
const limit = Math.min(100, Math.max(1, parseInt(String(req.query.limit ?? "50"), 10)));
const before = req.query.before as string | undefined;
if (!roomId) {
res.status(400).json({ error: "roomId required" });
return;
}
if (!userInRoom(req.userId!, roomId)) {
res.status(403).json({ error: "Forbidden" });
return;
}
const rows = before
? (db
.prepare(
`SELECT id, room_id, sender_id, body, created_at, kind
FROM messages WHERE room_id = ? AND created_at < ?
ORDER BY created_at DESC LIMIT ?`
)
.all(roomId, before, limit) as Array<Record<string, unknown>>)
: (db
.prepare(
`SELECT id, room_id, sender_id, body, created_at, kind
FROM messages WHERE room_id = ?
ORDER BY created_at DESC LIMIT ?`
)
.all(roomId, limit) as Array<Record<string, unknown>>);
const messages = rows.reverse().map((m) => ({
id: m.id,
roomId: m.room_id,
senderId: m.sender_id,
body: m.body,
createdAt: m.created_at,
kind: m.kind,
}));
res.json({ messages });
});
/** Mark messages as read (extended). */
messageRouter.post("/read", (req: AuthedRequest, res) => {
const { roomId, upToMessageId } = req.body as {
roomId?: string;
upToMessageId?: string;
};
if (!roomId || !upToMessageId) {
res.status(400).json({ error: "roomId and upToMessageId required" });
return;
}
if (!userInRoom(req.userId!, roomId)) {
res.status(403).json({ error: "Forbidden" });
return;
}
const msgs = db
.prepare(
`SELECT id FROM messages WHERE room_id = ? AND created_at <=
(SELECT created_at FROM messages WHERE id = ? AND room_id = ?)`
)
.all(roomId, upToMessageId, roomId) as { id: string }[];
const ins = db.prepare(
`INSERT OR IGNORE INTO message_reads (message_id, user_id) VALUES (?, ?)`
);
for (const m of msgs) {
ins.run(m.id, req.userId!);
}
broadcastToRoom(roomId, {
type: "read",
roomId,
userId: req.userId!,
upToMessageId,
});
res.json({ ok: true, count: msgs.length });
});
/** Search within a context (messages in user's rooms). */
messageRouter.get("/search", (req: AuthedRequest, res) => {
const contextId = req.query.contextId as string | undefined;
const q = (req.query.q as string | undefined)?.trim();
if (!contextId || !q) {
res.status(400).json({ error: "contextId and q required" });
return;
}
const member = db
.prepare(`SELECT 1 FROM context_members WHERE user_id = ? AND context_id = ?`)
.get(req.userId!, contextId);
if (!member) {
res.status(403).json({ error: "Forbidden" });
return;
}
const needle = `%${q.replace(/%/g, "")}%`;
const hits = db
.prepare(
`SELECT m.id, m.room_id, m.body, m.created_at, m.sender_id
FROM messages m
JOIN rooms r ON r.id = m.room_id
JOIN room_members rm ON rm.room_id = m.room_id AND rm.user_id = ?
WHERE r.context_id = ? AND m.body LIKE ?
ORDER BY m.created_at DESC LIMIT 30`
)
.all(req.userId!, contextId, needle) as Array<{
id: string;
room_id: string;
body: string;
created_at: string;
sender_id: string;
}>;
res.json({
results: hits.map((h) => ({
messageId: h.id,
roomId: h.room_id,
body: h.body,
createdAt: h.created_at,
senderId: h.sender_id,
})),
});
});

View File

@@ -0,0 +1,146 @@
import { Router } from "express";
import { db } from "../db.js";
import type { AuthedRequest } from "../middleware.js";
import { requireAuth } from "../middleware.js";
import { isMemberOfContext } from "../contextAccess.js";
export const profileRouter = Router();
profileRouter.use(requireAuth);
function parseContextId(req: AuthedRequest): string | null {
const q = req.query.contextId as string | undefined;
return q ?? null;
}
/** Own profile in a context — only if member. */
profileRouter.get("/me", (req: AuthedRequest, res) => {
const contextId = parseContextId(req);
if (!contextId) {
res.status(400).json({ error: "contextId query required" });
return;
}
if (!isMemberOfContext(req.userId!, contextId)) {
res.status(403).json({ error: "Forbidden" });
return;
}
const row = db
.prepare(
`SELECT display_name, avatar_url, status_message, updated_at
FROM profiles WHERE user_id = ? AND context_id = ?`
)
.get(req.userId!, contextId) as
| {
display_name: string;
avatar_url: string | null;
status_message: string | null;
updated_at: string;
}
| undefined;
if (!row) {
res.status(404).json({ error: "Profile not found" });
return;
}
res.json({
userId: req.userId,
contextId,
displayName: row.display_name,
avatarUrl: row.avatar_url,
statusMessage: row.status_message,
updatedAt: row.updated_at,
});
});
profileRouter.patch("/me", (req: AuthedRequest, res) => {
const contextId = parseContextId(req);
if (!contextId) {
res.status(400).json({ error: "contextId query required" });
return;
}
if (!isMemberOfContext(req.userId!, contextId)) {
res.status(403).json({ error: "Forbidden" });
return;
}
const { displayName, avatarUrl, statusMessage } = req.body as {
displayName?: string;
avatarUrl?: string | null;
statusMessage?: string | null;
};
const cur = db
.prepare(`SELECT display_name FROM profiles WHERE user_id = ? AND context_id = ?`)
.get(req.userId!, contextId) as { display_name: string } | undefined;
if (!cur) {
res.status(404).json({ error: "Profile not found" });
return;
}
const sets: string[] = [];
const vals: unknown[] = [];
if (displayName !== undefined) {
sets.push("display_name = ?");
vals.push(displayName);
}
if (avatarUrl !== undefined) {
sets.push("avatar_url = ?");
vals.push(avatarUrl);
}
if (statusMessage !== undefined) {
sets.push("status_message = ?");
vals.push(statusMessage);
}
if (sets.length === 0) {
res.json({ ok: true });
return;
}
sets.push("updated_at = datetime('now')");
vals.push(req.userId!, contextId);
const params = vals as string[];
db.prepare(
`UPDATE profiles SET ${sets.join(", ")} WHERE user_id = ? AND context_id = ?`
).run(...params);
res.json({ ok: true });
});
/**
* Other user's profile — only visible when both share the same context.
* No cross-context leakage: contextId is mandatory.
*/
profileRouter.get("/user/:targetUserId", (req: AuthedRequest, res) => {
const contextId = parseContextId(req);
const { targetUserId } = req.params;
if (!contextId) {
res.status(400).json({ error: "contextId query required" });
return;
}
if (!isMemberOfContext(req.userId!, contextId)) {
res.status(403).json({ error: "Forbidden" });
return;
}
if (!isMemberOfContext(targetUserId, contextId)) {
res.status(404).json({ error: "Not found" });
return;
}
const row = db
.prepare(
`SELECT display_name, avatar_url, status_message, updated_at
FROM profiles WHERE user_id = ? AND context_id = ?`
)
.get(targetUserId, contextId) as
| {
display_name: string;
avatar_url: string | null;
status_message: string | null;
updated_at: string;
}
| undefined;
if (!row) {
res.status(404).json({ error: "Not found" });
return;
}
res.json({
userId: targetUserId,
contextId,
displayName: row.display_name,
avatarUrl: row.avatar_url,
statusMessage: row.status_message,
updatedAt: row.updated_at,
});
});

View File

@@ -0,0 +1,24 @@
import { Router } from "express";
import { db } from "../db.js";
import type { AuthedRequest } from "../middleware.js";
import { requireAuth } from "../middleware.js";
export const pushRouter = Router();
pushRouter.use(requireAuth);
/** Register FCM token for this user (payload routing uses contextId + roomId in app). */
pushRouter.post("/register", (req: AuthedRequest, res) => {
const { token, platform } = req.body as { token?: string; platform?: string };
if (!token) {
res.status(400).json({ error: "token required" });
return;
}
db.prepare(
`INSERT INTO push_tokens (user_id, token, platform, updated_at)
VALUES (?, ?, ?, datetime('now'))
ON CONFLICT(user_id, token) DO UPDATE SET
platform = excluded.platform,
updated_at = datetime('now')`
).run(req.userId!, token, platform ?? "unknown");
res.json({ ok: true });
});

View File

@@ -0,0 +1,139 @@
import { Router } from "express";
import { randomUUID } from "node:crypto";
import { db } from "../db.js";
import type { AuthedRequest } from "../middleware.js";
import { requireAuth } from "../middleware.js";
import { isMemberOfContext } from "../contextAccess.js";
export const roomRouter = Router();
roomRouter.use(requireAuth);
function userInRoom(userId: string, roomId: string): boolean {
const r = db
.prepare(`SELECT 1 FROM room_members WHERE user_id = ? AND room_id = ?`)
.get(userId, roomId) as { 1: number } | undefined;
return !!r;
}
/** List rooms in a context the user belongs to. */
roomRouter.get("/", (req: AuthedRequest, res) => {
const contextId = req.query.contextId as string | undefined;
if (!contextId) {
res.status(400).json({ error: "contextId required" });
return;
}
if (!isMemberOfContext(req.userId!, contextId)) {
res.status(403).json({ error: "Forbidden" });
return;
}
const rooms = db
.prepare(
`SELECT r.id, r.is_group, r.name, r.created_at,
(SELECT body FROM messages m WHERE m.room_id = r.id ORDER BY m.created_at DESC LIMIT 1) AS last_body,
(SELECT created_at FROM messages m WHERE m.room_id = r.id ORDER BY m.created_at DESC LIMIT 1) AS last_at
FROM rooms r
JOIN room_members rm ON rm.room_id = r.id AND rm.user_id = ?
WHERE r.context_id = ?
ORDER BY last_at IS NULL, last_at DESC`
)
.all(req.userId!, contextId) as Array<{
id: string;
is_group: number;
name: string | null;
created_at: string;
last_body: string | null;
last_at: string | null;
}>;
res.json({ rooms });
});
/** Open or create a 1:1 room in context. */
roomRouter.post("/direct", (req: AuthedRequest, res) => {
const { contextId, otherUserId } = req.body as {
contextId?: string;
otherUserId?: string;
};
if (!contextId || !otherUserId) {
res.status(400).json({ error: "contextId and otherUserId required" });
return;
}
if (!isMemberOfContext(req.userId!, contextId)) {
res.status(403).json({ error: "Forbidden" });
return;
}
if (!isMemberOfContext(otherUserId, contextId)) {
res.status(400).json({ error: "Other user is not in this context" });
return;
}
const u1 = req.userId!;
const u2 = otherUserId;
const pair = [u1, u2].sort();
const existing = db
.prepare(
`SELECT r.id FROM rooms r
JOIN room_members a ON a.room_id = r.id AND a.user_id = ?
JOIN room_members b ON b.room_id = r.id AND b.user_id = ?
WHERE r.context_id = ? AND r.is_group = 0`
)
.get(pair[0], pair[1], contextId) as { id: string } | undefined;
if (existing) {
res.json({ roomId: existing.id, created: false });
return;
}
const roomId = randomUUID();
db.prepare(
`INSERT INTO rooms (id, context_id, is_group, name) VALUES (?, ?, 0, NULL)`
).run(roomId, contextId);
db.prepare(`INSERT INTO room_members (room_id, user_id) VALUES (?, ?)`).run(roomId, u1);
db.prepare(`INSERT INTO room_members (room_id, user_id) VALUES (?, ?)`).run(roomId, u2);
res.status(201).json({ roomId, created: true });
});
/** Create group room (extended feature). */
roomRouter.post("/group", (req: AuthedRequest, res) => {
const { contextId, name, memberIds } = req.body as {
contextId?: string;
name?: string;
memberIds?: string[];
};
if (!contextId || !name) {
res.status(400).json({ error: "contextId and name required" });
return;
}
if (!isMemberOfContext(req.userId!, contextId)) {
res.status(403).json({ error: "Forbidden" });
return;
}
const roomId = randomUUID();
db.prepare(
`INSERT INTO rooms (id, context_id, is_group, name) VALUES (?, ?, 1, ?)`
).run(roomId, contextId, name);
const members = new Set<string>([req.userId!, ...(memberIds ?? [])]);
for (const uid of members) {
if (!isMemberOfContext(uid, contextId)) continue;
db.prepare(`INSERT INTO room_members (room_id, user_id) VALUES (?, ?)`).run(
roomId,
uid
);
}
res.status(201).json({ roomId });
});
roomRouter.get("/:roomId/participants", (req: AuthedRequest, res) => {
const { roomId } = req.params;
if (!userInRoom(req.userId!, roomId)) {
res.status(403).json({ error: "Forbidden" });
return;
}
const ctx = db
.prepare(`SELECT context_id FROM rooms WHERE id = ?`)
.get(roomId) as { context_id: string } | undefined;
if (!ctx) {
res.status(404).json({ error: "Not found" });
return;
}
const userIds = db
.prepare(`SELECT user_id FROM room_members WHERE room_id = ?`)
.all(roomId) as { user_id: string }[];
res.json({ contextId: ctx.context_id, userIds: userIds.map((u) => u.user_id) });
});

230
server/src/seed.ts Normal file
View File

@@ -0,0 +1,230 @@
import bcrypt from "bcryptjs";
import { randomUUID } from "node:crypto";
import { db } from "./db.js";
const DEMO_EMAIL_ALICE = "alice@demo.msn";
const DEMO_EMAIL_BOB = "bob@demo.msn";
const DEMO_PASSWORD = "demo1234";
/** Stable UUIDs for docs and debugging. */
export const DemoIds = {
alice: "a1111111-1111-4111-8111-111111111111",
bob: "b2222222-2222-4222-8222-222222222222",
ctxAlicePersonal: "c1111111-1111-4111-8111-111111111111",
ctxBobPersonal: "c2222222-2222-4222-8222-222222222222",
ctxWork: "d3333333-3333-4333-8333-333333333333",
roomWorkDm: "e4444444-4444-4444-8444-444444444444",
/** AliceBob DM in Alice's personal context */
roomPersonalDm: "f1111111-1111-4111-8111-111111111111",
/** Group room in work context */
roomWorkGroup: "g1111111-1111-4111-8111-111111111111",
} as const;
/** Stable portrait URLs for Flutter NetworkImage (HTTPS, JPG). */
const DEMO_AVATAR_ALICE_PERSONAL = "https://i.pravatar.cc/400?img=47";
const DEMO_AVATAR_BOB_PERSONAL = "https://i.pravatar.cc/400?img=33";
const DEMO_AVATAR_ALICE_WORK = "https://i.pravatar.cc/400?img=12";
const DEMO_AVATAR_BOB_WORK = "https://i.pravatar.cc/400?img=59";
/**
* Demo users, personal + work contexts, profiles, DM + sample messages.
* Skips if alice@demo.msn already exists (idempotent).
*/
export function seedDemoIfNeeded(): void {
const exists = db.prepare(`SELECT id FROM users WHERE email = ?`).get(DEMO_EMAIL_ALICE) as
| { id: string }
| undefined;
if (exists) {
return;
}
const hash = bcrypt.hashSync(DEMO_PASSWORD, 10);
const { alice, bob, ctxAlicePersonal, ctxBobPersonal, ctxWork, roomWorkDm } = DemoIds;
db.prepare(`INSERT INTO users (id, email, password_hash) VALUES (?, ?, ?)`).run(
alice,
DEMO_EMAIL_ALICE,
hash
);
db.prepare(`INSERT INTO users (id, email, password_hash) VALUES (?, ?, ?)`).run(
bob,
DEMO_EMAIL_BOB,
hash
);
db.prepare(`INSERT INTO contexts (id, name, kind) VALUES (?, ?, 'personal')`).run(
ctxAlicePersonal,
"일상"
);
db.prepare(`INSERT INTO contexts (id, name, kind) VALUES (?, ?, 'personal')`).run(
ctxBobPersonal,
"일상"
);
db.prepare(
`INSERT INTO contexts (id, name, kind, retention_days, screenshot_blocked) VALUES (?, ?, 'work', 365, 0)`
).run(ctxWork, "데모 회사");
db.prepare(
`INSERT INTO context_members (context_id, user_id, role) VALUES (?, ?, 'owner')`
).run(ctxAlicePersonal, alice);
db.prepare(
`INSERT INTO context_members (context_id, user_id, role) VALUES (?, ?, 'owner')`
).run(ctxBobPersonal, bob);
db.prepare(
`INSERT INTO context_members (context_id, user_id, role) VALUES (?, ?, 'owner')`
).run(ctxWork, alice);
db.prepare(
`INSERT INTO context_members (context_id, user_id, role) VALUES (?, ?, 'member')`
).run(ctxWork, bob);
db.prepare(
`INSERT INTO profiles (user_id, context_id, display_name, avatar_url, status_message) VALUES (?, ?, ?, ?, ?)`
).run(
alice,
ctxAlicePersonal,
"앨리스",
DEMO_AVATAR_ALICE_PERSONAL,
"일상 프로필 · 친구들과 수다"
);
db.prepare(
`INSERT INTO profiles (user_id, context_id, display_name, avatar_url, status_message) VALUES (?, ?, ?, ?, ?)`
).run(bob, ctxBobPersonal, "밥", DEMO_AVATAR_BOB_PERSONAL, "일상 상태 메시지");
db.prepare(
`INSERT INTO profiles (user_id, context_id, display_name, avatar_url, status_message) VALUES (?, ?, ?, ?, ?)`
).run(alice, ctxWork, "김앨리스 (기획)", DEMO_AVATAR_ALICE_WORK, "회사 맥락 전용 표시명");
db.prepare(
`INSERT INTO profiles (user_id, context_id, display_name, avatar_url, status_message) VALUES (?, ?, ?, ?, ?)`
).run(bob, ctxWork, "박밥 (백엔드)", DEMO_AVATAR_BOB_WORK, "동료에게만 보이는 이름");
db.prepare(
`INSERT INTO rooms (id, context_id, is_group, name) VALUES (?, ?, 0, NULL)`
).run(roomWorkDm, ctxWork);
db.prepare(`INSERT INTO room_members (room_id, user_id) VALUES (?, ?)`).run(roomWorkDm, alice);
db.prepare(`INSERT INTO room_members (room_id, user_id) VALUES (?, ?)`).run(roomWorkDm, bob);
const m1 = randomUUID();
const m2 = randomUUID();
const m3 = randomUUID();
db.prepare(
`INSERT INTO messages (id, room_id, sender_id, body, kind) VALUES (?, ?, ?, ?, 'text')`
).run(m1, roomWorkDm, bob, "안녕하세요, 회의 자료 공유드립니다.");
db.prepare(
`INSERT INTO messages (id, room_id, sender_id, body, kind) VALUES (?, ?, ?, ?, 'text')`
).run(m2, roomWorkDm, alice, "확인했습니다. 오후에 뵐게요.");
db.prepare(
`INSERT INTO messages (id, room_id, sender_id, body, kind) VALUES (?, ?, ?, ?, 'text')`
).run(m3, roomWorkDm, bob, "네, 감사합니다.");
console.log("");
console.log("========== DEMO DATA SEEDED ==========");
console.log(` ${DEMO_EMAIL_ALICE} / ${DEMO_PASSWORD} (Alice — switch to \"데모 회사\")`);
console.log(` ${DEMO_EMAIL_BOB} / ${DEMO_PASSWORD} (Bob — second account)`);
console.log("======================================");
console.log("");
}
/**
* Fills demo avatar_url for existing DBs seeded before avatars were added.
* Safe to run on every startup (only updates NULL/empty for known demo rows).
*/
export function backfillDemoAvatarsIfNeeded(): void {
const { alice, bob, ctxAlicePersonal, ctxBobPersonal, ctxWork } = DemoIds;
const rows: Array<[string, string, string]> = [
[alice, ctxAlicePersonal, DEMO_AVATAR_ALICE_PERSONAL],
[bob, ctxBobPersonal, DEMO_AVATAR_BOB_PERSONAL],
[alice, ctxWork, DEMO_AVATAR_ALICE_WORK],
[bob, ctxWork, DEMO_AVATAR_BOB_WORK],
];
const stmt = db.prepare(
`UPDATE profiles SET avatar_url = ? WHERE user_id = ? AND context_id = ? AND (avatar_url IS NULL OR avatar_url = '')`
);
for (const [uid, cid, url] of rows) {
stmt.run(url, uid, cid);
}
}
/**
* Extra demo: Bob in Alice's personal context, personal DM, work group room + messages.
* Idempotent — safe on every startup (uses INSERT OR IGNORE / message count checks).
*/
export function extendDemoDataIfNeeded(): void {
const exists = db.prepare(`SELECT id FROM users WHERE email = ?`).get(DEMO_EMAIL_ALICE) as
| { id: string }
| undefined;
if (!exists) {
return;
}
const { alice, bob, ctxAlicePersonal, ctxWork, roomPersonalDm, roomWorkGroup } = DemoIds;
db.prepare(
`INSERT OR IGNORE INTO context_members (context_id, user_id, role) VALUES (?, ?, 'member')`
).run(ctxAlicePersonal, bob);
db.prepare(
`INSERT OR IGNORE INTO profiles (user_id, context_id, display_name, avatar_url, status_message) VALUES (?, ?, ?, ?, ?)`
).run(
bob,
ctxAlicePersonal,
"밥",
DEMO_AVATAR_BOB_PERSONAL,
"앨리스 일상에서 보이는 상태"
);
db.prepare(
`INSERT OR IGNORE INTO rooms (id, context_id, is_group, name) VALUES (?, ?, 0, NULL)`
).run(roomPersonalDm, ctxAlicePersonal);
db.prepare(`INSERT OR IGNORE INTO room_members (room_id, user_id) VALUES (?, ?)`).run(
roomPersonalDm,
alice
);
db.prepare(`INSERT OR IGNORE INTO room_members (room_id, user_id) VALUES (?, ?)`).run(
roomPersonalDm,
bob
);
const personalMsgN = db
.prepare(`SELECT COUNT(*) as n FROM messages WHERE room_id = ?`)
.get(roomPersonalDm) as { n: number };
if (personalMsgN.n === 0) {
const a = randomUUID();
const b = randomUUID();
const c = randomUUID();
db.prepare(
`INSERT INTO messages (id, room_id, sender_id, body, kind) VALUES (?, ?, ?, ?, 'text')`
).run(a, roomPersonalDm, alice, "주말에 카페 갈래?");
db.prepare(
`INSERT INTO messages (id, room_id, sender_id, body, kind) VALUES (?, ?, ?, ?, 'text')`
).run(b, roomPersonalDm, bob, "좋아, 몇 시에 볼까?");
db.prepare(
`INSERT INTO messages (id, room_id, sender_id, body, kind) VALUES (?, ?, ?, ?, 'text')`
).run(c, roomPersonalDm, alice, "2시 어때?");
}
db.prepare(
`INSERT OR IGNORE INTO rooms (id, context_id, is_group, name) VALUES (?, ?, 1, ?)`
).run(roomWorkGroup, ctxWork, "프로젝트 A");
db.prepare(`INSERT OR IGNORE INTO room_members (room_id, user_id) VALUES (?, ?)`).run(
roomWorkGroup,
alice
);
db.prepare(`INSERT OR IGNORE INTO room_members (room_id, user_id) VALUES (?, ?)`).run(
roomWorkGroup,
bob
);
const groupMsgN = db
.prepare(`SELECT COUNT(*) as n FROM messages WHERE room_id = ?`)
.get(roomWorkGroup) as { n: number };
if (groupMsgN.n === 0) {
const m1 = randomUUID();
const m2 = randomUUID();
db.prepare(
`INSERT INTO messages (id, room_id, sender_id, body, kind) VALUES (?, ?, ?, ?, 'text')`
).run(m1, roomWorkGroup, alice, "[프로젝트 A] 킥오프 슬라이드 올려뒀어요.");
db.prepare(
`INSERT INTO messages (id, room_id, sender_id, body, kind) VALUES (?, ?, ?, ?, 'text')`
).run(m2, roomWorkGroup, bob, "확인했습니다. 내일 정리해서 공유할게요.");
}
}

13
server/tsconfig.json Normal file
View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*"]
}