This commit is contained in:
481
todo-service.js
481
todo-service.js
@ -1,145 +1,432 @@
|
||||
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.users = new Map(); // userId -> SignalClient
|
||||
this.todos = new Map(); // todoId -> encrypted todo
|
||||
this.userTodos = new Map(); // userId -> Set of todoIds
|
||||
this.sharedSessions = new Map(); // userId+recipientId -> shared session
|
||||
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();
|
||||
}
|
||||
|
||||
async createUser(userId) {
|
||||
if (this.users.has(userId)) {
|
||||
console.log(`User ${userId} already exists. Logging in.`);
|
||||
return { userId };
|
||||
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!");
|
||||
}
|
||||
}
|
||||
|
||||
const client = await SignalClient.create(userId);
|
||||
this.users.set(userId, client);
|
||||
this.userTodos.set(userId, new Set());
|
||||
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 };
|
||||
}
|
||||
|
||||
getUser(userId) {
|
||||
const user = this.users.get(userId);
|
||||
if (!user) {
|
||||
throw new Error("User not found. Please create the user first.");
|
||||
}
|
||||
return user;
|
||||
getAllUsers() {
|
||||
return this.db
|
||||
.prepare("SELECT userId FROM users")
|
||||
.all()
|
||||
.map((u) => u.userId);
|
||||
}
|
||||
|
||||
async addTodo(userId, todoText) {
|
||||
const client = this.getUser(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();
|
||||
|
||||
// Self-encrypt the todo
|
||||
const encryptedTodo = await client.encrypt(todoText);
|
||||
// Create the todo group
|
||||
this.db
|
||||
.prepare(
|
||||
"INSERT INTO todo_groups (todoGroupId, createdBy, createdAt) VALUES (?, ?, ?)",
|
||||
)
|
||||
.run(todoGroupId, userId, now);
|
||||
|
||||
this.todos.set(todoId, {
|
||||
id: todoId,
|
||||
userId,
|
||||
encrypted: encryptedTodo,
|
||||
createdAt: new Date().toISOString(),
|
||||
originalText: todoText, // Store original for sharing (in a real app, this would be encrypted)
|
||||
});
|
||||
// Add the creator as a participant
|
||||
this.db
|
||||
.prepare(
|
||||
"INSERT INTO todo_participants (todoGroupId, userId, joinedAt) VALUES (?, ?, ?)",
|
||||
)
|
||||
.run(todoGroupId, userId, now);
|
||||
|
||||
this.userTodos.get(userId).add(todoId);
|
||||
console.log(`Todo added for user ${userId}: ${todoId}`);
|
||||
// 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 todoId;
|
||||
return todoGroupId;
|
||||
}
|
||||
|
||||
async getTodos(userId) {
|
||||
const client = this.getUser(userId);
|
||||
const todoIds = this.userTodos.get(userId) || new Set();
|
||||
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 todoId of todoIds) {
|
||||
const encryptedTodo = this.todos.get(todoId);
|
||||
if (encryptedTodo) {
|
||||
try {
|
||||
const decryptedText = await client.decrypt(encryptedTodo.encrypted);
|
||||
todos.push({
|
||||
id: todoId,
|
||||
text: decryptedText,
|
||||
createdAt: encryptedTodo.createdAt,
|
||||
sharedBy: encryptedTodo.sharedBy,
|
||||
sharedWith: encryptedTodo.sharedWith || [],
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Failed to decrypt todo ${todoId}:`, error);
|
||||
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));
|
||||
}
|
||||
|
||||
async deleteTodo(userId, todoId) {
|
||||
const userTodos = this.userTodos.get(userId);
|
||||
if (!userTodos || !userTodos.has(todoId)) {
|
||||
throw new Error("Todo not found");
|
||||
}
|
||||
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");
|
||||
|
||||
userTodos.delete(todoId);
|
||||
const decryptedText = client.decrypt(JSON.parse(row.encrypted));
|
||||
|
||||
// Don't delete from todos map if it's shared with others
|
||||
const todo = this.todos.get(todoId);
|
||||
if (todo && (!todo.sharedWith || todo.sharedWith.length === 0)) {
|
||||
this.todos.delete(todoId);
|
||||
}
|
||||
// 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);
|
||||
|
||||
console.log(`Todo deleted: ${todoId}`);
|
||||
return true;
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
|
||||
// Share a todo with another user
|
||||
async shareTodo(userId, todoId, recipientId) {
|
||||
// Validate users and todo
|
||||
const senderClient = this.getUser(userId);
|
||||
const recipientClient = this.getUser(recipientId);
|
||||
updateTodoStatus(userId, todoGroupId, completed) {
|
||||
console.log(
|
||||
`updateTodoStatus called: userId=${userId}, todoGroupId=${todoGroupId}, completed=${completed}`,
|
||||
);
|
||||
|
||||
const todo = this.todos.get(todoId);
|
||||
if (!todo) {
|
||||
throw new Error("Todo not found");
|
||||
// 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");
|
||||
}
|
||||
|
||||
// Get the original text (in a real app, this would be re-encrypted for the recipient)
|
||||
const originalText = todo.originalText;
|
||||
if (!originalText) {
|
||||
throw new Error("Cannot share this todo");
|
||||
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);
|
||||
}
|
||||
|
||||
// Encrypt the todo for the recipient
|
||||
const encryptedForRecipient = await recipientClient.encrypt(originalText);
|
||||
console.log(`Affected users for todo update:`, participants);
|
||||
return participants;
|
||||
}
|
||||
|
||||
// Create a new todo ID for the shared copy
|
||||
const sharedTodoId = uuidv4();
|
||||
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);
|
||||
|
||||
// Store the shared todo
|
||||
this.todos.set(sharedTodoId, {
|
||||
id: sharedTodoId,
|
||||
userId: recipientId,
|
||||
encrypted: encryptedForRecipient,
|
||||
createdAt: new Date().toISOString(),
|
||||
originalText: originalText,
|
||||
sharedBy: userId,
|
||||
});
|
||||
|
||||
// Add to recipient's todos
|
||||
this.userTodos.get(recipientId).add(sharedTodoId);
|
||||
|
||||
// Track sharing in the original todo
|
||||
if (!todo.sharedWith) {
|
||||
todo.sharedWith = [];
|
||||
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");
|
||||
}
|
||||
todo.sharedWith.push(recipientId);
|
||||
|
||||
console.log(
|
||||
`Todo ${todoId} shared from ${userId} to ${recipientId} as ${sharedTodoId}`,
|
||||
`Deleting todo group ${todoGroupId} for all participants:`,
|
||||
participants,
|
||||
);
|
||||
return sharedTodoId;
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user