오대리ㅣㅣㅣㅣ
This commit is contained in:
BIN
server/data/msn.db
Normal file
BIN
server/data/msn.db
Normal file
Binary file not shown.
1738
server/package-lock.json
generated
Normal file
1738
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
server/package.json
Normal file
28
server/package.json
Normal 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
14
server/src/auth.ts
Normal 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;
|
||||
}
|
||||
17
server/src/contextAccess.ts
Normal file
17
server/src/contextAccess.ts
Normal 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
89
server/src/db.ts
Normal 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
42
server/src/index.ts
Normal 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
21
server/src/middleware.ts
Normal 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
197
server/src/realtime.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
85
server/src/routes/authRoutes.ts
Normal file
85
server/src/routes/authRoutes.ts
Normal 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 });
|
||||
});
|
||||
125
server/src/routes/contextRoutes.ts
Normal file
125
server/src/routes/contextRoutes.ts
Normal 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 });
|
||||
});
|
||||
162
server/src/routes/messageRoutes.ts
Normal file
162
server/src/routes/messageRoutes.ts
Normal 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,
|
||||
})),
|
||||
});
|
||||
});
|
||||
146
server/src/routes/profileRoutes.ts
Normal file
146
server/src/routes/profileRoutes.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
24
server/src/routes/pushRoutes.ts
Normal file
24
server/src/routes/pushRoutes.ts
Normal 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 });
|
||||
});
|
||||
139
server/src/routes/roomRoutes.ts
Normal file
139
server/src/routes/roomRoutes.ts
Normal 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
230
server/src/seed.ts
Normal 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",
|
||||
/** Alice–Bob 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
13
server/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
Reference in New Issue
Block a user