feat: enhance Appwrite integration and chat functionality

- Added support for encrypted coach chats with a new `coach_chats` collection in the Appwrite database.
- Updated `.env.example` to include `OLLAMA_API_KEY`, `OLLAMA_MODEL`, and `APPWRITE_API_KEY` for server-side configurations.
- Introduced a setup script in `package.json` for initializing Appwrite database tables.
- Enhanced the Vite configuration to proxy requests to the Ollama API.
- Updated the main application structure to accommodate new chat features and improved theme management.
- Refined CSS styles for better UI consistency and added new components for chat functionality.
This commit is contained in:
Ned Halksworth
2026-05-22 22:39:38 +01:00
committed by Ned
parent bd3e970286
commit 08372febfe
12 changed files with 1845 additions and 388 deletions
+1
View File
@@ -8,6 +8,7 @@ export const appwriteConfig = {
projectId: env.VITE_APPWRITE_PROJECT_ID || "6a0752ee001fb2ef7138",
databaseId: env.VITE_APPWRITE_DATABASE_ID || "redbull_tracker",
collectionId: env.VITE_APPWRITE_COLLECTION_ID || "intake_entries",
chatCollectionId: env.VITE_APPWRITE_CHAT_COLLECTION_ID || "coach_chats",
oauthSuccessUrl: resolveOAuthUrl(env.VITE_APPWRITE_OAUTH_SUCCESS_URL),
oauthFailureUrl: resolveOAuthUrl(env.VITE_APPWRITE_OAUTH_FAILURE_URL),
};
+178
View File
@@ -0,0 +1,178 @@
import type { Models } from "appwrite";
import type { CoachChat } from "../types";
import { appwriteConfig, ID, Permission, Query, Role, tablesDB } from "./appwrite";
const CHAT_CRYPTO_VERSION = 1;
const KEY_ITERATIONS = 210_000;
type EncryptedChatRow = Models.Row & {
userId: string;
encryptedTitle: string;
encryptedMessages: string;
titleIv: string;
messagesIv: string;
salt: string;
version: number;
updatedAt: string;
};
type EncryptedValue = {
ciphertext: string;
iv: string;
};
export async function listEncryptedChats(userId: string, passphrase: string) {
const response = await tablesDB.listRows<EncryptedChatRow>({
databaseId: appwriteConfig.databaseId,
tableId: appwriteConfig.chatCollectionId,
queries: [Query.equal("userId", userId), Query.orderDesc("updatedAt"), Query.limit(50)],
});
const chats: CoachChat[] = [];
for (const row of response.rows) {
chats.push(await decryptChatRow(row, passphrase));
}
return chats;
}
export async function createEncryptedChat(userId: string, passphrase: string, chat: CoachChat) {
const row = await tablesDB.createRow<EncryptedChatRow>({
databaseId: appwriteConfig.databaseId,
tableId: appwriteConfig.chatCollectionId,
rowId: ID.custom(chat.id),
data: await toEncryptedRowData(userId, passphrase, chat),
permissions: userRowPermissions(userId),
});
return decryptChatRow(row, passphrase);
}
export async function updateEncryptedChat(userId: string, passphrase: string, chat: CoachChat) {
const row = await tablesDB.updateRow<EncryptedChatRow>({
databaseId: appwriteConfig.databaseId,
tableId: appwriteConfig.chatCollectionId,
rowId: chat.id,
data: await toEncryptedRowData(userId, passphrase, chat),
permissions: userRowPermissions(userId),
});
return decryptChatRow(row, passphrase);
}
export async function deleteEncryptedChat(id: string) {
await tablesDB.deleteRow({
databaseId: appwriteConfig.databaseId,
tableId: appwriteConfig.chatCollectionId,
rowId: id,
});
}
export function chatStorageErrorMessage(error: unknown) {
if (error instanceof Error) {
if (/decrypt|operation failed|unable to decrypt/i.test(error.message)) {
return "Encrypted chat key could not unlock saved chats.";
}
if (/not found|404/i.test(error.message)) {
return `Appwrite chat table '${appwriteConfig.chatCollectionId}' was not found.`;
}
if (/permissions?.*create|action 'create'|not authorized|401|unauthorized/i.test(error.message)) {
return `Appwrite chat table needs Users -> Create and row security on '${appwriteConfig.chatCollectionId}'.`;
}
return error.message;
}
return "Encrypted chat storage failed.";
}
async function toEncryptedRowData(userId: string, passphrase: string, chat: CoachChat) {
const salt = crypto.getRandomValues(new Uint8Array(16));
const key = await deriveKey(passphrase, userId, salt);
const title = await encryptText(chat.title, key);
const messages = await encryptText(JSON.stringify(chat.messages), key);
return {
userId,
encryptedTitle: title.ciphertext,
encryptedMessages: messages.ciphertext,
titleIv: title.iv,
messagesIv: messages.iv,
salt: bytesToBase64(salt),
version: CHAT_CRYPTO_VERSION,
updatedAt: chat.updatedAt,
};
}
async function decryptChatRow(row: EncryptedChatRow, passphrase: string): Promise<CoachChat> {
const salt = base64ToBytes(row.salt);
const key = await deriveKey(passphrase, row.userId, salt);
const title = await decryptText({ ciphertext: row.encryptedTitle, iv: row.titleIv }, key);
const messages = JSON.parse(await decryptText({ ciphertext: row.encryptedMessages, iv: row.messagesIv }, key));
return {
id: row.$id,
userId: row.userId,
title,
messages,
createdAt: row.$createdAt,
updatedAt: row.updatedAt || row.$updatedAt,
};
}
async function deriveKey(passphrase: string, userId: string, salt: Uint8Array) {
const material = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(`${userId}:${passphrase}`),
"PBKDF2",
false,
["deriveKey"],
);
return crypto.subtle.deriveKey(
{ name: "PBKDF2", salt: bytesToArrayBuffer(salt), iterations: KEY_ITERATIONS, hash: "SHA-256" },
material,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"],
);
}
function bytesToArrayBuffer(bytes: Uint8Array) {
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
}
async function encryptText(value: string, key: CryptoKey): Promise<EncryptedValue> {
const iv = crypto.getRandomValues(new Uint8Array(12));
const encrypted = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, new TextEncoder().encode(value));
return { ciphertext: bytesToBase64(new Uint8Array(encrypted)), iv: bytesToBase64(iv) };
}
async function decryptText(value: EncryptedValue, key: CryptoKey) {
const decrypted = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: base64ToBytes(value.iv) },
key,
base64ToBytes(value.ciphertext),
);
return new TextDecoder().decode(decrypted);
}
function bytesToBase64(bytes: Uint8Array) {
let binary = "";
bytes.forEach((byte) => {
binary += String.fromCharCode(byte);
});
return btoa(binary);
}
function base64ToBytes(value: string) {
const binary = atob(value);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return bytes;
}
function userRowPermissions(userId: string) {
const role = Role.user(userId);
return [Permission.read(role), Permission.update(role), Permission.delete(role)];
}