diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7a999fc --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# Database backend: "sqlite" (default) or "supabase" +DB_BACKEND=sqlite + +# SQLite settings (used when DB_BACKEND=sqlite) +DB_PATH=social2000.db + +# Supabase settings (used when DB_BACKEND=supabase) +SUPABASE_URL=https://your-project.supabase.co +SUPABASE_SERVICE_ROLE_KEY=your-service-role-key + +# Server port +PORT=3000 diff --git a/bun.lock b/bun.lock index be5f57e..45c15f8 100644 --- a/bun.lock +++ b/bun.lock @@ -1,9 +1,12 @@ { "lockfileVersion": 1, - "configVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "social2000", + "dependencies": { + "@supabase/supabase-js": "^2.100.1", + }, "devDependencies": { "@types/bun": "latest", }, @@ -13,14 +16,36 @@ }, }, "packages": { - "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], + "@supabase/auth-js": ["@supabase/auth-js@2.100.1", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-c5FB4nrG7cs1mLSzFGuIVl2iR2YO5XkSJ96uF4zubYm8YDn71XOi2emE9sBm/avfGCj61jaRBLOvxEAVnpys0Q=="], + + "@supabase/functions-js": ["@supabase/functions-js@2.100.1", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-mo8QheoV4KR+wSubtyEWhZUxWnCM7YZ23TncccMAlbWAHb8YTDqRGRm9IalWCAswniKyud6buZCk9snRqI86KA=="], + + "@supabase/phoenix": ["@supabase/phoenix@0.4.0", "", {}, "sha512-RHSx8bHS02xwfHdAbX5Lpbo6PXbgyf7lTaXTlwtFDPwOIw64NnVRwFAXGojHhjtVYI+PEPNSWwkL90f4agN3bw=="], + + "@supabase/postgrest-js": ["@supabase/postgrest-js@2.100.1", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-OIh4mOSo2LdqF2kox76OAPDtcSs+PwKABJOjc6plUV4/LXhFEsI2uwdEEIs7K7fd141qehWEVl/Y+Ts0fNvYsw=="], + + "@supabase/realtime-js": ["@supabase/realtime-js@2.100.1", "", { "dependencies": { "@supabase/phoenix": "^0.4.0", "@types/ws": "^8.18.1", "tslib": "2.8.1", "ws": "^8.18.2" } }, "sha512-FHuRWPX4qZQ4x+0Q+ZrKaBZnOiVGiwsgiAUJM98pYRib1yeaE/fOM1lZ1ozd+4gA8Udw23OyaD8SxKS5mT5NYw=="], + + "@supabase/storage-js": ["@supabase/storage-js@2.100.1", "", { "dependencies": { "iceberg-js": "^0.8.1", "tslib": "2.8.1" } }, "sha512-x9xpEIoWM4xKiAlwfWTgHPSN6N4Y0aS4FVU4F6ZPbq7Gayw08SrtC6/YH/gOr8CjXQr0HxXYXDop2xGTSjubYA=="], - "@types/node": ["@types/node@25.3.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ=="], + "@supabase/supabase-js": ["@supabase/supabase-js@2.100.1", "", { "dependencies": { "@supabase/auth-js": "2.100.1", "@supabase/functions-js": "2.100.1", "@supabase/postgrest-js": "2.100.1", "@supabase/realtime-js": "2.100.1", "@supabase/storage-js": "2.100.1" } }, "sha512-CAeFm5sfX8sbTzxoxRafhohreIzl9a7R6qHTck3MrgTqm5M5g/u0IHfEKYzI9w/17r8NINl8UZrw2i08wrO7Iw=="], - "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], + "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], - "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], + + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + + "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], + + "iceberg-js": ["iceberg-js@0.8.1", "", {}, "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, ""], "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], } } diff --git a/db.ts b/db.ts index be27a7a..e450b2e 100644 --- a/db.ts +++ b/db.ts @@ -1,467 +1,57 @@ -import { Database } from "bun:sqlite"; - -const dbPath = process.env.DB_PATH || "social2000.db"; -const db = new Database(dbPath); - -db.run("PRAGMA journal_mode = WAL"); -db.run("PRAGMA foreign_keys = ON"); - -db.run(` - CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT NOT NULL UNIQUE COLLATE NOCASE, - password_hash TEXT NOT NULL, - created_at TEXT DEFAULT (datetime('now')) - ) -`); - -db.run(` - CREATE TABLE IF NOT EXISTS sessions ( - token TEXT PRIMARY KEY, - user_id INTEGER NOT NULL, - created_at TEXT DEFAULT (datetime('now')), - expires_at TEXT NOT NULL, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - ) -`); - -db.run(` - CREATE TABLE IF NOT EXISTS websites ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - domain TEXT NOT NULL UNIQUE COLLATE NOCASE, - created_at TEXT DEFAULT (datetime('now')), - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - ) -`); - -db.run(` - CREATE TABLE IF NOT EXISTS tags ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL UNIQUE COLLATE NOCASE - ) -`); - -db.run(` - CREATE TABLE IF NOT EXISTS website_tags ( - website_id INTEGER NOT NULL, - tag_id INTEGER NOT NULL, - PRIMARY KEY (website_id, tag_id), - FOREIGN KEY (website_id) REFERENCES websites(id) ON DELETE CASCADE, - FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE - ) -`); - -db.run(` - CREATE TABLE IF NOT EXISTS friend_requests ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - from_user_id INTEGER NOT NULL, - to_user_id INTEGER NOT NULL, - status TEXT NOT NULL DEFAULT 'pending', - created_at TEXT DEFAULT (datetime('now')), - FOREIGN KEY (from_user_id) REFERENCES users(id) ON DELETE CASCADE, - FOREIGN KEY (to_user_id) REFERENCES users(id) ON DELETE CASCADE, - UNIQUE(from_user_id, to_user_id) - ) -`); - -db.run(` - CREATE TABLE IF NOT EXISTS friends ( - user_id INTEGER NOT NULL, - friend_id INTEGER NOT NULL, - created_at TEXT DEFAULT (datetime('now')), - PRIMARY KEY (user_id, friend_id), - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, - FOREIGN KEY (friend_id) REFERENCES users(id) ON DELETE CASCADE - ) -`); - -db.run(` - CREATE TABLE IF NOT EXISTS messages ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - from_user_id INTEGER NOT NULL, - to_user_id INTEGER NOT NULL, - content TEXT NOT NULL, - read INTEGER NOT NULL DEFAULT 0, - created_at TEXT DEFAULT (datetime('now')), - FOREIGN KEY (from_user_id) REFERENCES users(id) ON DELETE CASCADE, - FOREIGN KEY (to_user_id) REFERENCES users(id) ON DELETE CASCADE - ) -`); - -export async function createUser(username: string, password: string) { - const hash = await Bun.password.hash(password); - const result = db.prepare( - "INSERT INTO users (username, password_hash) VALUES (?, ?)" - ).run(username, hash); - return db.prepare("SELECT id, username, created_at FROM users WHERE id = ?").get(result.lastInsertRowid); -} - -export function findUserByUsername(username: string) { - return db.prepare("SELECT * FROM users WHERE username = ?").get(username) as - | { id: number; username: string; password_hash: string; created_at: string } - | null; -} - -export function createSession(userId: number): string { - const token = crypto.randomUUID(); - const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(); - db.prepare( - "INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)" - ).run(token, userId, expiresAt); - return token; -} - -export function getSessionUser(token: string) { - const session = db.prepare( - "SELECT user_id FROM sessions WHERE token = ? AND expires_at > datetime('now')" - ).get(token) as { user_id: number } | null; - if (!session) return null; - return db.prepare("SELECT id, username FROM users WHERE id = ?").get(session.user_id) as - | { id: number; username: string } - | null; -} - -export function deleteSession(token: string) { - db.prepare("DELETE FROM sessions WHERE token = ?").run(token); -} - -export function getWebsitesByUserId(userId: number) { - return db.prepare( - "SELECT id, domain, created_at FROM websites WHERE user_id = ? ORDER BY created_at DESC" - ).all(userId) as { id: number; domain: string; created_at: string }[]; -} - -export function countWebsitesByUserId(userId: number): number { - const row = db.prepare( - "SELECT COUNT(*) as count FROM websites WHERE user_id = ?" - ).get(userId) as { count: number }; - return row.count; -} - -export function findWebsiteByDomain(domain: string) { - return db.prepare( - "SELECT id, user_id, domain FROM websites WHERE domain = ?" - ).get(domain) as { id: number; user_id: number; domain: string } | null; -} - -export function getWebsiteByDomainAndUserId(domain: string, userId: number) { - return db.prepare( - "SELECT id, user_id, domain, created_at FROM websites WHERE domain = ? AND user_id = ?" - ).get(domain, userId) as { id: number; user_id: number; domain: string; created_at: string } | null; -} - -export function deleteWebsite(id: number, userId: number): boolean { - const result = db.prepare( - "DELETE FROM websites WHERE id = ? AND user_id = ?" - ).run(id, userId); - return result.changes > 0; -} - -export function createWebsite(userId: number, domain: string) { - const result = db.prepare( - "INSERT INTO websites (user_id, domain) VALUES (?, ?)" - ).run(userId, domain); - return db.prepare("SELECT id, domain, created_at FROM websites WHERE id = ?").get(result.lastInsertRowid); -} - -// --- Git Websites --- - -db.run(` - CREATE TABLE IF NOT EXISTS git_websites ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - website_id INTEGER NOT NULL UNIQUE, - git_url TEXT NOT NULL, - git_username TEXT, - git_password TEXT, - git_branch TEXT, - git_commit_hash TEXT, - git_commit_message TEXT, - FOREIGN KEY (website_id) REFERENCES websites(id) ON DELETE CASCADE - ) -`); - -export function createGitWebsite(websiteId: number, gitUrl: string, gitUsername?: string, gitPassword?: string) { - db.prepare( - "INSERT INTO git_websites (website_id, git_url, git_username, git_password) VALUES (?, ?, ?, ?)" - ).run(websiteId, gitUrl, gitUsername || null, gitPassword || null); -} - -export function getGitWebsite(websiteId: number) { - return db.prepare( - "SELECT * FROM git_websites WHERE website_id = ?" - ).get(websiteId) as { - id: number; website_id: number; git_url: string; - git_username: string | null; git_password: string | null; - git_branch: string | null; git_commit_hash: string | null; git_commit_message: string | null; - } | null; -} - -export function getGitWebsiteByDomain(domain: string) { - return db.prepare(` - SELECT gw.*, w.domain, w.user_id - FROM git_websites gw - JOIN websites w ON w.id = gw.website_id - WHERE w.domain = ? - `).get(domain) as { - id: number; website_id: number; git_url: string; - git_username: string | null; git_password: string | null; - git_branch: string | null; git_commit_hash: string | null; git_commit_message: string | null; - domain: string; user_id: number; - } | null; -} - -export function updateGitWebsiteCommit(websiteId: number, commitHash: string, commitMessage: string) { - db.prepare( - "UPDATE git_websites SET git_commit_hash = ?, git_commit_message = ? WHERE website_id = ?" - ).run(commitHash, commitMessage, websiteId); -} - -export function updateGitWebsiteBranch(websiteId: number, branch: string) { - db.prepare( - "UPDATE git_websites SET git_branch = ? WHERE website_id = ?" - ).run(branch, websiteId); -} - -export function deleteGitWebsite(websiteId: number) { - db.prepare("DELETE FROM git_websites WHERE website_id = ?").run(websiteId); -} - -// --- External Websites --- - -db.run(` - CREATE TABLE IF NOT EXISTS external_websites ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - website_id INTEGER NOT NULL UNIQUE, - external_url TEXT NOT NULL, - FOREIGN KEY (website_id) REFERENCES websites(id) ON DELETE CASCADE - ) -`); - -export function createExternalWebsite(websiteId: number, externalUrl: string) { - db.prepare( - "INSERT INTO external_websites (website_id, external_url) VALUES (?, ?)" - ).run(websiteId, externalUrl); -} - -export function getExternalWebsite(websiteId: number) { - return db.prepare( - "SELECT * FROM external_websites WHERE website_id = ?" - ).get(websiteId) as { - id: number; website_id: number; external_url: string; - } | null; -} - -export function getExternalWebsiteByDomain(domain: string) { - return db.prepare(` - SELECT ew.*, w.domain, w.user_id - FROM external_websites ew - JOIN websites w ON w.id = ew.website_id - WHERE w.domain = ? - `).get(domain) as { - id: number; website_id: number; external_url: string; - domain: string; user_id: number; - } | null; -} - -export function updateExternalWebsiteUrl(websiteId: number, externalUrl: string) { - db.prepare( - "UPDATE external_websites SET external_url = ? WHERE website_id = ?" - ).run(externalUrl, websiteId); -} - -export function deleteExternalWebsite(websiteId: number) { - db.prepare("DELETE FROM external_websites WHERE website_id = ?").run(websiteId); -} - -export function getOrCreateTag(name: string): { id: number; name: string } { - const normalized = name.toLowerCase(); - const existing = db.prepare("SELECT id, name FROM tags WHERE name = ?").get(normalized) as { id: number; name: string } | null; - if (existing) return existing; - const result = db.prepare("INSERT INTO tags (name) VALUES (?)").run(normalized); - return { id: Number(result.lastInsertRowid), name: normalized }; -} - -export function setWebsiteTags(websiteId: number, tagNames: string[]) { - db.prepare("DELETE FROM website_tags WHERE website_id = ?").run(websiteId); - for (const name of tagNames) { - const tag = getOrCreateTag(name); - db.prepare("INSERT OR IGNORE INTO website_tags (website_id, tag_id) VALUES (?, ?)").run(websiteId, tag.id); - } -} - -export function getTagsForWebsite(websiteId: number): string[] { - const rows = db.prepare( - "SELECT t.name FROM tags t JOIN website_tags wt ON t.id = wt.tag_id WHERE wt.website_id = ? ORDER BY t.name" - ).all(websiteId) as { name: string }[]; - return rows.map(r => r.name); -} - -export function getWebsitesByTag(tagName: string): { domain: string; username: string }[] { - return db.prepare(` - SELECT w.domain, u.username - FROM websites w - JOIN website_tags wt ON w.id = wt.website_id - JOIN tags t ON t.id = wt.tag_id - JOIN users u ON u.id = w.user_id - WHERE t.name = ? - ORDER BY w.domain - `).all(tagName.toLowerCase()) as { domain: string; username: string }[]; -} - -export function getAllTagsWithCounts(): { name: string; count: number }[] { - return db.prepare(` - SELECT t.name, COUNT(wt.website_id) as count - FROM tags t - JOIN website_tags wt ON t.id = wt.tag_id - GROUP BY t.id - HAVING count > 0 - ORDER BY count DESC, t.name ASC - `).all() as { name: string; count: number }[]; -} - -// ============================================= -// Friend requests -// ============================================= - -export function createFriendRequest(fromUserId: number, toUserId: number): boolean { - try { - db.prepare( - "INSERT INTO friend_requests (from_user_id, to_user_id, status) VALUES (?, ?, 'pending')" - ).run(fromUserId, toUserId); - return true; - } catch { - return false; - } -} - -export function getPendingFriendRequests(userId: number) { - return db.prepare(` - SELECT fr.id, fr.from_user_id, u.username as from_username, fr.created_at - FROM friend_requests fr - JOIN users u ON u.id = fr.from_user_id - WHERE fr.to_user_id = ? AND fr.status = 'pending' - ORDER BY fr.created_at DESC - `).all(userId) as { id: number; from_user_id: number; from_username: string; created_at: string }[]; -} - -export function getSentFriendRequests(userId: number) { - return db.prepare(` - SELECT fr.id, fr.to_user_id, u.username as to_username, fr.status, fr.created_at - FROM friend_requests fr - JOIN users u ON u.id = fr.to_user_id - WHERE fr.from_user_id = ? AND fr.status = 'pending' - ORDER BY fr.created_at DESC - `).all(userId) as { id: number; to_user_id: number; to_username: string; status: string; created_at: string }[]; -} - -export function acceptFriendRequest(requestId: number, userId: number): boolean { - const request = db.prepare( - "SELECT id, from_user_id, to_user_id FROM friend_requests WHERE id = ? AND to_user_id = ? AND status = 'pending'" - ).get(requestId, userId) as { id: number; from_user_id: number; to_user_id: number } | null; - if (!request) return false; - - db.prepare("UPDATE friend_requests SET status = 'accepted' WHERE id = ?").run(requestId); - db.prepare("INSERT OR IGNORE INTO friends (user_id, friend_id) VALUES (?, ?)").run(request.from_user_id, request.to_user_id); - db.prepare("INSERT OR IGNORE INTO friends (user_id, friend_id) VALUES (?, ?)").run(request.to_user_id, request.from_user_id); - return true; -} - -export function declineFriendRequest(requestId: number, userId: number): boolean { - const result = db.prepare( - "DELETE FROM friend_requests WHERE id = ? AND to_user_id = ? AND status = 'pending'" - ).run(requestId, userId); - return result.changes > 0; -} - -export function areFriends(userId: number, friendId: number): boolean { - const row = db.prepare( - "SELECT 1 FROM friends WHERE user_id = ? AND friend_id = ?" - ).get(userId, friendId); - return !!row; -} - -export function getFriends(userId: number) { - return db.prepare(` - SELECT u.id, u.username - FROM friends f - JOIN users u ON u.id = f.friend_id - WHERE f.user_id = ? - ORDER BY u.username COLLATE NOCASE - `).all(userId) as { id: number; username: string }[]; -} - -export function removeFriend(userId: number, friendId: number): boolean { - db.prepare("DELETE FROM friends WHERE user_id = ? AND friend_id = ?").run(userId, friendId); - db.prepare("DELETE FROM friends WHERE user_id = ? AND friend_id = ?").run(friendId, userId); - // Also clean up any friend requests between them - db.prepare("DELETE FROM friend_requests WHERE (from_user_id = ? AND to_user_id = ?) OR (from_user_id = ? AND to_user_id = ?)").run(userId, friendId, friendId, userId); - return true; -} - -export function searchUsers(query: string, currentUserId: number) { - return db.prepare(` - SELECT id, username FROM users - WHERE username LIKE ? AND id != ? - ORDER BY username COLLATE NOCASE - LIMIT 20 - `).all(`%${query}%`, currentUserId) as { id: number; username: string }[]; -} - -export function findUserById(id: number) { - return db.prepare("SELECT id, username FROM users WHERE id = ?").get(id) as { id: number; username: string } | null; -} - -// ============================================= -// Messages -// ============================================= - -export function createMessage(fromUserId: number, toUserId: number, content: string) { - const result = db.prepare( - "INSERT INTO messages (from_user_id, to_user_id, content) VALUES (?, ?, ?)" - ).run(fromUserId, toUserId, content); - return db.prepare("SELECT id, from_user_id, to_user_id, content, read, created_at FROM messages WHERE id = ?").get(result.lastInsertRowid) as { - id: number; from_user_id: number; to_user_id: number; content: string; read: number; created_at: string; - }; -} - -export function getMessages(userId: number, otherUserId: number, limit: number = 50) { - return db.prepare(` - SELECT id, from_user_id, to_user_id, content, read, created_at - FROM messages - WHERE (from_user_id = ? AND to_user_id = ?) OR (from_user_id = ? AND to_user_id = ?) - ORDER BY created_at DESC - LIMIT ? - `).all(userId, otherUserId, otherUserId, userId, limit) as { - id: number; from_user_id: number; to_user_id: number; content: string; read: number; created_at: string; - }[]; -} - -export function markMessagesAsRead(userId: number, fromUserId: number) { - db.prepare( - "UPDATE messages SET read = 1 WHERE to_user_id = ? AND from_user_id = ? AND read = 0" - ).run(userId, fromUserId); -} - -export function getUnreadCounts(userId: number) { - return db.prepare(` - SELECT from_user_id, COUNT(*) as count - FROM messages - WHERE to_user_id = ? AND read = 0 - GROUP BY from_user_id - `).all(userId) as { from_user_id: number; count: number }[]; -} - -export function getTotalUnreadCount(userId: number): number { - const row = db.prepare( - "SELECT COUNT(*) as count FROM messages WHERE to_user_id = ? AND read = 0" - ).get(userId) as { count: number }; - return row.count; -} - -export function hasPendingRequest(fromUserId: number, toUserId: number): boolean { - const row = db.prepare( - "SELECT 1 FROM friend_requests WHERE from_user_id = ? AND to_user_id = ? AND status = 'pending'" - ).get(fromUserId, toUserId); - return !!row; -} +import type { DatabaseBackend } from "./db_interface"; + +const backendName: string = process.env.DB_BACKEND || "sqlite"; +let db: DatabaseBackend; + +if (backendName === "supabase") { + const { createSupabaseBackend } = await import("./db_supabase"); + db = createSupabaseBackend(); +} else { + const { createSqliteBackend } = await import("./db_sqlite"); + db = createSqliteBackend(); +} + +// Re-export all functions from the chosen backend +export const createUser = db.createUser.bind(db); +export const findUserByUsername = db.findUserByUsername.bind(db); +export const createSession = db.createSession.bind(db); +export const getSessionUser = db.getSessionUser.bind(db); +export const deleteSession = db.deleteSession.bind(db); +export const getWebsitesByUserId = db.getWebsitesByUserId.bind(db); +export const countWebsitesByUserId = db.countWebsitesByUserId.bind(db); +export const findWebsiteByDomain = db.findWebsiteByDomain.bind(db); +export const getWebsiteByDomainAndUserId = db.getWebsiteByDomainAndUserId.bind(db); +export const deleteWebsite = db.deleteWebsite.bind(db); +export const createWebsite = db.createWebsite.bind(db); +export const createGitWebsite = db.createGitWebsite.bind(db); +export const getGitWebsite = db.getGitWebsite.bind(db); +export const getGitWebsiteByDomain = db.getGitWebsiteByDomain.bind(db); +export const updateGitWebsiteCommit = db.updateGitWebsiteCommit.bind(db); +export const updateGitWebsiteBranch = db.updateGitWebsiteBranch.bind(db); +export const deleteGitWebsite = db.deleteGitWebsite.bind(db); +export const createExternalWebsite = db.createExternalWebsite.bind(db); +export const getExternalWebsite = db.getExternalWebsite.bind(db); +export const getExternalWebsiteByDomain = db.getExternalWebsiteByDomain.bind(db); +export const updateExternalWebsiteUrl = db.updateExternalWebsiteUrl.bind(db); +export const deleteExternalWebsite = db.deleteExternalWebsite.bind(db); +export const getOrCreateTag = db.getOrCreateTag.bind(db); +export const setWebsiteTags = db.setWebsiteTags.bind(db); +export const getTagsForWebsite = db.getTagsForWebsite.bind(db); +export const getWebsitesByTag = db.getWebsitesByTag.bind(db); +export const getAllTagsWithCounts = db.getAllTagsWithCounts.bind(db); +export const createFriendRequest = db.createFriendRequest.bind(db); +export const getPendingFriendRequests = db.getPendingFriendRequests.bind(db); +export const getSentFriendRequests = db.getSentFriendRequests.bind(db); +export const acceptFriendRequest = db.acceptFriendRequest.bind(db); +export const declineFriendRequest = db.declineFriendRequest.bind(db); +export const areFriends = db.areFriends.bind(db); +export const getFriends = db.getFriends.bind(db); +export const removeFriend = db.removeFriend.bind(db); +export const searchUsers = db.searchUsers.bind(db); +export const findUserById = db.findUserById.bind(db); +export const hasPendingRequest = db.hasPendingRequest.bind(db); +export const createMessage = db.createMessage.bind(db); +export const getMessages = db.getMessages.bind(db); +export const markMessagesAsRead = db.markMessagesAsRead.bind(db); +export const getUnreadCounts = db.getUnreadCounts.bind(db); +export const getTotalUnreadCount = db.getTotalUnreadCount.bind(db); diff --git a/db_interface.ts b/db_interface.ts new file mode 100644 index 0000000..1611ecb --- /dev/null +++ b/db_interface.ts @@ -0,0 +1,75 @@ +// DatabaseBackend interface — every function is async so both SQLite and Supabase can implement it. + +export interface DatabaseBackend { + createUser(username: string, password: string): Promise<{ id: number; username: string; created_at: string } | null>; + findUserByUsername(username: string): Promise<{ id: number; username: string; password_hash: string; created_at: string } | null>; + createSession(userId: number): Promise; + getSessionUser(token: string): Promise<{ id: number; username: string } | null>; + deleteSession(token: string): Promise; + + // Websites + getWebsitesByUserId(userId: number): Promise<{ id: number; domain: string; created_at: string }[]>; + countWebsitesByUserId(userId: number): Promise; + findWebsiteByDomain(domain: string): Promise<{ id: number; user_id: number; domain: string } | null>; + getWebsiteByDomainAndUserId(domain: string, userId: number): Promise<{ id: number; user_id: number; domain: string; created_at: string } | null>; + deleteWebsite(id: number, userId: number): Promise; + createWebsite(userId: number, domain: string): Promise<{ id: number; domain: string; created_at: string } | null>; + + // Git Websites + createGitWebsite(websiteId: number, gitUrl: string, gitUsername?: string, gitPassword?: string): Promise; + getGitWebsite(websiteId: number): Promise<{ + id: number; website_id: number; git_url: string; + git_username: string | null; git_password: string | null; + git_branch: string | null; git_commit_hash: string | null; git_commit_message: string | null; + } | null>; + getGitWebsiteByDomain(domain: string): Promise<{ + id: number; website_id: number; git_url: string; + git_username: string | null; git_password: string | null; + git_branch: string | null; git_commit_hash: string | null; git_commit_message: string | null; + domain: string; user_id: number; + } | null>; + updateGitWebsiteCommit(websiteId: number, commitHash: string, commitMessage: string): Promise; + updateGitWebsiteBranch(websiteId: number, branch: string): Promise; + deleteGitWebsite(websiteId: number): Promise; + + // External Websites + createExternalWebsite(websiteId: number, externalUrl: string): Promise; + getExternalWebsite(websiteId: number): Promise<{ id: number; website_id: number; external_url: string } | null>; + getExternalWebsiteByDomain(domain: string): Promise<{ + id: number; website_id: number; external_url: string; + domain: string; user_id: number; + } | null>; + updateExternalWebsiteUrl(websiteId: number, externalUrl: string): Promise; + deleteExternalWebsite(websiteId: number): Promise; + + // Tags + getOrCreateTag(name: string): Promise<{ id: number; name: string }>; + setWebsiteTags(websiteId: number, tagNames: string[]): Promise; + getTagsForWebsite(websiteId: number): Promise; + getWebsitesByTag(tagName: string): Promise<{ domain: string; username: string }[]>; + getAllTagsWithCounts(): Promise<{ name: string; count: number }[]>; + + // Friend requests + createFriendRequest(fromUserId: number, toUserId: number): Promise; + getPendingFriendRequests(userId: number): Promise<{ id: number; from_user_id: number; from_username: string; created_at: string }[]>; + getSentFriendRequests(userId: number): Promise<{ id: number; to_user_id: number; to_username: string; status: string; created_at: string }[]>; + acceptFriendRequest(requestId: number, userId: number): Promise; + declineFriendRequest(requestId: number, userId: number): Promise; + areFriends(userId: number, friendId: number): Promise; + getFriends(userId: number): Promise<{ id: number; username: string }[]>; + removeFriend(userId: number, friendId: number): Promise; + searchUsers(query: string, currentUserId: number): Promise<{ id: number; username: string }[]>; + findUserById(id: number): Promise<{ id: number; username: string } | null>; + hasPendingRequest(fromUserId: number, toUserId: number): Promise; + + // Messages + createMessage(fromUserId: number, toUserId: number, content: string): Promise<{ + id: number; from_user_id: number; to_user_id: number; content: string; read: number; created_at: string; + }>; + getMessages(userId: number, otherUserId: number, limit?: number): Promise<{ + id: number; from_user_id: number; to_user_id: number; content: string; read: number; created_at: string; + }[]>; + markMessagesAsRead(userId: number, fromUserId: number): Promise; + getUnreadCounts(userId: number): Promise<{ from_user_id: number; count: number }[]>; + getTotalUnreadCount(userId: number): Promise; +} diff --git a/db_sqlite.ts b/db_sqlite.ts new file mode 100644 index 0000000..4d82157 --- /dev/null +++ b/db_sqlite.ts @@ -0,0 +1,475 @@ +import { Database } from "bun:sqlite"; +import type { DatabaseBackend } from "./db_interface"; + +export function createSqliteBackend(dbPath?: string): DatabaseBackend { + const path = dbPath || process.env.DB_PATH || "social2000.db"; + const db = new Database(path); + + db.run("PRAGMA journal_mode = WAL"); + db.run("PRAGMA foreign_keys = ON"); + + db.run(` + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE COLLATE NOCASE, + password_hash TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now')) + ) + `); + + db.run(` + CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + user_id INTEGER NOT NULL, + created_at TEXT DEFAULT (datetime('now')), + expires_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + `); + + db.run(` + CREATE TABLE IF NOT EXISTS websites ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + domain TEXT NOT NULL UNIQUE COLLATE NOCASE, + created_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + `); + + db.run(` + CREATE TABLE IF NOT EXISTS tags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE COLLATE NOCASE + ) + `); + + db.run(` + CREATE TABLE IF NOT EXISTS website_tags ( + website_id INTEGER NOT NULL, + tag_id INTEGER NOT NULL, + PRIMARY KEY (website_id, tag_id), + FOREIGN KEY (website_id) REFERENCES websites(id) ON DELETE CASCADE, + FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE + ) + `); + + db.run(` + CREATE TABLE IF NOT EXISTS friend_requests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + from_user_id INTEGER NOT NULL, + to_user_id INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + created_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (from_user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (to_user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(from_user_id, to_user_id) + ) + `); + + db.run(` + CREATE TABLE IF NOT EXISTS friends ( + user_id INTEGER NOT NULL, + friend_id INTEGER NOT NULL, + created_at TEXT DEFAULT (datetime('now')), + PRIMARY KEY (user_id, friend_id), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (friend_id) REFERENCES users(id) ON DELETE CASCADE + ) + `); + + db.run(` + CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + from_user_id INTEGER NOT NULL, + to_user_id INTEGER NOT NULL, + content TEXT NOT NULL, + read INTEGER NOT NULL DEFAULT 0, + created_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (from_user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (to_user_id) REFERENCES users(id) ON DELETE CASCADE + ) + `); + + db.run(` + CREATE TABLE IF NOT EXISTS git_websites ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + website_id INTEGER NOT NULL UNIQUE, + git_url TEXT NOT NULL, + git_username TEXT, + git_password TEXT, + git_branch TEXT, + git_commit_hash TEXT, + git_commit_message TEXT, + FOREIGN KEY (website_id) REFERENCES websites(id) ON DELETE CASCADE + ) + `); + + db.run(` + CREATE TABLE IF NOT EXISTS external_websites ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + website_id INTEGER NOT NULL UNIQUE, + external_url TEXT NOT NULL, + FOREIGN KEY (website_id) REFERENCES websites(id) ON DELETE CASCADE + ) + `); + + function getOrCreateTag(name: string): { id: number; name: string } { + const normalized = name.toLowerCase(); + const existing = db.prepare("SELECT id, name FROM tags WHERE name = ?").get(normalized) as { id: number; name: string } | null; + if (existing) return existing; + const result = db.prepare("INSERT INTO tags (name) VALUES (?)").run(normalized); + return { id: Number(result.lastInsertRowid), name: normalized }; + } + + const backend: DatabaseBackend = { + async createUser(username: string, password: string) { + const hash = await Bun.password.hash(password); + const result = db.prepare( + "INSERT INTO users (username, password_hash) VALUES (?, ?)" + ).run(username, hash); + return db.prepare("SELECT id, username, created_at FROM users WHERE id = ?").get(result.lastInsertRowid) as { id: number; username: string; created_at: string } | null; + }, + + async findUserByUsername(username: string) { + return db.prepare("SELECT * FROM users WHERE username = ?").get(username) as + | { id: number; username: string; password_hash: string; created_at: string } + | null; + }, + + async createSession(userId: number): Promise { + const token = crypto.randomUUID(); + const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(); + db.prepare( + "INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)" + ).run(token, userId, expiresAt); + return token; + }, + + async getSessionUser(token: string) { + const session = db.prepare( + "SELECT user_id FROM sessions WHERE token = ? AND expires_at > datetime('now')" + ).get(token) as { user_id: number } | null; + if (!session) return null; + return db.prepare("SELECT id, username FROM users WHERE id = ?").get(session.user_id) as + | { id: number; username: string } + | null; + }, + + async deleteSession(token: string) { + db.prepare("DELETE FROM sessions WHERE token = ?").run(token); + }, + + async getWebsitesByUserId(userId: number) { + return db.prepare( + "SELECT id, domain, created_at FROM websites WHERE user_id = ? ORDER BY created_at DESC" + ).all(userId) as { id: number; domain: string; created_at: string }[]; + }, + + async countWebsitesByUserId(userId: number): Promise { + const row = db.prepare( + "SELECT COUNT(*) as count FROM websites WHERE user_id = ?" + ).get(userId) as { count: number }; + return row.count; + }, + + async findWebsiteByDomain(domain: string) { + return db.prepare( + "SELECT id, user_id, domain FROM websites WHERE domain = ?" + ).get(domain) as { id: number; user_id: number; domain: string } | null; + }, + + async getWebsiteByDomainAndUserId(domain: string, userId: number) { + return db.prepare( + "SELECT id, user_id, domain, created_at FROM websites WHERE domain = ? AND user_id = ?" + ).get(domain, userId) as { id: number; user_id: number; domain: string; created_at: string } | null; + }, + + async deleteWebsite(id: number, userId: number): Promise { + const result = db.prepare( + "DELETE FROM websites WHERE id = ? AND user_id = ?" + ).run(id, userId); + return result.changes > 0; + }, + + async createWebsite(userId: number, domain: string) { + const result = db.prepare( + "INSERT INTO websites (user_id, domain) VALUES (?, ?)" + ).run(userId, domain); + return db.prepare("SELECT id, domain, created_at FROM websites WHERE id = ?").get(result.lastInsertRowid) as { id: number; domain: string; created_at: string } | null; + }, + + // --- Git Websites --- + + async createGitWebsite(websiteId: number, gitUrl: string, gitUsername?: string, gitPassword?: string) { + db.prepare( + "INSERT INTO git_websites (website_id, git_url, git_username, git_password) VALUES (?, ?, ?, ?)" + ).run(websiteId, gitUrl, gitUsername || null, gitPassword || null); + }, + + async getGitWebsite(websiteId: number) { + return db.prepare( + "SELECT * FROM git_websites WHERE website_id = ?" + ).get(websiteId) as { + id: number; website_id: number; git_url: string; + git_username: string | null; git_password: string | null; + git_branch: string | null; git_commit_hash: string | null; git_commit_message: string | null; + } | null; + }, + + async getGitWebsiteByDomain(domain: string) { + return db.prepare(` + SELECT gw.*, w.domain, w.user_id + FROM git_websites gw + JOIN websites w ON w.id = gw.website_id + WHERE w.domain = ? + `).get(domain) as { + id: number; website_id: number; git_url: string; + git_username: string | null; git_password: string | null; + git_branch: string | null; git_commit_hash: string | null; git_commit_message: string | null; + domain: string; user_id: number; + } | null; + }, + + async updateGitWebsiteCommit(websiteId: number, commitHash: string, commitMessage: string) { + db.prepare( + "UPDATE git_websites SET git_commit_hash = ?, git_commit_message = ? WHERE website_id = ?" + ).run(commitHash, commitMessage, websiteId); + }, + + async updateGitWebsiteBranch(websiteId: number, branch: string) { + db.prepare( + "UPDATE git_websites SET git_branch = ? WHERE website_id = ?" + ).run(branch, websiteId); + }, + + async deleteGitWebsite(websiteId: number) { + db.prepare("DELETE FROM git_websites WHERE website_id = ?").run(websiteId); + }, + + // --- External Websites --- + + async createExternalWebsite(websiteId: number, externalUrl: string) { + db.prepare( + "INSERT INTO external_websites (website_id, external_url) VALUES (?, ?)" + ).run(websiteId, externalUrl); + }, + + async getExternalWebsite(websiteId: number) { + return db.prepare( + "SELECT * FROM external_websites WHERE website_id = ?" + ).get(websiteId) as { + id: number; website_id: number; external_url: string; + } | null; + }, + + async getExternalWebsiteByDomain(domain: string) { + return db.prepare(` + SELECT ew.*, w.domain, w.user_id + FROM external_websites ew + JOIN websites w ON w.id = ew.website_id + WHERE w.domain = ? + `).get(domain) as { + id: number; website_id: number; external_url: string; + domain: string; user_id: number; + } | null; + }, + + async updateExternalWebsiteUrl(websiteId: number, externalUrl: string) { + db.prepare( + "UPDATE external_websites SET external_url = ? WHERE website_id = ?" + ).run(externalUrl, websiteId); + }, + + async deleteExternalWebsite(websiteId: number) { + db.prepare("DELETE FROM external_websites WHERE website_id = ?").run(websiteId); + }, + + // --- Tags --- + + async getOrCreateTag(name: string) { + return getOrCreateTag(name); + }, + + async setWebsiteTags(websiteId: number, tagNames: string[]) { + db.prepare("DELETE FROM website_tags WHERE website_id = ?").run(websiteId); + for (const name of tagNames) { + const tag = getOrCreateTag(name); + db.prepare("INSERT OR IGNORE INTO website_tags (website_id, tag_id) VALUES (?, ?)").run(websiteId, tag.id); + } + }, + + async getTagsForWebsite(websiteId: number): Promise { + const rows = db.prepare( + "SELECT t.name FROM tags t JOIN website_tags wt ON t.id = wt.tag_id WHERE wt.website_id = ? ORDER BY t.name" + ).all(websiteId) as { name: string }[]; + return rows.map(r => r.name); + }, + + async getWebsitesByTag(tagName: string) { + return db.prepare(` + SELECT w.domain, u.username + FROM websites w + JOIN website_tags wt ON w.id = wt.website_id + JOIN tags t ON t.id = wt.tag_id + JOIN users u ON u.id = w.user_id + WHERE t.name = ? + ORDER BY w.domain + `).all(tagName.toLowerCase()) as { domain: string; username: string }[]; + }, + + async getAllTagsWithCounts() { + return db.prepare(` + SELECT t.name, COUNT(wt.website_id) as count + FROM tags t + JOIN website_tags wt ON t.id = wt.tag_id + GROUP BY t.id + HAVING count > 0 + ORDER BY count DESC, t.name ASC + `).all() as { name: string; count: number }[]; + }, + + // --- Friend requests --- + + async createFriendRequest(fromUserId: number, toUserId: number): Promise { + try { + db.prepare( + "INSERT INTO friend_requests (from_user_id, to_user_id, status) VALUES (?, ?, 'pending')" + ).run(fromUserId, toUserId); + return true; + } catch { + return false; + } + }, + + async getPendingFriendRequests(userId: number) { + return db.prepare(` + SELECT fr.id, fr.from_user_id, u.username as from_username, fr.created_at + FROM friend_requests fr + JOIN users u ON u.id = fr.from_user_id + WHERE fr.to_user_id = ? AND fr.status = 'pending' + ORDER BY fr.created_at DESC + `).all(userId) as { id: number; from_user_id: number; from_username: string; created_at: string }[]; + }, + + async getSentFriendRequests(userId: number) { + return db.prepare(` + SELECT fr.id, fr.to_user_id, u.username as to_username, fr.status, fr.created_at + FROM friend_requests fr + JOIN users u ON u.id = fr.to_user_id + WHERE fr.from_user_id = ? AND fr.status = 'pending' + ORDER BY fr.created_at DESC + `).all(userId) as { id: number; to_user_id: number; to_username: string; status: string; created_at: string }[]; + }, + + async acceptFriendRequest(requestId: number, userId: number): Promise { + const request = db.prepare( + "SELECT id, from_user_id, to_user_id FROM friend_requests WHERE id = ? AND to_user_id = ? AND status = 'pending'" + ).get(requestId, userId) as { id: number; from_user_id: number; to_user_id: number } | null; + if (!request) return false; + + db.prepare("UPDATE friend_requests SET status = 'accepted' WHERE id = ?").run(requestId); + db.prepare("INSERT OR IGNORE INTO friends (user_id, friend_id) VALUES (?, ?)").run(request.from_user_id, request.to_user_id); + db.prepare("INSERT OR IGNORE INTO friends (user_id, friend_id) VALUES (?, ?)").run(request.to_user_id, request.from_user_id); + return true; + }, + + async declineFriendRequest(requestId: number, userId: number): Promise { + const result = db.prepare( + "DELETE FROM friend_requests WHERE id = ? AND to_user_id = ? AND status = 'pending'" + ).run(requestId, userId); + return result.changes > 0; + }, + + async areFriends(userId: number, friendId: number): Promise { + const row = db.prepare( + "SELECT 1 FROM friends WHERE user_id = ? AND friend_id = ?" + ).get(userId, friendId); + return !!row; + }, + + async getFriends(userId: number) { + return db.prepare(` + SELECT u.id, u.username + FROM friends f + JOIN users u ON u.id = f.friend_id + WHERE f.user_id = ? + ORDER BY u.username COLLATE NOCASE + `).all(userId) as { id: number; username: string }[]; + }, + + async removeFriend(userId: number, friendId: number): Promise { + db.prepare("DELETE FROM friends WHERE user_id = ? AND friend_id = ?").run(userId, friendId); + db.prepare("DELETE FROM friends WHERE user_id = ? AND friend_id = ?").run(friendId, userId); + db.prepare("DELETE FROM friend_requests WHERE (from_user_id = ? AND to_user_id = ?) OR (from_user_id = ? AND to_user_id = ?)").run(userId, friendId, friendId, userId); + return true; + }, + + async searchUsers(query: string, currentUserId: number) { + return db.prepare(` + SELECT id, username FROM users + WHERE username LIKE ? AND id != ? + ORDER BY username COLLATE NOCASE + LIMIT 20 + `).all(`%${query}%`, currentUserId) as { id: number; username: string }[]; + }, + + async findUserById(id: number) { + return db.prepare("SELECT id, username FROM users WHERE id = ?").get(id) as { id: number; username: string } | null; + }, + + async hasPendingRequest(fromUserId: number, toUserId: number): Promise { + const row = db.prepare( + "SELECT 1 FROM friend_requests WHERE from_user_id = ? AND to_user_id = ? AND status = 'pending'" + ).get(fromUserId, toUserId); + return !!row; + }, + + // --- Messages --- + + async createMessage(fromUserId: number, toUserId: number, content: string) { + const result = db.prepare( + "INSERT INTO messages (from_user_id, to_user_id, content) VALUES (?, ?, ?)" + ).run(fromUserId, toUserId, content); + return db.prepare("SELECT id, from_user_id, to_user_id, content, read, created_at FROM messages WHERE id = ?").get(result.lastInsertRowid) as { + id: number; from_user_id: number; to_user_id: number; content: string; read: number; created_at: string; + }; + }, + + async getMessages(userId: number, otherUserId: number, limit: number = 50) { + return db.prepare(` + SELECT id, from_user_id, to_user_id, content, read, created_at + FROM messages + WHERE (from_user_id = ? AND to_user_id = ?) OR (from_user_id = ? AND to_user_id = ?) + ORDER BY created_at DESC + LIMIT ? + `).all(userId, otherUserId, otherUserId, userId, limit) as { + id: number; from_user_id: number; to_user_id: number; content: string; read: number; created_at: string; + }[]; + }, + + async markMessagesAsRead(userId: number, fromUserId: number) { + db.prepare( + "UPDATE messages SET read = 1 WHERE to_user_id = ? AND from_user_id = ? AND read = 0" + ).run(userId, fromUserId); + }, + + async getUnreadCounts(userId: number) { + return db.prepare(` + SELECT from_user_id, COUNT(*) as count + FROM messages + WHERE to_user_id = ? AND read = 0 + GROUP BY from_user_id + `).all(userId) as { from_user_id: number; count: number }[]; + }, + + async getTotalUnreadCount(userId: number): Promise { + const row = db.prepare( + "SELECT COUNT(*) as count FROM messages WHERE to_user_id = ? AND read = 0" + ).get(userId) as { count: number }; + return row.count; + }, + }; + + return backend; +} diff --git a/db_supabase.ts b/db_supabase.ts new file mode 100644 index 0000000..faf5eae --- /dev/null +++ b/db_supabase.ts @@ -0,0 +1,591 @@ +import { createClient, type SupabaseClient } from "@supabase/supabase-js"; +import type { DatabaseBackend } from "./db_interface"; + +export function createSupabaseBackend(): DatabaseBackend { + const supabaseUrl = process.env.SUPABASE_URL; + const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + + if (!supabaseUrl || !supabaseKey) { + throw new Error("SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY must be set when DB_BACKEND=supabase"); + } + + const supabase: SupabaseClient = createClient(supabaseUrl, supabaseKey); + + const backend: DatabaseBackend = { + async createUser(username: string, password: string) { + const password_hash = await Bun.password.hash(password); + const { data, error } = await supabase + .from("users") + .insert({ username, password_hash }) + .select("id, username, created_at") + .single(); + if (error) throw error; + return data; + }, + + async findUserByUsername(username: string) { + const { data, error } = await supabase + .from("users") + .select("id, username, password_hash, created_at") + .ilike("username", username) + .maybeSingle(); + if (error) throw error; + return data; + }, + + async createSession(userId: number): Promise { + const token = crypto.randomUUID(); + const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(); + const { error } = await supabase + .from("sessions") + .insert({ token, user_id: userId, expires_at: expiresAt }); + if (error) throw error; + return token; + }, + + async getSessionUser(token: string) { + const { data: session, error: sessionError } = await supabase + .from("sessions") + .select("user_id") + .eq("token", token) + .gt("expires_at", new Date().toISOString()) + .maybeSingle(); + if (sessionError) throw sessionError; + if (!session) return null; + + const { data: user, error: userError } = await supabase + .from("users") + .select("id, username") + .eq("id", session.user_id) + .maybeSingle(); + if (userError) throw userError; + return user; + }, + + async deleteSession(token: string) { + const { error } = await supabase + .from("sessions") + .delete() + .eq("token", token); + if (error) throw error; + }, + + async getWebsitesByUserId(userId: number) { + const { data, error } = await supabase + .from("websites") + .select("id, domain, created_at") + .eq("user_id", userId) + .order("created_at", { ascending: false }); + if (error) throw error; + return data || []; + }, + + async countWebsitesByUserId(userId: number): Promise { + const { count, error } = await supabase + .from("websites") + .select("*", { count: "exact", head: true }) + .eq("user_id", userId); + if (error) throw error; + return count || 0; + }, + + async findWebsiteByDomain(domain: string) { + const { data, error } = await supabase + .from("websites") + .select("id, user_id, domain") + .ilike("domain", domain) + .maybeSingle(); + if (error) throw error; + return data; + }, + + async getWebsiteByDomainAndUserId(domain: string, userId: number) { + const { data, error } = await supabase + .from("websites") + .select("id, user_id, domain, created_at") + .ilike("domain", domain) + .eq("user_id", userId) + .maybeSingle(); + if (error) throw error; + return data; + }, + + async deleteWebsite(id: number, userId: number): Promise { + const { data, error } = await supabase + .from("websites") + .delete() + .eq("id", id) + .eq("user_id", userId) + .select(); + if (error) throw error; + return (data?.length || 0) > 0; + }, + + async createWebsite(userId: number, domain: string) { + const { data, error } = await supabase + .from("websites") + .insert({ user_id: userId, domain }) + .select("id, domain, created_at") + .single(); + if (error) throw error; + return data; + }, + + // --- Git Websites --- + + async createGitWebsite(websiteId: number, gitUrl: string, gitUsername?: string, gitPassword?: string) { + const { error } = await supabase + .from("git_websites") + .insert({ + website_id: websiteId, + git_url: gitUrl, + git_username: gitUsername || null, + git_password: gitPassword || null, + }); + if (error) throw error; + }, + + async getGitWebsite(websiteId: number) { + const { data, error } = await supabase + .from("git_websites") + .select("*") + .eq("website_id", websiteId) + .maybeSingle(); + if (error) throw error; + return data; + }, + + async getGitWebsiteByDomain(domain: string) { + const { data, error } = await supabase + .from("git_websites") + .select("*, websites!inner(domain, user_id)") + .ilike("websites.domain", domain) + .maybeSingle(); + if (error) throw error; + if (!data) return null; + // Flatten the join result to match the expected interface + const website = data.websites as any; + return { + id: data.id, + website_id: data.website_id, + git_url: data.git_url, + git_username: data.git_username, + git_password: data.git_password, + git_branch: data.git_branch, + git_commit_hash: data.git_commit_hash, + git_commit_message: data.git_commit_message, + domain: website.domain, + user_id: website.user_id, + }; + }, + + async updateGitWebsiteCommit(websiteId: number, commitHash: string, commitMessage: string) { + const { error } = await supabase + .from("git_websites") + .update({ git_commit_hash: commitHash, git_commit_message: commitMessage }) + .eq("website_id", websiteId); + if (error) throw error; + }, + + async updateGitWebsiteBranch(websiteId: number, branch: string) { + const { error } = await supabase + .from("git_websites") + .update({ git_branch: branch }) + .eq("website_id", websiteId); + if (error) throw error; + }, + + async deleteGitWebsite(websiteId: number) { + const { error } = await supabase + .from("git_websites") + .delete() + .eq("website_id", websiteId); + if (error) throw error; + }, + + // --- External Websites --- + + async createExternalWebsite(websiteId: number, externalUrl: string) { + const { error } = await supabase + .from("external_websites") + .insert({ website_id: websiteId, external_url: externalUrl }); + if (error) throw error; + }, + + async getExternalWebsite(websiteId: number) { + const { data, error } = await supabase + .from("external_websites") + .select("*") + .eq("website_id", websiteId) + .maybeSingle(); + if (error) throw error; + return data; + }, + + async getExternalWebsiteByDomain(domain: string) { + const { data, error } = await supabase + .from("external_websites") + .select("*, websites!inner(domain, user_id)") + .ilike("websites.domain", domain) + .maybeSingle(); + if (error) throw error; + if (!data) return null; + const website = data.websites as any; + return { + id: data.id, + website_id: data.website_id, + external_url: data.external_url, + domain: website.domain, + user_id: website.user_id, + }; + }, + + async updateExternalWebsiteUrl(websiteId: number, externalUrl: string) { + const { error } = await supabase + .from("external_websites") + .update({ external_url: externalUrl }) + .eq("website_id", websiteId); + if (error) throw error; + }, + + async deleteExternalWebsite(websiteId: number) { + const { error } = await supabase + .from("external_websites") + .delete() + .eq("website_id", websiteId); + if (error) throw error; + }, + + // --- Tags --- + + async getOrCreateTag(name: string) { + const normalized = name.toLowerCase(); + // Try to find existing tag + const { data: existing, error: findError } = await supabase + .from("tags") + .select("id, name") + .ilike("name", normalized) + .maybeSingle(); + if (findError) throw findError; + if (existing) return existing; + + // Create new tag + const { data: created, error: insertError } = await supabase + .from("tags") + .insert({ name: normalized }) + .select("id, name") + .single(); + if (insertError) { + // Handle race condition: tag may have been created concurrently + if (insertError.code === "23505") { + const { data: retry, error: retryError } = await supabase + .from("tags") + .select("id, name") + .ilike("name", normalized) + .single(); + if (retryError) throw retryError; + return retry; + } + throw insertError; + } + return created; + }, + + async setWebsiteTags(websiteId: number, tagNames: string[]) { + // Use RPC function for atomicity + const { error } = await supabase.rpc("set_website_tags", { + p_website_id: websiteId, + p_tag_names: tagNames.map(n => n.toLowerCase()), + }); + if (error) throw error; + }, + + async getTagsForWebsite(websiteId: number): Promise { + const { data, error } = await supabase + .from("website_tags") + .select("tags(name)") + .eq("website_id", websiteId) + .order("tag_id"); + if (error) throw error; + return (data || []).map((r: any) => r.tags.name).sort(); + }, + + async getWebsitesByTag(tagName: string) { + const normalized = tagName.toLowerCase(); + const { data, error } = await supabase + .from("website_tags") + .select("websites!inner(domain, users!inner(username))") + .eq("tags.name", normalized) + .order("website_id"); + + // Supabase's nested filter syntax is tricky - use a simpler approach with RPC or raw SQL + // Instead, let's do it in two steps + if (error) { + // Fallback: get tag id first, then websites + const { data: tag, error: tagError } = await supabase + .from("tags") + .select("id") + .ilike("name", normalized) + .maybeSingle(); + if (tagError) throw tagError; + if (!tag) return []; + + const { data: wts, error: wtsError } = await supabase + .from("website_tags") + .select("website_id") + .eq("tag_id", tag.id); + if (wtsError) throw wtsError; + if (!wts || wts.length === 0) return []; + + const websiteIds = wts.map(wt => wt.website_id); + const { data: websites, error: wsError } = await supabase + .from("websites") + .select("domain, users!inner(username)") + .in("id", websiteIds) + .order("domain"); + if (wsError) throw wsError; + return (websites || []).map((w: any) => ({ + domain: w.domain, + username: w.users.username, + })); + } + + return (data || []).map((r: any) => ({ + domain: r.websites.domain, + username: r.websites.users.username, + })); + }, + + async getAllTagsWithCounts() { + // Use RPC or build the query + const { data, error } = await supabase.rpc("get_all_tags_with_counts"); + if (error) { + // Fallback: do it client-side + const { data: tags, error: tagsError } = await supabase + .from("tags") + .select("id, name"); + if (tagsError) throw tagsError; + if (!tags) return []; + + const { data: wts, error: wtsError } = await supabase + .from("website_tags") + .select("tag_id"); + if (wtsError) throw wtsError; + + const counts = new Map(); + for (const wt of wts || []) { + counts.set(wt.tag_id, (counts.get(wt.tag_id) || 0) + 1); + } + + return tags + .filter(t => (counts.get(t.id) || 0) > 0) + .map(t => ({ name: t.name, count: counts.get(t.id) || 0 })) + .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)); + } + return data || []; + }, + + // --- Friend requests --- + + async createFriendRequest(fromUserId: number, toUserId: number): Promise { + const { error } = await supabase + .from("friend_requests") + .insert({ from_user_id: fromUserId, to_user_id: toUserId, status: "pending" }); + if (error) return false; + return true; + }, + + async getPendingFriendRequests(userId: number) { + const { data, error } = await supabase + .from("friend_requests") + .select("id, from_user_id, users!friend_requests_from_user_id_fkey(username), created_at") + .eq("to_user_id", userId) + .eq("status", "pending") + .order("created_at", { ascending: false }); + if (error) throw error; + return (data || []).map((r: any) => ({ + id: r.id, + from_user_id: r.from_user_id, + from_username: r.users.username, + created_at: r.created_at, + })); + }, + + async getSentFriendRequests(userId: number) { + const { data, error } = await supabase + .from("friend_requests") + .select("id, to_user_id, users!friend_requests_to_user_id_fkey(username), status, created_at") + .eq("from_user_id", userId) + .eq("status", "pending") + .order("created_at", { ascending: false }); + if (error) throw error; + return (data || []).map((r: any) => ({ + id: r.id, + to_user_id: r.to_user_id, + to_username: r.users.username, + status: r.status, + created_at: r.created_at, + })); + }, + + async acceptFriendRequest(requestId: number, userId: number): Promise { + // Use RPC function for atomicity + const { data, error } = await supabase.rpc("accept_friend_request", { + p_request_id: requestId, + p_user_id: userId, + }); + if (error) return false; + return data === true; + }, + + async declineFriendRequest(requestId: number, userId: number): Promise { + const { data, error } = await supabase + .from("friend_requests") + .delete() + .eq("id", requestId) + .eq("to_user_id", userId) + .eq("status", "pending") + .select(); + if (error) return false; + return (data?.length || 0) > 0; + }, + + async areFriends(userId: number, friendId: number): Promise { + const { data, error } = await supabase + .from("friends") + .select("user_id") + .eq("user_id", userId) + .eq("friend_id", friendId) + .maybeSingle(); + if (error) return false; + return !!data; + }, + + async getFriends(userId: number) { + const { data, error } = await supabase + .from("friends") + .select("users!friends_friend_id_fkey(id, username)") + .eq("user_id", userId); + if (error) throw error; + return (data || []) + .map((r: any) => ({ id: r.users.id, username: r.users.username })) + .sort((a: any, b: any) => a.username.toLowerCase().localeCompare(b.username.toLowerCase())); + }, + + async removeFriend(userId: number, friendId: number): Promise { + // Use RPC function for atomicity + const { error } = await supabase.rpc("remove_friend", { + p_user_id: userId, + p_friend_id: friendId, + }); + if (error) throw error; + return true; + }, + + async searchUsers(query: string, currentUserId: number) { + const { data, error } = await supabase + .from("users") + .select("id, username") + .ilike("username", `%${query}%`) + .neq("id", currentUserId) + .order("username") + .limit(20); + if (error) throw error; + return data || []; + }, + + async findUserById(id: number) { + const { data, error } = await supabase + .from("users") + .select("id, username") + .eq("id", id) + .maybeSingle(); + if (error) throw error; + return data; + }, + + async hasPendingRequest(fromUserId: number, toUserId: number): Promise { + const { data, error } = await supabase + .from("friend_requests") + .select("id") + .eq("from_user_id", fromUserId) + .eq("to_user_id", toUserId) + .eq("status", "pending") + .maybeSingle(); + if (error) return false; + return !!data; + }, + + // --- Messages --- + + async createMessage(fromUserId: number, toUserId: number, content: string) { + const { data, error } = await supabase + .from("messages") + .insert({ from_user_id: fromUserId, to_user_id: toUserId, content }) + .select("id, from_user_id, to_user_id, content, read, created_at") + .single(); + if (error) throw error; + // PostgreSQL uses boolean for `read`, but the interface uses number (0/1) + // to match SQLite's INTEGER representation. Normalize here. + return { ...data, read: data.read ? 1 : 0 }; + }, + + async getMessages(userId: number, otherUserId: number, limit: number = 50) { + const { data, error } = await supabase + .from("messages") + .select("id, from_user_id, to_user_id, content, read, created_at") + .or( + `and(from_user_id.eq.${userId},to_user_id.eq.${otherUserId}),and(from_user_id.eq.${otherUserId},to_user_id.eq.${userId})` + ) + .order("created_at", { ascending: false }) + .limit(limit); + if (error) throw error; + // Normalize PostgreSQL boolean `read` to number (0/1) for interface compatibility + return (data || []).map(m => ({ ...m, read: m.read ? 1 : 0 })); + }, + + async markMessagesAsRead(userId: number, fromUserId: number) { + const { error } = await supabase + .from("messages") + .update({ read: true }) + .eq("to_user_id", userId) + .eq("from_user_id", fromUserId) + .eq("read", false); + if (error) throw error; + }, + + async getUnreadCounts(userId: number) { + // Supabase doesn't support GROUP BY easily, use RPC + const { data, error } = await supabase.rpc("get_unread_counts", { + p_user_id: userId, + }); + if (error) { + // Fallback: get all unread messages and count client-side + const { data: msgs, error: msgsError } = await supabase + .from("messages") + .select("from_user_id") + .eq("to_user_id", userId) + .eq("read", false); + if (msgsError) throw msgsError; + const counts = new Map(); + for (const m of msgs || []) { + counts.set(m.from_user_id, (counts.get(m.from_user_id) || 0) + 1); + } + return Array.from(counts.entries()).map(([from_user_id, count]) => ({ from_user_id, count })); + } + return data || []; + }, + + async getTotalUnreadCount(userId: number): Promise { + const { count, error } = await supabase + .from("messages") + .select("*", { count: "exact", head: true }) + .eq("to_user_id", userId) + .eq("read", false); + if (error) throw error; + return count || 0; + }, + }; + + return backend; +} diff --git a/index.ts b/index.ts index 8feec81..b916375 100644 --- a/index.ts +++ b/index.ts @@ -21,9 +21,9 @@ import {listStaticSites, getStaticSiteTags} from "./static_sites"; // Track online users: userId -> Set of WebSocket connections const onlineUsers = new Map>(); -function broadcastOnlineStatus(userId: number, online: boolean) { +async function broadcastOnlineStatus(userId: number, online: boolean) { // Notify all friends of this user about their status change - const friends = getFriends(userId); + const friends = await getFriends(userId); for (const friend of friends) { const friendSockets = onlineUsers.get(friend.id); if (friendSockets) { @@ -48,12 +48,12 @@ Bun.serve({ ...friendRoutes, ...messageRoutes, "/api/friends/online": { - GET: (req: any) => { + GET: async (req: any) => { const token = getSessionFromRequest(req); if (!token) return Response.json({ ok: false, error: "Not logged in." }, { status: 401 }); - const user = getSessionUser(token); + const user = await getSessionUser(token); if (!user) return Response.json({ ok: false, error: "Not logged in." }, { status: 401 }); - const friends = getFriends(user.id); + const friends = await getFriends(user.id); const onlineFriends = friends.filter(f => onlineUsers.has(f.id)).map(f => f.id); return Response.json({ online: onlineFriends }); }, @@ -61,19 +61,19 @@ Bun.serve({ "/api/websites/:domain/tags": { GET: async (req: any) => { const domain = decodeURIComponent(req.params.domain); - const website = findWebsiteByDomain(domain); + const website = await findWebsiteByDomain(domain); if (!website) return Response.json({ ok: false, error: "Not found." }, { status: 404 }); - const tags = getTagsForWebsite(website.id); + const tags = await getTagsForWebsite(website.id); return Response.json({ tags }); }, PUT: async (req: any) => { const token = getSessionFromRequest(req); if (!token) return Response.json({ ok: false, error: "Not logged in." }, { status: 401 }); - const user = getSessionUser(token); + const user = await getSessionUser(token); if (!user) return Response.json({ ok: false, error: "Not logged in." }, { status: 401 }); const domain = decodeURIComponent(req.params.domain); - const website = getWebsiteByDomainAndUserId(domain, user.id); + const website = await getWebsiteByDomainAndUserId(domain, user.id); if (!website) return Response.json({ ok: false, error: "Forbidden." }, { status: 403 }); // @ts-ignore @@ -93,7 +93,7 @@ Bun.serve({ } } - setWebsiteTags(website.id, tags.map((t: string) => t.toLowerCase())); + await setWebsiteTags(website.id, tags.map((t: string) => t.toLowerCase())); return Response.json({ ok: true }); }, }, @@ -101,9 +101,9 @@ Bun.serve({ GET: async () => { // Collect DB tag counts (only published sites) const tagCounts: Record = {}; - const allTags = getAllTagsWithCounts(); + const allTags = await getAllTagsWithCounts(); for (const tag of allTags) { - const websites = getWebsitesByTag(tag.name); + const websites = await getWebsitesByTag(tag.name); let publishedCount = 0; for (const w of websites) { const pubFile = Bun.file(`${WEBSITES_DIR}/${w.domain}/published/index.html`); @@ -131,7 +131,7 @@ Bun.serve({ "/api/tags/:tag/websites": { GET: async (req: any) => { const tag = decodeURIComponent(req.params.tag).toLowerCase(); - const allWebsites = getWebsitesByTag(tag); + const allWebsites = await getWebsitesByTag(tag); const websites = []; for (const w of allWebsites) { const pubFile = Bun.file(`${WEBSITES_DIR}/${w.domain}/published/index.html`); @@ -164,15 +164,15 @@ Bun.serve({ onlineUsers.get(userId)!.add(ws); broadcastOnlineStatus(userId, true); }, - message(ws: any, message: any) { + async message(ws: any, message: any) { try { const data = JSON.parse(String(message)); if (data.type === "message" && data.toUserId && data.content) { const fromUserId = ws.data?.userId; if (!fromUserId) return; - if (!areFriends(fromUserId, data.toUserId)) return; + if (!(await areFriends(fromUserId, data.toUserId))) return; if (typeof data.content !== "string" || data.content.trim().length === 0 || data.content.length > 2000) return; - const msg = createMessage(fromUserId, data.toUserId, data.content.trim()); + const msg = await createMessage(fromUserId, data.toUserId, data.content.trim()); // Send to recipient if online const recipientSockets = onlineUsers.get(data.toUserId); if (recipientSockets) { @@ -202,13 +202,13 @@ Bun.serve({ } }, }, - fetch(req: Request, server: any) { + async fetch(req: Request, server: any) { const url = new URL(req.url); // Handle WebSocket upgrade for /ws path if (url.pathname === "/ws") { const token = getSessionFromRequest(req); if (!token) return new Response("Unauthorized", { status: 401 }); - const user = getSessionUser(token); + const user = await getSessionUser(token); if (!user) return new Response("Unauthorized", { status: 401 }); const upgraded = server.upgrade(req, { data: { userId: user.id, username: user.username } }); if (upgraded) return undefined; diff --git a/package.json b/package.json index 7145993..7cc970a 100644 --- a/package.json +++ b/package.json @@ -8,5 +8,8 @@ }, "peerDependencies": { "typescript": "^5" + }, + "dependencies": { + "@supabase/supabase-js": "^2.100.1" } } diff --git a/plans/supabase_migration.md b/plans/supabase_migration.md new file mode 100644 index 0000000..8c084c0 --- /dev/null +++ b/plans/supabase_migration.md @@ -0,0 +1,303 @@ +# Plan: Add Supabase Backend (Dual SQLite/Supabase Support) + +## Overview + +Add Supabase (hosted PostgreSQL) as an **optional** database backend alongside the existing SQLite backend. The site selects which backend to use at runtime via the `DB_BACKEND` environment variable (`sqlite` or `supabase`). When unset, it defaults to `sqlite`, so existing behavior — including tests and local development — is completely unchanged. + +This is achieved by: +1. Defining a `DatabaseBackend` TypeScript interface with async versions of all 43 current db functions +2. Implementing that interface twice: once wrapping the existing SQLite code, once using the Supabase JS client +3. Exporting a single backend instance from `db.ts` chosen by the runtime flag +4. Making all callers `await` the now-async functions (required since the Supabase path is async) + +**Key benefit**: Tests and local development keep using SQLite (fast, in-process, zero setup). Production or staging can point to Supabase by setting two env vars. + +## Current State + +- **Database**: SQLite via `bun:sqlite`, configured in `db.ts` +- **Tables**: 10 tables (users, sessions, websites, tags, website_tags, friend_requests, friends, messages, git_websites, external_websites) +- **Functions**: 43 exported functions in `db.ts`, all sync except `createUser` +- **Callers**: 6 files import from `db.ts` (index.ts, routes/auth.ts, routes/websites.ts, routes/git_websites.ts, routes/friends.ts, routes/messages.ts) +- **Tests**: Integration tests using isolated SQLite databases via `DB_PATH` env var +- **No raw SQL outside `db.ts`** — full encapsulation + +## Runtime Configuration + +| Env Var | Purpose | Default | +|---------|---------|---------| +| `DB_BACKEND` | Select backend: `"sqlite"` or `"supabase"` | `"sqlite"` | +| `DB_PATH` | SQLite database file path (only used when `DB_BACKEND=sqlite`) | `"social2000.db"` | +| `SUPABASE_URL` | Supabase project URL (only used when `DB_BACKEND=supabase`) | — | +| `SUPABASE_SERVICE_ROLE_KEY` | Supabase service role key (only used when `DB_BACKEND=supabase`) | — | + +## SQLite → PostgreSQL Dialect Changes (for the Supabase backend) + +| SQLite | PostgreSQL | +|--------|-----------| +| `INTEGER PRIMARY KEY AUTOINCREMENT` | `SERIAL PRIMARY KEY` or `BIGSERIAL PRIMARY KEY` | +| `TEXT DEFAULT (datetime('now'))` | `TIMESTAMPTZ DEFAULT now()` | +| `COLLATE NOCASE` | Use `citext` extension or `LOWER()` comparisons | +| `datetime('now')` in queries | `now()` | +| `INSERT OR IGNORE` | `INSERT ... ON CONFLICT DO NOTHING` | +| `result.lastInsertRowid` | Use `RETURNING id` clause | +| `result.changes > 0` | Check returned rows or `rowCount` | +| `LIKE` (case-insensitive by default in SQLite) | `ILIKE` for case-insensitive matching | +| `read INTEGER NOT NULL DEFAULT 0` | `read BOOLEAN NOT NULL DEFAULT false` | +| `PRAGMA journal_mode = WAL` | Not needed (Supabase handles this) | +| `PRAGMA foreign_keys = ON` | Foreign keys are always on in PostgreSQL | + +## Todos + +### 1. (Completed) Define the `DatabaseBackend` interface + +Create `db_interface.ts` with a TypeScript interface that declares every database function as async: + +```ts +export interface DatabaseBackend { + createUser(username: string, password: string): Promise<{ id: number; username: string; created_at: string } | null>; + findUserByUsername(username: string): Promise<{ id: number; username: string; password_hash: string; created_at: string } | null>; + createSession(userId: number): Promise; + getSessionUser(token: string): Promise<{ id: number; username: string } | null>; + deleteSession(token: string): Promise; + // ... all 43 functions +} +``` + +Every function returns a `Promise`, even the ones that are currently sync in the SQLite implementation (they'll just wrap results in `Promise.resolve()`). The return types match the existing shapes exactly so callers don't need data-format changes. + +**Tests**: Type-check both implementations against the interface. + +--- + +### 2. (Completed) Extract the SQLite implementation into `db_sqlite.ts` + +Move the existing `db.ts` code into `db_sqlite.ts`, wrapping it in a class (or object) that implements `DatabaseBackend`: + +- Move all `CREATE TABLE` statements and `PRAGMA` calls into the constructor/init +- Wrap each sync function to return a `Promise` (e.g., `async findUserByUsername(username: string) { return db.prepare(...).get(username); }`) +- `createUser` is already async — no change needed +- Keep `DB_PATH` env var support for test isolation +- The `messages.read` column stays as `INTEGER (0/1)` on SQLite + +No behavioral changes — this is a pure extraction/refactor. + +**Tests**: Run the existing test suite (unchanged) to confirm SQLite backend still works identically. + +--- + +### 3. (Completed) Add Supabase dependency and environment configuration + +- Install `@supabase/supabase-js` via `bun install @supabase/supabase-js` +- Create a `.env.example` file documenting all env vars (`DB_BACKEND`, `DB_PATH`, `SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`) +- Update `.gitignore` to include `.env` if not already present + +**Tests**: Verify the Supabase client initializes without errors when env vars are set. + +--- + +### 4. (Completed) Create PostgreSQL schema migration + +Create a `supabase/migrations/` directory with a SQL migration file that defines all 10 tables in PostgreSQL dialect: + +- **users**: `id SERIAL PRIMARY KEY`, `username CITEXT NOT NULL UNIQUE`, `password_hash TEXT NOT NULL`, `created_at TIMESTAMPTZ DEFAULT now()` +- **sessions**: `token TEXT PRIMARY KEY`, `user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE`, `created_at TIMESTAMPTZ DEFAULT now()`, `expires_at TIMESTAMPTZ NOT NULL` +- **websites**: `id SERIAL PRIMARY KEY`, `user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE`, `domain CITEXT NOT NULL UNIQUE`, `created_at TIMESTAMPTZ DEFAULT now()` +- **tags**: `id SERIAL PRIMARY KEY`, `name CITEXT NOT NULL UNIQUE` +- **website_tags**: composite PK `(website_id, tag_id)` with foreign keys and cascade deletes +- **friend_requests**: `id SERIAL PRIMARY KEY`, `from_user_id`/`to_user_id` with foreign keys, `status TEXT NOT NULL DEFAULT 'pending'`, `created_at TIMESTAMPTZ DEFAULT now()`, `UNIQUE(from_user_id, to_user_id)` +- **friends**: composite PK `(user_id, friend_id)` with foreign keys and cascade deletes +- **messages**: `id SERIAL PRIMARY KEY`, `from_user_id`/`to_user_id` with foreign keys, `content TEXT NOT NULL`, `read BOOLEAN NOT NULL DEFAULT false`, `created_at TIMESTAMPTZ DEFAULT now()` +- **git_websites**: `id SERIAL PRIMARY KEY`, `website_id INTEGER NOT NULL UNIQUE REFERENCES websites(id) ON DELETE CASCADE`, plus git fields +- **external_websites**: `id SERIAL PRIMARY KEY`, `website_id INTEGER NOT NULL UNIQUE REFERENCES websites(id) ON DELETE CASCADE`, `external_url TEXT NOT NULL` + +Enable the `citext` extension at the top of the migration for case-insensitive text columns. + +Start without Row Level Security (RLS) policies — use the service role key for all server-side access. + +**Tests**: Run the migration against a test Supabase project and verify all tables are created. + +--- + +### 5. (Completed) Implement the Supabase backend in `db_supabase.ts` + +Create `db_supabase.ts` implementing the `DatabaseBackend` interface using `@supabase/supabase-js`: + +- **Initialization**: `createClient(supabaseUrl, supabaseKey)` from env vars. No `CREATE TABLE` or `PRAGMA` calls (schema managed via migrations). +- **Query conversion**: Replace `db.prepare("...").run/get/all()` with `supabase.from('table').select/insert/update/delete()` calls using the Supabase query builder. +- **Return values**: Extract `data` from Supabase `{ data, error }` responses. Throw or log on errors. Normalize return shapes to match the interface (same as SQLite backend). +- **`RETURNING` clause**: Use `.select()` after `.insert()` or `.update()` to get back inserted/updated rows. +- **Case-insensitive lookups**: Handled natively by `citext` columns. +- **Boolean conversion**: The `messages.read` column is `BOOLEAN` in PostgreSQL. The Supabase backend returns `true/false` — normalize to match the interface's return types (either both return boolean, or both return 0/1 — decide and document in the interface). + +Key function-by-function notes: +- `createUser`: `supabase.from('users').insert({ username, password_hash }).select('id, username, created_at').single()` +- `getSessionUser`: Two queries or a join. Could use `supabase.from('sessions').select('user_id, users(id, username)').eq('token', token).gt('expires_at', new Date().toISOString()).single()` with a foreign key join. +- `setWebsiteTags`: `supabase.from('website_tags').delete().eq('website_id', websiteId)` then batch insert +- `acceptFriendRequest`: Multi-step — consider an RPC function for atomicity (see Todo 6) +- `searchUsers`: Use `.ilike('username', `%${query}%`)` filter +- `getMessages`: Use `.or()` filter for the bidirectional message query + +**Tests**: Write tests for the Supabase backend that mirror the existing SQLite tests. These tests require a running Supabase instance (local or hosted) and are opt-in (only run when `SUPABASE_URL` is set). + +--- + +### 6. (Completed) Handle transactional operations via PostgreSQL RPC functions + +Several functions perform multi-step operations that need atomicity: + +- `acceptFriendRequest`: SELECT + UPDATE + 2x INSERT +- `removeFriend`: 3x DELETE +- `setWebsiteTags`: DELETE + loop of INSERT + +Create PostgreSQL functions in `supabase/migrations/002_rpc_functions.sql`: + +```sql +CREATE OR REPLACE FUNCTION accept_friend_request(p_request_id INTEGER, p_user_id INTEGER) RETURNS BOOLEAN ... +CREATE OR REPLACE FUNCTION set_website_tags(p_website_id INTEGER, p_tag_names TEXT[]) RETURNS VOID ... +CREATE OR REPLACE FUNCTION remove_friend(p_user_id INTEGER, p_friend_id INTEGER) RETURNS BOOLEAN ... +``` + +Call these from `db_supabase.ts` via `supabase.rpc('accept_friend_request', { ... })`. + +The SQLite backend doesn't need these — its synchronous, single-connection nature provides implicit atomicity. + +**Tests**: Test that transactional operations succeed and fail correctly in the Supabase backend. + +--- + +### 7. (Completed) Wire up the backend selector in `db.ts` + +Rewrite `db.ts` to be a thin dispatcher: + +```ts +import type { DatabaseBackend } from "./db_interface"; + +const backend: string = process.env.DB_BACKEND || "sqlite"; +let db: DatabaseBackend; + +if (backend === "supabase") { + const { createSupabaseBackend } = await import("./db_supabase"); + db = createSupabaseBackend(); +} else { + const { createSqliteBackend } = await import("./db_sqlite"); + db = createSqliteBackend(); +} + +// Re-export all functions from the chosen backend +export const createUser = db.createUser.bind(db); +export const findUserByUsername = db.findUserByUsername.bind(db); +export const createSession = db.createSession.bind(db); +// ... all 43 functions +``` + +Using dynamic `import()` ensures the Supabase dependency is only loaded when `DB_BACKEND=supabase`, so SQLite-only setups don't need `@supabase/supabase-js` installed. + +The exported function signatures remain the same (just all async now), so callers import from `db.ts` exactly as before — they don't know or care which backend is active. + +**Tests**: Test that the correct backend is instantiated based on `DB_BACKEND` env var. + +--- + +### 8. (Completed) Make all callers async-aware + +Since all `db.ts` functions now return `Promise`, every caller must `await` them. Update these files: + +- **`index.ts`**: Update route handlers and WebSocket message handler to await DB calls (e.g., `findWebsiteByDomain`, `getSessionUser`, `createMessage`, `areFriends`, etc.) +- **`routes/auth.ts`**: Await `findUserByUsername`, `createUser`, `createSession`, `getSessionUser`, `deleteSession`, `createWebsite` +- **`routes/websites.ts`**: Await all website CRUD functions, external website functions +- **`routes/git_websites.ts`**: Await all git website functions +- **`routes/friends.ts`**: Await all friend-related functions, user search +- **`routes/messages.ts`**: Await all message functions + +Most route handlers are already async (they return `Response` or `Promise`), so adding `await` keywords should be straightforward. The main concern is `index.ts` where some calls may be in synchronous contexts (e.g., the WebSocket `message` handler — needs to be made async or use `.then()`). + +**Tests**: Run the full existing test suite against the SQLite backend. Everything should still pass since the only change is adding `await` to already-sync-returning-Promise functions. + +--- + +### 9. (Completed) Update documentation and `.env.example` + +- Create `.env.example`: + ``` + # Database backend: "sqlite" (default) or "supabase" + DB_BACKEND=sqlite + + # SQLite settings (used when DB_BACKEND=sqlite) + DB_PATH=social2000.db + + # Supabase settings (used when DB_BACKEND=supabase) + SUPABASE_URL=https://your-project.supabase.co + SUPABASE_SERVICE_ROLE_KEY=your-service-role-key + ``` +- Update `README.md` with: + - How to run locally (default SQLite, no extra setup) + - How to switch to Supabase (set env vars, run migrations) + - How to run Supabase locally via `supabase start` for development + +**Tests**: N/A (documentation only). + +--- + +### 10. Add data migration script (optional, for existing deployments) + +If there's existing production data in SQLite that needs to be migrated to Supabase: + +- Create `scripts/migrate_sqlite_to_supabase.ts` that: + 1. Reads all data from the SQLite database + 2. Transforms it as needed (e.g., `read` INTEGER → BOOLEAN) + 3. Inserts it into Supabase tables in dependency order (users first, then sessions/websites, then dependent tables) +- Password hashes transfer directly since `Bun.password.hash()` produces standard bcrypt/argon2 hashes stored as text. + +**Tests**: Test the migration script against a copy of production data. + +--- + +## File Changes Summary + +| File | Change | +|------|--------| +| `db_interface.ts` | **New file** — TypeScript interface for all 43 database functions (all async) | +| `db_sqlite.ts` | **New file** — SQLite implementation of `DatabaseBackend` (extracted from current `db.ts`) | +| `db_supabase.ts` | **New file** — Supabase implementation of `DatabaseBackend` | +| `db.ts` | **Rewrite** — thin dispatcher that selects backend based on `DB_BACKEND` env var, re-exports all functions | +| `package.json` | Add `@supabase/supabase-js` dependency | +| `.env.example` | **New file** — document all env vars | +| `.gitignore` | Add `.env` | +| `supabase/migrations/001_initial.sql` | **New file** — PostgreSQL schema for all 10 tables | +| `supabase/migrations/002_rpc_functions.sql` | **New file** — PostgreSQL functions for transactional operations | +| `index.ts` | Add `await` to all DB function calls | +| `routes/auth.ts` | Add `await` to all DB function calls | +| `routes/websites.ts` | Add `await` to all DB function calls | +| `routes/git_websites.ts` | Add `await` to all DB function calls | +| `routes/friends.ts` | Add `await` to all DB function calls | +| `routes/messages.ts` | Add `await` to all DB function calls | +| `tests/setup.ts` | **No changes needed** (tests keep using SQLite by default) | +| `tests/*.test.ts` | **No changes needed** (SQLite backend is default) | +| `scripts/migrate_sqlite_to_supabase.ts` | **New file** (optional) — one-time data migration | +| `README.md` | Update setup/deployment instructions | + +## Architecture Diagram + +``` + db.ts (dispatcher) + DB_BACKEND env var check + / \ + "sqlite" "supabase" + | | + db_sqlite.ts db_supabase.ts + (wraps bun:sqlite) (wraps @supabase/supabase-js) + | | + social2000.db Supabase (hosted PG) + + Both implement: db_interface.ts (DatabaseBackend interface) +``` + +## Risks and Considerations + +1. **Async conversion scope**: Converting all 43 functions to async and adding `await` across 6 caller files is a large change. Risk of missing an `await` somewhere, leading to subtle bugs. Mitigate by running the full test suite against the SQLite backend after the conversion. +2. **Return type normalization**: The two backends may return slightly different data shapes (e.g., `boolean` vs `0/1` for `messages.read`, `Date` vs `string` for timestamps). The interface must specify exact types and both implementations must conform. +3. **Latency difference**: SQLite is in-process (~0ms overhead). Supabase adds network round-trips per query. Multi-query operations like `acceptFriendRequest` will be noticeably slower on Supabase. Mitigate with PostgreSQL RPC functions. +4. **Transaction support**: Supabase JS client lacks native transactions. Multi-step atomic operations use PostgreSQL functions via RPC (SQLite backend doesn't need this since its synchronous nature provides implicit atomicity). +5. **Dependency size**: `@supabase/supabase-js` is only loaded when `DB_BACKEND=supabase` (dynamic import), so SQLite-only deployments don't pay the cost — though the package will be in `node_modules`. +6. **Test coverage**: Existing tests validate SQLite behavior. Supabase-specific tests are opt-in and require a running Supabase instance. There's a risk of SQLite-only bugs not caught until Supabase is used in production. Mitigate by running the same test suite against both backends in CI (with a Supabase test project). +7. **Offline development**: SQLite works offline. Supabase requires network access unless using local Supabase CLI (`supabase start`). diff --git a/routes/auth.ts b/routes/auth.ts index f23a118..1472b30 100644 --- a/routes/auth.ts +++ b/routes/auth.ts @@ -3,10 +3,10 @@ import { findUserByUsername, createUser, createSession, getSessionUser, deleteSe export const authRoutes = { "/api/me": { - GET: (req: Request) => { + GET: async (req: Request) => { const token = getSessionFromRequest(req); if (!token) return Response.json({ username: null }); - const user = getSessionUser(token); + const user = await getSessionUser(token); return Response.json({ username: user ? user.username : null }); }, }, @@ -18,14 +18,14 @@ export const authRoutes = { return Response.json({ ok: false, error: "Username and password are required." }, { status: 400 }); } - const existing = findUserByUsername(username); + const existing = await findUserByUsername(username); if (existing) { return Response.json({ ok: false, error: "That username is already taken." }, { status: 400 }); } const user = await createUser(username, password); - createWebsite((user as any).id, `${username.toLowerCase()}.profile`); - const token = createSession((user as any).id); + await createWebsite((user as any).id, `${username.toLowerCase()}.profile`); + const token = await createSession((user as any).id); const res = Response.json({ ok: true }); res.headers.append("Set-Cookie", `session=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${7 * 24 * 60 * 60}`); @@ -40,7 +40,7 @@ export const authRoutes = { return Response.json({ ok: false, error: "Username and password are required." }, { status: 400 }); } - const user = findUserByUsername(username); + const user = await findUserByUsername(username); if (!user) { return Response.json({ ok: false, error: "Invalid username or password." }, { status: 401 }); } @@ -50,7 +50,7 @@ export const authRoutes = { return Response.json({ ok: false, error: "Invalid username or password." }, { status: 401 }); } - const token = createSession(user.id); + const token = await createSession(user.id); const res = Response.json({ ok: true }); res.headers.append("Set-Cookie", `session=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${7 * 24 * 60 * 60}`); @@ -58,9 +58,9 @@ export const authRoutes = { }, }, "/api/logout": { - POST: (req: Request) => { + POST: async (req: Request) => { const token = getSessionFromRequest(req); - if (token) deleteSession(token); + if (token) await deleteSession(token); const res = Response.json({ ok: true }); res.headers.append("Set-Cookie", "session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0"); return res; diff --git a/routes/friends.ts b/routes/friends.ts index a58a668..f3cd147 100644 --- a/routes/friends.ts +++ b/routes/friends.ts @@ -24,109 +24,109 @@ function requireAuth(req: Request) { export const friendRoutes = { "/api/friends": { - GET: (req: Request) => { - const user = requireAuth(req); + GET: async (req: Request) => { + const user = await requireAuth(req); if (!user) return Response.json({ ok: false, error: "Not logged in." }, { status: 401 }); - const friends = getFriends(user.id); + const friends = await getFriends(user.id); return Response.json({ friends }); }, }, "/api/friends/:friendId": { - DELETE: (req: Request) => { - const user = requireAuth(req); + DELETE: async (req: Request) => { + const user = await requireAuth(req); if (!user) return Response.json({ ok: false, error: "Not logged in." }, { status: 401 }); const friendId = Number((req as any).params.friendId); - if (!friendId || !areFriends(user.id, friendId)) { + if (!friendId || !(await areFriends(user.id, friendId))) { return Response.json({ ok: false, error: "Not friends." }, { status: 400 }); } - removeFriend(user.id, friendId); + await removeFriend(user.id, friendId); return Response.json({ ok: true }); }, }, "/api/friends/:friendId/websites": { - GET: (req: Request) => { - const user = requireAuth(req); + GET: async (req: Request) => { + const user = await requireAuth(req); if (!user) return Response.json({ ok: false, error: "Not logged in." }, { status: 401 }); const friendId = Number((req as any).params.friendId); - if (!friendId || !areFriends(user.id, friendId)) { + if (!friendId || !(await areFriends(user.id, friendId))) { return Response.json({ ok: false, error: "Not friends." }, { status: 400 }); } - const websites = getWebsitesByUserId(friendId); + const websites = await getWebsitesByUserId(friendId); return Response.json({ websites }); }, }, "/api/friend-requests": { - GET: (req: Request) => { - const user = requireAuth(req); + GET: async (req: Request) => { + const user = await requireAuth(req); if (!user) return Response.json({ ok: false, error: "Not logged in." }, { status: 401 }); - const received = getPendingFriendRequests(user.id); - const sent = getSentFriendRequests(user.id); + const received = await getPendingFriendRequests(user.id); + const sent = await getSentFriendRequests(user.id); return Response.json({ received, sent }); }, POST: async (req: Request) => { - const user = requireAuth(req); + const user = await requireAuth(req); if (!user) return Response.json({ ok: false, error: "Not logged in." }, { status: 401 }); const { username } = await req.json(); if (!username) return Response.json({ ok: false, error: "Username is required." }, { status: 400 }); - const target = findUserByUsername(username); + const target = await findUserByUsername(username); if (!target) return Response.json({ ok: false, error: "User not found." }, { status: 404 }); if (target.id === user.id) return Response.json({ ok: false, error: "You cannot add yourself." }, { status: 400 }); - if (areFriends(user.id, target.id)) return Response.json({ ok: false, error: "Already friends." }, { status: 400 }); - if (hasPendingRequest(user.id, target.id)) return Response.json({ ok: false, error: "Friend request already sent." }, { status: 400 }); + if (await areFriends(user.id, target.id)) return Response.json({ ok: false, error: "Already friends." }, { status: 400 }); + if (await hasPendingRequest(user.id, target.id)) return Response.json({ ok: false, error: "Friend request already sent." }, { status: 400 }); // Check if the other user already sent us a request - auto-accept - if (hasPendingRequest(target.id, user.id)) { - const pending = getPendingFriendRequests(user.id); + if (await hasPendingRequest(target.id, user.id)) { + const pending = await getPendingFriendRequests(user.id); const existing = pending.find(r => r.from_user_id === target.id); if (existing) { - acceptFriendRequest(existing.id, user.id); + await acceptFriendRequest(existing.id, user.id); return Response.json({ ok: true, autoAccepted: true }); } } - const ok = createFriendRequest(user.id, target.id); + const ok = await createFriendRequest(user.id, target.id); if (!ok) return Response.json({ ok: false, error: "Could not send request." }, { status: 400 }); return Response.json({ ok: true }); }, }, "/api/friend-requests/:requestId/accept": { - POST: (req: Request) => { - const user = requireAuth(req); + POST: async (req: Request) => { + const user = await requireAuth(req); if (!user) return Response.json({ ok: false, error: "Not logged in." }, { status: 401 }); const requestId = Number((req as any).params.requestId); - const ok = acceptFriendRequest(requestId, user.id); + const ok = await acceptFriendRequest(requestId, user.id); if (!ok) return Response.json({ ok: false, error: "Request not found." }, { status: 404 }); return Response.json({ ok: true }); }, }, "/api/friend-requests/:requestId/decline": { - POST: (req: Request) => { - const user = requireAuth(req); + POST: async (req: Request) => { + const user = await requireAuth(req); if (!user) return Response.json({ ok: false, error: "Not logged in." }, { status: 401 }); const requestId = Number((req as any).params.requestId); - const ok = declineFriendRequest(requestId, user.id); + const ok = await declineFriendRequest(requestId, user.id); if (!ok) return Response.json({ ok: false, error: "Request not found." }, { status: 404 }); return Response.json({ ok: true }); }, }, "/api/users/search": { - GET: (req: Request) => { - const user = requireAuth(req); + GET: async (req: Request) => { + const user = await requireAuth(req); if (!user) return Response.json({ ok: false, error: "Not logged in." }, { status: 401 }); const url = new URL(req.url); const q = url.searchParams.get("q") || ""; if (q.length < 1) return Response.json({ users: [] }); - const users = searchUsers(q, user.id); + const users = await searchUsers(q, user.id); // For each user, indicate their relationship status - const results = users.map(u => ({ + const results = await Promise.all(users.map(async u => ({ id: u.id, username: u.username, - isFriend: areFriends(user.id, u.id), - requestSent: hasPendingRequest(user.id, u.id), - requestReceived: hasPendingRequest(u.id, user.id), - })); + isFriend: await areFriends(user.id, u.id), + requestSent: await hasPendingRequest(user.id, u.id), + requestReceived: await hasPendingRequest(u.id, user.id), + }))); return Response.json({ users: results }); }, }, diff --git a/routes/git_websites.ts b/routes/git_websites.ts index dfd6bad..42c7cf8 100644 --- a/routes/git_websites.ts +++ b/routes/git_websites.ts @@ -27,15 +27,15 @@ function getAuthenticatedUser(req: Request) { export const gitWebsiteRoutes = { "/api/websites/:domain/git/setup": { POST: async (req: Request) => { - const user = getAuthenticatedUser(req); + const user = await getAuthenticatedUser(req); if (!user) return Response.json({ ok: false, error: "Not logged in." }, { status: 401 }); const domain = decodeURIComponent((req as any).params.domain); - const website = getWebsiteByDomainAndUserId(domain, user.id); + const website = await getWebsiteByDomainAndUserId(domain, user.id); if (!website) return Response.json({ ok: false, error: "Forbidden." }, { status: 403 }); // Check if already set up as git - const existing = getGitWebsite(website.id); + const existing = await getGitWebsite(website.id); if (existing) return Response.json({ ok: false, error: "Git is already set up for this site." }, { status: 400 }); const body = await req.json(); @@ -60,9 +60,9 @@ export const gitWebsiteRoutes = { try { const result = await cloneRepo(domain, git_url, git_username, git_password); - createGitWebsite(website.id, git_url, git_username, git_password); - updateGitWebsiteCommit(website.id, result.commit_hash, result.commit_message); - updateGitWebsiteBranch(website.id, result.branch); + await createGitWebsite(website.id, git_url, git_username, git_password); + await updateGitWebsiteCommit(website.id, result.commit_hash, result.commit_message); + await updateGitWebsiteBranch(website.id, result.branch); return Response.json({ ok: true, @@ -79,10 +79,10 @@ export const gitWebsiteRoutes = { "/api/websites/:domain/git/status": { GET: async (req: Request) => { const domain = decodeURIComponent((req as any).params.domain); - const website = findWebsiteByDomain(domain); + const website = await findWebsiteByDomain(domain); if (!website) return Response.json({ ok: false, error: "Not found." }, { status: 404 }); - const gitInfo = getGitWebsite(website.id); + const gitInfo = await getGitWebsite(website.id); if (!gitInfo) { return Response.json({ is_git: false }); } @@ -99,19 +99,19 @@ export const gitWebsiteRoutes = { "/api/websites/:domain/git/pull": { POST: async (req: Request) => { - const user = getAuthenticatedUser(req); + const user = await getAuthenticatedUser(req); if (!user) return Response.json({ ok: false, error: "Not logged in." }, { status: 401 }); const domain = decodeURIComponent((req as any).params.domain); - const website = getWebsiteByDomainAndUserId(domain, user.id); + const website = await getWebsiteByDomainAndUserId(domain, user.id); if (!website) return Response.json({ ok: false, error: "Forbidden." }, { status: 403 }); - const gitInfo = getGitWebsite(website.id); + const gitInfo = await getGitWebsite(website.id); if (!gitInfo) return Response.json({ ok: false, error: "This site is not git-backed." }, { status: 400 }); try { const result = await pullLatest(domain); - updateGitWebsiteCommit(website.id, result.commit_hash, result.commit_message); + await updateGitWebsiteCommit(website.id, result.commit_hash, result.commit_message); return Response.json({ ok: true, @@ -126,14 +126,14 @@ export const gitWebsiteRoutes = { "/api/websites/:domain/git/branch": { PUT: async (req: Request) => { - const user = getAuthenticatedUser(req); + const user = await getAuthenticatedUser(req); if (!user) return Response.json({ ok: false, error: "Not logged in." }, { status: 401 }); const domain = decodeURIComponent((req as any).params.domain); - const website = getWebsiteByDomainAndUserId(domain, user.id); + const website = await getWebsiteByDomainAndUserId(domain, user.id); if (!website) return Response.json({ ok: false, error: "Forbidden." }, { status: 403 }); - const gitInfo = getGitWebsite(website.id); + const gitInfo = await getGitWebsite(website.id); if (!gitInfo) return Response.json({ ok: false, error: "This site is not git-backed." }, { status: 400 }); const body = await req.json(); @@ -150,8 +150,8 @@ export const gitWebsiteRoutes = { try { const result = await checkoutBranch(domain, branch); - updateGitWebsiteBranch(website.id, result.branch); - updateGitWebsiteCommit(website.id, result.commit_hash, result.commit_message); + await updateGitWebsiteBranch(website.id, result.branch); + await updateGitWebsiteCommit(website.id, result.commit_hash, result.commit_message); return Response.json({ ok: true, @@ -167,17 +167,17 @@ export const gitWebsiteRoutes = { "/api/websites/:domain/git": { DELETE: async (req: Request) => { - const user = getAuthenticatedUser(req); + const user = await getAuthenticatedUser(req); if (!user) return Response.json({ ok: false, error: "Not logged in." }, { status: 401 }); const domain = decodeURIComponent((req as any).params.domain); - const website = getWebsiteByDomainAndUserId(domain, user.id); + const website = await getWebsiteByDomainAndUserId(domain, user.id); if (!website) return Response.json({ ok: false, error: "Forbidden." }, { status: 403 }); - const gitInfo = getGitWebsite(website.id); + const gitInfo = await getGitWebsite(website.id); if (!gitInfo) return Response.json({ ok: false, error: "This site is not git-backed." }, { status: 400 }); - deleteGitWebsite(website.id); + await deleteGitWebsite(website.id); // Remove the cloned directory try { diff --git a/routes/messages.ts b/routes/messages.ts index f47864e..b31fd5b 100644 --- a/routes/messages.ts +++ b/routes/messages.ts @@ -18,32 +18,32 @@ function requireAuth(req: Request) { export const messageRoutes = { "/api/messages/unread": { - GET: (req: Request) => { - const user = requireAuth(req); + GET: async (req: Request) => { + const user = await requireAuth(req); if (!user) return Response.json({ ok: false, error: "Not logged in." }, { status: 401 }); - const counts = getUnreadCounts(user.id); - const total = getTotalUnreadCount(user.id); + const counts = await getUnreadCounts(user.id); + const total = await getTotalUnreadCount(user.id); return Response.json({ counts, total }); }, }, "/api/messages/:userId": { - GET: (req: Request) => { - const user = requireAuth(req); + GET: async (req: Request) => { + const user = await requireAuth(req); if (!user) return Response.json({ ok: false, error: "Not logged in." }, { status: 401 }); const otherUserId = Number((req as any).params.userId); - if (!otherUserId || !areFriends(user.id, otherUserId)) { + if (!otherUserId || !(await areFriends(user.id, otherUserId))) { return Response.json({ ok: false, error: "Not friends." }, { status: 400 }); } - const messages = getMessages(user.id, otherUserId); + const messages = await getMessages(user.id, otherUserId); // Mark messages from this user as read - markMessagesAsRead(user.id, otherUserId); + await markMessagesAsRead(user.id, otherUserId); return Response.json({ messages }); }, POST: async (req: Request) => { - const user = requireAuth(req); + const user = await requireAuth(req); if (!user) return Response.json({ ok: false, error: "Not logged in." }, { status: 401 }); const otherUserId = Number((req as any).params.userId); - if (!otherUserId || !areFriends(user.id, otherUserId)) { + if (!otherUserId || !(await areFriends(user.id, otherUserId))) { return Response.json({ ok: false, error: "Not friends." }, { status: 400 }); } const { content } = await req.json(); @@ -53,7 +53,7 @@ export const messageRoutes = { if (content.length > 2000) { return Response.json({ ok: false, error: "Message too long (max 2000 characters)." }, { status: 400 }); } - const message = createMessage(user.id, otherUserId, content.trim()); + const message = await createMessage(user.id, otherUserId, content.trim()); return Response.json({ ok: true, message }); }, }, diff --git a/routes/websites.ts b/routes/websites.ts index 9472b07..0518363 100644 --- a/routes/websites.ts +++ b/routes/websites.ts @@ -24,18 +24,18 @@ const DEFAULT_TEMPLATE = ` export const websiteRoutes = { "/api/me/websites": { - GET: (req: Request) => { + GET: async (req: Request) => { const token = getSessionFromRequest(req); if (!token) return Response.json({ websites: [] }); - const user = getSessionUser(token); + const user = await getSessionUser(token); if (!user) return Response.json({ websites: [] }); - const websites = getWebsitesByUserId(user.id); + const websites = await getWebsitesByUserId(user.id); return Response.json({ websites }); }, POST: async (req: Request) => { const token = getSessionFromRequest(req); if (!token) return Response.json({ ok: false, error: "Not logged in." }, { status: 401 }); - const user = getSessionUser(token); + const user = await getSessionUser(token); if (!user) return Response.json({ ok: false, error: "Not logged in." }, { status: 401 }); const { domain: rawDomain } = await req.json(); @@ -46,7 +46,7 @@ export const websiteRoutes = { return Response.json({ ok: false, error: "Invalid domain. Supported TLDs: .com, .net, .org, .us, .co.uk" }, { status: 400 }); } - if (countWebsitesByUserId(user.id) >= 5) { + if (await countWebsitesByUserId(user.id) >= 5) { return Response.json({ ok: false, error: "You can only register up to 5 sites." }, { status: 400 }); } @@ -54,11 +54,11 @@ export const websiteRoutes = { return Response.json({ ok: false, error: "That domain is reserved." }, { status: 400 }); } - if (findWebsiteByDomain(domain)) { + if (await findWebsiteByDomain(domain)) { return Response.json({ ok: false, error: "That domain is already registered." }, { status: 400 }); } - createWebsite(user.id, domain); + await createWebsite(user.id, domain); return Response.json({ ok: true }); }, }, @@ -66,11 +66,11 @@ export const websiteRoutes = { GET: async (req: Request) => { const token = getSessionFromRequest(req); if (!token) return Response.json({ ok: false, error: "Not logged in." }, { status: 401 }); - const user = getSessionUser(token); + const user = await getSessionUser(token); if (!user) return Response.json({ ok: false, error: "Not logged in." }, { status: 401 }); const domain = decodeURIComponent((req as any).params.domain); - const website = getWebsiteByDomainAndUserId(domain, user.id); + const website = await getWebsiteByDomainAndUserId(domain, user.id); if (!website) return Response.json({ ok: false, error: "Forbidden." }, { status: 403 }); const filePath = `${WEBSITES_DIR}/${domain}/draft/index.html`; @@ -83,11 +83,11 @@ export const websiteRoutes = { PUT: async (req: Request) => { const token = getSessionFromRequest(req); if (!token) return Response.json({ ok: false, error: "Not logged in." }, { status: 401 }); - const user = getSessionUser(token); + const user = await getSessionUser(token); if (!user) return Response.json({ ok: false, error: "Not logged in." }, { status: 401 }); const domain = decodeURIComponent((req as any).params.domain); - const website = getWebsiteByDomainAndUserId(domain, user.id); + const website = await getWebsiteByDomainAndUserId(domain, user.id); if (!website) return Response.json({ ok: false, error: "Forbidden." }, { status: 403 }); const { source } = await req.json(); @@ -101,11 +101,11 @@ export const websiteRoutes = { POST: async (req: Request) => { const token = getSessionFromRequest(req); if (!token) return Response.json({ ok: false, error: "Not logged in." }, { status: 401 }); - const user = getSessionUser(token); + const user = await getSessionUser(token); if (!user) return Response.json({ ok: false, error: "Not logged in." }, { status: 401 }); const domain = decodeURIComponent((req as any).params.domain); - const website = getWebsiteByDomainAndUserId(domain, user.id); + const website = await getWebsiteByDomainAndUserId(domain, user.id); if (!website) return Response.json({ ok: false, error: "Forbidden." }, { status: 403 }); const draftPath = `${WEBSITES_DIR}/${domain}/draft/index.html`; @@ -134,11 +134,11 @@ export const websiteRoutes = { return Response.json({ source: content }); } - const website = findWebsiteByDomain(domain); + const website = await findWebsiteByDomain(domain); if (!website) return Response.json({ ok: false, error: "Not found." }, { status: 404 }); // Check if this is an external website - const externalInfo = getExternalWebsiteByDomain(domain); + const externalInfo = await getExternalWebsiteByDomain(domain); if (externalInfo) { try { const externalRes = await fetch(externalInfo.external_url, { @@ -156,7 +156,7 @@ export const websiteRoutes = { } // Check if this is a git-backed website - const gitInfo = getGitWebsiteByDomain(domain); + const gitInfo = await getGitWebsiteByDomain(domain); if (gitInfo) { const safePath = requestedPath === "/" || requestedPath === "" ? "index.html" : requestedPath.replace(/^\//, ""); // Path traversal protection using resolve @@ -184,10 +184,10 @@ export const websiteRoutes = { "/api/websites/:domain/external": { GET: async (req: Request) => { const domain = decodeURIComponent((req as any).params.domain); - const website = findWebsiteByDomain(domain); + const website = await findWebsiteByDomain(domain); if (!website) return Response.json({ ok: false, error: "Not found." }, { status: 404 }); - const externalInfo = getExternalWebsite(website.id); + const externalInfo = await getExternalWebsite(website.id); if (!externalInfo) { return Response.json({ is_external: false }); } @@ -196,11 +196,11 @@ export const websiteRoutes = { POST: async (req: Request) => { const token = getSessionFromRequest(req); if (!token) return Response.json({ ok: false, error: "Not logged in." }, { status: 401 }); - const user = getSessionUser(token); + const user = await getSessionUser(token); if (!user) return Response.json({ ok: false, error: "Not logged in." }, { status: 401 }); const domain = decodeURIComponent((req as any).params.domain); - const website = getWebsiteByDomainAndUserId(domain, user.id); + const website = await getWebsiteByDomainAndUserId(domain, user.id); if (!website) return Response.json({ ok: false, error: "Forbidden." }, { status: 403 }); const body = await req.json(); @@ -219,11 +219,11 @@ export const websiteRoutes = { return Response.json({ ok: false, error: "Invalid URL." }, { status: 400 }); } - const existing = getExternalWebsite(website.id); + const existing = await getExternalWebsite(website.id); if (existing) { - updateExternalWebsiteUrl(website.id, external_url); + await updateExternalWebsiteUrl(website.id, external_url); } else { - createExternalWebsite(website.id, external_url); + await createExternalWebsite(website.id, external_url); } return Response.json({ ok: true }); @@ -231,17 +231,17 @@ export const websiteRoutes = { DELETE: async (req: Request) => { const token = getSessionFromRequest(req); if (!token) return Response.json({ ok: false, error: "Not logged in." }, { status: 401 }); - const user = getSessionUser(token); + const user = await getSessionUser(token); if (!user) return Response.json({ ok: false, error: "Not logged in." }, { status: 401 }); const domain = decodeURIComponent((req as any).params.domain); - const website = getWebsiteByDomainAndUserId(domain, user.id); + const website = await getWebsiteByDomainAndUserId(domain, user.id); if (!website) return Response.json({ ok: false, error: "Forbidden." }, { status: 403 }); - const existing = getExternalWebsite(website.id); + const existing = await getExternalWebsite(website.id); if (!existing) return Response.json({ ok: false, error: "No external URL configured." }, { status: 400 }); - deleteExternalWebsite(website.id); + await deleteExternalWebsite(website.id); return Response.json({ ok: true }); }, }, @@ -249,17 +249,17 @@ export const websiteRoutes = { DELETE: async (req: Request) => { const token = getSessionFromRequest(req); if (!token) return Response.json({ ok: false, error: "Not logged in." }, { status: 401 }); - const user = getSessionUser(token); + const user = await getSessionUser(token); if (!user) return Response.json({ ok: false, error: "Not logged in." }, { status: 401 }); const domain = decodeURIComponent((req as any).params.domain); if (domain.endsWith(".profile")) { return Response.json({ ok: false, error: "Cannot delete your profile site." }, { status: 403 }); } - const website = getWebsiteByDomainAndUserId(domain, user.id); + const website = await getWebsiteByDomainAndUserId(domain, user.id); if (!website) return Response.json({ ok: false, error: "Forbidden." }, { status: 403 }); - deleteWebsite(website.id, user.id); + await deleteWebsite(website.id, user.id); const siteDir = `${WEBSITES_DIR}/${domain}`; try { diff --git a/supabase/migrations/001_initial.sql b/supabase/migrations/001_initial.sql new file mode 100644 index 0000000..d7e6047 --- /dev/null +++ b/supabase/migrations/001_initial.sql @@ -0,0 +1,95 @@ +-- Enable citext extension for case-insensitive text columns +CREATE EXTENSION IF NOT EXISTS citext; + +-- Users +CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + username CITEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at TIMESTAMPTZ DEFAULT now() +); + +-- Sessions +CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ DEFAULT now(), + expires_at TIMESTAMPTZ NOT NULL +); + +-- Websites +CREATE TABLE IF NOT EXISTS websites ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + domain CITEXT NOT NULL UNIQUE, + created_at TIMESTAMPTZ DEFAULT now() +); + +-- Tags +CREATE TABLE IF NOT EXISTS tags ( + id SERIAL PRIMARY KEY, + name CITEXT NOT NULL UNIQUE +); + +-- Website Tags (many-to-many) +CREATE TABLE IF NOT EXISTS website_tags ( + website_id INTEGER NOT NULL REFERENCES websites(id) ON DELETE CASCADE, + tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + PRIMARY KEY (website_id, tag_id) +); + +-- Friend Requests +CREATE TABLE IF NOT EXISTS friend_requests ( + id SERIAL PRIMARY KEY, + from_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + to_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + status TEXT NOT NULL DEFAULT 'pending', + created_at TIMESTAMPTZ DEFAULT now(), + UNIQUE(from_user_id, to_user_id) +); + +-- Friends (symmetric relationship stored as two rows) +CREATE TABLE IF NOT EXISTS friends ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + friend_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ DEFAULT now(), + PRIMARY KEY (user_id, friend_id) +); + +-- Messages +CREATE TABLE IF NOT EXISTS messages ( + id SERIAL PRIMARY KEY, + from_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + to_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + content TEXT NOT NULL, + read BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ DEFAULT now() +); + +-- Git Websites +CREATE TABLE IF NOT EXISTS git_websites ( + id SERIAL PRIMARY KEY, + website_id INTEGER NOT NULL UNIQUE REFERENCES websites(id) ON DELETE CASCADE, + git_url TEXT NOT NULL, + git_username TEXT, + git_password TEXT, + git_branch TEXT, + git_commit_hash TEXT, + git_commit_message TEXT +); + +-- External Websites +CREATE TABLE IF NOT EXISTS external_websites ( + id SERIAL PRIMARY KEY, + website_id INTEGER NOT NULL UNIQUE REFERENCES websites(id) ON DELETE CASCADE, + external_url TEXT NOT NULL +); + +-- Indexes for common queries +CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at); +CREATE INDEX IF NOT EXISTS idx_websites_user_id ON websites(user_id); +CREATE INDEX IF NOT EXISTS idx_friend_requests_to_user ON friend_requests(to_user_id, status); +CREATE INDEX IF NOT EXISTS idx_friend_requests_from_user ON friend_requests(from_user_id, status); +CREATE INDEX IF NOT EXISTS idx_friends_user_id ON friends(user_id); +CREATE INDEX IF NOT EXISTS idx_messages_to_user ON messages(to_user_id, read); +CREATE INDEX IF NOT EXISTS idx_messages_conversation ON messages(from_user_id, to_user_id, created_at); diff --git a/supabase/migrations/002_rpc_functions.sql b/supabase/migrations/002_rpc_functions.sql new file mode 100644 index 0000000..5eba21f --- /dev/null +++ b/supabase/migrations/002_rpc_functions.sql @@ -0,0 +1,104 @@ +-- RPC function: accept_friend_request +-- Atomically: validates the request, updates status, and creates bidirectional friend rows +CREATE OR REPLACE FUNCTION accept_friend_request(p_request_id INTEGER, p_user_id INTEGER) +RETURNS BOOLEAN +LANGUAGE plpgsql +AS $$ +DECLARE + v_from_user_id INTEGER; + v_to_user_id INTEGER; +BEGIN + -- Find and validate the pending request + SELECT from_user_id, to_user_id INTO v_from_user_id, v_to_user_id + FROM friend_requests + WHERE id = p_request_id AND to_user_id = p_user_id AND status = 'pending'; + + IF NOT FOUND THEN + RETURN false; + END IF; + + -- Update request status + UPDATE friend_requests SET status = 'accepted' WHERE id = p_request_id; + + -- Create bidirectional friend entries + INSERT INTO friends (user_id, friend_id) VALUES (v_from_user_id, v_to_user_id) ON CONFLICT DO NOTHING; + INSERT INTO friends (user_id, friend_id) VALUES (v_to_user_id, v_from_user_id) ON CONFLICT DO NOTHING; + + RETURN true; +END; +$$; + +-- RPC function: remove_friend +-- Atomically removes both friend rows and cleans up friend requests +CREATE OR REPLACE FUNCTION remove_friend(p_user_id INTEGER, p_friend_id INTEGER) +RETURNS BOOLEAN +LANGUAGE plpgsql +AS $$ +BEGIN + DELETE FROM friends WHERE user_id = p_user_id AND friend_id = p_friend_id; + DELETE FROM friends WHERE user_id = p_friend_id AND friend_id = p_user_id; + DELETE FROM friend_requests + WHERE (from_user_id = p_user_id AND to_user_id = p_friend_id) + OR (from_user_id = p_friend_id AND to_user_id = p_user_id); + RETURN true; +END; +$$; + +-- RPC function: set_website_tags +-- Atomically replaces all tags for a website +CREATE OR REPLACE FUNCTION set_website_tags(p_website_id INTEGER, p_tag_names TEXT[]) +RETURNS VOID +LANGUAGE plpgsql +AS $$ +DECLARE + v_tag_name TEXT; + v_tag_id INTEGER; +BEGIN + -- Remove existing tags + DELETE FROM website_tags WHERE website_id = p_website_id; + + -- Add new tags + FOREACH v_tag_name IN ARRAY p_tag_names LOOP + v_tag_name := lower(v_tag_name); + + -- Get or create the tag + SELECT id INTO v_tag_id FROM tags WHERE name = v_tag_name; + IF NOT FOUND THEN + INSERT INTO tags (name) VALUES (v_tag_name) + ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name + RETURNING id INTO v_tag_id; + END IF; + + -- Link tag to website + INSERT INTO website_tags (website_id, tag_id) VALUES (p_website_id, v_tag_id) ON CONFLICT DO NOTHING; + END LOOP; +END; +$$; + +-- RPC function: get_unread_counts +-- Returns unread message counts grouped by sender +CREATE OR REPLACE FUNCTION get_unread_counts(p_user_id INTEGER) +RETURNS TABLE(from_user_id INTEGER, count BIGINT) +LANGUAGE sql +STABLE +AS $$ + SELECT from_user_id, COUNT(*) as count + FROM messages + WHERE to_user_id = p_user_id AND read = false + GROUP BY from_user_id; +$$; + +-- RPC function: get_all_tags_with_counts +-- Returns all tags with their website counts +CREATE OR REPLACE FUNCTION get_all_tags_with_counts() +RETURNS TABLE(name CITEXT, count BIGINT) +LANGUAGE sql +STABLE +AS $$ + SELECT t.name, COUNT(wt.website_id) as count + FROM tags t + JOIN website_tags wt ON t.id = wt.tag_id + GROUP BY t.id, t.name + HAVING COUNT(wt.website_id) > 0 + ORDER BY count DESC, t.name ASC; +$$;