import { DatabaseSync } from "node:sqlite"; import "dotenv/config"; import { SignalClient } from "./signal-crypto.js"; import { v4 as uuidv4 } from "uuid"; export class EncryptedTodoService { constructor() { this.db = new DatabaseSync(process.env.SQLITE_DB_PATH); this.users = new Map(); // Create tables with new schema this.db.exec(` CREATE TABLE IF NOT EXISTS users ( userId TEXT PRIMARY KEY ); CREATE TABLE IF NOT EXISTS todos ( id TEXT PRIMARY KEY, todoGroupId TEXT, userId TEXT, encrypted TEXT, createdAt TEXT, originalText TEXT, completed INTEGER, sharedBy TEXT, originalTodoId TEXT, FOREIGN KEY(userId) REFERENCES users(userId) ); CREATE TABLE IF NOT EXISTS todo_groups ( todoGroupId TEXT PRIMARY KEY, createdBy TEXT, createdAt TEXT, FOREIGN KEY(createdBy) REFERENCES users(userId) ); CREATE TABLE IF NOT EXISTS todo_participants ( todoGroupId TEXT, userId TEXT, joinedAt TEXT, PRIMARY KEY(todoGroupId, userId), FOREIGN KEY(todoGroupId) REFERENCES todo_groups(todoGroupId), FOREIGN KEY(userId) REFERENCES users(userId) ); CREATE TABLE IF NOT EXISTS todo_shares ( todoId TEXT, sharedWith TEXT, FOREIGN KEY(todoId) REFERENCES todos(id), FOREIGN KEY(sharedWith) REFERENCES users(userId) ); `); // Migrate existing data to new schema this.migrateToNewSchema(); // Load existing users from database on startup this.loadExistingUsers(); } migrateToNewSchema() { // Check if we need to migrate by looking for todos without todoGroupId const oldTodos = this.db .prepare("SELECT * FROM todos WHERE todoGroupId IS NULL") .all(); if (oldTodos.length > 0) { console.log(`Migrating ${oldTodos.length} todos to new schema...`); for (const todo of oldTodos) { const todoGroupId = uuidv4(); const now = new Date().toISOString(); // If this is an original todo (not shared) if (!todo.sharedBy && !todo.originalTodoId) { // Create todo group this.db .prepare( "INSERT OR IGNORE INTO todo_groups (todoGroupId, createdBy, createdAt) VALUES (?, ?, ?)", ) .run(todoGroupId, todo.userId, todo.createdAt || now); // Add creator as participant this.db .prepare( "INSERT OR IGNORE INTO todo_participants (todoGroupId, userId, joinedAt) VALUES (?, ?, ?)", ) .run(todoGroupId, todo.userId, todo.createdAt || now); // Update the todo with todoGroupId this.db .prepare("UPDATE todos SET todoGroupId = ? WHERE id = ?") .run(todoGroupId, todo.id); // Find and migrate shared copies const sharedCopies = this.db .prepare("SELECT * FROM todos WHERE originalTodoId = ?") .all(todo.id); for (const sharedTodo of sharedCopies) { // Add shared user as participant this.db .prepare( "INSERT OR IGNORE INTO todo_participants (todoGroupId, userId, joinedAt) VALUES (?, ?, ?)", ) .run(todoGroupId, sharedTodo.userId, sharedTodo.createdAt || now); // Update shared todo with same todoGroupId this.db .prepare("UPDATE todos SET todoGroupId = ? WHERE id = ?") .run(todoGroupId, sharedTodo.id); } } // If this is a shared todo without a group (shouldn't happen after above, but just in case) else if (todo.originalTodoId && !todo.todoGroupId) { const originalTodo = this.db .prepare("SELECT todoGroupId FROM todos WHERE id = ?") .get(todo.originalTodoId); if (originalTodo && originalTodo.todoGroupId) { this.db .prepare("UPDATE todos SET todoGroupId = ? WHERE id = ?") .run(originalTodo.todoGroupId, todo.id); // Add as participant if not already this.db .prepare( "INSERT OR IGNORE INTO todo_participants (todoGroupId, userId, joinedAt) VALUES (?, ?, ?)", ) .run( originalTodo.todoGroupId, todo.userId, todo.createdAt || now, ); } } } console.log("Migration completed!"); } } loadExistingUsers() { const existingUsers = this.db.prepare("SELECT userId FROM users").all(); for (const user of existingUsers) { if (!this.users.has(user.userId)) { this.users.set(user.userId, SignalClient.create(user.userId)); console.log(`Loaded existing user: ${user.userId}`); } } } createUser(userId) { this.db .prepare("INSERT OR IGNORE INTO users (userId) VALUES (?)") .run(userId); if (!this.users.has(userId)) { this.users.set(userId, SignalClient.create(userId)); } return { userId }; } getAllUsers() { return this.db .prepare("SELECT userId FROM users") .all() .map((u) => u.userId); } addTodo(userId, todoText) { const todoGroupId = uuidv4(); const client = this.users.get(userId); const todoId = uuidv4(); const encryptedTodo = client.encrypt(todoText); const now = new Date().toISOString(); // Create the todo group this.db .prepare( "INSERT INTO todo_groups (todoGroupId, createdBy, createdAt) VALUES (?, ?, ?)", ) .run(todoGroupId, userId, now); // Add the creator as a participant this.db .prepare( "INSERT INTO todo_participants (todoGroupId, userId, joinedAt) VALUES (?, ?, ?)", ) .run(todoGroupId, userId, now); // Create the todo entry for the creator this.db .prepare( "INSERT INTO todos (id, todoGroupId, userId, encrypted, createdAt, originalText, completed) VALUES (?, ?, ?, ?, ?, ?, ?)", ) .run( todoId, todoGroupId, userId, JSON.stringify(encryptedTodo), now, todoText, 0, ); return todoGroupId; } getTodos(userId) { const client = this.users.get(userId); if (!client) { console.error(`No client found for user ${userId}`); return []; } const rows = this.db .prepare("SELECT * FROM todos WHERE userId = ?") .all(userId); const todos = []; for (const row of rows) { try { // Skip todos without todoGroupId (migration didn't work) if (!row.todoGroupId) { console.warn(`Todo ${row.id} has no todoGroupId, skipping`); continue; } const decryptedText = client.decrypt(JSON.parse(row.encrypted)); // Get all participants in this todo group const participants = this.db .prepare("SELECT userId FROM todo_participants WHERE todoGroupId = ?") .all(row.todoGroupId) .map((p) => p.userId); // Get group creator const groupInfo = this.db .prepare("SELECT createdBy FROM todo_groups WHERE todoGroupId = ?") .get(row.todoGroupId); todos.push({ id: row.todoGroupId, // Use todoGroupId as the primary identifier text: decryptedText, createdAt: row.createdAt, completed: !!row.completed, participants: participants.length > 0 ? participants : [userId], // Fallback to current user createdBy: groupInfo?.createdBy || userId, // Fallback to current user isCreator: (groupInfo?.createdBy || userId) === userId, }); } catch (e) { console.error(`Error processing todo ${row.id}:`, e); } } return todos.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); } getTodo(userId, todoGroupId) { const client = this.users.get(userId); const row = this.db .prepare("SELECT * FROM todos WHERE todoGroupId = ? AND userId = ?") .get(todoGroupId, userId); if (!row) throw new Error("Todo not found"); const decryptedText = client.decrypt(JSON.parse(row.encrypted)); // Get all participants in this todo group const participants = this.db .prepare("SELECT userId FROM todo_participants WHERE todoGroupId = ?") .all(todoGroupId) .map((p) => p.userId); // Get group creator const groupInfo = this.db .prepare("SELECT createdBy FROM todo_groups WHERE todoGroupId = ?") .get(todoGroupId); return { id: todoGroupId, text: decryptedText, createdAt: row.createdAt, completed: !!row.completed, participants: participants, createdBy: groupInfo?.createdBy, isCreator: groupInfo?.createdBy === userId, }; } updateTodoStatus(userId, todoGroupId, completed) { console.log( `updateTodoStatus called: userId=${userId}, todoGroupId=${todoGroupId}, completed=${completed}`, ); // Get all participants in this todo group const participants = this.db .prepare("SELECT userId FROM todo_participants WHERE todoGroupId = ?") .all(todoGroupId) .map((p) => p.userId); if (!participants.includes(userId)) { throw new Error("User is not a participant in this todo group"); } console.log(`Updating todo for all participants:`, participants); // Update todos for all participants for (const participantId of participants) { this.db .prepare( "UPDATE todos SET completed = ? WHERE todoGroupId = ? AND userId = ?", ) .run(completed ? 1 : 0, todoGroupId, participantId); } console.log(`Affected users for todo update:`, participants); return participants; } deleteTodo(userId, todoGroupId) { // Get all participants in this todo group const participants = this.db .prepare("SELECT userId FROM todo_participants WHERE todoGroupId = ?") .all(todoGroupId) .map((p) => p.userId); if (!participants.includes(userId)) { throw new Error("User is not a participant in this todo group"); } // Check if user is the creator of this todo group const groupInfo = this.db .prepare("SELECT createdBy FROM todo_groups WHERE todoGroupId = ?") .get(todoGroupId); if (!groupInfo) { throw new Error("Todo group not found"); } if (groupInfo.createdBy !== userId) { throw new Error("Only the creator can delete this todo"); } console.log( `Deleting todo group ${todoGroupId} for all participants:`, participants, ); // Delete all todo entries for this group this.db.prepare("DELETE FROM todos WHERE todoGroupId = ?").run(todoGroupId); // Delete all participants this.db .prepare("DELETE FROM todo_participants WHERE todoGroupId = ?") .run(todoGroupId); // Delete the group itself this.db .prepare("DELETE FROM todo_groups WHERE todoGroupId = ?") .run(todoGroupId); return { deletedIds: [todoGroupId], affectedUsers: participants, }; } shareTodo(userId, todoGroupId, recipientId) { // Ensure recipient user exists if (!this.users.has(recipientId)) { this.users.set(recipientId, SignalClient.create(recipientId)); } // Check if user is a participant in this todo group const participants = this.db .prepare("SELECT userId FROM todo_participants WHERE todoGroupId = ?") .all(todoGroupId) .map((p) => p.userId); if (!participants.includes(userId)) { throw new Error("User is not a participant in this todo group"); } // Check if user is the creator of this todo group const groupInfo = this.db .prepare("SELECT createdBy FROM todo_groups WHERE todoGroupId = ?") .get(todoGroupId); if (!groupInfo) { throw new Error("Todo group not found"); } if (groupInfo.createdBy !== userId) { throw new Error("Only the creator can add participants to this todo"); } if (participants.includes(recipientId)) { throw new Error("User is already a participant in this todo group"); } // Get the original text from the sender's todo const senderTodo = this.db .prepare("SELECT * FROM todos WHERE todoGroupId = ? AND userId = ?") .get(todoGroupId, userId); if (!senderTodo) throw new Error("Todo not found"); const recipientClient = this.users.get(recipientId); const encryptedForRecipient = recipientClient.encrypt( senderTodo.originalText, ); const recipientTodoId = uuidv4(); const now = new Date().toISOString(); // Add recipient as a participant this.db .prepare( "INSERT INTO todo_participants (todoGroupId, userId, joinedAt) VALUES (?, ?, ?)", ) .run(todoGroupId, recipientId, now); // Create todo entry for the recipient this.db .prepare( "INSERT INTO todos (id, todoGroupId, userId, encrypted, createdAt, originalText, completed) VALUES (?, ?, ?, ?, ?, ?, ?)", ) .run( recipientTodoId, todoGroupId, recipientId, JSON.stringify(encryptedForRecipient), now, senderTodo.originalText, senderTodo.completed, ); return todoGroupId; } }