import { createClient } from "@libsql/client"; import "dotenv/config"; import { SignalClient } from "./signal-crypto.js"; import { v4 as uuidv4 } from "uuid"; export class EncryptedTodoService { constructor() { this.db = createClient({ url: `file:${process.env.SQLITE_DB_PATH}`, }); this.users = new Map(); // Initialize database and migrate this.initializeDatabase(); } async initializeDatabase() { // Create tables with new schema await this.db.execute(` CREATE TABLE IF NOT EXISTS users ( userId TEXT PRIMARY KEY ); `); await this.db.execute(` 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) ); `); await this.db.execute(` CREATE TABLE IF NOT EXISTS todo_groups ( todoGroupId TEXT PRIMARY KEY, createdBy TEXT, createdAt TEXT, FOREIGN KEY(createdBy) REFERENCES users(userId) ); `); await this.db.execute(` 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) ); `); await this.db.execute(` 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 await this.migrateToNewSchema(); // Load existing users from database on startup await this.loadExistingUsers(); } async migrateToNewSchema() { // Check if we need to migrate by looking for todos without todoGroupId const oldTodos = await this.db.execute( "SELECT * FROM todos WHERE todoGroupId IS NULL", ); if (oldTodos.rows.length > 0) { console.log(`Migrating ${oldTodos.rows.length} todos to new schema...`); for (const todo of oldTodos.rows) { const todoGroupId = uuidv4(); const now = new Date().toISOString(); // If this is an original todo (not shared) if (!todo.sharedBy && !todo.originalTodoId) { // Create todo group await this.db.execute( "INSERT OR IGNORE INTO todo_groups (todoGroupId, createdBy, createdAt) VALUES (?, ?, ?)", [todoGroupId, todo.userId, todo.createdAt || now], ); // Add creator as participant await this.db.execute( "INSERT OR IGNORE INTO todo_participants (todoGroupId, userId, joinedAt) VALUES (?, ?, ?)", [todoGroupId, todo.userId, todo.createdAt || now], ); // Update the todo with todoGroupId await this.db.execute( "UPDATE todos SET todoGroupId = ? WHERE id = ?", [todoGroupId, todo.id], ); // Find and migrate shared copies const sharedCopies = await this.db.execute( "SELECT * FROM todos WHERE originalTodoId = ?", [todo.id], ); for (const sharedTodo of sharedCopies.rows) { // Add shared user as participant await this.db.execute( "INSERT OR IGNORE INTO todo_participants (todoGroupId, userId, joinedAt) VALUES (?, ?, ?)", [todoGroupId, sharedTodo.userId, sharedTodo.createdAt || now], ); // Update shared todo with same todoGroupId await this.db.execute( "UPDATE todos SET todoGroupId = ? WHERE id = ?", [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 = await this.db.execute( "SELECT todoGroupId FROM todos WHERE id = ?", [todo.originalTodoId], ); if ( originalTodo.rows.length > 0 && originalTodo.rows[0].todoGroupId ) { await this.db.execute( "UPDATE todos SET todoGroupId = ? WHERE id = ?", [originalTodo.rows[0].todoGroupId, todo.id], ); // Add as participant if not already await this.db.execute( "INSERT OR IGNORE INTO todo_participants (todoGroupId, userId, joinedAt) VALUES (?, ?, ?)", [ originalTodo.rows[0].todoGroupId, todo.userId, todo.createdAt || now, ], ); } } } console.log("Migration completed!"); } } async loadExistingUsers() { const existingUsers = await this.db.execute("SELECT userId FROM users"); for (const user of existingUsers.rows) { if (!this.users.has(user.userId)) { this.users.set(user.userId, SignalClient.create(user.userId)); console.log(`Loaded existing user: ${user.userId}`); } } } async createUser(userId) { await this.db.execute("INSERT OR IGNORE INTO users (userId) VALUES (?)", [ userId, ]); if (!this.users.has(userId)) { this.users.set(userId, SignalClient.create(userId)); } return { userId }; } async getAllUsers() { const result = await this.db.execute("SELECT userId FROM users"); return result.rows.map((u) => u.userId); } async 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 await this.db.execute( "INSERT INTO todo_groups (todoGroupId, createdBy, createdAt) VALUES (?, ?, ?)", [todoGroupId, userId, now], ); // Add the creator as a participant await this.db.execute( "INSERT INTO todo_participants (todoGroupId, userId, joinedAt) VALUES (?, ?, ?)", [todoGroupId, userId, now], ); // Create the todo entry for the creator await this.db.execute( "INSERT INTO todos (id, todoGroupId, userId, encrypted, createdAt, originalText, completed) VALUES (?, ?, ?, ?, ?, ?, ?)", [ todoId, todoGroupId, userId, JSON.stringify(encryptedTodo), now, todoText, 0, ], ); return todoGroupId; } async getTodos(userId) { const client = this.users.get(userId); if (!client) { console.error(`No client found for user ${userId}`); return []; } const rows = await this.db.execute("SELECT * FROM todos WHERE userId = ?", [ userId, ]); const todos = []; for (const row of rows.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 = await this.db.execute( "SELECT userId FROM todo_participants WHERE todoGroupId = ?", [row.todoGroupId], ); // Get group creator const groupInfo = await this.db.execute( "SELECT createdBy FROM todo_groups WHERE todoGroupId = ?", [row.todoGroupId], ); todos.push({ id: row.todoGroupId, // Use todoGroupId as the primary identifier text: decryptedText, createdAt: row.createdAt, completed: !!row.completed, participants: participants.rows.length > 0 ? participants.rows.map((p) => p.userId) : [userId], // Fallback to current user createdBy: groupInfo.rows[0]?.createdBy || userId, // Fallback to current user isCreator: (groupInfo.rows[0]?.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)); } async getTodo(userId, todoGroupId) { const client = this.users.get(userId); const row = await this.db.execute( "SELECT * FROM todos WHERE todoGroupId = ? AND userId = ?", [todoGroupId, userId], ); if (row.rows.length === 0) throw new Error("Todo not found"); const todoRow = row.rows[0]; const decryptedText = client.decrypt(JSON.parse(todoRow.encrypted)); // Get all participants in this todo group const participants = await this.db.execute( "SELECT userId FROM todo_participants WHERE todoGroupId = ?", [todoGroupId], ); // Get group creator const groupInfo = await this.db.execute( "SELECT createdBy FROM todo_groups WHERE todoGroupId = ?", [todoGroupId], ); return { id: todoGroupId, text: decryptedText, createdAt: todoRow.createdAt, completed: !!todoRow.completed, participants: participants.rows.map((p) => p.userId), createdBy: groupInfo.rows[0]?.createdBy, isCreator: groupInfo.rows[0]?.createdBy === userId, }; } async updateTodoStatus(userId, todoGroupId, completed) { console.log( `updateTodoStatus called: userId=${userId}, todoGroupId=${todoGroupId}, completed=${completed}`, ); // Get all participants in this todo group const participants = await this.db.execute( "SELECT userId FROM todo_participants WHERE todoGroupId = ?", [todoGroupId], ); const participantIds = participants.rows.map((p) => p.userId); if (!participantIds.includes(userId)) { throw new Error("User is not a participant in this todo group"); } console.log(`Updating todo for all participants:`, participantIds); // Update todos for all participants for (const participantId of participantIds) { await this.db.execute( "UPDATE todos SET completed = ? WHERE todoGroupId = ? AND userId = ?", [completed ? 1 : 0, todoGroupId, participantId], ); } console.log(`Affected users for todo update:`, participantIds); return participantIds; } async deleteTodo(userId, todoGroupId) { // Get all participants in this todo group const participants = await this.db.execute( "SELECT userId FROM todo_participants WHERE todoGroupId = ?", [todoGroupId], ); const participantIds = participants.rows.map((p) => p.userId); if (!participantIds.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 = await this.db.execute( "SELECT createdBy FROM todo_groups WHERE todoGroupId = ?", [todoGroupId], ); if (groupInfo.rows.length === 0) { throw new Error("Todo group not found"); } if (groupInfo.rows[0].createdBy !== userId) { throw new Error("Only the creator can delete this todo"); } console.log( `Deleting todo group ${todoGroupId} for all participants:`, participantIds, ); // Delete all todo entries for this group await this.db.execute("DELETE FROM todos WHERE todoGroupId = ?", [ todoGroupId, ]); // Delete all participants await this.db.execute( "DELETE FROM todo_participants WHERE todoGroupId = ?", [todoGroupId], ); // Delete the group itself await this.db.execute("DELETE FROM todo_groups WHERE todoGroupId = ?", [ todoGroupId, ]); return { deletedIds: [todoGroupId], affectedUsers: participantIds, }; } async 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 = await this.db.execute( "SELECT userId FROM todo_participants WHERE todoGroupId = ?", [todoGroupId], ); const participantIds = participants.rows.map((p) => p.userId); if (!participantIds.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 = await this.db.execute( "SELECT createdBy FROM todo_groups WHERE todoGroupId = ?", [todoGroupId], ); if (groupInfo.rows.length === 0) { throw new Error("Todo group not found"); } if (groupInfo.rows[0].createdBy !== userId) { throw new Error("Only the creator can add participants to this todo"); } if (participantIds.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 = await this.db.execute( "SELECT * FROM todos WHERE todoGroupId = ? AND userId = ?", [todoGroupId, userId], ); if (senderTodo.rows.length === 0) throw new Error("Todo not found"); const senderTodoRow = senderTodo.rows[0]; const recipientClient = this.users.get(recipientId); const encryptedForRecipient = recipientClient.encrypt( senderTodoRow.originalText, ); const recipientTodoId = uuidv4(); const now = new Date().toISOString(); // Add recipient as a participant await this.db.execute( "INSERT INTO todo_participants (todoGroupId, userId, joinedAt) VALUES (?, ?, ?)", [todoGroupId, recipientId, now], ); // Create todo entry for the recipient await this.db.execute( "INSERT INTO todos (id, todoGroupId, userId, encrypted, createdAt, originalText, completed) VALUES (?, ?, ?, ?, ?, ?, ?)", [ recipientTodoId, todoGroupId, recipientId, JSON.stringify(encryptedForRecipient), now, senderTodoRow.originalText, senderTodoRow.completed, ], ); return todoGroupId; } }