All checks were successful
Deploy Encrypted Todo App / build-and-push (push) Successful in 2m36s
471 lines
14 KiB
JavaScript
471 lines
14 KiB
JavaScript
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;
|
|
}
|
|
}
|